@gobing-ai/knowledge-kit 0.0.28 → 0.0.30

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 (97) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +114 -38
  3. package/package.json +1 -1
  4. package/plugins/generations/daily-article-gen/dist/index.js +45 -5
  5. package/plugins/generations/daily-article-gen/src/index.ts +59 -4
  6. package/plugins/generations/dailynews-gen/dist/index.js +46 -9
  7. package/plugins/generations/dailynews-gen/src/index.ts +36 -1
  8. package/plugins/generations/dailynews-gen/src/script-builder.ts +15 -12
  9. package/plugins/generations/dailynews-gen/src/script-plan.ts +21 -3
  10. package/plugins/generations/episode-plan-gen/dist/index.js +55 -4
  11. package/plugins/generations/episode-plan-gen/src/index.ts +80 -2
  12. package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Bold.otf +0 -0
  13. package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Regular.otf +0 -0
  14. package/plugins/generations/image-gen/assets/fonts/OFL.txt +92 -0
  15. package/plugins/generations/image-gen/assets/fonts/SOURCE.md +15 -0
  16. package/plugins/generations/image-gen/dist/index.js +700 -22
  17. package/plugins/generations/image-gen/package.json +1 -0
  18. package/plugins/generations/image-gen/presets/formats/daily-news-cover.yaml +2 -2
  19. package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +19 -11
  20. package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +2 -1
  21. package/plugins/generations/image-gen/scripts/compose-e2e-artifact.ts +135 -0
  22. package/plugins/generations/image-gen/src/bytes.ts +116 -0
  23. package/plugins/generations/image-gen/src/compose/card.ts +294 -0
  24. package/plugins/generations/image-gen/src/compose/chromium.ts +147 -0
  25. package/plugins/generations/image-gen/src/compose/cmap.ts +177 -0
  26. package/plugins/generations/image-gen/src/index.ts +211 -13
  27. package/plugins/generations/image-gen/src/job.ts +12 -0
  28. package/plugins/generations/image-gen/src/presets.ts +53 -13
  29. package/plugins/generations/image-gen/src/providers/minimax.ts +12 -4
  30. package/plugins/generations/image-gen/src/providers/types.ts +12 -1
  31. package/plugins/kk/plugin.json +1 -1
  32. package/plugins/kk/scripts/daily-voice-style.zh.md +27 -14
  33. package/plugins/kk/scripts/itc-stages.ts +21 -3
  34. package/plugins/kk/scripts/kk-workflow-stages.ts +461 -10
  35. package/plugins/kk/scripts/topic-boundary.json +96 -0
  36. package/plugins/kk/scripts/validate-voicescript.ts +15 -0
  37. package/plugins/kk/skills/content-judge/SKILL.md +30 -0
  38. package/plugins/kk/skills/content-judge/references/workflow-integration.md +9 -2
  39. package/plugins/kk/skills/content-safety/SKILL.md +163 -0
  40. package/plugins/kk/skills/content-safety/references/platforms/apple-podcasts.md +36 -0
  41. package/plugins/kk/skills/content-safety/references/platforms/netease-music.md +31 -0
  42. package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +32 -0
  43. package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +35 -0
  44. package/plugins/kk/skills/publish/SKILL.md +1 -1
  45. package/plugins/kk/skills/xhs-images/SKILL.md +11 -7
  46. package/plugins/kk/skills/xhs-images/references/card-layouts.md +4 -4
  47. package/plugins/kk/workflows/judge-gated-publish-example.yaml +18 -10
  48. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +139 -24
  49. package/plugins/kk/workflows/kk-itc.yaml +21 -8
  50. package/plugins/publishings/podcast-pub/dist/index.js +4 -0
  51. package/plugins/publishings/podcast-pub/src/index.ts +2 -0
  52. package/plugins/publishings/podcast-pub/src/show-notes.ts +6 -0
  53. package/plugins/publishings/xhs-pub/dist/index.js +1 -1
  54. package/plugins/publishings/xhs-pub/src/automation-guard.ts +2 -2
  55. package/plugins/publishings/xhs-pub/src/index.ts +1 -1
  56. package/plugins/publishings/xhs-pub/src/profile-check.ts +2 -2
  57. package/plugins/sp/lib/env.ts +0 -46
  58. package/plugins/sp/lib/inline-run.generated.mjs +0 -1489
  59. package/plugins/sp/scripts/batch-preflight.mjs +0 -346
  60. package/plugins/sp/scripts/batch-preflight.ts +0 -459
  61. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +0 -615
  62. package/plugins/sp/scripts/daily-summary/daily-summary.ts +0 -846
  63. package/plugins/sp/scripts/daily-summary/logger.ts +0 -28
  64. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +0 -223
  65. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +0 -367
  66. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +0 -132
  67. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +0 -169
  68. package/plugins/sp/scripts/feature-dev-precheck.mjs +0 -171
  69. package/plugins/sp/scripts/feature-dev-precheck.ts +0 -238
  70. package/plugins/sp/scripts/feature-sync-bounded.mjs +0 -285
  71. package/plugins/sp/scripts/feature-sync-bounded.ts +0 -478
  72. package/plugins/sp/scripts/feature-verification-steps.ts +0 -275
  73. package/plugins/sp/scripts/history-anatomy-cache.mjs +0 -902
  74. package/plugins/sp/scripts/history-anatomy-cache.ts +0 -1028
  75. package/plugins/sp/scripts/idea-handoff.mjs +0 -22
  76. package/plugins/sp/scripts/idea-handoff.ts +0 -44
  77. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +0 -185
  78. package/plugins/sp/scripts/inline-run-setup.ts +0 -722
  79. package/plugins/sp/scripts/pr-reviewing.mjs +0 -769
  80. package/plugins/sp/scripts/pr-reviewing.ts +0 -925
  81. package/plugins/sp/scripts/quality-gate.mjs +0 -179
  82. package/plugins/sp/scripts/quality-gate.ts +0 -217
  83. package/plugins/sp/scripts/script-contract-check.ts +0 -319
  84. package/plugins/sp/scripts/stage-registry-adapter.ts +0 -1533
  85. package/plugins/sp/scripts/surface-drift-inventory.ts +0 -929
  86. package/plugins/sp/scripts/task-evidence-precheck.ts +0 -181
  87. package/plugins/sp/scripts/task-size-precheck.ts +0 -175
  88. package/plugins/sp/scripts/transition-shim-check.ts +0 -238
  89. package/plugins/sp/scripts/validate-commands.ts +0 -689
  90. package/plugins/sp/scripts/validate-flag-contracts.ts +0 -878
  91. package/plugins/sp/scripts/verify-answer-lint.ts +0 -530
  92. package/plugins/sp/scripts/workflow-step-profile.mjs +0 -316
  93. package/plugins/sp/scripts/workflow-step-profile.ts +0 -456
  94. package/plugins/sp/scripts/wrapup-drift-probe.mjs +0 -190
  95. package/plugins/sp/scripts/wrapup-drift-probe.ts +0 -258
  96. package/plugins/sp/scripts/wrapup-steps.mjs +0 -444
  97. package/plugins/sp/scripts/wrapup-steps.ts +0 -552
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "plugins": [
8
8
  {
9
- "version": "0.0.28",
9
+ "version": "0.0.30",
10
10
  "name": "kk",
11
11
  "source": "./plugins/kk",
12
12
  "description": "knowledge-kit — a set of toolkit for knowledge management"
package/dist/index.js CHANGED
@@ -26530,6 +26530,11 @@ function killSubtree(pid) {
26530
26530
  } catch {}
26531
26531
  }
26532
26532
  async function killProcessTree(pid) {
26533
+ if (process.platform !== "win32") {
26534
+ try {
26535
+ process.kill(-pid, "SIGKILL");
26536
+ } catch {}
26537
+ }
26533
26538
  const sweep = () => {
26534
26539
  for (const child of listChildren(pid)) {
26535
26540
  killSubtree(child);
@@ -26549,7 +26554,8 @@ async function spawnPluginEntryAsync(opts) {
26549
26554
  cwd: opts.cwd,
26550
26555
  env: pluginEntryEnv(opts),
26551
26556
  stdout: "pipe",
26552
- stderr: "pipe"
26557
+ stderr: "pipe",
26558
+ detached: process.platform !== "win32"
26553
26559
  });
26554
26560
  if (opts.timeoutMs !== undefined) {
26555
26561
  timer = setTimeout(() => {
@@ -27021,41 +27027,70 @@ function aggregate(entries) {
27021
27027
  }
27022
27028
  return { ok: summary.failed === 0, summary, targets: entries };
27023
27029
  }
27024
- async function fanout(content, targets, dispatch) {
27025
- const entries = [];
27026
- for (const target of targets) {
27027
- const started = Date.now();
27028
- let entry;
27029
- try {
27030
- const result = await dispatch(target);
27031
- const durationMs = Date.now() - started;
27032
- entry = result.ok ? {
27033
- target,
27034
- ok: true,
27035
- status: "published",
27036
- ...result.url !== undefined ? { url: result.url } : {},
27037
- ...result.id !== undefined ? { id: result.id } : {},
27038
- ...result.metadata !== undefined ? { metadata: result.metadata } : {},
27039
- durationMs
27040
- } : {
27041
- target,
27042
- ok: false,
27043
- status: "failed",
27044
- error: result.error ?? result.message ?? "publish failed",
27045
- durationMs
27046
- };
27047
- } catch (err) {
27048
- entry = {
27049
- target,
27050
- ok: false,
27051
- status: "failed",
27052
- error: err instanceof Error ? err.message : String(err),
27053
- durationMs: Date.now() - started
27054
- };
27055
- }
27056
- entries.push(entry);
27030
+ async function dispatchOne(target, dispatch, timeoutMs) {
27031
+ const started = Date.now();
27032
+ try {
27033
+ const result = timeoutMs === undefined ? await dispatch(target) : await withTimeout(dispatch(target), timeoutMs, target);
27034
+ const durationMs = Date.now() - started;
27035
+ return result.ok ? {
27036
+ target,
27037
+ ok: true,
27038
+ status: "published",
27039
+ ...result.url !== undefined ? { url: result.url } : {},
27040
+ ...result.id !== undefined ? { id: result.id } : {},
27041
+ ...result.metadata !== undefined ? { metadata: result.metadata } : {},
27042
+ durationMs
27043
+ } : {
27044
+ target,
27045
+ ok: false,
27046
+ status: "failed",
27047
+ error: result.error ?? result.message ?? "publish failed",
27048
+ durationMs
27049
+ };
27050
+ } catch (err) {
27051
+ return {
27052
+ target,
27053
+ ok: false,
27054
+ status: "failed",
27055
+ error: err instanceof Error ? err.message : String(err),
27056
+ durationMs: Date.now() - started
27057
+ };
27057
27058
  }
27058
- return aggregate(entries);
27059
+ }
27060
+ function withTimeout(promise2, timeoutMs, target) {
27061
+ let timer;
27062
+ const budget = new Promise((_resolve, reject) => {
27063
+ timer = setTimeout(() => reject(new Error(`target '${target}' exceeded its ${timeoutMs}ms budget`)), timeoutMs);
27064
+ });
27065
+ return Promise.race([promise2, budget]).finally(() => clearTimeout(timer));
27066
+ }
27067
+ async function fanout(content, targets, dispatch, options = {}) {
27068
+ const outcomes = new Map;
27069
+ const runOne = async (target) => {
27070
+ outcomes.set(target, await dispatchOne(target, dispatch, options.timeoutMs));
27071
+ };
27072
+ if (options.mode === "serial") {
27073
+ for (const target of targets)
27074
+ await runOne(target);
27075
+ } else {
27076
+ const serial = new Set(options.serialTargets ?? []);
27077
+ const parallel = targets.filter((target) => !serial.has(target));
27078
+ const solo = targets.filter((target) => serial.has(target));
27079
+ const jobs = options.jobs !== undefined && options.jobs > 0 ? options.jobs : parallel.length;
27080
+ let cursor2 = 0;
27081
+ const worker = async () => {
27082
+ while (cursor2 < parallel.length) {
27083
+ const target = parallel[cursor2++];
27084
+ if (target === undefined)
27085
+ return;
27086
+ await runOne(target);
27087
+ }
27088
+ };
27089
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(jobs, parallel.length)) }, worker));
27090
+ for (const target of solo)
27091
+ await runOne(target);
27092
+ }
27093
+ return aggregate(targets.map((target) => outcomes.get(target)).filter((entry) => entry !== undefined));
27059
27094
  }
27060
27095
  async function readContent(fs2, path3) {
27061
27096
  if (!await fs2.exists(path3)) {
@@ -27139,10 +27174,14 @@ async function readAggregateResult(fs2, path3) {
27139
27174
  function parseArgs2(argv) {
27140
27175
  const targets = [];
27141
27176
  const pluginsPath = [];
27177
+ const serialTargets = [];
27142
27178
  let contentPath;
27143
27179
  let outPath;
27144
27180
  let retryFromPath;
27145
27181
  let pluginsRoot;
27182
+ let serial = false;
27183
+ let jobs;
27184
+ let targetTimeoutMs;
27146
27185
  for (let i = 0;i < argv.length; i++) {
27147
27186
  const arg = argv[i];
27148
27187
  if (arg === "--content") {
@@ -27193,6 +27232,34 @@ function parseArgs2(argv) {
27193
27232
  throw new Error("--plugins-path requires a directory path");
27194
27233
  }
27195
27234
  pluginsPath.push(value);
27235
+ } else if (arg === "--serial") {
27236
+ serial = true;
27237
+ } else if (arg === "--jobs") {
27238
+ const value = argv[++i];
27239
+ if (value === undefined) {
27240
+ throw new Error("--jobs requires a positive integer");
27241
+ }
27242
+ const parsed = Number(value);
27243
+ if (!Number.isInteger(parsed) || parsed < 1) {
27244
+ throw new Error(`--jobs requires a positive integer, got '${value}'`);
27245
+ }
27246
+ jobs = parsed;
27247
+ } else if (arg === "--serial-target") {
27248
+ const value = argv[++i];
27249
+ if (value === undefined) {
27250
+ throw new Error("--serial-target requires a plugin name");
27251
+ }
27252
+ serialTargets.push(value);
27253
+ } else if (arg === "--target-timeout") {
27254
+ const value = argv[++i];
27255
+ if (value === undefined) {
27256
+ throw new Error("--target-timeout requires milliseconds");
27257
+ }
27258
+ const parsed = Number(value);
27259
+ if (!Number.isInteger(parsed) || parsed < 1) {
27260
+ throw new Error(`--target-timeout requires a positive integer in milliseconds, got '${value}'`);
27261
+ }
27262
+ targetTimeoutMs = parsed;
27196
27263
  } else {
27197
27264
  throw new Error(`Unknown argument: ${arg}`);
27198
27265
  }
@@ -27212,7 +27279,11 @@ function parseArgs2(argv) {
27212
27279
  outPath,
27213
27280
  ...retryFromPath !== undefined ? { retryFromPath } : {},
27214
27281
  ...pluginsRoot !== undefined ? { pluginsRoot } : {},
27215
- ...pluginsPath.length > 0 ? { pluginsPath } : {}
27282
+ ...pluginsPath.length > 0 ? { pluginsPath } : {},
27283
+ ...serial ? { serial } : {},
27284
+ ...jobs !== undefined ? { jobs } : {},
27285
+ ...serialTargets.length > 0 ? { serialTargets } : {},
27286
+ ...targetTimeoutMs !== undefined ? { targetTimeoutMs } : {}
27216
27287
  };
27217
27288
  }
27218
27289
  async function pluginDispatch(target, contentPath, discovery) {
@@ -27315,7 +27386,12 @@ async function runFanout(argv, deps = {}) {
27315
27386
  if (deps.dispatch === undefined && !validateTargets(targets, discovery)) {
27316
27387
  return EXIT_INPUT2;
27317
27388
  }
27318
- const fresh = await fanout(content, targets, dispatch);
27389
+ const fresh = await fanout(content, targets, dispatch, {
27390
+ ...opts.serial === true ? { mode: "serial" } : {},
27391
+ ...opts.jobs !== undefined ? { jobs: opts.jobs } : {},
27392
+ ...opts.serialTargets !== undefined ? { serialTargets: opts.serialTargets } : {},
27393
+ ...opts.targetTimeoutMs !== undefined ? { timeoutMs: opts.targetTimeoutMs } : {}
27394
+ });
27319
27395
  const result = prior !== undefined ? aggregate([...skipped, ...retainedFailed, ...fresh.targets]) : fresh;
27320
27396
  try {
27321
27397
  await fs2.writeFile(resolve4(opts.outPath), `${JSON.stringify(result, null, 2)}
@@ -27399,7 +27475,7 @@ function withFaninOptions(cmd) {
27399
27475
  return cmd.option("--in <path>", "Input Doc[] JSON file (repeatable)", collectRepeatable, []).option("--source <plugin>:<file>", "Ingestion source to spawn concurrently: plugin name and its input file (repeatable)", collectRepeatable, []).option("--source-timeout <ms>", "Per-source timeout in milliseconds (default 1800000)").option("--source-timeout-override <plugin>:<ms>", "Per-source timeout override for one named --source plugin, e.g. horizon-ingest:2700000 (repeatable; wins over --source-timeout/default)", collectRepeatable, []).requiredOption("--out <path>", "Output merged Doc[] JSON file path").option("--plugins-root <dir>", "Exclusive root containing plugins (isolation/tests only)").option("--plugins-path <dir>", "Extra plugin root to union with defaults (repeatable)", collectRepeatable, []);
27400
27476
  }
27401
27477
  function withFanoutOptions(cmd) {
27402
- return cmd.requiredOption("--content <path>", "Source ContentSchema JSON file path").option("--target <plugin>", "Target publish plugin name (repeatable)", collectRepeatable, []).requiredOption("--out <path>", "Aggregate Result JSON file path").option("--retry-from <path>", "Prior Result JSON file to retry only previously failed targets").option("--plugins-root <dir>", "Exclusive root containing plugins (isolation/tests only)").option("--plugins-path <dir>", "Extra plugin root to union with defaults (repeatable)", collectRepeatable, []);
27478
+ return cmd.requiredOption("--content <path>", "Source ContentSchema JSON file path").option("--target <plugin>", "Target publish plugin name (repeatable)", collectRepeatable, []).requiredOption("--out <path>", "Aggregate Result JSON file path").option("--retry-from <path>", "Prior Result JSON file to retry only previously failed targets").option("--serial", "Dispatch the parallel batch one target at a time (pre-2026-10-03 behaviour)").option("--jobs <n>", "Concurrent-start bound for the parallel batch (default: every target at once)").option("--serial-target <plugin>", "Target kept out of the parallel batch and run alone afterwards, e.g. a headed browser channel or a shared checkout (repeatable)", collectRepeatable, []).option("--target-timeout <ms>", "Per-target wall-clock budget; an overrun is recorded failed and siblings continue").option("--plugins-root <dir>", "Exclusive root containing plugins (isolation/tests only)").option("--plugins-path <dir>", "Extra plugin root to union with defaults (repeatable)", collectRepeatable, []);
27403
27479
  }
27404
27480
  function registerExecutorCommand(program2) {
27405
27481
  const executor = program2.command("executor").description("Run knowledge-kit plugin steps (invoke, fan-in, fan-out)");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/knowledge-kit",
3
- "version": "0.0.28",
3
+ "version": "0.0.30",
4
4
  "description": "An ingest → create → publish content pipeline CLI (Bun).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22035,13 +22035,17 @@ ${metaLine ? `${metaLine}
22035
22035
 
22036
22036
  ` : ""}${stripScaffold(doc2.body ?? "").trim()}`;
22037
22037
  }
22038
+ function episodeTitle(date6) {
22039
+ return `AI \u4E71\u7096 \xB7 ${date6}`;
22040
+ }
22038
22041
  function renderDailyArticle(docs, date6) {
22039
22042
  const items = docs.map(renderDoc).join(`
22040
22043
 
22041
22044
  `);
22042
- return `# \u6BCF\u65E5 AI \u8D44\u8BAF \u2014 ${date6}
22045
+ const intro = docs.length > 0 ? `\u300CAI \u4E71\u7096\u300D\u662F\u300C\u54B8\u8BDD\u54B8\u8BF4\u300D\u7684 AI \u6280\u672F\u5206\u4EAB\u680F\u76EE\uFF1A\u5DE5\u5177\u3001\u8BBA\u6587\u4E0E\u5B9E\u8DF5\u968F\u804A\u3002\u672C\u671F\u5171 ${docs.length} \u6761\u5185\u5BB9\uFF0C\u5747\u9644\u53EF\u70B9\u51FB\u6765\u6E90\u94FE\u63A5\u3002` : "\u300CAI \u4E71\u7096\u300D\u662F\u300C\u54B8\u8BDD\u54B8\u8BF4\u300D\u7684 AI \u6280\u672F\u5206\u4EAB\u680F\u76EE\uFF1A\u5DE5\u5177\u3001\u8BBA\u6587\u4E0E\u5B9E\u8DF5\u968F\u804A\u3002\u672C\u671F\u6CA1\u6709\u8DB3\u591F\u624E\u5B9E\u7684\u53EF\u804A\u5185\u5BB9\u3002";
22046
+ return `# ${episodeTitle(date6)}
22043
22047
 
22044
- \u672C\u671F\u5171 ${docs.length} \u6761\u65B0\u95FB\uFF0C\u5747\u9644\u53EF\u70B9\u51FB\u6765\u6E90\u94FE\u63A5\u3002
22048
+ ${intro}
22045
22049
 
22046
22050
  ${items}
22047
22051
  `;
@@ -22054,10 +22058,42 @@ function tagsFor(docs) {
22054
22058
  });
22055
22059
  return [AI_NEWS_TAG, ...existing.filter((t) => t !== AI_NEWS_TAG)];
22056
22060
  }
22061
+ var LEADING_H1_RE = /^#(?!#)\s*[^\n]+\n+/.source;
22062
+ var NOTE_EXCLUDED_RE = /\u672A\u5199\u5165[\uFF1A:](.*)/g;
22063
+ var HEX_ID_RE = /(?:[a-zA-Z0-9_-]+:)?[0-9a-f]{16}/g;
22064
+ function excludedIdsFromNote(body) {
22065
+ const ids = new Set;
22066
+ if (!body)
22067
+ return ids;
22068
+ for (const note of body.matchAll(NOTE_EXCLUDED_RE)) {
22069
+ for (const id of (note[1] ?? "").matchAll(HEX_ID_RE)) {
22070
+ ids.add(id[0]);
22071
+ const colon = id[0].indexOf(":");
22072
+ if (colon !== -1)
22073
+ ids.add(id[0].slice(colon + 1));
22074
+ }
22075
+ }
22076
+ return ids;
22077
+ }
22057
22078
  function docsToContent(docs, date6, bodyOverride) {
22079
+ const title = episodeTitle(date6);
22080
+ let body = renderDailyArticle(docs, date6);
22081
+ if (bodyOverride) {
22082
+ const stripped = stripScaffold(bodyOverride.trim()).trim().replace(new RegExp(LEADING_H1_RE), "");
22083
+ body = `# ${title}
22084
+
22085
+ ${stripped}
22086
+ `;
22087
+ }
22088
+ body = `${body.trimEnd()}
22089
+
22090
+ ---
22091
+
22092
+ *\u6587\u5B57\u7531 AI \u8F85\u52A9\u6574\u7406\uFF0C\u97F3\u9891\u4E3A AI \u5408\u6210\u914D\u97F3\u3002*
22093
+ `;
22058
22094
  return ContentSchema.parse({
22059
- title: `\u6BCF\u65E5 AI \u8D44\u8BAF \u2014 ${date6}`,
22060
- body: bodyOverride ? stripScaffold(bodyOverride).trim() : renderDailyArticle(docs, date6),
22095
+ title,
22096
+ body,
22061
22097
  format: "markdown",
22062
22098
  references: docs.filter((d) => d.sourceUri).map((d) => ({ url: d.sourceUri, title: d.title, cite: d.id })),
22063
22099
  metadata: { generator: "kk:daily-article", date: date6, doc_count: docs.length, tags: tagsFor(docs) }
@@ -22074,7 +22110,9 @@ async function processGeneratorIO(inputPath, outputPath) {
22074
22110
  if (bodyOverride.trim() === "")
22075
22111
  bodyOverride = undefined;
22076
22112
  }
22077
- const content = docsToContent(docs, date6, bodyOverride);
22113
+ const excluded = excludedIdsFromNote(bodyOverride);
22114
+ const kept = excluded.size === 0 ? docs : docs.filter((d) => !excluded.has(d.id) && !(d.id.includes(":") && excluded.has(d.id.slice(d.id.indexOf(":") + 1))));
22115
+ const content = docsToContent(kept, date6, bodyOverride);
22078
22116
  const fs = createNodeFileSystem();
22079
22117
  const outDir = dirname2(outputPath);
22080
22118
  if (outDir && outDir !== ".")
@@ -22110,5 +22148,7 @@ export {
22110
22148
  renderDailyArticle,
22111
22149
  processGeneratorIO,
22112
22150
  main2 as main,
22151
+ excludedIdsFromNote,
22152
+ episodeTitle,
22113
22153
  docsToContent
22114
22154
  };
@@ -102,10 +102,25 @@ function renderDoc(doc: Doc, index: number): string {
102
102
  return `## ${index + 1}. ${heading}\n\n${metaLine ? `${metaLine}\n\n` : ''}${stripScaffold(doc.body ?? '').trim()}`;
103
103
  }
104
104
 
105
+ /**
106
+ * Episode title for the 「AI 乱炖」 column: fixed `AI 乱炖 · YYYY-MM-DD` (2026-10-02 operator
107
+ * simplification, superseding the 0189 R1/R4 `AI 乱炖|<lead item> · date` pattern). The date
108
+ * alone identifies the episode; the day's stories live in the show notes, cards and post body.
109
+ * Pure function so the agent-written and template article paths, ID3, RSS and show notes all
110
+ * derive one identical title.
111
+ */
112
+ export function episodeTitle(date: string): string {
113
+ return `AI 乱炖 · ${date}`;
114
+ }
115
+
105
116
  /** Render the article markdown for a merged Doc[] (pure — exported for tests). */
106
117
  export function renderDailyArticle(docs: Doc[], date: string): string {
107
118
  const items = docs.map(renderDoc).join('\n\n');
108
- return `# 每日 AI 资讯 — ${date}\n\n本期共 ${docs.length} 条新闻,均附可点击来源链接。\n\n${items}\n`;
119
+ const intro =
120
+ docs.length > 0
121
+ ? `「AI 乱炖」是「咸话咸说」的 AI 技术分享栏目:工具、论文与实践随聊。本期共 ${docs.length} 条内容,均附可点击来源链接。`
122
+ : '「AI 乱炖」是「咸话咸说」的 AI 技术分享栏目:工具、论文与实践随聊。本期没有足够扎实的可聊内容。';
123
+ return `# ${episodeTitle(date)}\n\n${intro}\n\n${items}\n`;
109
124
  }
110
125
 
111
126
  /** Single-source daily tag: stamped on every Content this generator emits (task 0119). */
@@ -120,11 +135,43 @@ function tagsFor(docs: Doc[]): string[] {
120
135
  return [AI_NEWS_TAG, ...existing.filter((t) => t !== AI_NEWS_TAG)];
121
136
  }
122
137
 
138
+ /** A leading H1 line — agent bodies used to carry their own title; the canonical title is computed (task 0189 R4). */
139
+ const LEADING_H1_RE = /^#(?!#)\s*[^\n]+\n+/.source;
140
+
141
+ /** ids the article-write note marks as dropped on the topic boundary, e.g. 「…未写入:<id>(原因)、<id>(原因)」. */
142
+ const NOTE_EXCLUDED_RE = /未写入[::](.*)/g;
143
+ const HEX_ID_RE = /(?:[a-zA-Z0-9_-]+:)?[0-9a-f]{16}/g;
144
+
145
+ /** Parse the boundary-note ids out of an article body so references/metadata never cite excluded docs (judge finding 2026-10-02). */
146
+ export function excludedIdsFromNote(body: string | undefined): Set<string> {
147
+ const ids = new Set<string>();
148
+ if (!body) return ids;
149
+ for (const note of body.matchAll(NOTE_EXCLUDED_RE)) {
150
+ for (const id of (note[1] ?? '').matchAll(HEX_ID_RE)) {
151
+ ids.add(id[0]);
152
+ const colon = id[0].indexOf(':');
153
+ if (colon !== -1) ids.add(id[0].slice(colon + 1));
154
+ }
155
+ }
156
+ return ids;
157
+ }
158
+
123
159
  /** Build the article Content from a merged Doc[]; `bodyOverride` replaces the mechanical render (agent-written prose), still scaffold-stripped as a safety net. */
124
160
  export function docsToContent(docs: Doc[], date: string, bodyOverride?: string): Content {
161
+ const title = episodeTitle(date);
162
+ let body = renderDailyArticle(docs, date);
163
+ if (bodyOverride) {
164
+ // The canonical episode title is computed from the lead item; a leading H1 the agent may
165
+ // still have written is replaced (not duplicated) so body and metadata agree on every path.
166
+ const stripped = stripScaffold(bodyOverride.trim()).trim().replace(new RegExp(LEADING_H1_RE), '');
167
+ body = `# ${title}\n\n${stripped}\n`;
168
+ }
169
+ // Task 0189 R1: the AI-production note rides on every article path (mechanical and agent
170
+ // prose). Podcast show notes are rendered separately by podcast-pub and do not inherit it.
171
+ body = `${body.trimEnd()}\n\n---\n\n*文字由 AI 辅助整理,音频为 AI 合成配音。*\n`;
125
172
  return ContentSchema.parse({
126
- title: `每日 AI 资讯 — ${date}`,
127
- body: bodyOverride ? stripScaffold(bodyOverride).trim() : renderDailyArticle(docs, date),
173
+ title,
174
+ body,
128
175
  format: 'markdown',
129
176
  references: docs.filter((d) => d.sourceUri).map((d) => ({ url: d.sourceUri, title: d.title, cite: d.id })),
130
177
  metadata: { generator: 'kk:daily-article', date, doc_count: docs.length, tags: tagsFor(docs) },
@@ -143,7 +190,15 @@ export async function processGeneratorIO(inputPath: string, outputPath: string):
143
190
  bodyOverride = await Bun.file(bodyFile).text();
144
191
  if (bodyOverride.trim() === '') bodyOverride = undefined;
145
192
  }
146
- const content = docsToContent(docs, date, bodyOverride);
193
+ const excluded = excludedIdsFromNote(bodyOverride);
194
+ const kept =
195
+ excluded.size === 0
196
+ ? docs
197
+ : docs.filter(
198
+ (d) =>
199
+ !excluded.has(d.id) && !(d.id.includes(':') && excluded.has(d.id.slice(d.id.indexOf(':') + 1))),
200
+ );
201
+ const content = docsToContent(kept, date, bodyOverride);
147
202
  const fs = createNodeFileSystem();
148
203
  const outDir = dirname(outputPath);
149
204
  if (outDir && outDir !== '.') await fs.ensureDir(outDir);
@@ -22116,10 +22116,10 @@ function buildNewsVoiceScript(docs, options) {
22116
22116
  const broadcastDate = options?.date ?? new Date;
22117
22117
  const dateStr = formatBroadcastDate(broadcastDate, language);
22118
22118
  if (docs.length === 0) {
22119
- const title2 = options?.title || (isZh ? "\u4ECA\u65E5AI\u79D1\u6280\u6F2B\u8C08\uFF08\u6682\u65E0\u66F4\u65B0\uFF09" : "AI Daily Briefing (No Updates)");
22119
+ const title2 = options?.title || (isZh ? "AI \u4E71\u7096\uFF08\u6682\u65E0\u66F4\u65B0\uFF09" : "AI \u4E71\u7096 (No Updates)");
22120
22120
  const segments2 = isZh ? [
22121
22121
  {
22122
- text: `\u5927\u5BB6\u597D\uFF0C\u4ECA\u5929\u662F${dateStr}\u3002\u6B22\u8FCE\u6536\u542C\u7531${voiceProfile}\u4E3A\u60A8\u5E26\u6765\u7684\u4ECA\u65E5AI\u79D1\u6280\u6F2B\u8C08\u3002\u4ECA\u5929\u79D1\u6280\u5708\u6682\u65E0\u6700\u65B0\u7684\u7CBE\u9009\u52A8\u6001\u3002`,
22122
+ text: `\u5927\u5BB6\u597D\uFF0C\u4ECA\u5929\u662F${dateStr}\u3002\u6B22\u8FCE\u6536\u542C\u300C\u54B8\u8BDD\u54B8\u8BF4\u300D\u7684\u300CAI \u4E71\u7096\u300D\u680F\u76EE\uFF0C\u6211\u662F${voiceProfile}\u3002\u4ECA\u5929\u6CA1\u6709\u8DB3\u591F\u624E\u5B9E\u7684\u53EF\u804A\u5185\u5BB9\u3002`,
22123
22123
  gap_ms: 400,
22124
22124
  emotion: "calm"
22125
22125
  },
@@ -22130,7 +22130,7 @@ function buildNewsVoiceScript(docs, options) {
22130
22130
  }
22131
22131
  ] : [
22132
22132
  {
22133
- text: `Hello and welcome. Today is ${dateStr}. This is ${voiceProfile} with your AI Daily Briefing. There are no new featured stories today.`,
22133
+ text: `Hello and welcome. Today is ${dateStr}. This is ${voiceProfile} with AI \u4E71\u7096, our chat about AI tools, papers, and hands-on practice. There is nothing solid enough to share today.`,
22134
22134
  gap_ms: 400,
22135
22135
  emotion: "calm"
22136
22136
  },
@@ -22151,7 +22151,7 @@ function buildNewsVoiceScript(docs, options) {
22151
22151
  segments: segments2
22152
22152
  });
22153
22153
  }
22154
- const title = options?.title || (isZh ? `\u4ECA\u65E5AI\u79D1\u6280\u6F2B\u8C08\uFF08\u5171${docs.length}\u6761\uFF09` : `AI Daily Briefing (${docs.length} stories)`);
22154
+ const title = options?.title || (isZh ? `AI \u4E71\u7096\uFF08\u5171${docs.length}\u6761\uFF09` : `AI \u4E71\u7096 (${docs.length} stories)`);
22155
22155
  const segments = [];
22156
22156
  let hostName = options?.speakers?.host?.name || voiceProfile.replace(/[-_]?(news|voice|bot|profile)$/i, "") || voiceProfile;
22157
22157
  if (hostName.length > 0) {
@@ -22161,7 +22161,7 @@ function buildNewsVoiceScript(docs, options) {
22161
22161
  const planItems = new Map((scriptPlan?.items ?? []).map((item) => [item.id, item]));
22162
22162
  const countText = isZh ? `${formatChineseCount(docs.length)}\u6761` : `${docs.length}`;
22163
22163
  const titlesSummary = docs.slice(0, 3).map((d) => (d.title ?? "").trim()).filter(Boolean).join("\u3001");
22164
- const introText = isZh ? scriptPlan ? `\u5927\u5BB6\u597D\uFF0C\u4ECA\u5929\u662F${dateStr}\uFF0C\u6B22\u8FCE\u6536\u542C\u7531${hostName}\u4E3A\u60A8\u5E26\u6765\u7684\u4ECA\u65E5AI\u79D1\u6280\u6F2B\u8C08\u3002${scriptPlan.intro}` : `\u5927\u5BB6\u597D\uFF0C\u4ECA\u5929\u662F${dateStr}\uFF0C\u6B22\u8FCE\u6536\u542C\u7531${hostName}\u4E3A\u60A8\u5E26\u6765\u7684\u4ECA\u65E5AI\u79D1\u6280\u6F2B\u8C08\u3002\u4ECA\u5929\u79D1\u6280\u5708\u6709\u51E0\u6761\u975E\u5E38\u6709\u770B\u70B9\u7684\u5927\u6D88\u606F\uFF0C\u6211\u7CBE\u9009\u4E86${countText}\u6700\u503C\u5F97\u5173\u6CE8\u7684\u91CD\u70B9\u52A8\u6001\u6765\u548C\u5927\u5BB6\u804A\u4E00\u804A${titlesSummary ? `\uFF0C\u5305\u62EC${titlesSummary}` : ""}\u3002\u8BDD\u4E0D\u591A\u8BF4\uFF0C\u6211\u4EEC\u9A6C\u4E0A\u8FDB\u5165\u4ECA\u5929\u7684\u6B63\u9898\u3002` : `Hello and welcome. Today is ${dateStr}. This is ${hostName} with your AI Daily Briefing. In today's episode, we have ${countText} fascinating stories from the AI world. Let's dive straight in.`;
22164
+ const introText = isZh ? scriptPlan ? `\u5927\u5BB6\u597D\uFF0C\u4ECA\u5929\u662F${dateStr}\uFF0C\u6B22\u8FCE\u6536\u542C\u300C\u54B8\u8BDD\u54B8\u8BF4\u300D\u7684\u300CAI \u4E71\u7096\u300D\u680F\u76EE\uFF0C\u6211\u662F${hostName}\u3002${scriptPlan.intro}` : `\u5927\u5BB6\u597D\uFF0C\u4ECA\u5929\u662F${dateStr}\uFF0C\u6B22\u8FCE\u6536\u542C\u300C\u54B8\u8BDD\u54B8\u8BF4\u300D\u7684\u300CAI \u4E71\u7096\u300D\u680F\u76EE\uFF0C\u6211\u662F${hostName}\u3002\u4ECA\u5929\u6211\u4ECE AI \u5DE5\u5177\u3001\u8BBA\u6587\u548C\u5B9E\u8DF5\u4E2D\u6574\u7406\u4E86${countText}\u503C\u5F97\u804A\u7684\u5185\u5BB9${titlesSummary ? `\uFF0C\u5305\u62EC${titlesSummary}` : ""}\uFF0C\u54B1\u4EEC\u4E00\u6761\u6761\u6765\u804A\u3002` : `Hello and welcome. Today is ${dateStr}. This is ${hostName} with AI \u4E71\u7096, where we talk about AI tools, papers, and hands-on practice. In today's episode, we have ${countText} stories. Let's dive straight in.`;
22165
22165
  segments.push({
22166
22166
  text: normalizeBroadcastText(introText, isZh),
22167
22167
  gap_ms: 700,
@@ -22196,7 +22196,7 @@ function buildNewsVoiceScript(docs, options) {
22196
22196
  });
22197
22197
  }
22198
22198
  });
22199
- const outroText = isZh ? scriptPlan ? `${scriptPlan.outro}\u611F\u8C22\u60A8\u7684\u6536\u542C\uFF0C\u6211\u662F${hostName}\uFF0C\u795D\u60A8\u62E5\u6709\u5145\u5B9E\u9AD8\u6548\u7684\u4E00\u5929\uFF0C\u6211\u4EEC\u4E0B\u671F\u8282\u76EE\u4E0D\u89C1\u4E0D\u6563\uFF01` : `\u597D\u4E86\uFF0C\u4EE5\u4E0A\u5C31\u662F\u4ECA\u5929AI\u79D1\u6280\u6F2B\u8C08\u7684\u5168\u90E8\u5185\u5BB9\u3002\u4E0D\u77E5\u9053\u4ECA\u5929\u8FD9\u51E0\u6761\u6D88\u606F\u6709\u6CA1\u6709\u7ED9\u60A8\u5E26\u6765\u4E00\u4E9B\u65B0\u7684\u542F\u53D1\uFF1F\u611F\u8C22\u60A8\u7684\u6536\u542C\uFF0C\u6211\u662F${hostName}\uFF0C\u795D\u60A8\u62E5\u6709\u5145\u5B9E\u9AD8\u6548\u7684\u4E00\u5929\uFF0C\u6211\u4EEC\u4E0B\u671F\u8282\u76EE\u4E0D\u89C1\u4E0D\u6563\uFF01` : `That wraps up today's AI Daily Briefing. I hope today's highlights gave you some useful takeaways. Thank you for listening. This is ${hostName}, wishing you a productive and wonderful day. Until next time!`;
22199
+ const outroText = isZh ? scriptPlan ? `${scriptPlan.outro}\u672C\u671F\u97F3\u9891\u4E3A AI \u5408\u6210\u914D\u97F3\u3002\u611F\u8C22\u60A8\u7684\u6536\u542C\uFF0C\u6211\u662F${hostName}\uFF0C\u795D\u60A8\u62E5\u6709\u5145\u5B9E\u9AD8\u6548\u7684\u4E00\u5929\uFF0C\u6211\u4EEC\u4E0B\u671F\u8282\u76EE\u4E0D\u89C1\u4E0D\u6563\uFF01` : `\u597D\u4E86\uFF0C\u672C\u671FAI\u4E71\u7096\u5C31\u804A\u5230\u8FD9\u91CC\u3002\u5E0C\u671B\u4ECA\u5929\u8FD9\u4E9B\u5DE5\u5177\u3001\u8BBA\u6587\u548C\u5B9E\u8DF5\u7684\u7ECF\u9A8C\u5BF9\u60A8\u6709\u542F\u53D1\u3002\u672C\u671F\u97F3\u9891\u4E3A AI \u5408\u6210\u914D\u97F3\u3002\u611F\u8C22\u60A8\u7684\u6536\u542C\uFF0C\u6211\u662F${hostName}\uFF0C\u795D\u60A8\u62E5\u6709\u5145\u5B9E\u9AD8\u6548\u7684\u4E00\u5929\uFF0C\u6211\u4EEC\u4E0B\u671F\u8282\u76EE\u4E0D\u89C1\u4E0D\u6563\uFF01` : `That wraps up this episode of AI \u4E71\u7096. I hope today's takeaways were useful. The audio in this episode is AI-synthesized. Thank you for listening. This is ${hostName}, wishing you a productive and wonderful day. Until next time!`;
22200
22200
  segments.push({
22201
22201
  text: normalizeBroadcastText(outroText, isZh),
22202
22202
  gap_ms: 300,
@@ -22344,11 +22344,16 @@ var ScriptPlanItemSchema = exports_external.object({
22344
22344
  lead: exports_external.string().min(1),
22345
22345
  insight: exports_external.string().min(1)
22346
22346
  });
22347
+ var ScriptPlanExclusionSchema = exports_external.object({
22348
+ id: exports_external.string().min(1),
22349
+ reason: exports_external.string().min(1)
22350
+ });
22347
22351
  var ScriptPlanSchema = exports_external.object({
22348
22352
  version: exports_external.literal(1),
22349
22353
  intro: exports_external.string().min(1),
22350
22354
  outro: exports_external.string().min(1),
22351
- items: exports_external.array(ScriptPlanItemSchema)
22355
+ items: exports_external.array(ScriptPlanItemSchema),
22356
+ excluded: exports_external.array(ScriptPlanExclusionSchema).optional()
22352
22357
  });
22353
22358
  var StyleContractSchema = exports_external.object({
22354
22359
  caps: exports_external.object({
@@ -22386,8 +22391,15 @@ function validateScriptPlan(plan, docs, style) {
22386
22391
  errors5.push({ itemId: item.id, rule: "unknown-id" });
22387
22392
  }
22388
22393
  const plannedIds = new Set(valid.items.map((item) => item.id));
22394
+ const excludedIds = new Set((valid.excluded ?? []).map((ex) => ex.id));
22395
+ for (const ex of valid.excluded ?? []) {
22396
+ if (!docIds.has(ex.id))
22397
+ errors5.push({ itemId: ex.id, rule: "unknown-id" });
22398
+ if (plannedIds.has(ex.id))
22399
+ errors5.push({ itemId: ex.id, rule: "conflict-id" });
22400
+ }
22389
22401
  for (const doc2 of docs) {
22390
- if (!plannedIds.has(doc2.id))
22402
+ if (!plannedIds.has(doc2.id) && !excludedIds.has(doc2.id))
22391
22403
  errors5.push({ itemId: doc2.id, rule: "missing-item" });
22392
22404
  }
22393
22405
  if (valid.intro.length > style.caps.intro)
@@ -22456,6 +22468,24 @@ async function resolveScriptPlan(fs, docs) {
22456
22468
  }
22457
22469
  return { scriptPlan: validated.plan, scriptSource: "plan", scriptPlanErrors: [] };
22458
22470
  }
22471
+ function broadcastDateFromEnv(raw) {
22472
+ if (!raw)
22473
+ return;
22474
+ const match = raw.match(/^(\d{4})-?(\d{2})-?(\d{2})$/);
22475
+ if (match === null) {
22476
+ echoError(`dailynews-gen: ignoring malformed DAILYNEWS_DATE "${raw}" (expected YYYYMMDD or YYYY-MM-DD)`);
22477
+ return;
22478
+ }
22479
+ const year = Number(match[1]);
22480
+ const month = Number(match[2]) - 1;
22481
+ const day = Number(match[3]);
22482
+ const date6 = new Date(year, month, day);
22483
+ if (date6.getFullYear() !== year || date6.getMonth() !== month || date6.getDate() !== day) {
22484
+ echoError(`dailynews-gen: ignoring out-of-range DAILYNEWS_DATE "${raw}"`);
22485
+ return;
22486
+ }
22487
+ return date6;
22488
+ }
22459
22489
  async function processGeneratorIO(inputPath, outputPath, options) {
22460
22490
  const fs = createNodeFileSystem();
22461
22491
  let docs;
@@ -22475,7 +22505,13 @@ async function processGeneratorIO(inputPath, outputPath, options) {
22475
22505
  }
22476
22506
  const isZh = (options?.language || "zh").toLowerCase() === "zh";
22477
22507
  const planOutcome = isZh ? await resolveScriptPlan(fs, docs) : { scriptSource: "template", scriptPlanErrors: [] };
22478
- const script = buildNewsVoiceScript(docs, { ...options, scriptPlan: planOutcome.scriptPlan });
22508
+ const excludedIds = new Set(planOutcome.scriptPlan?.excluded?.map((ex) => ex.id) ?? []);
22509
+ if (excludedIds.size > 0) {
22510
+ echoError(`dailynews-gen: topic boundary excludes ${excludedIds.size} doc(s): ${[...excludedIds].join(", ")}`);
22511
+ docs = docs.filter((doc2) => !excludedIds.has(doc2.id));
22512
+ }
22513
+ const date6 = options?.date ?? broadcastDateFromEnv(process.env.DAILYNEWS_DATE);
22514
+ const script = buildNewsVoiceScript(docs, { ...options, date: date6, scriptPlan: planOutcome.scriptPlan });
22479
22515
  const yamlBody = formatVoiceScriptToYaml(script);
22480
22516
  const content = {
22481
22517
  title: script.title,
@@ -22556,6 +22592,7 @@ export {
22556
22592
  formatChineseCount,
22557
22593
  formatBroadcastDate,
22558
22594
  buildNewsVoiceScript,
22595
+ broadcastDateFromEnv,
22559
22596
  VoiceSpeakerSchema,
22560
22597
  VoiceScriptSegmentSchema,
22561
22598
  VoiceScriptSchema,
@@ -66,6 +66,32 @@ async function resolveScriptPlan(fs: FileSystem, docs: Doc[]): Promise<ScriptPla
66
66
  return { scriptPlan: validated.plan, scriptSource: 'plan', scriptPlanErrors: [] };
67
67
  }
68
68
 
69
+ /**
70
+ * `DAILYNEWS_DATE` (YYYYMMDD or `YYYY-MM-DD`) → a local-midnight Date. Task 0189: the broadcast
71
+ * greeting must carry the episode's run date, not the render date — a 9/29 episode rebuilt on 9/30
72
+ * must not greet "今天是9月30日". Parsed as local calendar parts, never UTC (`new Date('2026-09-29')`
73
+ * is UTC midnight and renders the previous day west of UTC). Absent → undefined; garbled or
74
+ * out-of-range (the Date constructor rolls over, e.g. month 13) → undefined with a warn so a
75
+ * caller bug surfaces instead of silently broadcasting the wrong day.
76
+ */
77
+ export function broadcastDateFromEnv(raw: string | undefined): Date | undefined {
78
+ if (!raw) return undefined;
79
+ const match = raw.match(/^(\d{4})-?(\d{2})-?(\d{2})$/);
80
+ if (match === null) {
81
+ echoError(`dailynews-gen: ignoring malformed DAILYNEWS_DATE "${raw}" (expected YYYYMMDD or YYYY-MM-DD)`);
82
+ return undefined;
83
+ }
84
+ const year = Number(match[1]);
85
+ const month = Number(match[2]) - 1;
86
+ const day = Number(match[3]);
87
+ const date = new Date(year, month, day);
88
+ if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) {
89
+ echoError(`dailynews-gen: ignoring out-of-range DAILYNEWS_DATE "${raw}"`);
90
+ return undefined;
91
+ }
92
+ return date;
93
+ }
94
+
69
95
  export async function processGeneratorIO(
70
96
  inputPath: string,
71
97
  outputPath: string,
@@ -101,7 +127,16 @@ export async function processGeneratorIO(
101
127
  const planOutcome = isZh
102
128
  ? await resolveScriptPlan(fs, docs)
103
129
  : { scriptSource: 'template' as const, scriptPlanErrors: [] as ScriptPlanError[] };
104
- const script = buildNewsVoiceScript(docs, { ...options, scriptPlan: planOutcome.scriptPlan });
130
+ // Topic boundary (task 0189): docs the plan declares excluded are not spoken, not counted,
131
+ // and not referenced — title, storyCount, and references must match what is actually said.
132
+ const excludedIds = new Set(planOutcome.scriptPlan?.excluded?.map((ex) => ex.id) ?? []);
133
+ if (excludedIds.size > 0) {
134
+ echoError(`dailynews-gen: topic boundary excludes ${excludedIds.size} doc(s): ${[...excludedIds].join(', ')}`);
135
+ docs = docs.filter((doc) => !excludedIds.has(doc.id));
136
+ }
137
+ // Explicit option wins; else DAILYNEWS_DATE (workflow backfill/retry); else the builder's now.
138
+ const date = options?.date ?? broadcastDateFromEnv(process.env.DAILYNEWS_DATE);
139
+ const script = buildNewsVoiceScript(docs, { ...options, date, scriptPlan: planOutcome.scriptPlan });
105
140
  const yamlBody = formatVoiceScriptToYaml(script);
106
141
 
107
142
  const content: Content = {