@gobing-ai/knowledge-kit 0.0.23 → 0.0.25

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 (67) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +137 -59
  3. package/package.json +4 -4
  4. package/plugins/generations/content-gen/dist/index.js +97 -45
  5. package/plugins/generations/core-facts-gen/dist/index.js +106 -57
  6. package/plugins/generations/daily-article-gen/dist/index.js +105 -56
  7. package/plugins/generations/dailynews-gen/dist/index.js +235 -57
  8. package/plugins/generations/dailynews-gen/src/index.ts +67 -2
  9. package/plugins/generations/dailynews-gen/src/script-builder.ts +36 -15
  10. package/plugins/generations/dailynews-gen/src/script-plan.ts +142 -0
  11. package/plugins/generations/episode-plan-gen/dist/index.js +115 -66
  12. package/plugins/generations/gemini-tts/README.md +24 -0
  13. package/plugins/generations/gemini-tts/dist/index.js +326 -260
  14. package/plugins/generations/gemini-tts/src/gemini-client.ts +41 -11
  15. package/plugins/generations/image-gen/dist/index.js +338 -117
  16. package/plugins/generations/image-gen/presets/formats/daily-news-cover.yaml +19 -0
  17. package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +55 -0
  18. package/plugins/generations/image-gen/presets/styles/daily-news.yaml +9 -0
  19. package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +7 -0
  20. package/plugins/generations/image-gen/src/index.ts +148 -14
  21. package/plugins/generations/image-gen/src/job.ts +30 -0
  22. package/plugins/generations/image-gen/src/providers/errors.ts +91 -0
  23. package/plugins/generations/image-gen/src/providers/types.ts +79 -0
  24. package/plugins/generations/news-report-gen/dist/index.js +136 -55
  25. package/plugins/generations/news-report-gen/src/index.ts +57 -1
  26. package/plugins/generations/voice-gen/dist/index.js +119 -67
  27. package/plugins/ingestions/aihot-ingest/dist/index.js +103 -53
  28. package/plugins/ingestions/horizon-ingest/dist/index.js +102 -53
  29. package/plugins/ingestions/karakeep-local/dist/index.js +273 -224
  30. package/plugins/ingestions/last30days-ingest/dist/index.js +97 -48
  31. package/plugins/ingestions/web-search/dist/index.js +121 -72
  32. package/plugins/kk/config.example.yaml +13 -0
  33. package/plugins/kk/plugin.json +1 -1
  34. package/plugins/kk/scripts/daily-voice-style.zh.md +52 -0
  35. package/plugins/kk/scripts/kk-workflow-stages.ts +433 -16
  36. package/plugins/kk/skills/publish/SKILL.md +24 -1
  37. package/plugins/kk/skills/xhs-images/SKILL.md +89 -0
  38. package/plugins/kk/skills/xhs-images/references/card-layouts.md +35 -0
  39. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +162 -11
  40. package/plugins/publishings/emdash-pub/dist/index.js +94 -45
  41. package/plugins/publishings/infoq-pub/dist/index.js +94 -45
  42. package/plugins/publishings/juejin-pub/dist/index.js +94 -45
  43. package/plugins/publishings/medium-pub/dist/index.js +94 -45
  44. package/plugins/publishings/podcast-pub/dist/index.js +115 -55
  45. package/plugins/publishings/podcast-pub/src/map.ts +30 -10
  46. package/plugins/publishings/podcast-pub/src/show-notes.ts +6 -1
  47. package/plugins/publishings/qiita-pub/dist/index.js +94 -45
  48. package/plugins/publishings/substack-pub/dist/index.js +94 -45
  49. package/plugins/publishings/surfdash-pub/dist/index.js +99 -63
  50. package/plugins/publishings/surfdash-pub/src/index.ts +10 -1
  51. package/plugins/publishings/wechatmp-pub/dist/index.js +94 -45
  52. package/plugins/publishings/x-pub/dist/index.js +94 -45
  53. package/plugins/publishings/xhs-pub/dist/index.js +8908 -229
  54. package/plugins/publishings/xhs-pub/package.json +1 -0
  55. package/plugins/publishings/xhs-pub/plugin.json +1 -1
  56. package/plugins/publishings/xhs-pub/src/automation-guard.ts +128 -0
  57. package/plugins/publishings/xhs-pub/src/index.ts +753 -24
  58. package/plugins/publishings/xhs-pub/src/profile-check.ts +133 -0
  59. package/plugins/publishings/zenn-pub/dist/index.js +94 -45
  60. package/plugins/sp/lib/env.ts +46 -0
  61. package/plugins/sp/lib/inline-run.generated.mjs +1489 -0
  62. package/plugins/sp/scripts/feature-verification-steps.ts +275 -0
  63. package/plugins/sp/scripts/inline-run-setup.ts +571 -47
  64. package/plugins/sp/scripts/wrapup-drift-probe.mjs +190 -0
  65. package/plugins/sp/scripts/wrapup-drift-probe.ts +258 -0
  66. package/plugins/sp/scripts/wrapup-steps.mjs +77 -6
  67. package/plugins/sp/scripts/wrapup-steps.ts +91 -5
@@ -32,8 +32,9 @@
32
32
  * comes from `realpath "$(command -v kk)"` (the installed package root), and falls back to the
33
33
  * cwd-relative `plugins/kk/scripts/kk-workflow-stages.ts` when that copy is absent — the checkout
34
34
  * tier kk-storm-research's render has always relied on, preserved on purpose. The daily-voice
35
- * workflow reaches the same script through `kk stage` (apps/cli/src/stage.ts), which applies the
36
- * same two resolution tiers in TypeScript.
35
+ * workflow reaches the same script through `kk stage` (apps/cli/src/stage.ts), which resolves a
36
+ * recognized checkout entry to this checked-in copy (never a stale `apps/cli/plugins/` stage) and an
37
+ * installed entry to its own package-shipped copy.
37
38
  *
38
39
  * SYNC: plugins/kk/workflows/{kk-solo-podcast,kk-itc,kk-storm-research}.yaml shell this script
39
40
  * (kk-daily-ai-voice goes through `kk stage`). Keep message substrings stable —
@@ -60,11 +61,39 @@ const SCRIPTS_DIR = import.meta.dir;
60
61
  const VALIDATE_SIDECAR = 'validate-voicescript.ts';
61
62
  const WRAP_SIDECAR = 'wrap-voicescript-doc.ts';
62
63
  const RENDER_SIDECAR = 'render-md.ts';
64
+ // F613: the zh style contract the script-write prompt reads (snapshot stage) and dailynews-gen
65
+ // consumes (DAILYNEWS_STYLE_FILE); no workflow var carries an override, so the chain is
66
+ // $KK_WORKFLOWS_DIR -> package copy -> $HOME.
67
+ const STYLE_SIDECAR = 'daily-voice-style.zh.md';
63
68
  const REVISE_COUNT = '.revise-count';
64
69
  // Post-translation title-dedup threshold (task 0138 R3). ponytail: the 2026-09-16 Gemini 3.8 Live
65
70
  // pair measures 0.417 zh title-only Jaccard, the nearest distinct pair 0.1 (4x margin). Character
66
71
  // bigrams need same-language input — pre-translate dedup over the mixed-language blend cannot work.
67
72
  const DEDUP_TITLE_THRESHOLD = 0.4;
73
+ // F614: the daily cover renders through the shipped daily-news presets; the brief's subject is
74
+ // the only per-run var. The format/style pins and the subject cap are part of the style
75
+ // contract — changing the look means editing the preset files, not this stage.
76
+ const COVER_FORMAT = 'daily-news-cover';
77
+ const COVER_STYLE = 'daily-news';
78
+ const COVER_SUBJECT_CAP = 240;
79
+ // E8: the XHS card series. Format and style are pinned exactly like the cover's; only the copy is
80
+ // model-authored. `XHS_MAX_CARDS` mirrors the format preset's own series guidance.
81
+ const XHS_FORMAT = 'xhs-card';
82
+ const XHS_MAX_CARDS = 10;
83
+ const XHS_TITLE_CAP = 20;
84
+ const XHS_BODY_CAP = 1000;
85
+ const XHS_ROLES: readonly string[] = ['cover', 'content', 'closing'];
86
+
87
+ /** One planned card, normalized: `index` zero-padded for the preset's artifact name. */
88
+ interface XhsCard {
89
+ index: string;
90
+ role: string;
91
+ copy: string;
92
+ points: string[];
93
+ layout: string;
94
+ subject: string;
95
+ sourcePoint: string;
96
+ }
68
97
 
69
98
  function fail(message: string): never {
70
99
  console.error(message);
@@ -414,6 +443,7 @@ const DAILY_DIRS = [
414
443
  '2-plan',
415
444
  '2-article',
416
445
  '2-cover',
446
+ '2-xhs',
417
447
  '2-script',
418
448
  '3-audio',
419
449
  '3-publish',
@@ -478,6 +508,12 @@ function pluginsArgs(pluginsPath: string): string[] {
478
508
  }
479
509
 
480
510
  /** `kk <args>` with the caller's env overrides; `soft` mirrors the shell's `|| true`. */
511
+ // spur --async exports SPUR_EXPECTED_DEFINITION_DIGEST (its own worker's identity binding) into
512
+ // every descendant env; a nested `spur workflow run` — postsurfing's publishing workflow under
513
+ // surfdash-pub — resolves a DIFFERENT digest and refuses to start. Strip both launcher vars at
514
+ // the nested-spur boundary (SPUR_WORKFLOW_RUN_ACTIVE is the older sibling guard, task 0133-era).
515
+ const NESTED_SPUR_UNSET = ['SPUR_WORKFLOW_RUN_ACTIVE', 'SPUR_EXPECTED_DEFINITION_DIGEST'] as const;
516
+
481
517
  function kk(
482
518
  args: string[],
483
519
  options: { env?: Record<string, string>; unset?: readonly string[]; soft?: boolean } = {},
@@ -861,6 +897,76 @@ function dailyArticle(args: string[]): void {
861
897
  });
862
898
  }
863
899
 
900
+ /**
901
+ * F614 R2/R4: validate the agent-written cover brief in the order R4 names — the first failing
902
+ * check is the fallback reason; `reason: null` carries the trimmed subject.
903
+ */
904
+ function coverBrief(briefPath: string): { reason: string | null; subject: string } {
905
+ if (!isFile(briefPath)) return { reason: 'missing-brief', subject: '' };
906
+ let parsed: unknown;
907
+ try {
908
+ parsed = JSON.parse(readFileSync(briefPath, 'utf-8'));
909
+ } catch {
910
+ return { reason: 'invalid-json', subject: '' };
911
+ }
912
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
913
+ return { reason: 'invalid-json', subject: '' };
914
+ }
915
+ const subject = (parsed as Record<string, unknown>).subject;
916
+ if (typeof subject !== 'string') return { reason: 'no-subject', subject: '' };
917
+ const trimmed = subject.trim();
918
+ if (trimmed === '' || trimmed.length > COVER_SUBJECT_CAP) return { reason: 'subject-cap', subject: '' };
919
+ return { reason: null, subject: trimmed };
920
+ }
921
+
922
+ /**
923
+ * F614 R3: build the image-gen envelope from the day's cover brief and render it. The subject is
924
+ * the only model-authored input (the workflow's brief agent.run wrote it); format, style,
925
+ * provider and model are pinned by this stage so no caller can drift the look. A missing or
926
+ * invalid brief degrades to the format's default subject (R4) — a plugin failure still fails
927
+ * the stage, matching the pre-F614 cover behavior.
928
+ */
929
+ function dailyCover(args: string[]): void {
930
+ const [workDir = '', runDate = '', pluginsPath = '', provider = '', model = '', fallback = ''] = args;
931
+ const briefPath = join(workDir, '2-cover', `${runDate}_07_cover-brief.json`);
932
+ const envPath = join(workDir, '2-cover', `${runDate}_07_cover_image-gen-in.json`);
933
+ const contentOut = join(workDir, '2-cover', 'content.json');
934
+ timed(workDir, 'cover', () => {
935
+ const brief = coverBrief(briefPath);
936
+ const vars: Record<string, string> = {};
937
+ if (brief.reason !== null) {
938
+ console.error(`cover brief: fallback (${brief.reason})`);
939
+ } else {
940
+ vars.subject = brief.subject;
941
+ }
942
+ const imageGen: Record<string, unknown> = {
943
+ job: 'cover',
944
+ format: COVER_FORMAT,
945
+ style: COVER_STYLE,
946
+ vars,
947
+ maxImages: 1,
948
+ };
949
+ if (provider !== '') imageGen.provider = provider;
950
+ if (model !== '') imageGen.model = model;
951
+ // D6 0166 R7: runtime-fallback opt-in. '' (the shipped var default) keeps strict fail-fast —
952
+ // F614's pinned look stays the default; 'true' = retry down AUTO_ORDER; anything else is a
953
+ // comma-split, trimmed candidate list. Unknown names are rejected by image-gen, not here.
954
+ if (fallback !== '') {
955
+ imageGen.fallback =
956
+ fallback === 'true'
957
+ ? true
958
+ : fallback
959
+ .split(',')
960
+ .map((s) => s.trim())
961
+ .filter(Boolean);
962
+ }
963
+ sted('write', envPath, () =>
964
+ writeJsonText(envPath, [{ id: 'daily-cover', title: runDate, body: '', metadata: { imageGen } }]),
965
+ );
966
+ kk(['executor', 'run', 'image-gen', '--in', envPath, '--out', contentOut, ...pluginsArgs(pluginsPath)]);
967
+ });
968
+ }
969
+
864
970
  function dailyCoverNormalize(args: string[]): void {
865
971
  const [workDir = '', runDate = ''] = args;
866
972
  // The pre-0133 shell had no timing trap, so this stage is not `timed`; it still fails through
@@ -878,15 +984,26 @@ function dailyCoverNormalize(args: string[]): void {
878
984
  }
879
985
 
880
986
  function coverNormalizeBody(workDir: string, runDate: string): void {
881
- const png = join(workDir, '2-cover', 'content-cover-01.png');
882
- const jpg = join(workDir, '2-cover', 'content-cover-01.jpg');
987
+ // F614 R5: the artifact name follows the format preset (`<stem>-<format>-NN.<ext>`,
988
+ // paths.ts), so normalize reads the path the plugin itself recorded in the manifest instead
989
+ // of a hard-coded name. The legacy content-cover-01.{png,jpg} paths stay as stale cleanup.
990
+ const legacyPng = join(workDir, '2-cover', 'content-cover-01.png');
991
+ const legacyJpg = join(workDir, '2-cover', 'content-cover-01.jpg');
883
992
  // Must match the name dailyPublishPrep and the article->cover workflow guard read:
884
993
  // `${runDate}_07_cover_cover.png` (a plain `cover-${runDate}.png` left options.coverPath
885
994
  // undefined and silently dropped the cover from both podcast and surfdash — 2026-09-16 run).
886
995
  const out = join(workDir, '2-cover', `${runDate}_07_cover_cover.png`);
887
- const source = isFile(png) ? png : isFile(jpg) ? jpg : '';
888
- if (source === '') failWith(1, 'cover normalize: no content-cover-01.{png,jpg} artifact');
889
- if (source === jpg) {
996
+ const images = metadataOf(readJson(join(workDir, '2-cover', 'content.json'))).images;
997
+ const first = Array.isArray(images) ? images[0] : undefined;
998
+ const recorded =
999
+ first !== null && typeof first === 'object' && typeof (first as Record<string, unknown>).path === 'string'
1000
+ ? ((first as Record<string, unknown>).path as string)
1001
+ : '';
1002
+ if (recorded === '' || !isFile(recorded)) {
1003
+ failWith(1, 'cover normalize: no image artifact in 2-cover/content.json');
1004
+ }
1005
+ const source = recorded;
1006
+ if (source.endsWith('.jpg') || source.endsWith('.jpeg')) {
890
1007
  const sips = spawnSync('sips', ['-s', 'format', 'png', source, '--out', out], { stdio: 'ignore' });
891
1008
  // Bun reports a missing binary as `status: undefined` (older versions: `null`) plus an
892
1009
  // `error` object — the pre-0133 shell's `command -v sips` failed the same way.
@@ -899,16 +1016,236 @@ function coverNormalizeBody(workDir: string, runDate: string): void {
899
1016
  } else {
900
1017
  sted('mv', source, () => renameSync(source, out));
901
1018
  }
902
- for (const stale of [jpg, png]) {
1019
+ for (const stale of new Set([legacyJpg, legacyPng, source])) {
903
1020
  try {
904
1021
  unlinkSync(stale);
905
1022
  } catch {
906
- // `rm -f` is silent when the file is already gone.
1023
+ // `rm -f` is silent when the file is already gone (a renamed source lands here too).
907
1024
  }
908
1025
  }
909
1026
  console.log(`cover normalized: ${runDate}_07_cover_cover.png`);
910
1027
  }
911
1028
 
1029
+ /** `metadata.images[0]` — the artifact the image-gen plugin recorded for one render. */
1030
+ function firstArtifact(content: Record<string, unknown> | null): Record<string, unknown> | null {
1031
+ const images = metadataOf(content).images;
1032
+ const first = Array.isArray(images) ? images[0] : undefined;
1033
+ return first !== null && typeof first === 'object' ? (first as Record<string, unknown>) : null;
1034
+ }
1035
+
1036
+ /**
1037
+ * E8: validate the agent-written XHS card plan. The plan is the only model-authored input —
1038
+ * format, style, provider and model are pinned by `dailyXhsRender`, matching F614's cover. Every
1039
+ * check fails loud naming the field, so an unusable plan skips the XHS target visibly instead of
1040
+ * publishing a partial card series (the owning state carries `onError: continue`).
1041
+ */
1042
+ function xhsPlan(planPath: string, count: number): { title: string; body: string; cards: XhsCard[] } {
1043
+ const parsed = requireJson(planPath, 'xhs plan');
1044
+ const title = typeof parsed.title === 'string' ? parsed.title.trim() : '';
1045
+ if (title === '' || title.length > XHS_TITLE_CAP) {
1046
+ failWith(1, `xhs plan: title must be 1..${XHS_TITLE_CAP} chars, got ${title.length}`);
1047
+ }
1048
+ const body = typeof parsed.body === 'string' ? parsed.body.trim() : '';
1049
+ if (body === '' || body.length > XHS_BODY_CAP) {
1050
+ failWith(1, `xhs plan: body must be 1..${XHS_BODY_CAP} chars, got ${body.length}`);
1051
+ }
1052
+ const raw = parsed.cards;
1053
+ if (!Array.isArray(raw) || raw.length !== count) {
1054
+ failWith(1, `xhs plan: expected ${count} cards (xhs_cards), got ${Array.isArray(raw) ? raw.length : 0}`);
1055
+ }
1056
+ const cards = raw.map((entry, position): XhsCard => {
1057
+ const card = (entry !== null && typeof entry === 'object' ? entry : {}) as Record<string, unknown>;
1058
+ const label = `xhs plan: card ${position + 1}`;
1059
+ const text = (key: string): string => {
1060
+ const value = card[key];
1061
+ if (typeof value !== 'string' || value.trim() === '') failWith(1, `${label} needs a non-empty '${key}'`);
1062
+ return value.trim();
1063
+ };
1064
+ const role = text('role');
1065
+ if (!XHS_ROLES.includes(role)) {
1066
+ failWith(1, `${label} role must be one of ${XHS_ROLES.join('|')}, got '${role}'`);
1067
+ }
1068
+ if (position === 0 && role !== 'cover') failWith(1, `${label} must be role 'cover' (the series opens on it)`);
1069
+ const points = card.points;
1070
+ if (!Array.isArray(points) || points.length < 1 || points.length > 4) {
1071
+ failWith(1, `${label} needs 1..4 'points', got ${Array.isArray(points) ? points.length : 0}`);
1072
+ }
1073
+ const bullets = points.map((point) => {
1074
+ if (typeof point !== 'string' || point.trim() === '') failWith(1, `${label} has an empty point`);
1075
+ return point.trim();
1076
+ });
1077
+ return {
1078
+ index: `${position + 1}`.padStart(2, '0'),
1079
+ role,
1080
+ copy: text('copy'),
1081
+ points: bullets,
1082
+ layout: text('layout'),
1083
+ subject: text('subject'),
1084
+ sourcePoint: text('sourcePoint'),
1085
+ };
1086
+ });
1087
+ return { title, body, cards };
1088
+ }
1089
+
1090
+ /**
1091
+ * E8: build one `image-gen` envelope per planned card, render them sequentially, and package the
1092
+ * ordered card manifest + `Content` that `xhs-pub` consumes. Format, style, provider and model
1093
+ * are pinned here (never model-authored), so the series cannot drift from its preset. Resume-safe:
1094
+ * a card whose recorded artifact is still on disk is not re-rendered — and not re-billed.
1095
+ */
1096
+ function dailyXhsRender(args: string[]): void {
1097
+ const [workDir = '', runDate = '', pluginsPath = '', countArg = '', style = '', provider = '', model = ''] = args;
1098
+ const count = Number.parseInt(countArg, 10);
1099
+ const planPath = join(workDir, '2-xhs', `${runDate}_18_xhs_plan.json`);
1100
+ const cardsDir = join(workDir, '2-xhs', 'cards');
1101
+ const manifestPath = join(workDir, '2-xhs', 'cards.json');
1102
+ const contentPath = join(workDir, '2-xhs', 'content.json');
1103
+ timed(workDir, 'xhs-render', () => {
1104
+ if (!Number.isInteger(count) || count < 1 || count > XHS_MAX_CARDS) {
1105
+ failWith(1, `xhs render: xhs_cards must be 1..${XHS_MAX_CARDS}, got '${countArg}'`);
1106
+ }
1107
+ const plan = xhsPlan(planPath, count);
1108
+ sted('mkdir', cardsDir, () => mkdirSync(cardsDir, { recursive: true }));
1109
+ const images: Record<string, unknown>[] = [];
1110
+ const cards: Record<string, unknown>[] = [];
1111
+ for (const card of plan.cards) {
1112
+ const out = join(cardsDir, `card-${card.index}.json`);
1113
+ const promptPath = join(cardsDir, `card-${card.index}.prompt.txt`);
1114
+ let artifact = firstArtifact(readJson(out));
1115
+ if (artifact === null || !isFile(typeof artifact.path === 'string' ? artifact.path : '')) {
1116
+ const envPath = join(cardsDir, `card-${card.index}.in.json`);
1117
+ const imageGen: Record<string, unknown> = {
1118
+ job: 'free',
1119
+ format: XHS_FORMAT,
1120
+ style,
1121
+ vars: {
1122
+ role: card.role,
1123
+ layout: card.layout,
1124
+ headline: card.copy,
1125
+ points: card.points.join('\n'),
1126
+ subject: card.subject,
1127
+ language: '简体中文',
1128
+ index: card.index,
1129
+ },
1130
+ maxImages: 1,
1131
+ };
1132
+ if (provider !== '') imageGen.provider = provider;
1133
+ if (model !== '') imageGen.model = model;
1134
+ sted('write', envPath, () =>
1135
+ writeJsonText(envPath, [
1136
+ { id: `xhs-card-${card.index}`, title: runDate, body: '', metadata: { imageGen } },
1137
+ ]),
1138
+ );
1139
+ // Sequential on purpose: one provider call per card, and the first failure stops
1140
+ // the spend — later cards stay unrendered for a resume run to pick up.
1141
+ kk(['executor', 'run', 'image-gen', '--in', envPath, '--out', out, ...pluginsArgs(pluginsPath)]);
1142
+ artifact = firstArtifact(requireJson(out, 'xhs render'));
1143
+ }
1144
+ if (artifact === null) failWith(1, `xhs render: card ${card.index} recorded no image artifact (${out})`);
1145
+ const recorded = artifact.path;
1146
+ if (typeof recorded !== 'string' || !isFile(recorded)) {
1147
+ failWith(1, `xhs render: card ${card.index} image artifact is not on disk: '${String(recorded)}'`);
1148
+ }
1149
+ // `image-gen` writes the artifact beside `--out`; the publisher requires absolute paths.
1150
+ const imagePath = resolve(recorded);
1151
+ sted('write', promptPath, () =>
1152
+ writeRaw(promptPath, typeof artifact.prompt === 'string' ? artifact.prompt : ''),
1153
+ );
1154
+ images.push({ ...artifact, path: imagePath });
1155
+ cards.push({
1156
+ index: card.index,
1157
+ role: card.role,
1158
+ sourcePoint: card.sourcePoint,
1159
+ copy: card.copy,
1160
+ layout: card.layout,
1161
+ promptPath,
1162
+ imagePath,
1163
+ status: 'rendered',
1164
+ });
1165
+ }
1166
+ sted('write', manifestPath, () => writeJsonText(manifestPath, { source: planPath, cards }));
1167
+ sted('write', contentPath, () =>
1168
+ writeJsonText(contentPath, {
1169
+ title: plan.title,
1170
+ body: plan.body,
1171
+ format: 'text',
1172
+ metadata: { images, publishStatus: 'draft' },
1173
+ }),
1174
+ );
1175
+ console.log(`xhs render: ${cards.length} cards packaged in card order (${contentPath})`);
1176
+ });
1177
+ }
1178
+
1179
+ /** A single-target xhs Result, not the fan-out aggregate: `ok` plus `metadata.publishStatus`. */
1180
+ function xhsStatus(path: string): string {
1181
+ const parsed = readJson(path);
1182
+ if (parsed?.ok !== true) return '';
1183
+ const status = metadataOf(parsed).publishStatus;
1184
+ return status === 'public' || status === 'draft' ? status : '';
1185
+ }
1186
+
1187
+ /**
1188
+ * E8: publish the packaged card series through `xhs-pub`'s script driver. Draft by default; live
1189
+ * only on an explicit launch-time `xhs_live=true` (ADR-022 — an unattended run never posts live on
1190
+ * its own). Non-blocking by operator decision: a missing/expired XHS session or a changed console
1191
+ * logs a loud failure and leaves the run alive, and the outcome never enters `publish_status`
1192
+ * (surfdash + podcast remain the episode's publication contract).
1193
+ */
1194
+ function dailyPublishXhs(args: string[]): void {
1195
+ const [workDir = '', runDate = '', pluginsPath = '', live = 'false'] = args;
1196
+ const content = join(workDir, '2-xhs', 'content.json');
1197
+ const input = join(workDir, '3-publish', `${runDate}_19_publish-xhs_content.json`);
1198
+ const result = join(workDir, '3-publish', `${runDate}_19_publish-xhs_result.json`);
1199
+ const want = live === 'true' ? 'public' : 'draft';
1200
+ timed(workDir, 'publish-xhs', () => {
1201
+ // Cache on the *requested* status: a cached draft must not silently satisfy a later
1202
+ // xhs_live=true re-run (that would report a live post that never happened).
1203
+ if (xhsStatus(result) === want) {
1204
+ console.log(`publish-xhs cached: ${want} already recorded in ${result}`);
1205
+ return;
1206
+ }
1207
+ if (!isFile(content)) {
1208
+ console.log(`publish-xhs: no card series at ${content} (render packaged none) — skip`);
1209
+ return;
1210
+ }
1211
+ const packaged = requireJson(content, 'publish-xhs');
1212
+ sted('write', input, () =>
1213
+ writeJsonText(input, { ...packaged, metadata: { ...metadataOf(packaged), publishStatus: want } }),
1214
+ );
1215
+ // `soft` keeps the XHS target non-blocking: the plugin still writes its own failed Result
1216
+ // and prints the failing step (login gate, upload, body, submit control), which is what the
1217
+ // operator needs to re-run — never a silent skip.
1218
+ const status = kk(['executor', 'run', 'xhs-pub', '--in', input, '--out', result, ...pluginsArgs(pluginsPath)], {
1219
+ soft: true,
1220
+ });
1221
+ console.log(
1222
+ status === 0
1223
+ ? `publish-xhs: ${want} dispatched; outcome recorded in ${result}`
1224
+ : `WARNING: publish-xhs — xhs-pub exited ${status}; XHS target skipped (run continues, see ${result})`,
1225
+ );
1226
+ });
1227
+ }
1228
+
1229
+ /**
1230
+ * F613 R1: snapshot the shipped style contract into the run's work dir. Spur workflows have no
1231
+ * workflow-dir var, so the script-write prompt reads this copy — and the snapshot freezes the
1232
+ * exact contract the run's plan was written against. Idempotent (an existing snapshot is kept);
1233
+ * a missing sidecar is a broken install, so `requireSidecar` fails loud.
1234
+ */
1235
+ function dailyScriptStyle(args: string[]): void {
1236
+ const [workDir = '', runDate = ''] = args;
1237
+ const snapshot = join(workDir, '2-script', `${runDate}_08_script-write_style.md`);
1238
+ if (isFile(snapshot)) {
1239
+ console.log(`script style cached: ${snapshot}`);
1240
+ return;
1241
+ }
1242
+ const sidecar = requireSidecar('', STYLE_SIDECAR, 'daily-voice-style');
1243
+ const scriptDir = join(workDir, '2-script');
1244
+ sted('mkdir', scriptDir, () => mkdirSync(scriptDir, { recursive: true }));
1245
+ sted('cp', snapshot, () => copyFileSync(sidecar, snapshot));
1246
+ console.log(`script style snapshot: ${snapshot}`);
1247
+ }
1248
+
912
1249
  function dailyScript(args: string[]): void {
913
1250
  const [workDir = '', runDate = '', pluginsPath = '', language = '', title = '', voiceProfile = ''] = args;
914
1251
  const yamlOut = join(workDir, '2-script', `${runDate}_08_script_voicescript.yaml`);
@@ -920,7 +1257,16 @@ function dailyScript(args: string[]): void {
920
1257
  return;
921
1258
  }
922
1259
  kk(['executor', 'run', 'dailynews-gen', '--in', planIn, '--out', contentOut, ...pluginsArgs(pluginsPath)], {
923
- env: { DAILYNEWS_LANGUAGE: language, DAILYNEWS_TITLE: title, VOICEBOX_DEFAULT_PROFILE: voiceProfile },
1260
+ env: {
1261
+ DAILYNEWS_LANGUAGE: language,
1262
+ DAILYNEWS_TITLE: title,
1263
+ VOICEBOX_DEFAULT_PROFILE: voiceProfile,
1264
+ // F613 R4: always passed as absolute paths — spawnPluginEntry runs plugins with
1265
+ // cwd = the plugin package dir, so a relative workDir would silently miss the
1266
+ // plan; a plan file absent at build time reports missing-plan (template fallback).
1267
+ DAILYNEWS_SCRIPT_PLAN: resolve(join(workDir, '2-script', `${runDate}_08_script-write_plan.json`)),
1268
+ DAILYNEWS_STYLE_FILE: resolve(resolveSidecar('', STYLE_SIDECAR)),
1269
+ },
924
1270
  });
925
1271
  const body = readJson(contentOut)?.body;
926
1272
  if (typeof body !== 'string') failWith(1, `script: ${contentOut} has no string body`);
@@ -1012,15 +1358,22 @@ function dailyIntro(args: string[]): void {
1012
1358
  console.log(`intro-music cached: ${contentPath}`);
1013
1359
  return;
1014
1360
  }
1015
- const rawFile = meta.mp3Path ?? meta.audioPath;
1016
- if (typeof rawFile !== 'string' || rawFile === '') {
1361
+ // Merge from the raw lossless render; mp3Path is the derived publish artifact.
1362
+ // Preferring mp3Path here made `<stem>.mp3` the concat input while the output
1363
+ // suffix rewrite (`.wav` -> `.intro.wav`) no longer matched, so ffmpeg saw one
1364
+ // path as both input #1 and output and aborted with "same as Input #1".
1365
+ const candidates = [meta.audioPath, meta.mp3Path].filter(
1366
+ (candidate): candidate is string => typeof candidate === 'string' && candidate !== '',
1367
+ );
1368
+ const rawFile = candidates.find((candidate) => existsSync(resolve(candidate))) ?? candidates[0];
1369
+ if (rawFile === undefined) {
1017
1370
  failWith(1, 'intro-music: voice content carries no mp3Path/audioPath');
1018
1371
  }
1019
1372
  const introPath = resolve(introSrc);
1020
1373
  if (!existsSync(introPath)) failWith(1, `intro-music: intro file not found: ${introPath}`);
1021
1374
  const mainPath = resolve(rawFile);
1022
1375
  if (!existsSync(mainPath)) failWith(1, `intro-music: generated audio not found: ${mainPath}`);
1023
- const mergedPath = mainPath.replace(/\.wav$/, '.intro.wav');
1376
+ const mergedPath = `${mainPath.replace(/\.(wav|mp3)$/i, '')}.intro.wav`;
1024
1377
  const probe = (file: string, entries: string): string =>
1025
1378
  spawnSync('ffprobe', [
1026
1379
  '-v',
@@ -1114,8 +1467,50 @@ function dailyQualityReport(args: string[]): void {
1114
1467
  });
1115
1468
  }
1116
1469
 
1470
+ /**
1471
+ * 0171 R3: Apple Podcasts reads the enclosure's ID3 tags, so the published MP3 carries
1472
+ * title/artist/album/date. `-c copy` moves the audio frames without a re-encode, and the temp
1473
+ * file + rename keeps a failed ffmpeg from truncating the episode in place. An empty value
1474
+ * omits that tag rather than failing. The caller runs this after intro-music, whose mp3
1475
+ * re-transcode would otherwise strip tags stamped earlier.
1476
+ */
1477
+ function stampId3Tags(audioFile: string, title: string, runDate: string, artist: string, album: string): void {
1478
+ const tmpFile = `${audioFile}.id3.tmp.mp3`;
1479
+ const metadata: string[] = [];
1480
+ const addTag = (name: string, value: string): void => {
1481
+ if (value.trim() !== '') metadata.push('-metadata', `${name}=${value}`);
1482
+ };
1483
+ addTag('title', title);
1484
+ addTag('artist', artist);
1485
+ addTag('album', album);
1486
+ // run_date is a digits-only stamp (`20260924`); ID3 wants `YYYY-MM-DD`.
1487
+ const isoDate = /^\d{8}$/.test(runDate)
1488
+ ? `${runDate.slice(0, 4)}-${runDate.slice(4, 6)}-${runDate.slice(6, 8)}`
1489
+ : runDate;
1490
+ addTag('date', isoDate);
1491
+ const stamp = spawnSync('ffmpeg', [
1492
+ '-y',
1493
+ '-hide_banner',
1494
+ '-loglevel',
1495
+ 'error',
1496
+ '-i',
1497
+ audioFile,
1498
+ '-c',
1499
+ 'copy',
1500
+ '-id3v2_version',
1501
+ '3',
1502
+ ...metadata,
1503
+ tmpFile,
1504
+ ]);
1505
+ if (stamp.status !== 0 || !existsSync(tmpFile)) {
1506
+ if (existsSync(tmpFile)) unlinkSync(tmpFile); // never leave a partial file beside the episode
1507
+ failWith(1, `publish-prep: ffmpeg ID3 stamp failed: ${stamp.stderr?.toString().trim()}`);
1508
+ }
1509
+ sted('rename', audioFile, () => renameSync(tmpFile, audioFile));
1510
+ }
1511
+
1117
1512
  function dailyPublishPrep(args: string[]): void {
1118
- const [workDir = '', runDate = ''] = args;
1513
+ const [workDir = '', runDate = '', artist = '', album = ''] = args;
1119
1514
  const articlePath = join(workDir, '2-article', `${runDate}_06_article_content.json`);
1120
1515
  const audioPath = join(workDir, '3-audio', `${runDate}_11_generate_content.json`);
1121
1516
  const prepPath = join(workDir, '3-publish', `${runDate}_13_publish-prep_content.json`);
@@ -1128,6 +1523,12 @@ function dailyPublishPrep(args: string[]): void {
1128
1523
  failWith(1, 'publish-prep: voice content carries no mp3Path/audioPath');
1129
1524
  }
1130
1525
  const audioFile = resolve(rawFile);
1526
+ // 0171 R2: publish-prep publishes metadata.mp3Path ?? audioPath, so a WAV here means
1527
+ // transcode_mp3 was turned off — the run would ship an enclosure Apple Podcasts rejects.
1528
+ if (!audioFile.endsWith('.mp3')) {
1529
+ failWith(1, `publish-prep: resolved audio is not an MP3 (transcode_mp3 must be "true"): ${audioFile}`);
1530
+ }
1531
+ stampId3Tags(audioFile, typeof article.title === 'string' ? article.title : '', runDate, artist, album);
1131
1532
  const resolvedCover = resolve(coverPath);
1132
1533
  const hasCover = existsSync(resolvedCover);
1133
1534
  // podcast-pub's documented contract reads metadata.coverImage (map.ts); options.coverPath
@@ -1172,7 +1573,7 @@ function dailyPublishSurfdash(args: string[]): void {
1172
1573
  ...pluginsArgs(pluginsPath),
1173
1574
  ],
1174
1575
  {
1175
- unset: ['SPUR_WORKFLOW_RUN_ACTIVE'],
1576
+ unset: NESTED_SPUR_UNSET,
1176
1577
  soft: true,
1177
1578
  },
1178
1579
  );
@@ -1491,6 +1892,8 @@ function dailyRunReport(args: string[]): void {
1491
1892
  // 20260917 funnel: the blended fan-in input, rendered as the 语料构成
1492
1893
  // source/category composition (absent -> omitted).
1493
1894
  NEWS_REPORT_BLENDED_FILE: join(absWorkDir, '1-ingest', `${runDate}_02_collect_blended.json`),
1895
+ // F613 R5: the script stage's Content, read best-effort (absent -> 脚本来源 omitted).
1896
+ NEWS_REPORT_SCRIPT_FILE: join(absWorkDir, '2-script', `${runDate}_08_script_content.json`),
1494
1897
  NEWS_REPORT_DATE: runDate,
1495
1898
  },
1496
1899
  });
@@ -1580,7 +1983,9 @@ function dailyTranslateSync(args: string[]): void {
1580
1983
  sted('write', sync, () =>
1581
1984
  writeJqText(sync, { body: '', options: { operation: 'sync', postPath: zhPostPath } }),
1582
1985
  );
1583
- kk(['executor', 'run', 'surfdash-pub', '--in', sync, '--out', syncResult, ...pluginsArgs(pluginsPath)]);
1986
+ kk(['executor', 'run', 'surfdash-pub', '--in', sync, '--out', syncResult, ...pluginsArgs(pluginsPath)], {
1987
+ unset: NESTED_SPUR_UNSET,
1988
+ });
1584
1989
  });
1585
1990
  }
1586
1991
 
@@ -1654,9 +2059,18 @@ if (import.meta.main)
1654
2059
  case 'daily-article':
1655
2060
  dailyArticle(rest);
1656
2061
  break;
2062
+ case 'daily-cover':
2063
+ dailyCover(rest);
2064
+ break;
1657
2065
  case 'daily-cover-normalize':
1658
2066
  dailyCoverNormalize(rest);
1659
2067
  break;
2068
+ case 'daily-xhs-render':
2069
+ dailyXhsRender(rest);
2070
+ break;
2071
+ case 'daily-script-style':
2072
+ dailyScriptStyle(rest);
2073
+ break;
1660
2074
  case 'daily-script':
1661
2075
  dailyScript(rest);
1662
2076
  break;
@@ -1684,6 +2098,9 @@ if (import.meta.main)
1684
2098
  case 'daily-publish-podcast':
1685
2099
  dailyPublishPodcast(rest);
1686
2100
  break;
2101
+ case 'daily-publish-xhs':
2102
+ dailyPublishXhs(rest);
2103
+ break;
1687
2104
  case 'daily-publish-classify':
1688
2105
  dailyPublishClassify(rest);
1689
2106
  break;
@@ -28,11 +28,34 @@ not add a `kk publish` noun.
28
28
  | juejin | `juejin-pub` | `browser` / `agent` | workflow `agent.run` |
29
29
  | infoq | `infoq-pub` | `browser` / `agent` | workflow `agent.run` |
30
30
  | substack | `substack-pub` | `browser` / `agent` | workflow `agent.run` |
31
- | xhs | `xhs-pub` | `browser` / `agent` | workflow `agent.run` |
31
+ | xhs | `xhs-pub` | `browser` / `script` | `kk executor run xhs-pub --in <content.json> --out <result.json>` — its default `script` driver uploads the ordered card images with Playwright `setInputFiles`; the `agent` driver fails loud for this channel (ADR-024) |
32
32
 
33
33
  Already-harvested file-cli channels (`qiita-pub`, `zenn-pub`, `surfdash-pub`,
34
34
  `emdash-pub`, `podcast-pub`) compose the same way.
35
35
 
36
+ ## XHS profile setup (0177)
37
+
38
+ `xhs-pub` reuses the signed-in session at `~/.config/kk/browser-profiles/xhs`; kk stores no
39
+ credentials. Before the first script-driver run (and after any session expiry), do a one-time
40
+ headed login, then verify reuse read-only:
41
+
42
+ ```bash
43
+ bun plugins/publishings/xhs-pub/src/profile-check.ts login # headed; sign in once in the window
44
+ bun plugins/publishings/xhs-pub/src/profile-check.ts check # headless; exit 0 = session verified
45
+ ```
46
+
47
+ `check` never signs in — with no saved profile it refuses (exit 1) instead of reporting success.
48
+ Overrides: `--profile <dir>`, `--timeout <ms>`; env fallback `XHS_PROFILE_DIR` / `KK_BROWSER_PROFILE_DIR`.
49
+
50
+ **ADR-025 (2026-09-28, after an XHS warning about automated activity).** Publish now runs this same
51
+ check as a **preflight** before opening anything headed — so a missing or expired session is a fast
52
+ refusal (`ok: false`) rather than a headed window sitting on the login gate for 120 s — and both
53
+ console visits are capped at **one headed publish cycle + one session check per 24 h**
54
+ (`~/.config/kk/xhs-automation.json`, override `KK_XHS_AUTOMATION_STATE`; a manual `check` draws on
55
+ the same `check` budget). A capped or sessionless publish fails with `ok: false` and a named reason.
56
+ `login` is never automated and never capped. Do not schedule XHS publishing: publish deliberately,
57
+ by hand, at most once a day, and prefer a draft.
58
+
36
59
  ## Do not
37
60
 
38
61
  - Embed Playwright selectors or reimplement a transport here.