@gobing-ai/knowledge-kit 0.0.27 → 0.0.29

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 (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/package.json +1 -1
  3. package/plugins/generations/daily-article-gen/dist/index.js +27 -4
  4. package/plugins/generations/daily-article-gen/src/index.ts +34 -3
  5. package/plugins/generations/dailynews-gen/dist/index.js +42 -19
  6. package/plugins/generations/dailynews-gen/src/index.ts +29 -1
  7. package/plugins/generations/dailynews-gen/src/script-builder.ts +33 -21
  8. package/plugins/generations/episode-plan-gen/dist/index.js +7 -0
  9. package/plugins/generations/episode-plan-gen/src/index.ts +12 -0
  10. package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Bold.otf +0 -0
  11. package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Regular.otf +0 -0
  12. package/plugins/generations/image-gen/assets/fonts/OFL.txt +92 -0
  13. package/plugins/generations/image-gen/assets/fonts/SOURCE.md +15 -0
  14. package/plugins/generations/image-gen/dist/index.js +597 -16
  15. package/plugins/generations/image-gen/package.json +1 -0
  16. package/plugins/generations/image-gen/presets/formats/daily-news-cover.yaml +2 -2
  17. package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +19 -11
  18. package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +2 -1
  19. package/plugins/generations/image-gen/scripts/compose-e2e-artifact.ts +135 -0
  20. package/plugins/generations/image-gen/src/bytes.ts +28 -0
  21. package/plugins/generations/image-gen/src/compose/card.ts +294 -0
  22. package/plugins/generations/image-gen/src/compose/chromium.ts +147 -0
  23. package/plugins/generations/image-gen/src/compose/cmap.ts +177 -0
  24. package/plugins/generations/image-gen/src/index.ts +167 -9
  25. package/plugins/generations/image-gen/src/job.ts +6 -0
  26. package/plugins/generations/image-gen/src/presets.ts +53 -13
  27. package/plugins/generations/image-gen/src/providers/google.ts +9 -1
  28. package/plugins/kk/plugin.json +1 -1
  29. package/plugins/kk/scripts/daily-voice-style.zh.md +25 -12
  30. package/plugins/kk/scripts/kk-workflow-stages.ts +344 -11
  31. package/plugins/kk/scripts/topic-boundary.json +105 -0
  32. package/plugins/kk/scripts/validate-voicescript.ts +15 -0
  33. package/plugins/kk/skills/content-safety/SKILL.md +163 -0
  34. package/plugins/kk/skills/content-safety/references/platforms/apple-podcasts.md +36 -0
  35. package/plugins/kk/skills/content-safety/references/platforms/netease-music.md +31 -0
  36. package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +32 -0
  37. package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +35 -0
  38. package/plugins/kk/skills/xhs-images/SKILL.md +11 -7
  39. package/plugins/kk/skills/xhs-images/references/card-layouts.md +4 -4
  40. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +118 -16
  41. package/plugins/publishings/podcast-pub/dist/index.js +4 -0
  42. package/plugins/publishings/podcast-pub/src/index.ts +2 -0
  43. package/plugins/publishings/podcast-pub/src/show-notes.ts +6 -0
  44. package/plugins/publishings/wechatmp-pub/dist/index.js +272 -115
  45. package/plugins/publishings/wechatmp-pub/src/index.ts +336 -40
  46. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +21 -9
  47. package/plugins/publishings/xhs-pub/dist/index.js +28 -22
  48. package/plugins/publishings/xhs-pub/src/index.ts +30 -22
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "kk",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "description": "knowledge-kit Claude Code plugin: skills, commands, subagents, hooks, and rules"
5
5
  }
@@ -1,33 +1,41 @@
1
- # 每日AI科技漫谈 · 主持风格契约(zh)
1
+ # AI 乱炖 · 主持风格契约(zh)
2
2
 
3
- `kk-daily-ai-voice` 的每一句口播都受这份契约约束(F613 R1)。`script-write` 提示词与
4
- `dailynews-gen` 的校验器读取同一份机器可读块,因此提示词与校验永远一致。
3
+ `kk-daily-ai-voice` 的每一句口播都受这份契约约束(F613 R1;0189 R1 改定位)。`script-write`
4
+ 提示词与 `dailynews-gen` 的校验器读取同一份机器可读块,因此提示词与校验永远一致。
5
+
6
+ ## 栏目定位(0189 R1)
7
+
8
+ 「AI 乱炖」是「咸话咸说」下的 AI 技术分享栏目,副题「工具、论文与实践随聊」。口播是开发者
9
+ 视角的技术分享,不是新闻通报:不播报时事,不以任何代称、绰号或「闲聊」包装政治、外交、
10
+ 军事、公共政策争议或社会公共事件主线的内容——这类条目在选题/QC 阶段就应被排除(0189 R2)。
11
+ 身份与经历必须真实:只讲真实做过的实测与实践,AI 配音参与按真实程度说明。
5
12
 
6
13
  ## 人设与语域
7
14
 
8
- - 口语化普通话,第一人称「我」,像一位冷静、有依据的科技播客主播。
15
+ - 口语化普通话,第一人称「我」,像一位冷静、有依据的 AI 技术分享主播。
9
16
  - 平实、克制、不炒作:不夸大单一消息的意义,不使用惊叹式渲染或营销话术。
10
- - 面向普通听众说明「这件事为什么重要」,而不是堆砌术语。
17
+ - 面向普通听众说明「这件事为什么重要」或「怎么用得上」,而不是堆砌术语。
11
18
 
12
19
  ## 固定开场与收尾
13
20
 
14
21
  开场问候与收尾致谢由 builder 固定输出,计划文本只填充两者之间的内容,节目身份永不漂移:
15
22
 
16
- - 开场:`大家好,今天是<日期>,欢迎收听由<主持人>为您带来的今日AI科技漫谈。`
23
+ - 开场:`大家好,今天是<日期>,欢迎收听「咸话咸说」的「AI 乱炖」栏目,我是<主持人>。`
17
24
  - 收尾:`感谢您的收听,我是<主持人>,祝您拥有充实高效的一天,我们下期节目不见不散!`
18
25
 
19
26
  ## 段落角色
20
27
 
21
- - `intro`:开场导语,串起今天几条主题,接在固定问候之后,不超过 120 字。
22
- - `lead`:一句话口语桥段,引出下一条新闻的【标题】;只做铺垫,不得复述标题本身,不超过 60 字。
23
- - `insight`:只基于这条新闻自身事实的 1–2 句点评,说明它为什么重要;不得挪用其他新闻的话题,不超过 160 字。
28
+ - `intro`:开场导语,串起今天几个主题,接在固定问候之后,不超过 120 字。
29
+ - `lead`:一句话口语桥段,引出下一个主题的【标题】;只做铺垫,不得复述标题本身,不超过 60 字。
30
+ - `insight`:只基于这一条自身可核事实的 1–2 句点评,说明它为什么重要或怎么用得上;不得挪用其他条目的话题,不超过 160 字。
24
31
  - `outro`:简短收束语,接在固定致谢之前,不超过 120 字。
25
32
 
26
33
  ## 机器可读契约
27
34
 
28
35
  `caps` 是各字段的字数上限;`banned` 是任何计划字段都不得出现的短语——包括旧模板的全部罐头
29
- 点评、旧模板的 首先/接着/最后 开头,以及 0133 的研究脚手架标签与对冲语(校验规则
30
- `banned:<短语>`)。
36
+ 点评、旧模板的 首先/接着/最后 开头、0133 的研究脚手架标签与对冲语(校验规则
37
+ `banned:<短语>`),以及 0189 R4 退役的旧栏目名(「每日 AI 资讯」「AI科技漫谈」
38
+ 「AI Daily Briefing」):任何路径都不得让旧新闻定位复活。
31
39
 
32
40
  ```json
33
41
  {
@@ -46,7 +54,12 @@
46
54
  "背景",
47
55
  "潜在影响",
48
56
  "读者宜另行核实",
49
- "仍待验证"
57
+ "仍待验证",
58
+ "每日AI资讯",
59
+ "每日 AI 资讯",
60
+ "今日AI科技漫谈",
61
+ "AI科技漫谈",
62
+ "AI Daily Briefing"
50
63
  ]
51
64
  }
52
65
  ```
@@ -717,6 +717,119 @@ function dailyPlan(args: string[]): void {
717
717
  });
718
718
  }
719
719
 
720
+ /**
721
+ * Topic-boundary sidecar (task 0189 R2). Sibling of this file; node-builtins-only read.
722
+ */
723
+ interface TopicBoundaryRule {
724
+ id: string;
725
+ reason: string;
726
+ keywords: string[];
727
+ }
728
+
729
+ interface TopicBoundarySpec {
730
+ version: number;
731
+ scanChars: number;
732
+ rules: TopicBoundaryRule[];
733
+ dateConflict: { reason: string };
734
+ }
735
+
736
+ interface TopicBoundaryExclusion {
737
+ id: string;
738
+ title: string;
739
+ rule: string;
740
+ reason: string;
741
+ matched: string;
742
+ }
743
+
744
+ function readTopicBoundary(): TopicBoundarySpec {
745
+ const path = join(SCRIPTS_DIR, 'topic-boundary.json');
746
+ let parsed: TopicBoundarySpec;
747
+ try {
748
+ parsed = JSON.parse(readFileSync(path, 'utf-8')) as TopicBoundarySpec;
749
+ } catch (err: unknown) {
750
+ failWith(1, `topic-boundary: spec unreadable at ${path}: ${err instanceof Error ? err.message : String(err)}`);
751
+ }
752
+ if (!Array.isArray(parsed.rules) || parsed.rules.length === 0) {
753
+ failWith(1, `topic-boundary: spec at ${path} carries no rules`);
754
+ }
755
+ return parsed;
756
+ }
757
+
758
+ /**
759
+ * Deterministic topic admission for the AI 乱炖 column (task 0189 R2): an item whose mainline is
760
+ * political/diplomatic-trade, military, public-policy dispute, or a social public event is
761
+ * excluded with its ID, rule and matched keyword recorded — sources stay untouched. A date that
762
+ * contradicts the run (publishedAt after the run date, e.g. an aggregated future-dated draft) is
763
+ * inadmissible as evidence (R3). Matches scan the title plus the first `scanChars` chars of the
764
+ * body — precision first: a body-mention far below the fold does not exclude. Matching is
765
+ * case-insensitive (task 0189 F2: both sides lowercased; zh unaffected) so headline-capitalized
766
+ * "President"/"Pentagon" still hit the lowercase English keywords in the spec.
767
+ */
768
+ export function applyTopicBoundary(
769
+ docs: unknown[],
770
+ runDate: string,
771
+ spec: TopicBoundarySpec,
772
+ ): {
773
+ kept: unknown[];
774
+ excluded: TopicBoundaryExclusion[];
775
+ } {
776
+ const excluded: TopicBoundaryExclusion[] = [];
777
+ const kept: unknown[] = [];
778
+ for (const doc of docs) {
779
+ const record = (doc ?? {}) as Record<string, unknown>;
780
+ const title = typeof record.title === 'string' ? record.title : '';
781
+ const body = typeof record.body === 'string' ? record.body : '';
782
+ const haystack = `${title}\n${body.slice(0, spec.scanChars)}`;
783
+ // Case-insensitive comparison (task 0189 F2): spec keywords are lowercase English; the
784
+ // recorded `matched` stays the spec's keyword verbatim. zh is unaffected by lowercasing.
785
+ const needle = haystack.toLowerCase();
786
+ let exclusion: TopicBoundaryExclusion | null = null;
787
+ for (const rule of spec.rules) {
788
+ // ASCII keywords must start at a word boundary ("selection" is not "election", "patriot"
789
+ // is not "riot"); the tail stays open so plurals/inflections still hit. zh keywords have
790
+ // no word boundaries and keep plain substring matching.
791
+ const matched = rule.keywords.find((keyword) => {
792
+ const lower = keyword.toLowerCase();
793
+ if (lower === '') return false;
794
+ return /^[a-z]/.test(lower)
795
+ ? new RegExp(`\\b${lower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`).test(needle)
796
+ : needle.includes(lower);
797
+ });
798
+ if (matched !== undefined) {
799
+ exclusion = { id: String(record.id ?? ''), title, rule: rule.id, reason: rule.reason, matched };
800
+ break;
801
+ }
802
+ }
803
+ if (exclusion === null) {
804
+ const publishedAt = metadataOf(record).publishedAt;
805
+ const stamp = typeof publishedAt === 'string' ? publishedAt : '';
806
+ const runIso = /^\d{8}$/.test(runDate)
807
+ ? `${runDate.slice(0, 4)}-${runDate.slice(4, 6)}-${runDate.slice(6, 8)}`
808
+ : '';
809
+ if (runIso !== '' && stamp !== '') {
810
+ const published = new Date(stamp);
811
+ if (
812
+ !Number.isNaN(published.getTime()) &&
813
+ // No zone suffix: run_date is a LOCAL day, so its end is local too — a UTC
814
+ // cutoff flags same-evening items west of Greenwich as future-dated.
815
+ published.getTime() > new Date(`${runIso}T23:59:59`).getTime()
816
+ ) {
817
+ exclusion = {
818
+ id: String(record.id ?? ''),
819
+ title,
820
+ rule: 'date-conflict',
821
+ reason: spec.dateConflict.reason,
822
+ matched: stamp,
823
+ };
824
+ }
825
+ }
826
+ }
827
+ if (exclusion !== null) excluded.push(exclusion);
828
+ else kept.push(doc);
829
+ }
830
+ return { kept, excluded };
831
+ }
832
+
720
833
  function dailyQcContent(args: string[]): void {
721
834
  const [
722
835
  workDir = '',
@@ -730,6 +843,7 @@ function dailyQcContent(args: string[]): void {
730
843
  ] = args;
731
844
  const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
732
845
  const annotated = join(workDir, '21-plan', `${runDate}_04_quality-control-content_annotated.json`);
846
+ const exclusions = join(workDir, '21-plan', `${runDate}_04_quality-control-content_exclusions.json`);
733
847
  const planContent = join(workDir, '21-plan', `${runDate}_03_plan_content.json`);
734
848
  timed(workDir, 'quality-control-content', () => {
735
849
  if (isFile(candidates)) {
@@ -760,6 +874,32 @@ function dailyQcContent(args: string[]): void {
760
874
  },
761
875
  );
762
876
  });
877
+ // Topic boundary (task 0189 R2) runs on every invocation — re-applying to already-filtered
878
+ // candidates is a no-op, so cached reruns stay idempotent while a crash between the candidates
879
+ // write and the exclusions write still converges on the next run. Zero kept docs leaves the
880
+ // candidates file empty for the workflow's existing done-no-content edge.
881
+ const boundaryDocs = metadataOf(readJson(candidates)).docs;
882
+ if (!Array.isArray(boundaryDocs)) return;
883
+ const { kept, excluded } = applyTopicBoundary(boundaryDocs, runDate, readTopicBoundary());
884
+ if (excluded.length === 0) return;
885
+ sted('write', exclusions, () =>
886
+ writeJsonText(exclusions, {
887
+ version: 1,
888
+ runDate,
889
+ excluded,
890
+ kept: kept.map((doc) => (doc as { id: string }).id),
891
+ }),
892
+ );
893
+ const content = requireJson(candidates, 'topic-boundary');
894
+ const meta = content.metadata;
895
+ if (meta === null || typeof meta !== 'object') {
896
+ failWith(1, 'topic-boundary: candidates content carries no metadata object');
897
+ }
898
+ (meta as Record<string, unknown>).docs = kept;
899
+ sted('write', candidates, () => writeJsonText(candidates, content));
900
+ console.log(
901
+ `topic-boundary: excluded ${excluded.length} item(s): ${excluded.map((entry) => `${entry.id}(${entry.rule})`).join(', ')}`,
902
+ );
763
903
  }
764
904
 
765
905
  /**
@@ -1090,7 +1230,8 @@ function xhsPlan(planPath: string, count: number): { title: string; body: string
1090
1230
  /**
1091
1231
  * E8: build one `image-gen` envelope per planned card, render them sequentially, and package the
1092
1232
  * 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:
1233
+ * are pinned here (never model-authored), so the series cannot drift from its preset. Card copy
1234
+ * is typeset by image-gen's composer; the provider prompt stays text-free (0181). Resume-safe:
1094
1235
  * a card whose recorded artifact is still on disk is not re-rendered — and not re-billed.
1095
1236
  */
1096
1237
  function dailyXhsRender(args: string[]): void {
@@ -1124,7 +1265,6 @@ function dailyXhsRender(args: string[]): void {
1124
1265
  headline: card.copy,
1125
1266
  points: card.points.join('\n'),
1126
1267
  subject: card.subject,
1127
- language: '简体中文',
1128
1268
  index: card.index,
1129
1269
  },
1130
1270
  maxImages: 1,
@@ -1136,8 +1276,10 @@ function dailyXhsRender(args: string[]): void {
1136
1276
  { id: `xhs-card-${card.index}`, title: runDate, body: '', metadata: { imageGen } },
1137
1277
  ]),
1138
1278
  );
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.
1279
+ // Sequential on purpose: one provider call per card (illustration only —
1280
+ // card copy is typeset by image-gen's composer, never prompted), and the
1281
+ // first failure stops the spend — later cards stay unrendered for a resume
1282
+ // run to pick up.
1141
1283
  kk(['executor', 'run', 'image-gen', '--in', envPath, '--out', out, ...pluginsArgs(pluginsPath)]);
1142
1284
  artifact = firstArtifact(requireJson(out, 'xhs render'));
1143
1285
  }
@@ -1148,6 +1290,15 @@ function dailyXhsRender(args: string[]): void {
1148
1290
  }
1149
1291
  // `image-gen` writes the artifact beside `--out`; the publisher requires absolute paths.
1150
1292
  const imagePath = resolve(recorded);
1293
+ // 0181: a composed card may also carry the text-free illustration layer; keep
1294
+ // its recorded path so the manifest shows both layers.
1295
+ const rawIllustration: unknown = artifact.illustration;
1296
+ const illustrationPath =
1297
+ typeof rawIllustration === 'object' &&
1298
+ rawIllustration !== null &&
1299
+ typeof (rawIllustration as Record<string, unknown>).path === 'string'
1300
+ ? resolve((rawIllustration as Record<string, unknown>).path as string)
1301
+ : undefined;
1151
1302
  sted('write', promptPath, () =>
1152
1303
  writeRaw(promptPath, typeof artifact.prompt === 'string' ? artifact.prompt : ''),
1153
1304
  );
@@ -1160,6 +1311,7 @@ function dailyXhsRender(args: string[]): void {
1160
1311
  layout: card.layout,
1161
1312
  promptPath,
1162
1313
  imagePath,
1314
+ illustrationPath,
1163
1315
  status: 'rendered',
1164
1316
  });
1165
1317
  }
@@ -1233,7 +1385,9 @@ function dailyPublishXhs(args: string[]): void {
1233
1385
  * outcome never enters `publish_status` (surfdash + podcast remain the publication contract).
1234
1386
  */
1235
1387
  function dailyPublishWechat(args: string[]): void {
1236
- const [workDir = '', runDate = '', pluginsPath = '', live = 'false'] = args;
1388
+ const [workDirRaw = '', runDate = '', pluginsPath = '', live = 'false', collectionArg = ''] = args;
1389
+ // Metadata carries file paths consumed by the plugin child process (cwd = plugin dir) - never emit relatives.
1390
+ const workDir = resolve(workDirRaw);
1237
1391
  const article = join(workDir, '22-article', `${runDate}_06_article_content.json`);
1238
1392
  const input = join(workDir, '31-publish', `${runDate}_20_publish-wechat_content.json`);
1239
1393
  const result = join(workDir, '31-publish', `${runDate}_20_publish-wechat_result.json`);
@@ -1250,8 +1404,35 @@ function dailyPublishWechat(args: string[]): void {
1250
1404
  return;
1251
1405
  }
1252
1406
  const packaged = requireJson(article, 'publish-wechat');
1407
+ // MP draft enrichment (operator preference 2026-09-29): 原创+作者, 赞赏, 合集, 原文链接
1408
+ // (the surfdash post), AI 生成声明, cover. The brand strings are this daily show's constants.
1409
+ const cover = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1410
+ const audio = join(workDir, '30-audio', `${runDate}_11_generate_content.mp3`);
1411
+ const wechatmp: Record<string, unknown> = {
1412
+ original: true,
1413
+ aiGenerated: true,
1414
+ author: '冰原奔狼',
1415
+ collection: collectionArg || 'AI 乱炖',
1416
+ ...(isFile(cover) ? { coverPath: cover } : {}),
1417
+ // Audio block at the article top (operator request 2026-09-29): the daily MP3 goes to
1418
+ // the 素材库 under the article title; the plugin reuses an existing same-title material.
1419
+ ...(isFile(audio)
1420
+ ? {
1421
+ audioPath: audio,
1422
+ audioTitle: typeof packaged.title === 'string' ? packaged.title : '',
1423
+ audioCoverPath: isFile(cover) ? cover : '',
1424
+ }
1425
+ : {}),
1426
+ };
1427
+ // WeChat-only closing line (operator request 2026-09-29): point readers/listeners back to
1428
+ // the show's platforms. Appended to the wechat input body only, never the master article.
1429
+ const outro = '\n\n欲听往期节目,可至小宇宙、Apple Podcast、YouTube 等平台搜索【咸话咸说】。';
1253
1430
  sted('write', input, () =>
1254
- writeJsonText(input, { ...packaged, metadata: { ...metadataOf(packaged), publishStatus: want } }),
1431
+ writeJsonText(input, {
1432
+ ...packaged,
1433
+ body: `${typeof packaged.body === 'string' ? packaged.body : ''}${outro}`,
1434
+ metadata: { ...metadataOf(packaged), publishStatus: want, wechatmp },
1435
+ }),
1255
1436
  );
1256
1437
  // `soft` keeps the WeChat target non-blocking: the plugin still writes its own failed Result
1257
1438
  // and prints the failing step (session preflight, editor paste, save control), which is what
@@ -1304,6 +1485,9 @@ function dailyScript(args: string[]): void {
1304
1485
  env: {
1305
1486
  DAILYNEWS_LANGUAGE: language,
1306
1487
  DAILYNEWS_TITLE: title,
1488
+ // Task 0189: the broadcast greeting carries the episode's run date, not the render
1489
+ // date — a backfilled/retried run must not greet "today".
1490
+ DAILYNEWS_DATE: runDate,
1307
1491
  VOICEBOX_DEFAULT_PROFILE: voiceProfile,
1308
1492
  // F613 R4: always passed as absolute paths — spawnPluginEntry runs plugins with
1309
1493
  // cwd = the plugin package dir, so a relative workDir would silently miss the
@@ -1328,6 +1512,91 @@ function dailyScript(args: string[]): void {
1328
1512
  });
1329
1513
  }
1330
1514
 
1515
+ /** Task 0189 R10 artifacts of the content-safety verdict gate (24-script, step 09). */
1516
+ function safetyPath(workDir: string, runDate: string, name: string): string {
1517
+ return join(workDir, '24-script', `${runDate}_09_${name}`);
1518
+ }
1519
+
1520
+ /**
1521
+ * Digest of the exact drafts under review (article Content + VoiceScript). A verdict is only valid
1522
+ * for the bytes it was issued on: the gate and publish-prep both recompute this, so an edit after
1523
+ * PASS — including a HITL edit at `review` — invalidates the stamp instead of riding on it.
1524
+ */
1525
+ function safetyDigest(workDir: string, runDate: string): string {
1526
+ const hash = createHash('sha256');
1527
+ for (const path of [
1528
+ join(workDir, '22-article', `${runDate}_06_article_content.json`),
1529
+ join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`),
1530
+ ]) {
1531
+ if (!isFile(path)) failWith(1, `safety-review: required draft not found: ${path}`);
1532
+ hash.update(readFileSync(path)).update('\0');
1533
+ }
1534
+ return hash.digest('hex');
1535
+ }
1536
+
1537
+ /**
1538
+ * Open a content-safety review of the current drafts: drop every artifact of an earlier review
1539
+ * (a retry must be re-reviewed on its final version) and write the request the two agent.run
1540
+ * steps read. The program passes context only — platform policy lives in the kk:content-safety
1541
+ * skill's reference files, never here.
1542
+ */
1543
+ function dailySafetyPrep(args: string[]): void {
1544
+ const [workDir = '', runDate = '', platforms = ''] = args;
1545
+ timed(workDir, 'safety-review', () => {
1546
+ const digest = safetyDigest(workDir, runDate);
1547
+ for (const name of [
1548
+ 'safety-review_report.json',
1549
+ 'safety-judge_verdict.json',
1550
+ 'safety-judge_status.txt',
1551
+ 'safety-judge_stamp.json',
1552
+ ]) {
1553
+ const stale = safetyPath(workDir, runDate, name);
1554
+ if (isFile(stale)) unlinkSync(stale);
1555
+ }
1556
+ const request = safetyPath(workDir, runDate, 'safety-review_request.json');
1557
+ const targets = platforms
1558
+ .split(',')
1559
+ .map((platform) => platform.trim())
1560
+ .filter(Boolean);
1561
+ sted('write', request, () => writeJsonText(request, { digest, platforms: targets, mode: 'detect-only' }));
1562
+ });
1563
+ }
1564
+
1565
+ /**
1566
+ * Consume the content-safety report and the content-judge verdict into pass|escalate|block (exit 0
1567
+ * in all three — routing is transition-owned). Fail-closed: only an explicit PASS verdict on the
1568
+ * unchanged drafts, with a well-formed report carrying no BLOCK, unblocks; a RISK decision or
1569
+ * `escalate: true` forces the human review regardless of the verdict score.
1570
+ */
1571
+ function dailySafetyGate(args: string[]): void {
1572
+ const [workDir = '', runDate = ''] = args;
1573
+ const statusFile = safetyPath(workDir, runDate, 'safety-judge_status.txt');
1574
+ const stampFile = safetyPath(workDir, runDate, 'safety-judge_stamp.json');
1575
+ timed(workDir, 'safety-judge', () => {
1576
+ const request = readJson(safetyPath(workDir, runDate, 'safety-review_request.json'));
1577
+ const report = readJson(safetyPath(workDir, runDate, 'safety-review_report.json'));
1578
+ const verdict = readJson(safetyPath(workDir, runDate, 'safety-judge_verdict.json'));
1579
+ const platforms = Array.isArray(report?.platforms) ? (report.platforms as Record<string, unknown>[]) : [];
1580
+ const decisions = platforms.map((platform) => platform?.decision);
1581
+ const digest = safetyDigest(workDir, runDate);
1582
+ let status = 'block';
1583
+ let reason = 'verdict PASS on unchanged drafts, no BLOCK/RISK';
1584
+ if (request?.digest !== digest) reason = 'drafts changed after the review was requested';
1585
+ else if (decisions.length === 0 || decisions.some((d) => d !== 'OK' && d !== 'RISK' && d !== 'BLOCK'))
1586
+ reason = 'content-safety report missing or malformed';
1587
+ else if (decisions.includes('BLOCK')) reason = 'content-safety report carries a BLOCK decision';
1588
+ else if (verdict?.verdict !== 'PASS')
1589
+ reason = `content-judge verdict is ${typeof verdict?.verdict === 'string' ? verdict.verdict : 'missing or unreadable'}`;
1590
+ else if (decisions.includes('RISK') || report?.escalate === true) {
1591
+ status = 'escalate';
1592
+ reason = 'RISK decision or escalation — human review required';
1593
+ } else status = 'pass';
1594
+ if (status !== 'block') sted('write', stampFile, () => writeJsonText(stampFile, { digest, status }));
1595
+ sted('write', statusFile, () => writeRaw(statusFile, status));
1596
+ console.log(`safety: status=${status} (${reason}); routing is transition-owned`);
1597
+ });
1598
+ }
1599
+
1331
1600
  function dailyWrapDocs(args: string[]): void {
1332
1601
  const [workDir = '', runDate = '', voiceProfile = ''] = args;
1333
1602
  const scriptPath = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
@@ -1575,6 +1844,17 @@ function dailyPublishPrep(args: string[]): void {
1575
1844
  stampId3Tags(audioFile, typeof article.title === 'string' ? article.title : '', runDate, artist, album);
1576
1845
  const resolvedCover = resolve(coverPath);
1577
1846
  const hasCover = existsSync(resolvedCover);
1847
+ // Daily-show itunes convention: season = year, episode = day-of-year. run_date arrives
1848
+ // dashed (`$(date +%F)`) or digits-only (formatRunDate); map.ts forwards numbers only,
1849
+ // so a malformed run_date omits both tags instead of failing the publish.
1850
+ const digits = runDate.replaceAll(/\D/g, '');
1851
+ const season = Number(digits.slice(0, 4));
1852
+ const month = Number(digits.slice(4, 6));
1853
+ const day = Number(digits.slice(6, 8));
1854
+ const episode =
1855
+ digits.length === 8 && month >= 1 && month <= 12 && day >= 1 && day <= 31
1856
+ ? Math.round((Date.UTC(season, month - 1, day) - Date.UTC(season, 0, 1)) / 86_400_000) + 1
1857
+ : 0;
1578
1858
  // podcast-pub's documented contract reads metadata.coverImage (map.ts); options.coverPath
1579
1859
  // alone was silently dropped by the mapper — episodes published without a cover.
1580
1860
  const merged = {
@@ -1584,6 +1864,7 @@ function dailyPublishPrep(args: string[]): void {
1584
1864
  audioFile,
1585
1865
  runDate,
1586
1866
  ...(hasCover ? { coverImage: resolvedCover } : {}),
1867
+ ...(episode > 0 ? { itunes: { season, episode } } : {}),
1587
1868
  },
1588
1869
  options: {
1589
1870
  audioFile,
@@ -1591,6 +1872,13 @@ function dailyPublishPrep(args: string[]): void {
1591
1872
  coverPath: hasCover ? resolvedCover : undefined,
1592
1873
  },
1593
1874
  };
1875
+ // Task 0189 R10: the last program seam before anything is dispatched. No stamp, or a
1876
+ // stamp issued on different bytes (edited after PASS, or an older draft), never publishes.
1877
+ const stamp = readJson(safetyPath(workDir, runDate, 'safety-judge_stamp.json'));
1878
+ if (stamp === null) failWith(1, 'publish-prep: no content-safety verdict stamp for this run — not publishing');
1879
+ if (stamp.digest !== safetyDigest(workDir, runDate)) {
1880
+ failWith(1, 'publish-prep: drafts changed after the content-safety verdict — re-run the review');
1881
+ }
1594
1882
  sted('write', prepPath, () => writeJsonText(prepPath, merged));
1595
1883
  });
1596
1884
  }
@@ -1647,6 +1935,9 @@ function dailyShowNotes(args: string[]): void {
1647
1935
  PODCAST_PUB_RUN_DATE: runDate,
1648
1936
  PODCAST_PUB_BRIEF_MODE: 'true',
1649
1937
  PODCAST_PUB_ARTICLE_POST_PATH: postPath,
1938
+ // Task 0189 R1 / Apple §1.11: AI-voice disclosure in the episode metadata, not only
1939
+ // in the audio sign-off and the article footer.
1940
+ PODCAST_PUB_FOOTER_NOTE: '本期文字由 AI 辅助整理,音频为 AI 合成配音。',
1650
1941
  ...(isFile(audioContent) ? { PODCAST_PUB_AUDIO_CONTENT: audioContent } : {}),
1651
1942
  },
1652
1943
  });
@@ -1765,7 +2056,7 @@ function dailyPublishPartial(args: string[]): void {
1765
2056
  * Idempotent per post; commits + pushes the surfdash checkout (SURFDASH_AUTO_PUSH=0 keeps commit-only).
1766
2057
  */
1767
2058
  function dailyPatchSurfdash(args: string[]): void {
1768
- const [workDir = '', runDate = '', surfdashRoot = ''] = args;
2059
+ const [workDir = '', runDate = '', surfdashRoot = '', pluginsPath = ''] = args;
1769
2060
  if (!surfdashRoot || !existsSync(surfdashRoot)) {
1770
2061
  console.log(`patch-surfdash: surfdash root '${surfdashRoot}' not found — skip`);
1771
2062
  return;
@@ -1802,9 +2093,11 @@ function dailyPatchSurfdash(args: string[]): void {
1802
2093
  const localCover = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1803
2094
  const dateIso = `${runDate.slice(0, 4)}-${runDate.slice(4, 6)}-${runDate.slice(6, 8)}`;
1804
2095
  const crossLink: Record<string, string> = {
1805
- zh: `🎧 本期已同步制作为播客节目,[点击收听《每日 AI 资讯 · ${dateIso}》](${showNotesUrl})。`,
1806
- en: `🎧 This episode is also available as a podcast: [listen to the Daily AI Briefing · ${dateIso}](${showNotesUrl}).`,
1807
- ja: `🎧 本エピソードはポッドキャストでも配信中:[Daily AI Briefing · ${dateIso} を聴く](${showNotesUrl})。`,
2096
+ // Task 0189 R4: cross-links carry the column's episode-title pattern, not the retired
2097
+ // news-bulletin name (the VoiceScript episode title is episodeTitle(lead, date)).
2098
+ zh: `🎧 本期已同步制作为播客节目,[点击收听《AI 乱炖 · ${dateIso}》](${showNotesUrl})。`,
2099
+ en: `🎧 This episode is also available as a podcast: [listen to AI 乱炖 · ${dateIso}](${showNotesUrl}).`,
2100
+ ja: `🎧 本エピソードはポッドキャストでも配信中:[AI 乱炖 · ${dateIso} を聴く](${showNotesUrl})。`,
1808
2101
  };
1809
2102
  let changed = 0;
1810
2103
  for (const [locale, dir] of [
@@ -1866,6 +2159,39 @@ function dailyPatchSurfdash(args: string[]): void {
1866
2159
  changed++;
1867
2160
  }
1868
2161
  }
2162
+ // 20260929 prod feedback: CMS cards (homepage hero/tag/search) read EmDash rows whose
2163
+ // featured_image is snapshotted at translate-sync time — i.e. the pre-patch
2164
+ // `./assets/cover.png`, which the deployed worker does not serve (404). The patch only
2165
+ // rewrites files, so re-sync the EmDash rows from the patched files through the same
2166
+ // `kk executor` path as translate-sync (its plugin child gets EMDASH_API_TOKEN from kk
2167
+ // config; a direct orchestrator spawn gets 403 from prod dev-bypass). Runs before the
2168
+ // changed===0 early-return so re-runs heal rows an earlier commit left stale
2169
+ // (20260925/20260929). Fixture tests pass no plugins-path and must not shell out.
2170
+ if (pluginsPath === '') {
2171
+ console.log('patch-surfdash: no plugins-path — skip emdash re-sync');
2172
+ } else {
2173
+ const syncIn = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync.json`);
2174
+ const syncResult = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync-result.json`);
2175
+ sted('write', syncIn, () =>
2176
+ writeJqText(syncIn, { body: '', options: { operation: 'sync', postPath: join(slugDir, 'index.md') } }),
2177
+ );
2178
+ const status = kk(
2179
+ ['executor', 'run', 'surfdash-pub', '--in', syncIn, '--out', syncResult, ...pluginsArgs(pluginsPath)],
2180
+ { unset: NESTED_SPUR_UNSET, soft: true },
2181
+ );
2182
+ const result = isFile(syncResult) ? readJson(syncResult) : null;
2183
+ const ok =
2184
+ status === 0 &&
2185
+ result !== null &&
2186
+ (result.ok === true || (result.targets as Array<{ ok?: boolean }> | undefined)?.[0]?.ok === true);
2187
+ if (!ok) {
2188
+ console.log(
2189
+ `patch-surfdash: WARNING: emdash re-sync failed (exit ${status}) — CMS cards keep the stale cover; fix: kk executor run surfdash-pub --in ${syncIn} --out ${syncResult} --plugins-path ${pluginsPath}`,
2190
+ );
2191
+ } else {
2192
+ console.log('patch-surfdash: emdash rows re-synced from patched files');
2193
+ }
2194
+ }
1869
2195
  if (changed === 0) {
1870
2196
  console.log('patch-surfdash: nothing to change');
1871
2197
  return;
@@ -2035,7 +2361,8 @@ function dailyTranslateSync(args: string[]): void {
2035
2361
 
2036
2362
  const [stage, ...rest] = process.argv.slice(2);
2037
2363
 
2038
- // Imported as a module (unit tests reach `dedupPlanByTitle`) → exports only, never a stage run;
2364
+ // Imported as a module (unit tests reach `dedupPlanByTitle` / `applyTopicBoundary`) → exports
2365
+ // only, never a stage run;
2039
2366
  // executed as the entrypoint → dispatch.
2040
2367
  if (import.meta.main)
2041
2368
  switch (stage) {
@@ -2118,6 +2445,12 @@ if (import.meta.main)
2118
2445
  case 'daily-script':
2119
2446
  dailyScript(rest);
2120
2447
  break;
2448
+ case 'daily-safety-prep':
2449
+ dailySafetyPrep(rest);
2450
+ break;
2451
+ case 'daily-safety-gate':
2452
+ dailySafetyGate(rest);
2453
+ break;
2121
2454
  case 'daily-wrap-docs':
2122
2455
  dailyWrapDocs(rest);
2123
2456
  break;