@gobing-ai/knowledge-kit 0.0.17 → 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 (74) 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 +57 -99
  8. package/plugins/generations/daily-article-gen/src/index.ts +12 -4
  9. package/plugins/generations/dailynews-gen/dist/index.js +57 -102
  10. package/plugins/generations/dailynews-gen/src/script-builder.ts +9 -1
  11. package/plugins/generations/episode-plan-gen/dist/index.js +120 -113
  12. package/plugins/generations/episode-plan-gen/src/index.ts +102 -22
  13. package/plugins/generations/image-gen/dist/index.js +63 -115
  14. package/plugins/generations/news-report-gen/dist/index.js +117 -99
  15. package/plugins/generations/news-report-gen/src/index.ts +117 -1
  16. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +1 -1
  17. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/voicescript.py +8 -0
  18. package/plugins/generations/voice-gen/dist/index.js +101 -117
  19. package/plugins/generations/voice-gen/src/concat.ts +34 -0
  20. package/plugins/generations/voice-gen/src/index.ts +6 -4
  21. package/plugins/generations/voice-gen/src/voicescript.ts +8 -0
  22. package/plugins/ingestions/aihot-ingest/dist/index.js +47 -97
  23. package/plugins/ingestions/horizon-ingest/dist/index.js +47 -96
  24. package/plugins/ingestions/karakeep-local/dist/index.js +217 -266
  25. package/plugins/ingestions/last30days-ingest/dist/index.js +42 -91
  26. package/plugins/ingestions/web-search/dist/index.js +68 -117
  27. package/plugins/kk/plugin.json +1 -1
  28. package/plugins/kk/scripts/kk-workflow-stages.ts +320 -62
  29. package/plugins/kk/skills/publish/SKILL.md +80 -0
  30. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +107 -107
  31. package/plugins/kk/workflows/leftover-publish-fanout-example.yaml +53 -0
  32. package/plugins/publishings/emdash-pub/dist/index.js +42 -91
  33. package/plugins/publishings/infoq-pub/dist/index.js +21994 -0
  34. package/plugins/publishings/infoq-pub/package.json +17 -0
  35. package/plugins/publishings/infoq-pub/plugin.json +7 -0
  36. package/plugins/publishings/infoq-pub/src/index.ts +123 -0
  37. package/plugins/publishings/infoq-pub/tsconfig.json +4 -0
  38. package/plugins/publishings/juejin-pub/dist/index.js +21994 -0
  39. package/plugins/publishings/juejin-pub/package.json +17 -0
  40. package/plugins/publishings/juejin-pub/plugin.json +7 -0
  41. package/plugins/publishings/juejin-pub/src/index.ts +123 -0
  42. package/plugins/publishings/juejin-pub/tsconfig.json +4 -0
  43. package/plugins/publishings/medium-pub/dist/index.js +22041 -0
  44. package/plugins/publishings/medium-pub/package.json +17 -0
  45. package/plugins/publishings/medium-pub/plugin.json +7 -0
  46. package/plugins/publishings/medium-pub/src/index.ts +215 -0
  47. package/plugins/publishings/medium-pub/tsconfig.json +4 -0
  48. package/plugins/publishings/podcast-pub/dist/index.js +183 -123
  49. package/plugins/publishings/podcast-pub/src/index.ts +47 -3
  50. package/plugins/publishings/podcast-pub/src/show-notes.ts +153 -34
  51. package/plugins/publishings/qiita-pub/dist/index.js +42 -91
  52. package/plugins/publishings/substack-pub/dist/index.js +21994 -0
  53. package/plugins/publishings/substack-pub/package.json +17 -0
  54. package/plugins/publishings/substack-pub/plugin.json +7 -0
  55. package/plugins/publishings/substack-pub/src/index.ts +123 -0
  56. package/plugins/publishings/substack-pub/tsconfig.json +4 -0
  57. package/plugins/publishings/surfdash-pub/dist/index.js +172 -103
  58. package/plugins/publishings/surfdash-pub/src/index.ts +32 -2
  59. package/plugins/publishings/wechatmp-pub/dist/index.js +22119 -0
  60. package/plugins/publishings/wechatmp-pub/package.json +17 -0
  61. package/plugins/publishings/wechatmp-pub/plugin.json +7 -0
  62. package/plugins/publishings/wechatmp-pub/src/index.ts +304 -0
  63. package/plugins/publishings/wechatmp-pub/tsconfig.json +4 -0
  64. package/plugins/publishings/x-pub/dist/index.js +22110 -0
  65. package/plugins/publishings/x-pub/package.json +17 -0
  66. package/plugins/publishings/x-pub/plugin.json +7 -0
  67. package/plugins/publishings/x-pub/src/index.ts +222 -0
  68. package/plugins/publishings/x-pub/tsconfig.json +4 -0
  69. package/plugins/publishings/xhs-pub/dist/index.js +21994 -0
  70. package/plugins/publishings/xhs-pub/package.json +17 -0
  71. package/plugins/publishings/xhs-pub/plugin.json +7 -0
  72. package/plugins/publishings/xhs-pub/src/index.ts +123 -0
  73. package/plugins/publishings/xhs-pub/tsconfig.json +4 -0
  74. package/plugins/publishings/zenn-pub/dist/index.js +42 -91
@@ -500,6 +500,16 @@ function metadataOf(value: Record<string, unknown> | null): Record<string, unkno
500
500
  return metadata !== null && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : {};
501
501
  }
502
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
+
503
513
  /** `jq -e '.targets[]? | select(.target == t and .ok == true and .status == "published")'`. */
504
514
  function targetPublished(path: string, target: string): boolean {
505
515
  const parsed = readJson(path);
@@ -752,6 +762,10 @@ function dailyArticle(args: string[]): void {
752
762
  console.log(`article cached: ${articleMd}`);
753
763
  return;
754
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`);
755
769
  kk(
756
770
  [
757
771
  'executor',
@@ -764,7 +778,10 @@ function dailyArticle(args: string[]): void {
764
778
  ...pluginsArgs(pluginsPath),
765
779
  ],
766
780
  {
767
- env: { ARTICLE_DATE: runDate },
781
+ env: {
782
+ ARTICLE_DATE: runDate,
783
+ ...(isFile(proseBody) ? { ARTICLE_BODY_FILE: proseBody } : {}),
784
+ },
768
785
  },
769
786
  );
770
787
  const body = readJson(articleContent)?.body;
@@ -792,7 +809,10 @@ function dailyCoverNormalize(args: string[]): void {
792
809
  function coverNormalizeBody(workDir: string, runDate: string): void {
793
810
  const png = join(workDir, '2-cover', 'content-cover-01.png');
794
811
  const jpg = join(workDir, '2-cover', 'content-cover-01.jpg');
795
- 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`);
796
816
  const source = isFile(png) ? png : isFile(jpg) ? jpg : '';
797
817
  if (source === '') failWith(1, 'cover normalize: no content-cover-01.{png,jpg} artifact');
798
818
  if (source === jpg) {
@@ -815,7 +835,7 @@ function coverNormalizeBody(workDir: string, runDate: string): void {
815
835
  // `rm -f` is silent when the file is already gone.
816
836
  }
817
837
  }
818
- console.log(`cover normalized: cover-${runDate}.png`);
838
+ console.log(`cover normalized: ${runDate}_07_cover_cover.png`);
819
839
  }
820
840
 
821
841
  function dailyScript(args: string[]): void {
@@ -864,7 +884,17 @@ function dailyWrapDocs(args: string[]): void {
864
884
  }
865
885
 
866
886
  function dailyGenerate(args: string[]): void {
867
- 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
+ }
868
898
  const docs = join(workDir, '3-audio', `${runDate}_10_wrap-docs_docs.json`);
869
899
  const content = join(workDir, '3-audio', `${runDate}_11_generate_content.json`);
870
900
  timed(workDir, 'generate', () => {
@@ -881,6 +911,111 @@ function dailyGenerate(args: string[]): void {
881
911
  });
882
912
  }
883
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
+
884
1019
  function dailyQualityReport(args: string[]): void {
885
1020
  const [workDir = '', runDate = ''] = args;
886
1021
  const content = join(workDir, '3-audio', `${runDate}_11_generate_content.json`);
@@ -904,7 +1039,7 @@ function dailyPublishPrep(args: string[]): void {
904
1039
  const prepPath = join(workDir, '3-publish', `${runDate}_13_publish-prep_content.json`);
905
1040
  const coverPath = join(workDir, '2-cover', `${runDate}_07_cover_cover.png`);
906
1041
  timed(workDir, 'publish-prep', () => {
907
- const article = readJson(articlePath) ?? {};
1042
+ const article = requireJson(articlePath, 'publish-prep');
908
1043
  const audioMeta = metadataOf(readJson(audioPath));
909
1044
  const rawFile = audioMeta.mp3Path ?? audioMeta.audioPath;
910
1045
  if (typeof rawFile !== 'string' || rawFile === '') {
@@ -912,13 +1047,21 @@ function dailyPublishPrep(args: string[]): void {
912
1047
  }
913
1048
  const audioFile = resolve(rawFile);
914
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.
915
1053
  const merged = {
916
1054
  ...article,
917
- metadata: { ...metadataOf(article), audioFile, runDate },
1055
+ metadata: {
1056
+ ...metadataOf(article),
1057
+ audioFile,
1058
+ runDate,
1059
+ ...(hasCover ? { coverImage: resolvedCover } : {}),
1060
+ },
918
1061
  options: {
919
1062
  audioFile,
920
1063
  durationSec: audioMeta.duration,
921
- coverPath: existsSync(resolvedCover) ? resolvedCover : undefined,
1064
+ coverPath: hasCover ? resolvedCover : undefined,
922
1065
  },
923
1066
  };
924
1067
  sted('write', prepPath, () => writeJsonText(prepPath, merged));
@@ -964,6 +1107,9 @@ function dailyShowNotes(args: string[]): void {
964
1107
  const result = join(workDir, '3-publish', `${runDate}_14_publish-surfdash_result.json`);
965
1108
  const out = join(workDir, '3-publish', `${runDate}_15_show-notes_show-notes.md`);
966
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`);
967
1113
  timed(workDir, 'show-notes', () => {
968
1114
  const effective = readJson(article)?.title;
969
1115
  const titleArg = title !== '' ? title : typeof effective === 'string' ? effective : '';
@@ -974,6 +1120,7 @@ function dailyShowNotes(args: string[]): void {
974
1120
  PODCAST_PUB_RUN_DATE: runDate,
975
1121
  PODCAST_PUB_BRIEF_MODE: 'true',
976
1122
  PODCAST_PUB_ARTICLE_POST_PATH: postPath,
1123
+ ...(isFile(audioContent) ? { PODCAST_PUB_AUDIO_CONTENT: audioContent } : {}),
977
1124
  },
978
1125
  });
979
1126
  });
@@ -1004,7 +1151,7 @@ function dailyPublishPodcast(args: string[]): void {
1004
1151
  return;
1005
1152
  }
1006
1153
  try {
1007
- const content = readJson(prep) ?? {};
1154
+ const content = requireJson(prep, 'publish-podcast');
1008
1155
  const showNotes = readFileSync(notes, 'utf-8');
1009
1156
  const merged = { ...content, metadata: { ...metadataOf(content), showNotes } };
1010
1157
  sted('write', prep, () => writeJsonText(prep, merged));
@@ -1084,6 +1231,95 @@ function dailyPublishPartial(args: string[]): void {
1084
1231
  });
1085
1232
  }
1086
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
+
1087
1323
  function dailyRunReport(args: string[]): void {
1088
1324
  const [
1089
1325
  workDir = '',
@@ -1108,6 +1344,9 @@ function dailyRunReport(args: string[]): void {
1108
1344
  return;
1109
1345
  }
1110
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);
1111
1350
  kk(['executor', 'run', 'news-report-gen', '--in', cands, '--out', reportJson, ...pluginsArgs(pluginsPath)], {
1112
1351
  env: {
1113
1352
  QC_MIN_QUALITY: minQuality,
@@ -1115,13 +1354,19 @@ function dailyRunReport(args: string[]): void {
1115
1354
  QC_MIN_URGENCY: minUrgency,
1116
1355
  QC_MIN_IMPACT: minImpact,
1117
1356
  NEWS_REPORT_REJECTED_FILE: join(
1118
- workDir,
1357
+ absWorkDir,
1119
1358
  '2-plan',
1120
1359
  `${runDate}_04_quality-control-content_candidates.rejected.json`,
1121
1360
  ),
1122
- NEWS_REPORT_TIMING_FILE: join(workDir, 'step-timing.json'),
1361
+ NEWS_REPORT_TIMING_FILE: join(absWorkDir, 'step-timing.json'),
1123
1362
  // 0139 R5b: the generate stage's QC audit, read best-effort (absent -> section omitted).
1124
- NEWS_REPORT_QC_FILE: join(workDir, '3-audio', `${runDate}_11_generate_content.json`),
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`),
1125
1370
  NEWS_REPORT_DATE: runDate,
1126
1371
  },
1127
1372
  });
@@ -1132,68 +1377,78 @@ function dailyRunReport(args: string[]): void {
1132
1377
  });
1133
1378
  }
1134
1379
 
1135
- 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 {
1136
1389
  const [workDir = '', runDate = '', pluginsPath = ''] = args;
1137
1390
  const result = join(workDir, '3-publish', `${runDate}_17_publish_result.json`);
1138
- const zhFile = join(workDir, '3-publish', `${runDate}_20_translate-en_zh-post-path.txt`);
1139
- const enFile = join(workDir, '3-publish', `${runDate}_20_translate-en_en-post-path.txt`);
1140
- const scaffold = join(workDir, '3-publish', `${runDate}_20_translate-en_scaffold-en.json`);
1141
- const scaffoldResult = join(workDir, '3-publish', `${runDate}_20_translate-en_scaffold-en-result.json`);
1142
- timed(workDir, 'translate-en', () => {
1391
+ const zhFile = join(workDir, '3-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
1392
+ timed(workDir, 'translate-localize', () => {
1143
1393
  const postPath = firstPostPath(readJson(result), 'surfdash-pub');
1144
1394
  const zh = postPath.endsWith('index.md') ? postPath : '';
1145
1395
  sted('write', zhFile, () => writeRaw(zhFile, zh));
1146
- const en = zh.replace('/zh/', '/en/');
1147
- sted('write', enFile, () => writeRaw(enFile, en));
1148
- if (zh === '') {
1149
- console.log('no zh post path — skip translate-en');
1150
- return;
1151
- }
1152
- if (isFile(en)) {
1153
- console.log(`en draft cached: ${en}`);
1154
- return;
1155
- }
1156
- sted('write', scaffold, () =>
1157
- writeJqText(scaffold, { body: '', options: { operation: 'scaffold-locale', postPath: zh, target: 'en' } }),
1158
- );
1159
- kk(['executor', 'run', 'surfdash-pub', '--in', scaffold, '--out', scaffoldResult, ...pluginsArgs(pluginsPath)]);
1160
- const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
1161
- sted('write', enFile, () => writeRaw(enFile, typeof draftPath === 'string' ? draftPath : ''));
1162
- });
1163
- }
1164
-
1165
- function dailyTranslateJa(args: string[]): void {
1166
- const [workDir = '', runDate = '', pluginsPath = ''] = args;
1167
- const zhFile = join(workDir, '3-publish', `${runDate}_20_translate-en_zh-post-path.txt`);
1168
- const jaFile = join(workDir, '3-publish', `${runDate}_22_translate-ja_ja-post-path.txt`);
1169
- const scaffold = join(workDir, '3-publish', `${runDate}_22_translate-ja_scaffold-ja.json`);
1170
- const scaffoldResult = join(workDir, '3-publish', `${runDate}_22_translate-ja_scaffold-ja-result.json`);
1171
- timed(workDir, 'translate-ja', () => {
1172
- const zh = isFile(zhFile) ? readFileSync(zhFile, 'utf-8') : '';
1173
- const ja = zh.replace('/zh/', '/ja/');
1174
- sted('write', jaFile, () => writeRaw(jaFile, ja));
1175
1396
  if (zh === '') {
1176
- 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');
1177
1409
  return;
1178
1410
  }
1179
- if (isFile(ja)) {
1180
- console.log(`ja draft cached: ${ja}`);
1181
- 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 : ''));
1182
1440
  }
1183
- sted('write', scaffold, () =>
1184
- writeJqText(scaffold, { body: '', options: { operation: 'scaffold-locale', postPath: zh, target: 'ja' } }),
1185
- );
1186
- kk(['executor', 'run', 'surfdash-pub', '--in', scaffold, '--out', scaffoldResult, ...pluginsArgs(pluginsPath)]);
1187
- const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
1188
- sted('write', jaFile, () => writeRaw(jaFile, typeof draftPath === 'string' ? draftPath : ''));
1189
1441
  });
1190
1442
  }
1191
1443
 
1192
1444
  function dailyTranslateSync(args: string[]): void {
1193
- const [workDir = '', runDate = '', pluginsPath = '', zhPostPath = ''] = args;
1445
+ const [workDir = '', runDate = '', pluginsPath = '', zhPathFile = ''] = args;
1194
1446
  const sync = join(workDir, '3-publish', `${runDate}_24_translate-sync_sync.json`);
1195
1447
  const syncResult = join(workDir, '3-publish', `${runDate}_24_translate-sync_sync-result.json`);
1196
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') : '';
1197
1452
  if (zhPostPath === '') {
1198
1453
  console.log('no zh post path — skip translate-sync');
1199
1454
  return;
@@ -1287,6 +1542,9 @@ if (import.meta.main)
1287
1542
  case 'daily-generate':
1288
1543
  dailyGenerate(rest);
1289
1544
  break;
1545
+ case 'daily-intro':
1546
+ dailyIntro(rest);
1547
+ break;
1290
1548
  case 'daily-quality-report':
1291
1549
  dailyQualityReport(rest);
1292
1550
  break;
@@ -1305,17 +1563,17 @@ if (import.meta.main)
1305
1563
  case 'daily-publish-classify':
1306
1564
  dailyPublishClassify(rest);
1307
1565
  break;
1566
+ case 'daily-patch-surfdash':
1567
+ dailyPatchSurfdash(rest);
1568
+ break;
1308
1569
  case 'daily-publish-partial':
1309
1570
  dailyPublishPartial(rest);
1310
1571
  break;
1311
1572
  case 'daily-run-report':
1312
1573
  dailyRunReport(rest);
1313
1574
  break;
1314
- case 'daily-translate-en':
1315
- dailyTranslateEn(rest);
1316
- break;
1317
- case 'daily-translate-ja':
1318
- dailyTranslateJa(rest);
1575
+ case 'daily-translate-localize':
1576
+ dailyTranslateLocalize(rest);
1319
1577
  break;
1320
1578
  case 'daily-translate-sync':
1321
1579
  dailyTranslateSync(rest);
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: publish
3
+ description: >-
4
+ Thin operator surface for leftover publish channels — "publish this draft to
5
+ Medium", "fan out to Qiita and Medium", "prepare a Juejin draft". Shells
6
+ `kk executor run` / `kk fanout` for file-cli and script channels; sequences
7
+ agent-default CMS channels as workflow `agent.run` steps. Does not own
8
+ transport.
9
+ ---
10
+
11
+ # publish — leftover channel operator surface
12
+
13
+ ## Purpose
14
+
15
+ Drive harvested `kind: publish` plugins after a draft exists. **The plugin owns
16
+ transport and Result.** This skill selects the channel, confirms draft vs live,
17
+ and shells `kk` — it does not call an LLM from inside a plugin process and does
18
+ not add a `kk publish` noun.
19
+
20
+ ## Channels
21
+
22
+ | Channel | Plugin | Transport | How this skill invokes it |
23
+ | --- | --- | --- | --- |
24
+ | medium | `medium-pub` | `file-cli` (token API) | `kk executor run medium-pub --in <content.json> --out <result.json>` |
25
+ | wechatmp | `wechatmp-pub` | `file-cli` (`draft/add`) | `kk executor run wechatmp-pub` (default operation `draft`) |
26
+ | x post | `x-pub` | `file-cli` (`POST /2/tweets`) | `kk executor run x-pub` (`options.operation` omitted / `post`) |
27
+ | x article | `x-pub` | `browser` / `agent` | workflow `agent.run` — not `kk fanout` |
28
+ | juejin | `juejin-pub` | `browser` / `agent` | workflow `agent.run` |
29
+ | infoq | `infoq-pub` | `browser` / `agent` | workflow `agent.run` |
30
+ | substack | `substack-pub` | `browser` / `agent` | workflow `agent.run` |
31
+ | xhs | `xhs-pub` | `browser` / `agent` | workflow `agent.run` |
32
+
33
+ Already-harvested file-cli channels (`qiita-pub`, `zenn-pub`, `surfdash-pub`,
34
+ `emdash-pub`, `podcast-pub`) compose the same way.
35
+
36
+ ## Do not
37
+
38
+ - Embed Playwright selectors or reimplement a transport here.
39
+ - Spawn an LLM from `kk fanout` (ADR-007 R4). Agent-default channels are
40
+ sequential workflow `agent.run` steps — see
41
+ `plugins/kk/workflows/leftover-publish-fanout-example.yaml`.
42
+ - Add a first-layer `kk publish` command.
43
+ - Put secrets in `--in` JSON or `plugin.json`. Env only.
44
+
45
+ ## Confirmation
46
+
47
+ Default Result is **draft / unpublished**. Live publish (`public`, WeChat
48
+ `freepublish/submit`, CMS submit) requires an explicit operator confirmation
49
+ in this skill before the plugin is invoked with a live status. Under
50
+ `KK_NONINTERACTIVE=1`, proceed only when the `--in` payload already carries
51
+ the confirmed live status; otherwise fail naming the switch.
52
+
53
+ ## Invocation
54
+
55
+ ```
56
+ <channel> [--in <content.json>] [--out <result.json>] [--live]
57
+ [--fanout <channel>,<channel>]
58
+ ```
59
+
60
+ 1. Resolve `--in` Content (required).
61
+ 2. If `--fanout` lists two or more **file-cli or script** channels: run
62
+ `kk fanout --in <content.json> --targets <list> --out <aggregate.json>`.
63
+ Each target contributes one `targets[]` Result entry.
64
+ 3. If the channel's default driver is `agent`: do **not** call `kk fanout`.
65
+ Tell the operator (or the workflow) to run the page against
66
+ `~/.config/kk/browser-profiles/<channel>` then `kk executor run <plugin>`
67
+ so the plugin writes Result. The example workflow sequences that as
68
+ `agent.run`.
69
+ 4. Otherwise: `kk executor run <plugin> --in … --out …`.
70
+ 5. Report `--out` `ok`, `target`, `id`/`url`, and `error` verbatim on failure.
71
+
72
+ ## Env (plugin-owned; never echoed)
73
+
74
+ | Plugin | Required |
75
+ | --- | --- |
76
+ | medium-pub | `MEDIUM_INTEGRATION_TOKEN` |
77
+ | wechatmp-pub | `WECHAT_APP_ID`, `WECHAT_APP_SECRET` |
78
+ | x-pub `post` | `X_API_KEY`, `X_API_SECRET`, `X_ACCESS_TOKEN`, `X_ACCESS_TOKEN_SECRET` |
79
+
80
+ Missing env → plugin exit 1, `Result.ok false`, error names the variable.