@gobing-ai/knowledge-kit 0.1.7 → 0.1.9

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 (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/board/collect/index.js +744 -1107
  3. package/dist/board/daily-voice/index.js +741 -107
  4. package/dist/board/itc/index.js +32 -6
  5. package/dist/index.js +1793 -103
  6. package/package.json +1 -1
  7. package/plugins/ingestions/horizon-ingest/dist/index.js +24 -1
  8. package/plugins/ingestions/horizon-ingest/src/index.ts +34 -0
  9. package/plugins/kk/config.example.yaml +8 -0
  10. package/plugins/kk/decisions/extract/ai-news.yaml +7 -1
  11. package/plugins/kk/plugin.json +1 -1
  12. package/plugins/kk/publish-presets/daily-ai-voice.json +123 -0
  13. package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +20 -0
  14. package/plugins/kk/schedules/kk-daily-publish3-overseas.cron.tmpl +17 -0
  15. package/plugins/kk/scripts/daily-stages.ts +664 -83
  16. package/plugins/kk/scripts/kk-workflow-stages.ts +11 -1
  17. package/plugins/kk/scripts/topic-boundary.json +18 -83
  18. package/plugins/kk/workflows/kk-collect.yaml +2 -0
  19. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +82 -8
  20. package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +842 -0
  21. package/plugins/kk/workflows/kk-daily-publish3.yaml +282 -0
  22. package/plugins/publishings/podcast-pub/README.md +65 -0
  23. package/plugins/publishings/podcast-pub/dist/index.js +1709 -274
  24. package/plugins/publishings/podcast-pub/plugin.json +1 -1
  25. package/plugins/publishings/podcast-pub/src/backfill.ts +465 -0
  26. package/plugins/publishings/podcast-pub/src/index.ts +211 -34
  27. package/plugins/publishings/podcast-pub/src/microfeed-client.ts +32 -3
  28. package/plugins/publishings/podcast-pub/src/seo.ts +1081 -0
  29. package/plugins/publishings/podcast-pub/src/show-notes.ts +71 -6
  30. package/plugins/publishings/surfdash-pub/dist/index.js +66 -5
  31. package/plugins/publishings/surfdash-pub/src/index.ts +94 -10
  32. package/plugins/publishings/wechatmp-pub/dist/index.js +54 -30
  33. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +8 -3
  34. package/plugins/publishings/wechatmp-pub/src/enrich.ts +58 -38
  35. package/plugins/publishings/wechatmp-pub/src/flow.ts +18 -1
  36. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +7 -1
@@ -162,7 +162,6 @@ export function dailyQcContent(args: string[]): void {
162
162
  },
163
163
  },
164
164
  );
165
- applyFloorGate(candidates, workDir, runDate, minMinutes, vdriver);
166
165
  });
167
166
  // Topic boundary (task 0189 R2) runs on every invocation — re-applying to already-filtered
168
167
  // candidates is a no-op, so cached reruns stay idempotent while a crash between the candidates
@@ -197,15 +196,21 @@ export function dailyQcContent(args: string[]): void {
197
196
  // `unknown-id`, the whole plan is rejected, and generate then sees a template with fewer sections.
198
197
  const currentDocs = excluded.length > 0 ? kept : boundaryDocs;
199
198
  const { kept: deduped, dropped } = dedupPlanByTitle(currentDocs as Array<{ id?: unknown; title?: unknown }>);
200
- if (dropped.length === 0) return;
201
- const dedupedContent = requireJson(candidates, 'qc-dedup');
202
- const dedupedMeta = dedupedContent.metadata;
203
- if (dedupedMeta === null || typeof dedupedMeta !== 'object') {
204
- failWith(1, 'qc-dedup: candidates content carries no metadata object');
199
+ if (dropped.length > 0) {
200
+ const dedupedContent = requireJson(candidates, 'qc-dedup');
201
+ const dedupedMeta = dedupedContent.metadata;
202
+ if (dedupedMeta === null || typeof dedupedMeta !== 'object') {
203
+ failWith(1, 'qc-dedup: candidates content carries no metadata object');
204
+ }
205
+ (dedupedMeta as Record<string, unknown>).docs = deduped;
206
+ sted('write', candidates, () => writeJsonText(candidates, dedupedContent));
207
+ console.error(`qc: dropped ${dropped.length} near-duplicate candidate(s): ${dropped.join(', ')}`);
205
208
  }
206
- (dedupedMeta as Record<string, unknown>).docs = deduped;
207
- sted('write', candidates, () => writeJsonText(candidates, dedupedContent));
208
- console.error(`qc: dropped ${dropped.length} near-duplicate candidate(s): ${dropped.join(', ')}`);
209
+ // Last, not inside the timed block above: the estimate must see the pool every shrink pass has
210
+ // already produced. 2026-10-22 cleared the floor on a 4-item pool (21.0 min estimated) that the
211
+ // topic boundary then cut to 3 (14.4 min), so the run paid for a full synthesis the measured
212
+ // gate refused. Covered here it also re-runs on a cached-rerun, which the timed block skipped.
213
+ applyFloorGate(candidates, workDir, runDate, minMinutes, vdriver);
209
214
  }
210
215
 
211
216
  /**
@@ -287,7 +292,7 @@ function applyFloorGate(
287
292
  }
288
293
 
289
294
  /** Widest window the extract ladder may reach; a "daily" show must not import a week-old story. */
290
- const LADDER_MAX_MS = 72 * 3_600_000;
295
+ const LADDER_MAX_MS = 168 * 3_600_000;
291
296
 
292
297
  const WINDOW_UNIT_MS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
293
298
 
@@ -307,13 +312,17 @@ function formatWindowMs(ms: number): string {
307
312
 
308
313
  /**
309
314
  * Pool extraction with a conditional window ladder (2026-10-19 operator decision, superseding the
310
- * 0266 D1 blanket rejection). `kk store extract` reads one first_seen window and `applyFloorGate`
315
+ * 0266 D1 blanket rejection; extended ×7/168h 2026-10-23 when upstream supply collapsed and the
316
+ * 72h ceiling again closed a day as no-content while a week of older unselected items sat in the
317
+ * store). `kk store extract` reads one first_seen window and `applyFloorGate`
311
318
  * then refuses a pool that cannot reach `episode_min_minutes`. Together they ate whole days: an
312
319
  * episode consumes 6-12 stories while a day of collection adds ~3-13 qualifying ones, so the 04:30
313
320
  * runs of 2026-10-18 and 2026-10-19 saw 2- and 3-item pools and closed as no-content while older
314
321
  * unselected items sat in the store. The ladder keeps the configured window as the first choice and
315
- * widens (×2, ×3, capped at 72h) ONLY when that pool's estimate misses the floor — a rich day is
316
- * extracted exactly once, and a thin one is filled from the backlog instead of dropped.
322
+ * widens (×2, ×3, ×7, capped at 168h) ONLY when that pool's estimate misses the floor — a rich day is
323
+ * extracted exactly once, and a thin one is filled from the backlog instead of dropped. The store's
324
+ * QC gates still read the aged items' decayed urgency, so a widened pool does not force stale stories
325
+ * into an episode by itself.
317
326
  *
318
327
  * The store's `selections` ledger still excludes every story a previous run took, so a widened
319
328
  * window can only add items no episode has used. The estimate here runs on the raw pool (before QC
@@ -329,7 +338,7 @@ export function dailyExtract(args: string[]): void {
329
338
  const floor = Number(minMinutes);
330
339
  const gated = minMinutes !== '' && Number.isFinite(floor) && floor > 0;
331
340
  const windows: number[] = [base];
332
- for (const factor of gated ? [2, 3] : []) {
341
+ for (const factor of gated ? [2, 3, 7] : []) {
333
342
  const ms = base * factor;
334
343
  if (ms > LADDER_MAX_MS) break;
335
344
  windows.push(ms);
@@ -574,6 +583,52 @@ export function dailyVoiceAudit(args: string[]): void {
574
583
  }
575
584
  }
576
585
 
586
+ /**
587
+ * Empty the QC candidates file so every downstream `pool-count --min 1` guard skips the content,
588
+ * audio, publish and XHS subtrees — the one mechanism both refusal paths use to close a run as
589
+ * no-content without aborting it (0266).
590
+ */
591
+ function emptyCandidates(workDir: string, runDate: string): void {
592
+ const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
593
+ const content = readJson(candidates);
594
+ if (content === null) return;
595
+ sted('write', candidates, () =>
596
+ writeJsonText(candidates, { ...content, metadata: { ...metadataOf(content), docs: [] } }),
597
+ );
598
+ }
599
+
600
+ /**
601
+ * The pre-spend refusal of `daily-generate`'s script gate: same artifact and the same candidate
602
+ * emptying as the measured gate, plus `stage: "script"` so the run report can name which gate
603
+ * closed the day. Nothing was synthesized, so there is no measured duration to record.
604
+ */
605
+ function refuseScriptBelowFloor(
606
+ workDir: string,
607
+ runDate: string,
608
+ gate: { chars: number; charRate: number; seconds: number; minMinutes: number },
609
+ ): void {
610
+ const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
611
+ const candidateDocs = metadataOf(readJson(candidates)).docs;
612
+ const keptCount = Array.isArray(candidateDocs) ? candidateDocs.length : 0;
613
+ const refusal = join(workDir, '30-audio', `${runDate}_11_generate_content_duration-refusal.json`);
614
+ const shown = (gate.seconds / 60).toFixed(1);
615
+ sted('write', refusal, () =>
616
+ writeJqText(refusal, {
617
+ stage: 'script',
618
+ estimatedMinutes: Math.round((gate.seconds / 60) * 10) / 10,
619
+ minMinutes: gate.minMinutes,
620
+ keptCount,
621
+ scriptChars: gate.chars,
622
+ charRate: gate.charRate,
623
+ reason: `the authored script estimates ${shown} min of speech, below the ${gate.minMinutes} min floor — refusing before the TTS spend`,
624
+ }),
625
+ );
626
+ emptyCandidates(workDir, runDate);
627
+ console.error(
628
+ `generate: script estimates ${shown} min (${gate.chars} chars at ${gate.charRate}/s), below the ${gate.minMinutes} min floor — candidates emptied, the run closes as no-content before any TTS spend (see ${refusal})`,
629
+ );
630
+ }
631
+
577
632
  export function dailyEpisodeDuration(args: string[]): void {
578
633
  const [workDir = '', runDate = '', minArg = '', maxArg = '', vdriver = ''] = args;
579
634
  if (!workDir) fail('missing work_dir');
@@ -609,11 +664,7 @@ export function dailyEpisodeDuration(args: string[]): void {
609
664
  // A kill between the two writes below leaves the refusal beside non-empty candidates, which would
610
665
  // let the pool-gated publish fan package a sub-floor episode — so finish the emptying here.
611
666
  if (isFile(refusal)) {
612
- if (content !== null && docs.length > 0) {
613
- sted('write', candidates, () =>
614
- writeJsonText(candidates, { ...content, metadata: { ...metadataOf(content), docs: [] } }),
615
- );
616
- }
667
+ if (docs.length > 0) emptyCandidates(workDir, runDate);
617
668
  console.log(`episode-duration: duration refusal already recorded (${refusal}) — nothing to do`);
618
669
  return;
619
670
  }
@@ -629,11 +680,7 @@ export function dailyEpisodeDuration(args: string[]): void {
629
680
  reason: `the episode ran ${shown} min of speech, below the ${minMinutes} min floor (pre-spend estimate ${estimated.minutes} min) — refusing after synthesis`,
630
681
  }),
631
682
  );
632
- if (content !== null) {
633
- sted('write', candidates, () =>
634
- writeJsonText(candidates, { ...content, metadata: { ...metadataOf(content), docs: [] } }),
635
- );
636
- }
683
+ emptyCandidates(workDir, runDate);
637
684
  console.error(
638
685
  `episode-duration: episode ran ${shown} min (estimate ${estimated.minutes} min), below the ${minMinutes}–${maxMinutes} min requirement — candidates emptied, the run closes as no-content (see ${refusal})`,
639
686
  );
@@ -645,6 +692,17 @@ export function dailyEpisodeDuration(args: string[]): void {
645
692
  `episode-duration: episode runs ${shown} min, above the ${minMinutes}–${maxMinutes} min requirement. Trim items or per-item treatment.`,
646
693
  );
647
694
  }
695
+ // A passing gate leaves this artifact behind only: the 2026-10-20 run published an 8.4-min
696
+ // episode with nothing on disk to say whether the gate ran, passed, or was stale (2026-10-22).
697
+ const verdict = join(workDir, '30-audio', `${runDate}_11_generate_content_duration.json`);
698
+ sted('write', verdict, () =>
699
+ writeJqText(verdict, {
700
+ measuredMinutes: Math.round(minutes * 10) / 10,
701
+ minMinutes,
702
+ maxMinutes,
703
+ ok: true,
704
+ }),
705
+ );
648
706
  console.log(`episode-duration ok: ${shown} min within ${minMinutes}–${maxMinutes} min`);
649
707
  }
650
708
 
@@ -844,6 +902,7 @@ export function dailyGenerate(args: string[]): void {
844
902
  vdriver = '',
845
903
  sectionConcurrency = '',
846
904
  episodeMaxMinutes = '',
905
+ episodeMinMinutes = '',
847
906
  ] = args;
848
907
  // 20260917 review: the workflow picks a voice driver by short name; both drivers' envs are
849
908
  // passed unconditionally so switching `vdriver` needs no shell changes. Unknown -> fail loud.
@@ -868,6 +927,37 @@ export function dailyGenerate(args: string[]): void {
868
927
  const docs = join(workDir, '30-audio', `${runDate}_10_wrap-docs_docs.json`);
869
928
  const content = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
870
929
 
930
+ // Pre-spend script floor gate (2026-10-22). QC's candidate-text estimate does not predict the
931
+ // spoken length — 20261020 planned 3968 chars and rendered 8.4 min, 20261021 planned 4548 and
932
+ // rendered 17.9 — but the authored VoiceScript IS the spoken text, and 14 measured episodes
933
+ // deliver it at 5.24-5.94 chars/s. Reusing `spokenCharRate` (the slowest measured band) makes the
934
+ // estimate over-predict slightly, which is the safe direction here: a thin day is refused before
935
+ // the 1-2 h synthesis, and one that would have cleared the floor is never refused.
936
+ const minMinutes = episodeMinMinutes === '' ? 0 : Number(episodeMinMinutes);
937
+ if (!Number.isFinite(minMinutes) || minMinutes < 0) {
938
+ failWith(1, `generate: invalid episode_min_minutes: ${episodeMinMinutes}`);
939
+ }
940
+ if (minMinutes > 0 && !wholeDocCache(content, voiceGenerator, transcodeMp3)) {
941
+ const scriptPath = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
942
+ if (isFile(scriptPath)) {
943
+ const script = Bun.YAML.parse(readFileSync(scriptPath, 'utf8')) as {
944
+ segments?: Array<{ text?: unknown }>;
945
+ };
946
+ const segments = Array.isArray(script.segments) ? script.segments : [];
947
+ const chars = segments.reduce(
948
+ (sum, segment) => sum + (typeof segment.text === 'string' ? segment.text.length : 0),
949
+ 0,
950
+ );
951
+ const charRate = spokenCharRate(vdriver);
952
+ const seconds = 8 + chars / charRate;
953
+ if (seconds < minMinutes * 60) {
954
+ refuseScriptBelowFloor(workDir, runDate, { chars, charRate, seconds, minMinutes });
955
+ return;
956
+ }
957
+ console.log(`script floor ok: ${chars} script chars ≈ ${(seconds / 60).toFixed(1)} min`);
958
+ }
959
+ }
960
+
871
961
  if (!sectionConcurrency) {
872
962
  timed(workDir, 'generate', () => {
873
963
  if (wholeDocCache(content, voiceGenerator, transcodeMp3)) {
@@ -938,11 +1028,14 @@ export function dailyGenerate(args: string[]): void {
938
1028
  if (existsSync(planPath)) {
939
1029
  try {
940
1030
  const plan = readJson(planPath) as { items?: unknown[] };
941
- if (Array.isArray(plan.items) && plan.items.length !== itemSections.length) {
942
- failWith(
943
- 1,
944
- `generate: plan item count (${plan.items.length}) does not match script item sections (${itemSections.length})`,
945
- );
1031
+ if (Array.isArray(plan.items)) {
1032
+ const expected = expectedItemSections(workDir, runDate, plan.items);
1033
+ if (expected !== itemSections.length) {
1034
+ failWith(
1035
+ 1,
1036
+ `generate: plan item count (${plan.items.length}, ${plan.items.length - expected} recorded as excluded) does not match script item sections (${itemSections.length})`,
1037
+ );
1038
+ }
946
1039
  }
947
1040
  } catch (e: unknown) {
948
1041
  if (e instanceof StageFailure) throw e;
@@ -1435,9 +1528,424 @@ export function dailyPlan(args: string[]): void {
1435
1528
  });
1436
1529
  }
1437
1530
 
1531
+ /**
1532
+ * The two public URLs every downstream channel links to (task 0327 R1).
1533
+ *
1534
+ * voice2 passes the real urls its tier-0 stages just produced; the variant stage passes
1535
+ * `{{article_url}}` / `{{podcast_url}}` so a generate-side bundle can be frozen before anything is
1536
+ * published, and `publish-matrix-send` resolves them from the tier-0 receipts at send time.
1537
+ */
1538
+ export interface PublishLinks {
1539
+ articleUrl: string;
1540
+ podcastUrl: string;
1541
+ }
1542
+
1543
+ /** Placeholder links: the variant contract publish-matrix-send resolves. */
1544
+ export const PLACEHOLDER_LINKS: PublishLinks = {
1545
+ articleUrl: '{{article_url}}',
1546
+ podcastUrl: '{{podcast_url}}',
1547
+ };
1548
+
1549
+ /** The wechatmp-only closing line (operator request 2026-09-29). */
1550
+ const WECHAT_OUTRO = '\n\n欲听往期节目,可至小宇宙、Apple Podcast、YouTube 等平台搜索【咸话咸说】。';
1551
+
1552
+ /** The brand strings this daily show pins on every 公众号 draft. */
1553
+ const WECHAT_BRAND = {
1554
+ author: '冰原奔狼',
1555
+ claimSource: '个人观点,仅供参考',
1556
+ coverSource: 'library',
1557
+ } as const;
1558
+
1559
+ /**
1560
+ * The 公众号 article payload (task 0327 R1): the article Content plus MP draft enrichment.
1561
+ *
1562
+ * Every field is consumed by `wechatmp-pub`'s flow; the only link it needs is the 原文链接, which is
1563
+ * the surfdash post — hence `links.articleUrl` rather than a path read.
1564
+ */
1565
+ export function buildWechatArticleContent(
1566
+ article: Record<string, unknown>,
1567
+ options: {
1568
+ links: PublishLinks;
1569
+ publishStatus: 'draft' | 'public';
1570
+ collection: string;
1571
+ coverPath?: string;
1572
+ audioPath?: string;
1573
+ audioTitle?: string;
1574
+ /**
1575
+ * The 咸话咸说 closer is an article-only sign-post (operator request 2026-09-29: "appended
1576
+ * to the wechat input body only, never the master article"). An audio-first 公众号 播客 post
1577
+ * carries the episode body, so it passes `false`.
1578
+ */
1579
+ appendOutro?: boolean;
1580
+ },
1581
+ ): Record<string, unknown> {
1582
+ const wechatmp: Record<string, unknown> = {
1583
+ original: true,
1584
+ // 赞赏 is enabled per episode; the plugin fills the 赞赏账户 only when the platform leaves it
1585
+ // empty (it defaults from 作者, operator walk 2026-10-10).
1586
+ reward: true,
1587
+ author: WECHAT_BRAND.author,
1588
+ collection: options.collection,
1589
+ // 创作来源 is the column's own stance, not an AI-declaration label (2026-10-10).
1590
+ claimSource: WECHAT_BRAND.claimSource,
1591
+ // 封面 comes from 图片库: the episode cover is uploaded there as the audio material's cover.
1592
+ coverSource: WECHAT_BRAND.coverSource,
1593
+ ...(options.links.articleUrl !== '' ? { originalUrl: options.links.articleUrl } : {}),
1594
+ ...(options.coverPath === undefined ? {} : { coverPath: options.coverPath }),
1595
+ ...(options.audioPath === undefined
1596
+ ? {}
1597
+ : {
1598
+ audioPath: options.audioPath,
1599
+ audioTitle: options.audioTitle ?? '',
1600
+ audioCoverPath: options.coverPath ?? '',
1601
+ }),
1602
+ };
1603
+ const body = typeof article.body === 'string' ? article.body : '';
1604
+ return {
1605
+ ...article,
1606
+ body: options.appendOutro === false ? body : `${body}${WECHAT_OUTRO}`,
1607
+ metadata: { ...metadataOf(article), publishStatus: options.publishStatus, wechatmp },
1608
+ };
1609
+ }
1610
+
1611
+ /**
1612
+ * The 小红书 发播客 payload (task 0327 R1, extracted from `xhsPodcastNote`).
1613
+ *
1614
+ * The run's publish-prep Content carries the final mp3, the cover and the body; this only restates
1615
+ * `metadata.audio` under the key the channel's 发播客 mode reads.
1616
+ */
1617
+ export function buildXhsEpisodeContent(
1618
+ prep: Record<string, unknown>,
1619
+ options: { publishStatus: 'draft' | 'public'; links?: PublishLinks },
1620
+ ): Record<string, unknown> {
1621
+ const metadata = metadataOf(prep);
1622
+ return {
1623
+ ...prep,
1624
+ metadata: {
1625
+ ...metadata,
1626
+ operation: '发播客',
1627
+ audio: metadata.audioFile,
1628
+ publishStatus: options.publishStatus,
1629
+ },
1630
+ };
1631
+ }
1632
+
1633
+ /** The 小红书 写长文 payload (task 0327 R1): the article Content with its publish status. */
1634
+ export function buildXhsArticleContent(
1635
+ article: Record<string, unknown>,
1636
+ options: { publishStatus: 'draft' | 'public' },
1637
+ ): Record<string, unknown> {
1638
+ return {
1639
+ ...article,
1640
+ metadata: { ...metadataOf(article), publishStatus: options.publishStatus },
1641
+ };
1642
+ }
1643
+
1644
+ /** The 小红书 上传图文 payload (task 0327 R1): the rendered card series plus its status. */
1645
+ export function buildXhsCardsContent(
1646
+ cards: Record<string, unknown>,
1647
+ options: { publishStatus: 'draft' | 'public'; followUps?: unknown[] },
1648
+ ): Record<string, unknown> {
1649
+ return {
1650
+ ...cards,
1651
+ metadata: {
1652
+ ...metadataOf(cards),
1653
+ publishStatus: options.publishStatus,
1654
+ ...(options.followUps === undefined ? {} : { followUps: options.followUps }),
1655
+ },
1656
+ };
1657
+ }
1658
+
1659
+ /** The podcast payload (task 0327 R1): publish-prep Content plus the episode's show notes. */
1660
+ export function buildPodcastContent(
1661
+ prep: Record<string, unknown>,
1662
+ options: { showNotes: string; podcast?: Record<string, unknown> },
1663
+ ): Record<string, unknown> {
1664
+ return {
1665
+ ...prep,
1666
+ metadata: {
1667
+ ...metadataOf(prep),
1668
+ showNotes: options.showNotes,
1669
+ // Podcast-specific SEO metadata (refined title/description, per-item plan) rides under
1670
+ // one key so a channel payload cannot leak it into another channel's fields.
1671
+ ...(options.podcast === undefined ? {} : { podcast: options.podcast }),
1672
+ },
1673
+ };
1674
+ }
1675
+
1676
+ /**
1677
+ * The surfdash payload (task 0327 R1): the article Content, unchanged.
1678
+ *
1679
+ * surfdash is the article's own publisher, so it consumes what `22-article` already composed — the
1680
+ * builder exists so the variant set is uniform, not because it reshapes anything.
1681
+ */
1682
+ export function buildSurfdashContent(article: Record<string, unknown>, _links: PublishLinks): Record<string, unknown> {
1683
+ return { ...article };
1684
+ }
1685
+
1686
+ /**
1687
+ * One channel's short copy as a Content (task 0327 R3): the body is the authored post text and the
1688
+ * episode cover rides along as `metadata.coverImage`.
1689
+ */
1690
+ /**
1691
+ * A channel's length budget: `max` in that channel's own counting unit.
1692
+ *
1693
+ * `weighted` is X's rule (CJK counts 2, a Latin letter 1) — the design's "280 weighted chars". Counting
1694
+ * code points instead let a 280-character Chinese post through at ~560 of X's units, which is exactly
1695
+ * the over-length draft the backstop exists to catch.
1696
+ */
1697
+ interface SocialLimit {
1698
+ max: number;
1699
+ weighted?: boolean;
1700
+ }
1701
+
1702
+ /** Weight of one code point in X's unit system: CJK ideographs and fullwidth forms count 2. */
1703
+ function weightedCharWeight(char: string): number {
1704
+ const code = char.codePointAt(0) ?? 0;
1705
+ const wide =
1706
+ (code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
1707
+ (code >= 0x2e80 && code <= 0xa4cf) || // CJK radicals … Yi
1708
+ (code >= 0xac00 && code <= 0xd7a3) || // Hangul syllables
1709
+ (code >= 0xf900 && code <= 0xfaff) || // CJK compatibility ideographs
1710
+ (code >= 0xfe30 && code <= 0xfe6f) || // CJK compatibility forms
1711
+ (code >= 0xff00 && code <= 0xff60) || // fullwidth forms
1712
+ (code >= 0xffe0 && code <= 0xffe6);
1713
+ return wide ? 2 : 1;
1714
+ }
1715
+
1716
+ /** Weighted length of `text` (code-point based, so an emoji counts once before its weight). */
1717
+ function weightedLength(text: string): number {
1718
+ let total = 0;
1719
+ for (const char of text) total += weightedCharWeight(char);
1720
+ return total;
1721
+ }
1722
+
1723
+ /**
1724
+ * One channel's short copy as a Content (task 0327 R3): the body is the authored post text and the
1725
+ * episode cover rides along as `metadata.coverImage`.
1726
+ *
1727
+ * The cap is a backstop, not the author: the prompt owns the limit, so an over-length draft is
1728
+ * truncated rather than rejected, and `truncatedFrom` records what was cut. Truncation respects the
1729
+ * channel's own unit — see {@link SocialLimit}.
1730
+ */
1731
+ export function buildSocialContent(
1732
+ copy: string,
1733
+ options: { platform: string; coverPath?: string; limit: SocialLimit },
1734
+ ): Record<string, unknown> {
1735
+ const trimmed = copy.trim();
1736
+ const measure = (text: string): number => (options.limit.weighted === true ? weightedLength(text) : text.length);
1737
+ let body = trimmed;
1738
+ if (measure(trimmed) > options.limit.max) {
1739
+ // Walk code points, adding while the budget holds: a slice by code units could split a
1740
+ // surrogate pair or bust the weighted budget on CJK text.
1741
+ let kept = '';
1742
+ let used = 0;
1743
+ for (const char of trimmed) {
1744
+ const weight = options.limit.weighted === true ? weightedCharWeight(char) : 1;
1745
+ if (used + weight > options.limit.max) break;
1746
+ kept += char;
1747
+ used += weight;
1748
+ }
1749
+ body = kept;
1750
+ }
1751
+ return {
1752
+ title: `${options.platform} · 每日AI乱炖`,
1753
+ format: 'text',
1754
+ body,
1755
+ metadata: {
1756
+ platform: options.platform,
1757
+ ...(body === trimmed
1758
+ ? {}
1759
+ : {
1760
+ truncatedFrom: measure(trimmed),
1761
+ truncatedUnit: options.limit.weighted === true ? 'weighted' : 'code_units',
1762
+ }),
1763
+ ...(options.coverPath === undefined ? {} : { coverImage: options.coverPath }),
1764
+ },
1765
+ };
1766
+ }
1767
+
1768
+ /**
1769
+ * Length budgets the social authoring prompt targets, enforced here as a backstop (task 0327 R3).
1770
+ * X counts CJK at 2 (`weighted`); weibo and threads count characters.
1771
+ */
1772
+ const SOCIAL_LIMITS: Record<string, SocialLimit> = {
1773
+ x: { max: 280, weighted: true },
1774
+ weibo: { max: 2000 },
1775
+ threads: { max: 500 },
1776
+ };
1777
+
1778
+ /** Variant key → file name: `social:x` becomes `social_x.content.json`. */
1779
+ function variantFileName(key: string): string {
1780
+ return `${key.replaceAll(':', '_')}.content.json`;
1781
+ }
1782
+
1783
+ /**
1784
+ * `daily-bundle-variants <workDir> <runDate> <pluginsPath>` — the generate-side payload builder
1785
+ * (task 0327 R2/R3).
1786
+ *
1787
+ * Everything the publish side used to assemble inline is produced here instead, once, as bundle
1788
+ * variants under `30-variants/`. Links are the `{{article_url}}` / `{{podcast_url}}` placeholders,
1789
+ * because at generate time nothing is published yet; `publish-matrix-send` resolves them from the
1790
+ * tier-0 receipts. A variant whose source artifact is absent (channel disabled, cards never
1791
+ * rendered, social copy not authored) is skipped with a log line, never invented.
1792
+ */
1793
+ export function dailyBundleVariants(args: string[]): void {
1794
+ const [workDir = '', runDate = '', _pluginsPath = ''] = args;
1795
+ timed(workDir, 'bundle-variants', () => {
1796
+ const outDir = join(workDir, '30-variants');
1797
+ sted('mkdir', outDir, () => mkdirSync(outDir, { recursive: true }));
1798
+ const articlePath = join(workDir, '22-article', `${runDate}_06_article_content.json`);
1799
+ const prepPath = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
1800
+ const notesPath = join(workDir, '31-publish', `${runDate}_15_show-notes_show-notes.md`);
1801
+ const cardsPath = join(workDir, '25-xhs', 'content.json');
1802
+ const coverPath = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1803
+ const socialDir = join(workDir, '26-social');
1804
+ const cover = isFile(coverPath) ? coverPath : undefined;
1805
+ const links = PLACEHOLDER_LINKS;
1806
+ const written: string[] = [];
1807
+ const skipped: string[] = [];
1808
+
1809
+ const article = readJson(articlePath);
1810
+ const prep = readJson(prepPath);
1811
+ const write = (key: string, content: Record<string, unknown>): void => {
1812
+ const file = join(outDir, variantFileName(key));
1813
+ sted('write', file, () => writeJsonText(file, content));
1814
+ written.push(key);
1815
+ };
1816
+
1817
+ // Generic variants first: a channel-specific variant wins over the shared one, so the
1818
+ // shared copy must exist for the channels the preset does not specialise.
1819
+ if (article === null) skipped.push('article (no 22-article content)');
1820
+ else {
1821
+ write('article', buildSurfdashContent(article, links));
1822
+ write('article@surfdash-pub', buildSurfdashContent(article, links));
1823
+ write('article@xhs-pub', buildXhsArticleContent(article, { publishStatus: 'draft' }));
1824
+ write(
1825
+ 'article@wechatmp-pub',
1826
+ buildWechatArticleContent(article, {
1827
+ links,
1828
+ publishStatus: 'draft',
1829
+ collection: '每日AI资讯',
1830
+ ...(cover === undefined ? {} : { coverPath: cover }),
1831
+ }),
1832
+ );
1833
+ }
1834
+
1835
+ if (prep === null) skipped.push('episode (no publish-prep content)');
1836
+ else {
1837
+ const showNotes = isFile(notesPath) ? readFileSync(notesPath, 'utf-8') : '';
1838
+ write('episode', buildPodcastContent(prep, { showNotes }));
1839
+ write('episode@podcast-pub', buildPodcastContent(prep, { showNotes }));
1840
+ write('episode@xhs-pub', buildXhsEpisodeContent(prep, { publishStatus: 'draft', links }));
1841
+ // 公众号 播客 shares the episode with podcast-pub: same audio, same cover, no article body.
1842
+ write(
1843
+ 'episode@wechatmp-pub',
1844
+ buildWechatArticleContent(prep, {
1845
+ links,
1846
+ publishStatus: 'draft',
1847
+ collection: '每日AI资讯',
1848
+ // Audio-first post: it carries the episode body, so no article closer.
1849
+ appendOutro: false,
1850
+ ...(cover === undefined ? {} : { coverPath: cover }),
1851
+ ...(typeof metadataOf(prep).audioFile === 'string'
1852
+ ? { audioPath: String(metadataOf(prep).audioFile) }
1853
+ : {}),
1854
+ }),
1855
+ );
1856
+ }
1857
+
1858
+ const cards = readJson(cardsPath);
1859
+ if (cards === null) skipped.push('cards (no rendered card series)');
1860
+ else write('cards@xhs-pub', buildXhsCardsContent(cards, { publishStatus: 'draft' }));
1861
+
1862
+ for (const platform of Object.keys(SOCIAL_LIMITS)) {
1863
+ const copyPath = join(socialDir, `${platform}.md`);
1864
+ if (!isFile(copyPath)) {
1865
+ skipped.push(`social:${platform} (no 26-social/${platform}.md)`);
1866
+ continue;
1867
+ }
1868
+ const limit = SOCIAL_LIMITS[platform] ?? { max: 500 };
1869
+ write(
1870
+ `social:${platform}`,
1871
+ buildSocialContent(readFileSync(copyPath, 'utf-8'), {
1872
+ platform,
1873
+ limit,
1874
+ ...(cover === undefined ? {} : { coverPath: cover }),
1875
+ }),
1876
+ );
1877
+ }
1878
+
1879
+ console.log(`bundle-variants: ${written.length} variant(s) in ${outDir}: ${written.join(', ')}`);
1880
+ if (skipped.length > 0) console.log(`bundle-variants: skipped ${skipped.join('; ')}`);
1881
+ });
1882
+ }
1883
+
1438
1884
  /**
1439
1885
  * Topic-boundary sidecar (task 0189 R2). Sibling of this file; node-builtins-only read.
1440
1886
  */
1887
+ /** Surfing frontmatter scalar. Inline on purpose: this file cannot import `@gobing-ai/*`. */
1888
+ function readFrontmatterScalar(filePath: string, key: string): string | undefined {
1889
+ if (!isFile(filePath)) return undefined;
1890
+ let raw: string;
1891
+ try {
1892
+ raw = readFileSync(filePath, 'utf-8');
1893
+ } catch {
1894
+ return undefined;
1895
+ }
1896
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
1897
+ const matter = block?.[1];
1898
+ if (!matter) return undefined;
1899
+ const match = new RegExp(`^${key}:\\s*(.*)$`, 'm').exec(matter);
1900
+ if (!match?.[1]) return undefined;
1901
+ const value = match[1].trim().replace(/^["']|["']$/g, '');
1902
+ return value.length > 0 ? value : undefined;
1903
+ }
1904
+
1905
+ function firstRefinedTitle(parsed: Record<string, unknown> | null): string {
1906
+ const targets = parsed?.targets;
1907
+ if (!Array.isArray(targets)) return '';
1908
+ for (const entry of targets) {
1909
+ if (entry === null || typeof entry !== 'object') continue;
1910
+ const record = entry as Record<string, unknown>;
1911
+ if (record.target !== 'surfdash-pub' || record.ok !== true) continue;
1912
+ const title = metadataOf(record).refinedTitle;
1913
+ if (typeof title === 'string' && title.trim() !== '') return title.trim();
1914
+ }
1915
+ return '';
1916
+ }
1917
+
1918
+ /** Plan titles and briefs in audio order. The show-notes filter wins when it dropped boundary items. */
1919
+ function planItemsForPodcast(workDir: string, runDate: string): Array<{ title: string; brief?: string }> {
1920
+ const filtered = join(workDir, '21-plan', `${runDate}_03_plan_plan-shownotes.json`);
1921
+ const full = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
1922
+ const path = isFile(filtered) ? filtered : full;
1923
+ if (!isFile(path)) return [];
1924
+ let parsed: unknown;
1925
+ try {
1926
+ parsed = JSON.parse(readFileSync(path, 'utf-8'));
1927
+ } catch {
1928
+ return [];
1929
+ }
1930
+ if (!Array.isArray(parsed)) return [];
1931
+ const items: Array<{ title: string; brief?: string }> = [];
1932
+ for (const entry of parsed) {
1933
+ if (entry === null || typeof entry !== 'object') continue;
1934
+ const record = entry as Record<string, unknown>;
1935
+ const title = typeof record.title === 'string' ? record.title.trim() : '';
1936
+ if (title === '') continue;
1937
+ const meta = record.metadata;
1938
+ const metaBrief =
1939
+ meta !== null && typeof meta === 'object' && typeof (meta as Record<string, unknown>).brief === 'string'
1940
+ ? String((meta as Record<string, unknown>).brief).trim()
1941
+ : '';
1942
+ const body = typeof record.body === 'string' ? record.body.trim() : '';
1943
+ const brief = metaBrief || (body.split(/(?<=[。!?])\s*/)[0] ?? '').trim();
1944
+ items.push(brief.length > 0 ? { title, brief } : { title });
1945
+ }
1946
+ return items;
1947
+ }
1948
+
1441
1949
  export function dailyPublishPodcast(args: string[]): void {
1442
1950
  const [workDir = '', runDate = '', pluginsPath = ''] = args;
1443
1951
  const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
@@ -1451,7 +1959,23 @@ export function dailyPublishPodcast(args: string[]): void {
1451
1959
  try {
1452
1960
  const content = requireJson(prep, 'publish-podcast');
1453
1961
  const showNotes = readFileSync(notes, 'utf-8');
1454
- const merged = { ...content, metadata: { ...metadataOf(content), showNotes } };
1962
+ const surfdash = readJson(join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`));
1963
+ const postPath = firstPostPath(surfdash, 'surfdash-pub');
1964
+ const refinedTitle = firstRefinedTitle(surfdash);
1965
+ const refinedDescription = postPath !== '' ? readFrontmatterScalar(postPath, 'description') : undefined;
1966
+ const planItems = planItemsForPodcast(workDir, runDate);
1967
+ const prior = metadataOf(content).podcast;
1968
+ const podcast: Record<string, unknown> =
1969
+ prior !== null && typeof prior === 'object' && !Array.isArray(prior)
1970
+ ? { ...(prior as Record<string, unknown>) }
1971
+ : {};
1972
+ if (refinedTitle !== '') podcast.refinedTitle = refinedTitle;
1973
+ if (refinedDescription) podcast.refinedDescription = refinedDescription;
1974
+ if (planItems.length > 0) podcast.planItems = planItems;
1975
+ const merged = buildPodcastContent(content, {
1976
+ showNotes,
1977
+ ...(Object.keys(podcast).length > 0 ? { podcast } : {}),
1978
+ });
1455
1979
  sted('write', prep, () => writeJsonText(prep, merged));
1456
1980
  kk(
1457
1981
  [
@@ -1620,47 +2144,26 @@ export function dailyPublishWechat(args: string[]): void {
1620
2144
  return;
1621
2145
  }
1622
2146
  const packaged = requireJson(article, 'publish-wechat');
1623
- // MP draft enrichment (operator preferences 2026-09-29, completed 2026-10-10): 原创, 赞赏,
1624
- // 作者, 合集, 原文链接 (the surfdash post), 创作来源, cover, audio material. The brand strings
1625
- // are this daily show's constants; every field here is consumed by `wechatmp-pub`'s flow.
2147
+ // MP draft enrichment (operator preferences 2026-09-29, completed 2026-10-10) lives in the
2148
+ // builder so a generate-side bundle can produce the same payload without publishing.
1626
2149
  const cover = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1627
2150
  const audio = join(workDir, '30-audio', `${runDate}_11_generate_content.mp3`);
1628
2151
  const surfdashResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
1629
2152
  const originalUrl = isFile(surfdashResult) ? firstTargetUrl(readJson(surfdashResult), 'surfdash-pub') : '';
1630
- const wechatmp: Record<string, unknown> = {
1631
- original: true,
1632
- // 赞赏 is enabled per episode; the plugin fills the 赞赏账户 only when the platform
1633
- // leaves it empty (it defaults from 作者, operator walk 2026-10-10).
1634
- reward: true,
1635
- author: '冰原奔狼',
1636
- collection: collectionArg || '每日AI资讯',
1637
- // 创作来源 is the column's own stance, not an AI-declaration label — the operator owns
1638
- // the wording (2026-10-10).
1639
- claimSource: '个人观点,仅供参考',
1640
- // 封面 comes from 图片库: the episode cover is uploaded there as the audio material's
1641
- // cover, so the library's newest entry is the intended image.
1642
- coverSource: 'library',
1643
- ...(originalUrl !== '' ? { originalUrl } : {}),
1644
- ...(isFile(cover) ? { coverPath: cover } : {}),
1645
- // Audio block at the article top (operator request 2026-09-29; the plugin UPLOADS the
1646
- // material when the 素材库 has no same-title entry, 2026-10-10).
1647
- ...(isFile(audio)
1648
- ? {
1649
- audioPath: audio,
1650
- audioTitle: typeof packaged.title === 'string' ? packaged.title : '',
1651
- audioCoverPath: isFile(cover) ? cover : '',
1652
- }
1653
- : {}),
1654
- };
1655
- // WeChat-only closing line (operator request 2026-09-29): point readers/listeners back to
1656
- // the show's platforms. Appended to the wechat input body only, never the master article.
1657
- const outro = '\n\n欲听往期节目,可至小宇宙、Apple Podcast、YouTube 等平台搜索【咸话咸说】。';
2153
+ const hasCover = isFile(cover);
2154
+ const hasAudio = isFile(audio);
2155
+ const firstLine = typeof packaged.title === 'string' ? packaged.title : '';
1658
2156
  sted('write', input, () =>
1659
- writeJsonText(input, {
1660
- ...packaged,
1661
- body: `${typeof packaged.body === 'string' ? packaged.body : ''}${outro}`,
1662
- metadata: { ...metadataOf(packaged), publishStatus: want, wechatmp },
1663
- }),
2157
+ writeJsonText(
2158
+ input,
2159
+ buildWechatArticleContent(packaged, {
2160
+ links: { articleUrl: originalUrl, podcastUrl: '' },
2161
+ publishStatus: want,
2162
+ collection: collectionArg || '每日AI资讯',
2163
+ ...(hasCover ? { coverPath: cover } : {}),
2164
+ ...(hasAudio ? { audioPath: audio, audioTitle: firstLine } : {}),
2165
+ }),
2166
+ ),
1664
2167
  );
1665
2168
  // `soft` keeps the WeChat target non-blocking: the plugin still writes its own failed Result
1666
2169
  // and prints the failing step (session preflight, editor paste, save control), which is what
@@ -1728,14 +2231,13 @@ export function dailyPublishXhs(args: string[]): void {
1728
2231
  const packaged = requireJson(content, 'publish-xhs');
1729
2232
  const followUp = podcast === 'true' ? xhsPodcastNote(prep, want) : undefined;
1730
2233
  sted('write', input, () =>
1731
- writeJsonText(input, {
1732
- ...packaged,
1733
- metadata: {
1734
- ...metadataOf(packaged),
2234
+ writeJsonText(
2235
+ input,
2236
+ buildXhsCardsContent(packaged, {
1735
2237
  publishStatus: want,
1736
2238
  ...(followUp === undefined ? {} : { followUps: [followUp] }),
1737
- },
1738
- }),
2239
+ }),
2240
+ ),
1739
2241
  );
1740
2242
  // `soft` keeps the XHS target non-blocking: the plugin still writes its own failed Result
1741
2243
  // and prints the failing step (login gate, upload, body, submit control), which is what the
@@ -1767,16 +2269,7 @@ function xhsPodcastNote(prepPath: string, publishStatus: 'draft' | 'public'): Re
1767
2269
  console.log(`publish-xhs: no publish-prep Content at ${prepPath} — the 发播客 note is skipped`);
1768
2270
  return undefined;
1769
2271
  }
1770
- const metadata = metadataOf(prep);
1771
- return {
1772
- ...prep,
1773
- metadata: {
1774
- ...metadata,
1775
- operation: '发播客',
1776
- audio: metadata.audioFile,
1777
- publishStatus,
1778
- },
1779
- };
2272
+ return buildXhsEpisodeContent(prep, { publishStatus });
1780
2273
  }
1781
2274
 
1782
2275
  /**
@@ -2091,6 +2584,16 @@ export function dailySafetyPrep(args: string[]): void {
2091
2584
  const [workDir = '', runDate = '', platforms = ''] = args;
2092
2585
  timed(workDir, 'safety-review', () => {
2093
2586
  const digest = safetyDigest(workDir, runDate);
2587
+ // Structural snapshot of the VoiceScript BEFORE any remediation edit. `daily-safety-revise`
2588
+ // restores it when the remediation pass changes the item-section structure without a recorded
2589
+ // exclusion: the agent is allowed to drop a 播报形态 line, not an item the article kept,
2590
+ // because `daily-wrap-docs` derives the spoken item sections from `gap_ms: 1200` and fails the
2591
+ // run when they no longer match the episode's included items (2026-10-21 04:30 run: 3
2592
+ // segments removed → 5 sections vs a 6-item plan → `generate` refused at 05:03 and the whole
2593
+ // day published nothing).
2594
+ const [, scriptPath] = safetyDraftPaths(workDir, runDate);
2595
+ const snapshot = safetyPath(workDir, runDate, 'safety-prep_script-snapshot.yaml');
2596
+ if (isFile(scriptPath)) sted('write', snapshot, () => writeRaw(snapshot, readFileSync(scriptPath, 'utf-8')));
2094
2597
  for (const name of [
2095
2598
  'safety-review_report.json',
2096
2599
  'safety-judge_verdict.json',
@@ -2150,6 +2653,7 @@ export function dailySafetyRevise(args: string[]): void {
2150
2653
  }
2151
2654
  }
2152
2655
  assertScriptContent(scriptPath);
2656
+ restoreStructureIfBroken(workDir, runDate, scriptPath);
2153
2657
  const path = join(workDir, SAFETY_REVISE_COUNT);
2154
2658
  const next = readSafetyReviseCount(workDir) + 1;
2155
2659
  sted('write', path, () => writeFileSync(path, `${next}\n`));
@@ -2157,6 +2661,71 @@ export function dailySafetyRevise(args: string[]): void {
2157
2661
  });
2158
2662
  }
2159
2663
 
2664
+ /**
2665
+ * The spoken item-section count the episode is contracted to carry: the script plan's items minus the
2666
+ * ones the article boundary recorded in `metadata.excludedIds`.
2667
+ *
2668
+ * The article drops boundary docs at its own stage, the safety gate aligns the VoiceScript with the
2669
+ * article it reviews, and `daily-show-notes` already filters the plan copy by the same ids — but
2670
+ * `daily-generate` compared the sections against the raw plan length, so the 2026-10-21 run refused a
2671
+ * 5-section script against a 6-item plan (the 6th was the article-excluded 通用汽车 buyback) after two
2672
+ * hours of safety review and rendered nothing. Every consumer must count the same set.
2673
+ */
2674
+ export function expectedItemSections(workDir: string, runDate: string, planItems: unknown[]): number {
2675
+ const excluded = metadataOf(
2676
+ readJson(join(workDir, '22-article', `${runDate}_06_article_content.json`)),
2677
+ ).excludedIds;
2678
+ if (!Array.isArray(excluded)) return planItems.length;
2679
+ const dropped = new Set(excluded.filter((id): id is string => typeof id === 'string' && id !== ''));
2680
+ if (dropped.size === 0) return planItems.length;
2681
+ return planItems.filter(
2682
+ (item) => !(item !== null && typeof item === 'object' && dropped.has(String((item as { id?: unknown }).id))),
2683
+ ).length;
2684
+ }
2685
+
2686
+ /**
2687
+ * Repair a remediation pass that changed the VoiceScript's item-section structure.
2688
+ *
2689
+ * `daily-wrap-docs` (and `daily-generate`) derive the spoken item sections from `gap_ms: 1200` and
2690
+ * refuse the run when they disagree with the episode's included items. The safety agent is allowed
2691
+ * to reshape lines, and to drop an item the article boundary already excluded (that drop is the
2692
+ * contract, and `expectedItemSections` counts it out), but an unrecorded drop breaks the one-to-one
2693
+ * audio/notes/plan relation — and on 2026-10-21 one killed the 04:30 run at `generate` after two
2694
+ * hours of safety review. The pre-revise bytes are snapshotted by `daily-safety-prep`, so an
2695
+ * unaccounted structural change is undone here, inside the budget loop, and named for the operator.
2696
+ * The budget is still spent, so the loop still converges (the re-review sees the unchanged script and
2697
+ * the gate classifies the residue) instead of the day failing hours later at the TTS seam.
2698
+ */
2699
+ function restoreStructureIfBroken(workDir: string, runDate: string, scriptPath: string): void {
2700
+ const planPath = join(workDir, '24-script', `${runDate}_08_script-write_plan.json`);
2701
+ const plan = readJson(planPath) as { items?: unknown[] } | null;
2702
+ if (plan === null || !Array.isArray(plan.items)) return;
2703
+ const snapshot = safetyPath(workDir, runDate, 'safety-prep_script-snapshot.yaml');
2704
+ if (!isFile(snapshot)) return;
2705
+ const sections = (path: string): number => {
2706
+ const parsed = Bun.YAML.parse(readFileSync(path, 'utf-8')) as { segments?: Array<Record<string, unknown>> };
2707
+ const segments = Array.isArray(parsed.segments) ? parsed.segments : [];
2708
+ let count = 0;
2709
+ let pending = false;
2710
+ for (const segment of segments.slice(1, -1)) {
2711
+ pending = true;
2712
+ if (segment.gap_ms === 1200) {
2713
+ count += 1;
2714
+ pending = false;
2715
+ }
2716
+ }
2717
+ return count + (pending ? 1 : 0);
2718
+ };
2719
+ const expected = expectedItemSections(workDir, runDate, plan.items);
2720
+ const actual = sections(scriptPath);
2721
+ if (actual === expected) return;
2722
+ const original = sections(snapshot);
2723
+ sted('write', scriptPath, () => writeRaw(scriptPath, readFileSync(snapshot, 'utf-8')));
2724
+ console.error(
2725
+ `safety-revise: the edit changed the VoiceScript item sections (${expected} included plan items, ${original} before, ${actual} after) — restored the pre-revision structure from ${basename(snapshot)}; audio/notes stay one-to-one with the plan`,
2726
+ );
2727
+ }
2728
+
2160
2729
  /** Block YAML for the fields a fitted VoiceScript actually carries. Strings use JSON quoting. */
2161
2730
  function voicescriptToBlockYaml(script: Record<string, unknown>): string {
2162
2731
  const lines: string[] = [];
@@ -2381,6 +2950,9 @@ export function dailyShowNotes(args: string[]): void {
2381
2950
  // Inlined parse — this file must not import @gobing-ai/* (header contract); the same note
2382
2951
  // grammar lives in daily-article-gen's excludedIdsFromNote.
2383
2952
  const postPath = firstPostPath(readJson(result), 'surfdash-pub');
2953
+ if (postPath === '') {
2954
+ console.error('WARN podcast-pub: brief mode requested but ARTICLE_POST_PATH is empty; using legacy layout');
2955
+ }
2384
2956
  const articleContent = readJson(article);
2385
2957
  const articleBody = articleContent?.body;
2386
2958
  const excluded = new Set<string>();
@@ -2527,11 +3099,20 @@ export function dailyTranslateSync(args: string[]): void {
2527
3099
  timed(workDir, 'translate-sync', () => {
2528
3100
  // Reads the marker file itself — the collapsed `translate` state has no
2529
3101
  // cross-state var to hand the path through.
2530
- const zhPostPath = isFile(zhPathFile) ? readFileSync(zhPathFile, 'utf-8') : '';
3102
+ const zhPostPath = isFile(zhPathFile) ? readFileSync(zhPathFile, 'utf-8').trim() : '';
2531
3103
  if (zhPostPath === '') {
2532
3104
  console.log('no zh post path — skip translate-sync');
2533
3105
  return;
2534
3106
  }
3107
+ // The 4th argument is the MARKER file, not the post (2026-10-22). Passing the post made the
3108
+ // plugin read the whole article as `postPath` and echo it back inside its error, which cost a
3109
+ // confusing round trip on a manual recovery run.
3110
+ if (zhPathFile.endsWith('.md') || !zhPostPath.startsWith('/') || !isFile(zhPostPath)) {
3111
+ failWith(
3112
+ 1,
3113
+ `translate-sync: 4th argument must be the zh marker file (${runDate}_20_translate-localize_zh-post-path.txt), not the post itself — '${zhPathFile}' holds ${zhPostPath.includes('\n') ? 'a multi-line document' : `'${zhPostPath}'`}`,
3114
+ );
3115
+ }
2535
3116
  sted('write', sync, () =>
2536
3117
  writeJqText(sync, { body: '', options: { operation: 'sync', postPath: zhPostPath } }),
2537
3118
  );