@gobing-ai/knowledge-kit 0.0.31 → 0.0.33

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 (79) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +32320 -15021
  3. package/package.json +4 -4
  4. package/plugins/generations/content-gen/dist/index.js +10 -9
  5. package/plugins/generations/core-facts-gen/dist/index.js +13 -12
  6. package/plugins/generations/daily-article-gen/dist/index.js +14 -13
  7. package/plugins/generations/daily-article-gen/src/index.ts +1 -1
  8. package/plugins/generations/dailynews-gen/dist/index.js +10 -9
  9. package/plugins/generations/episode-plan-gen/dist/index.js +58 -15
  10. package/plugins/generations/episode-plan-gen/src/index.ts +76 -6
  11. package/plugins/generations/gemini-tts/README.md +11 -0
  12. package/plugins/generations/gemini-tts/dist/index.js +10 -9
  13. package/plugins/generations/image-gen/dist/index.js +154 -151
  14. package/plugins/generations/image-gen/src/compose/chromium.ts +60 -42
  15. package/plugins/generations/news-report-gen/dist/index.js +13 -12
  16. package/plugins/generations/voice-gen/dist/index.js +10 -9
  17. package/plugins/ingestions/aihot-ingest/dist/index.js +13 -12
  18. package/plugins/ingestions/horizon-ingest/dist/index.js +13 -12
  19. package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
  20. package/plugins/ingestions/last30days-ingest/dist/index.js +13 -12
  21. package/plugins/ingestions/web-search/dist/index.js +11 -10
  22. package/plugins/kk/README.md +38 -0
  23. package/plugins/kk/decisions/extract/ai-news.yaml +21 -0
  24. package/plugins/kk/decisions/kk-items.yaml +113 -0
  25. package/plugins/kk/plugin.json +1 -1
  26. package/plugins/kk/schedules/cron.tmpl +15 -0
  27. package/plugins/kk/schedules/launchd.plist.tmpl +77 -0
  28. package/plugins/kk/scripts/daily-stages.ts +1533 -0
  29. package/plugins/kk/scripts/kk-workflow-stages.ts +268 -1228
  30. package/plugins/kk/skills/content-judge/SKILL.md +12 -2
  31. package/plugins/kk/skills/content-judge/references/rubrics.md +8 -2
  32. package/plugins/kk/skills/content-safety/SKILL.md +60 -18
  33. package/plugins/kk/skills/web-automation/SKILL.md +100 -0
  34. package/plugins/kk/skills/web-automation/references/browser-launch.md +86 -0
  35. package/plugins/kk/workflows/kk-collect.yaml +258 -0
  36. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +74 -11
  37. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +655 -0
  38. package/plugins/kk/workflows/kk-daily-safety.yaml +170 -0
  39. package/plugins/publishings/emdash-pub/dist/index.js +10 -9
  40. package/plugins/publishings/infoq-pub/dist/index.js +8695 -211
  41. package/plugins/publishings/infoq-pub/package.json +3 -1
  42. package/plugins/publishings/infoq-pub/plugin.json +2 -1
  43. package/plugins/publishings/infoq-pub/src/index.ts +358 -29
  44. package/plugins/publishings/infoq-pub/src/profile-check.ts +21 -0
  45. package/plugins/publishings/juejin-pub/dist/index.js +8746 -211
  46. package/plugins/publishings/juejin-pub/package.json +3 -1
  47. package/plugins/publishings/juejin-pub/plugin.json +2 -1
  48. package/plugins/publishings/juejin-pub/src/index.ts +345 -29
  49. package/plugins/publishings/juejin-pub/src/profile-check.ts +19 -0
  50. package/plugins/publishings/medium-pub/dist/index.js +10 -9
  51. package/plugins/publishings/podcast-pub/dist/index.js +10 -9
  52. package/plugins/publishings/qiita-pub/dist/index.js +10 -9
  53. package/plugins/publishings/substack-pub/dist/index.js +8687 -211
  54. package/plugins/publishings/substack-pub/package.json +3 -1
  55. package/plugins/publishings/substack-pub/plugin.json +2 -1
  56. package/plugins/publishings/substack-pub/src/index.ts +302 -29
  57. package/plugins/publishings/substack-pub/src/profile-check.ts +76 -0
  58. package/plugins/publishings/surfdash-pub/dist/index.js +10 -9
  59. package/plugins/publishings/wechatmp-pub/dist/index.js +1315 -352
  60. package/plugins/publishings/wechatmp-pub/plugin.json +1 -0
  61. package/plugins/publishings/wechatmp-pub/src/enrich.ts +229 -0
  62. package/plugins/publishings/wechatmp-pub/src/flow.ts +896 -0
  63. package/plugins/publishings/wechatmp-pub/src/index.ts +223 -520
  64. package/plugins/publishings/wechatmp-pub/src/modes.ts +403 -0
  65. package/plugins/publishings/wechatmp-pub/src/page.ts +260 -0
  66. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +84 -38
  67. package/plugins/publishings/x-pub/dist/index.js +8707 -223
  68. package/plugins/publishings/x-pub/package.json +3 -1
  69. package/plugins/publishings/x-pub/plugin.json +2 -1
  70. package/plugins/publishings/x-pub/src/index.ts +315 -23
  71. package/plugins/publishings/x-pub/src/profile-check.ts +19 -0
  72. package/plugins/publishings/xhs-pub/dist/index.js +897 -354
  73. package/plugins/publishings/xhs-pub/plugin.json +2 -1
  74. package/plugins/publishings/xhs-pub/src/flow.ts +572 -0
  75. package/plugins/publishings/xhs-pub/src/index.ts +108 -596
  76. package/plugins/publishings/xhs-pub/src/modes.ts +300 -0
  77. package/plugins/publishings/xhs-pub/src/page.ts +254 -0
  78. package/plugins/publishings/xhs-pub/src/profile-check.ts +29 -17
  79. package/plugins/publishings/zenn-pub/dist/index.js +10 -9
@@ -57,35 +57,46 @@ import {
57
57
  import { homedir } from 'node:os';
58
58
  import { basename, dirname, join, relative, resolve } from 'node:path';
59
59
 
60
- const SCRIPTS_DIR = import.meta.dir;
61
- const VALIDATE_SIDECAR = 'validate-voicescript.ts';
60
+ export const SCRIPTS_DIR = import.meta.dir;
61
+ export const VALIDATE_SIDECAR = 'validate-voicescript.ts';
62
62
  const WRAP_SIDECAR = 'wrap-voicescript-doc.ts';
63
63
  const RENDER_SIDECAR = 'render-md.ts';
64
64
  // F613: the zh style contract the script-write prompt reads (snapshot stage) and dailynews-gen
65
65
  // consumes (DAILYNEWS_STYLE_FILE); no workflow var carries an override, so the chain is
66
66
  // $KK_WORKFLOWS_DIR -> package copy -> $HOME.
67
- const STYLE_SIDECAR = 'daily-voice-style.zh.md';
67
+ export const STYLE_SIDECAR = 'daily-voice-style.zh.md';
68
68
  const REVISE_COUNT = '.revise-count';
69
+ /** Bounded draft-remediation counter for the daily safety loop (20261005). */
70
+ export const SAFETY_REVISE_COUNT = '.safety-revise-count';
71
+ /**
72
+ * Verdict criteria that describe *platform admission* (the daily's CN 资质口径 / 播报形态 rules) rather
73
+ * than a defect in the draft itself. Only these can be answered by dropping a publish target; every
74
+ * other `fail` is draft integrity, which would ship to the remaining targets too (20261005).
75
+ */
76
+ export const ADMISSION_CRITERIA = new Set(['jurisdictional-rules']);
77
+ /** content-safety category 3 (可识别个人信息): a draft property, so no target choice answers it. */
78
+ export const PII_FINDING_CATEGORY = 3;
79
+ export const SAFETY_PLATFORM_DECISIONS = new Set(['OK', 'RISK', 'BLOCK']);
69
80
  // Post-translation title-dedup threshold (task 0138 R3). ponytail: the 2026-09-16 Gemini 3.8 Live
70
81
  // pair measures 0.417 zh title-only Jaccard, the nearest distinct pair 0.1 (4x margin). Character
71
82
  // bigrams need same-language input — pre-translate dedup over the mixed-language blend cannot work.
72
- const DEDUP_TITLE_THRESHOLD = 0.4;
83
+ export const DEDUP_TITLE_THRESHOLD = 0.4;
73
84
  // F614: the daily cover renders through the shipped daily-news presets; the brief's subject is
74
85
  // the only per-run var. The format/style pins and the subject cap are part of the style
75
86
  // contract — changing the look means editing the preset files, not this stage.
76
- const COVER_FORMAT = 'daily-news-cover';
77
- const COVER_STYLE = 'daily-news';
78
- const COVER_SUBJECT_CAP = 240;
87
+ export const COVER_FORMAT = 'daily-news-cover';
88
+ export const COVER_STYLE = 'daily-news';
89
+ export const COVER_SUBJECT_CAP = 240;
79
90
  // E8: the XHS card series. Format and style are pinned exactly like the cover's; only the copy is
80
91
  // model-authored. `XHS_MAX_CARDS` mirrors the format preset's own series guidance.
81
- const XHS_FORMAT = 'xhs-card';
82
- const XHS_MAX_CARDS = 10;
83
- const XHS_TITLE_CAP = 20;
84
- const XHS_BODY_CAP = 1000;
85
- const XHS_ROLES: readonly string[] = ['cover', 'content', 'closing'];
92
+ export const XHS_FORMAT = 'xhs-card';
93
+ export const XHS_MAX_CARDS = 10;
94
+ export const XHS_TITLE_CAP = 20;
95
+ export const XHS_BODY_CAP = 1000;
96
+ export const XHS_ROLES: readonly string[] = ['cover', 'content', 'closing'];
86
97
 
87
98
  /** One planned card, normalized: `index` zero-padded for the preset's artifact name. */
88
- interface XhsCard {
99
+ export interface XhsCard {
89
100
  index: string;
90
101
  role: string;
91
102
  copy: string;
@@ -95,7 +106,7 @@ interface XhsCard {
95
106
  sourcePoint: string;
96
107
  }
97
108
 
98
- function fail(message: string): never {
109
+ export function fail(message: string): never {
99
110
  console.error(message);
100
111
  process.exit(1);
101
112
  }
@@ -116,7 +127,22 @@ async function loadItcStages(): Promise<typeof import('./itc-stages.ts')> {
116
127
  }
117
128
 
118
129
  /** coreutils-style reason for the errno codes the staged file operations can hit. */
119
- const ERRNO_REASONS: Record<string, string> = {
130
+
131
+ /**
132
+ * Load the daily-* helper sibling lazily (task 0211 R7): this runner is resolved — and copied
133
+ * alone — from installs where the sibling may be absent, so the daily-* stages fail loud with a
134
+ * remediation path instead of breaking every stage at load.
135
+ */
136
+ async function loadDailyStages(): Promise<typeof import('./daily-stages.ts')> {
137
+ try {
138
+ return await import('./daily-stages.ts');
139
+ } catch {
140
+ return fail(
141
+ 'daily-stages.ts not found next to kk-workflow-stages.ts — install or refresh the kk package (bun link in a checkout)',
142
+ );
143
+ }
144
+ }
145
+ export const ERRNO_REASONS: Record<string, string> = {
120
146
  EACCES: 'Permission denied',
121
147
  EROFS: 'Read-only file system',
122
148
  ENOSPC: 'No space left on device',
@@ -132,7 +158,7 @@ const ERRNO_REASONS: Record<string, string> = {
132
158
  * Run a filesystem operation, turning a failure into the named diagnostic the shell reported
133
159
  * (`mkdir: <path>: Permission denied`) instead of an uncaught Bun stack trace.
134
160
  */
135
- function sted(op: string, path: string, run: () => void): void {
161
+ export function sted(op: string, path: string, run: () => void): void {
136
162
  try {
137
163
  run();
138
164
  } catch (error) {
@@ -142,7 +168,7 @@ function sted(op: string, path: string, run: () => void): void {
142
168
  }
143
169
 
144
170
  /** `[ -f ]` semantics: exists AND is a regular file; an unreadable parent answers false, never throws. */
145
- function isFile(path: string): boolean {
171
+ export function isFile(path: string): boolean {
146
172
  try {
147
173
  return statSync(path, { throwIfNoEntry: false })?.isFile() === true;
148
174
  } catch {
@@ -156,7 +182,7 @@ function isFile(path: string): boolean {
156
182
  * before the config-dir copy on purpose (F8 R1) — the shipped bytes win unless the operator elected
157
183
  * that directory as an override.
158
184
  */
159
- function resolveSidecar(override: string, sidecar: string): string {
185
+ export function resolveSidecar(override: string, sidecar: string): string {
160
186
  if (override && isFile(override)) return override;
161
187
  const kkWorkflowsDir = process.env.KK_WORKFLOWS_DIR;
162
188
  if (kkWorkflowsDir) {
@@ -173,7 +199,7 @@ function resolveSidecar(override: string, sidecar: string): string {
173
199
  * that carries the override (`validate_script` / `wrap_script` / `render_script`) so the diagnostic
174
200
  * names the same thing the operator sees in the YAML.
175
201
  */
176
- function requireSidecar(override: string, sidecar: string, label: string): string {
202
+ export function requireSidecar(override: string, sidecar: string, label: string): string {
177
203
  const resolved = resolveSidecar(override, sidecar);
178
204
  if (!isFile(resolved)) {
179
205
  fail(
@@ -211,7 +237,7 @@ function writeJson(path: string, value: Record<string, unknown>): void {
211
237
 
212
238
  /** `.revise-count` lives at the work_dir root in every workflow that uses it. */
213
239
 
214
- function bumpReviseCount(workDir: string): void {
240
+ export function bumpReviseCount(workDir: string): void {
215
241
  const path = join(workDir, '.revise-count');
216
242
  let previous = Number.NaN;
217
243
  if (isFile(path))
@@ -221,6 +247,14 @@ function bumpReviseCount(workDir: string): void {
221
247
  sted('write', path, () => writeFileSync(path, `${(Number.isNaN(previous) ? 0 : previous) + 1}\n`));
222
248
  }
223
249
 
250
+ /** `0` when `.safety-revise-count` is absent or unparsable: a fresh run revises, it never blocks on state. */
251
+ export function readSafetyReviseCount(workDir: string): number {
252
+ const path = join(workDir, SAFETY_REVISE_COUNT);
253
+ if (!isFile(path)) return 0;
254
+ const value = Number.parseInt(readFileSync(path, 'utf-8'), 10);
255
+ return Number.isNaN(value) ? 0 : value;
256
+ }
257
+
224
258
  interface PrepareOptions {
225
259
  readonly workDirArg: string;
226
260
  readonly topic: string;
@@ -343,7 +377,7 @@ function prepareStorm(args: string[]): void {
343
377
  writeJson(join(workDir, 'topic.json'), topicFile(query));
344
378
  }
345
379
 
346
- function duration(args: string[]): void {
380
+ export function duration(args: string[]): void {
347
381
  const [workDirArg = '', targetDurationMin = ''] = args;
348
382
  if (!workDirArg) fail('missing work_dir');
349
383
  const targetMinutes = Number(targetDurationMin);
@@ -450,7 +484,7 @@ const DAILY_DIRS = [
450
484
  ] as const;
451
485
 
452
486
  /** `date -u +%FT%TZ` — the timestamp grammar the step-timing lines have always used. */
453
- function utcStamp(): string {
487
+ export function utcStamp(): string {
454
488
  return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
455
489
  }
456
490
 
@@ -458,7 +492,7 @@ function utcStamp(): string {
458
492
  * The pre-0133 shell wrapped every daily step in `trap … EXIT`, so the timing line lands on failure
459
493
  * too. The body signals failure by throwing `failWith`, never by calling `process.exit` directly.
460
494
  */
461
- class StageFailure extends Error {
495
+ export class StageFailure extends Error {
462
496
  constructor(
463
497
  readonly status: number,
464
498
  readonly named: boolean,
@@ -468,11 +502,11 @@ class StageFailure extends Error {
468
502
  }
469
503
  }
470
504
 
471
- function failWith(status: number, message?: string): never {
505
+ export function failWith(status: number, message?: string): never {
472
506
  throw new StageFailure(status, message !== undefined, message ?? `stage failed (status ${status})`);
473
507
  }
474
508
 
475
- function appendTiming(workDir: string, step: string, startedAt: string): void {
509
+ export function appendTiming(workDir: string, step: string, startedAt: string): void {
476
510
  if (!workDir) return;
477
511
  try {
478
512
  appendFileSync(
@@ -484,7 +518,7 @@ function appendTiming(workDir: string, step: string, startedAt: string): void {
484
518
  }
485
519
  }
486
520
 
487
- function timed(workDir: string, step: string, body: () => void): void {
521
+ export function timed(workDir: string, step: string, body: () => void): void {
488
522
  const startedAt = utcStamp();
489
523
  let status = 0;
490
524
  try {
@@ -503,7 +537,7 @@ function timed(workDir: string, step: string, body: () => void): void {
503
537
  if (status !== 0) process.exit(status);
504
538
  }
505
539
 
506
- function pluginsArgs(pluginsPath: string): string[] {
540
+ export function pluginsArgs(pluginsPath: string): string[] {
507
541
  return pluginsPath === '' ? [] : ['--plugins-path', pluginsPath];
508
542
  }
509
543
 
@@ -512,9 +546,9 @@ function pluginsArgs(pluginsPath: string): string[] {
512
546
  // every descendant env; a nested `spur workflow run` — postsurfing's publishing workflow under
513
547
  // surfdash-pub — resolves a DIFFERENT digest and refuses to start. Strip both launcher vars at
514
548
  // the nested-spur boundary (SPUR_WORKFLOW_RUN_ACTIVE is the older sibling guard, task 0133-era).
515
- const NESTED_SPUR_UNSET = ['SPUR_WORKFLOW_RUN_ACTIVE', 'SPUR_EXPECTED_DEFINITION_DIGEST'] as const;
549
+ export const NESTED_SPUR_UNSET = ['SPUR_WORKFLOW_RUN_ACTIVE', 'SPUR_EXPECTED_DEFINITION_DIGEST'] as const;
516
550
 
517
- function kk(
551
+ export function kk(
518
552
  args: string[],
519
553
  options: { env?: Record<string, string>; unset?: readonly string[]; soft?: boolean } = {},
520
554
  ): number {
@@ -533,22 +567,22 @@ function kk(
533
567
  return status;
534
568
  }
535
569
 
536
- function writeJsonText(path: string, value: unknown): void {
570
+ export function writeJsonText(path: string, value: unknown): void {
537
571
  // `Bun.write(path, JSON.stringify(value, null, 2))` — no trailing newline.
538
572
  writeFileSync(path, JSON.stringify(value, null, 2));
539
573
  }
540
574
 
541
- function writeJqText(path: string, value: unknown): void {
575
+ export function writeJqText(path: string, value: unknown): void {
542
576
  // `jq … > path` — pretty output WITH the trailing newline jq always prints.
543
577
  writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
544
578
  }
545
579
 
546
- function writeRaw(path: string, value: string): void {
580
+ export function writeRaw(path: string, value: string): void {
547
581
  // `printf '%s' … > path` / `bun -e 'Bun.write'` — no trailing newline.
548
582
  writeFileSync(path, value);
549
583
  }
550
584
 
551
- function readJson(path: string): Record<string, unknown> | null {
585
+ export function readJson(path: string): Record<string, unknown> | null {
552
586
  if (!isFile(path)) return null;
553
587
  try {
554
588
  return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
@@ -557,7 +591,7 @@ function readJson(path: string): Record<string, unknown> | null {
557
591
  }
558
592
  }
559
593
 
560
- function metadataOf(value: Record<string, unknown> | null): Record<string, unknown> {
594
+ export function metadataOf(value: Record<string, unknown> | null): Record<string, unknown> {
561
595
  const metadata = value?.metadata;
562
596
  return metadata !== null && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : {};
563
597
  }
@@ -566,14 +600,14 @@ function metadataOf(value: Record<string, unknown> | null): Record<string, unkno
566
600
  * Required stage input: a missing or unparsable file fails the stage naming the path, so a
567
601
  * run-date/path mismatch surfaces as its real cause instead of a downstream "field missing" error.
568
602
  */
569
- function requireJson(path: string, label: string): Record<string, unknown> {
603
+ export function requireJson(path: string, label: string): Record<string, unknown> {
570
604
  const parsed = readJson(path);
571
605
  if (parsed === null) failWith(1, `${label}: required input not found or unparsable: ${path}`);
572
606
  return parsed;
573
607
  }
574
608
 
575
609
  /** `jq -e '.targets[]? | select(.target == t and .ok == true and .status == "published")'`. */
576
- function targetPublished(path: string, target: string): boolean {
610
+ export function targetPublished(path: string, target: string): boolean {
577
611
  const parsed = readJson(path);
578
612
  const targets = parsed?.targets;
579
613
  if (!Array.isArray(targets)) return false;
@@ -612,6 +646,19 @@ function dailyPrepare(args: string[]): void {
612
646
  sted('write', join(workDir, 'run-date.txt'), () =>
613
647
  writeFileSync(join(workDir, 'run-date.txt'), `${dirDate}\n`),
614
648
  );
649
+ // The safety remediation budget belongs to the attempt, not to the day: a fresh run (or a
650
+ // same-day retry) starts with the full revise allowance instead of inheriting a spent one.
651
+ sted('write', join(workDir, SAFETY_REVISE_COUNT), () =>
652
+ writeFileSync(join(workDir, SAFETY_REVISE_COUNT), '0\n'),
653
+ );
654
+ // Version-stamp the run: every later stage resolves `kk` from PATH, so name the binary
655
+ // this run will actually use (20261006 outage: a stale global kk pre-dated the revise
656
+ // loop and the skew was only visible after the fact).
657
+ const versionProbe = spawnSync('kk', ['--version'], { encoding: 'utf8', timeout: 10_000 });
658
+ const kkVersion = versionProbe.status === 0 ? versionProbe.stdout.trim() : 'unknown';
659
+ sted('write', join(workDir, 'kk-version.txt'), () =>
660
+ writeFileSync(join(workDir, 'kk-version.txt'), `${kkVersion}\n`),
661
+ );
615
662
  // Anchor for the workflow: shell actions cannot set vars, so the YAML reads the
616
663
  // effective work_dir back into vars.work_dir from this cwd-relative file.
617
664
  const anchorDir = join('.spur', 'runs', 'kk-daily-ai-voice');
@@ -677,6 +724,35 @@ function resolveWorksDir(): string {
677
724
  return join(homedir(), '.config/kk/works');
678
725
  }
679
726
 
727
+ /**
728
+ * `collect-prepare <stamp> <limit> <topic> <horizonHours>` — kk-collect's workspace: the pinned stamp
729
+ * (else the UTC minute, `YYYYMMDDTHHMMZ`), the cwd-relative `.works/kk-collect/<stamp>` work dir, the
730
+ * anchor files the workflow reads back into vars (shell actions cannot set vars), and the three
731
+ * source configs `kk executor fan-in --source <plugin>:<file>` requires. Configs go through
732
+ * `writeJsonText` so a topic carrying quotes or backslashes stays valid JSON.
733
+ */
734
+ function collectPrepare(args: string[]): void {
735
+ const [stampArg = '', limit = '', topic = '', horizonHours = ''] = args;
736
+ const stamp = stampArg || new Date().toISOString().replace(/[-:]/g, '').slice(0, 13).concat('Z');
737
+ const workDir = join('.works', 'kk-collect', stamp);
738
+ const anchorDir = join('.spur', 'runs', 'kk-collect');
739
+ sted('mkdir', join(workDir, '10-ingest'), () => mkdirSync(join(workDir, '10-ingest'), { recursive: true }));
740
+ sted('mkdir', anchorDir, () => mkdirSync(anchorDir, { recursive: true }));
741
+ sted('write', join(anchorDir, 'work-dir.txt'), () =>
742
+ writeFileSync(join(anchorDir, 'work-dir.txt'), `${workDir}\n`),
743
+ );
744
+ sted('write', join(anchorDir, 'stamp.txt'), () => writeFileSync(join(anchorDir, 'stamp.txt'), `${stamp}\n`));
745
+ sted('write', join(workDir, '10-ingest/source.json'), () =>
746
+ writeJsonText(join(workDir, '10-ingest/source.json'), { limit: Number(limit) }),
747
+ );
748
+ sted('write', join(workDir, '10-ingest/last30days.json'), () =>
749
+ writeJsonText(join(workDir, '10-ingest/last30days.json'), { topic }),
750
+ );
751
+ sted('write', join(workDir, '10-ingest/horizon.json'), () =>
752
+ writeJsonText(join(workDir, '10-ingest/horizon.json'), { hours: Number(horizonHours) }),
753
+ );
754
+ }
755
+
680
756
  function dailyCollectFacts(args: string[]): void {
681
757
  const [workDir = '', runDate = '', pluginsPath = ''] = args;
682
758
  const content = join(workDir, '20-facts', `${runDate}_02_collect_content.json`);
@@ -684,7 +760,7 @@ function dailyCollectFacts(args: string[]): void {
684
760
  const blended = join(workDir, '10-ingest', `${runDate}_02_collect_blended.json`);
685
761
  timed(workDir, 'collect', () => {
686
762
  if (isFile(content) && isFile(facts)) {
687
- console.log(`core facts cached: ${facts}`);
763
+ console.error(`core facts cached: ${facts}`);
688
764
  return;
689
765
  }
690
766
  kk(['executor', 'run', 'core-facts-gen', '--in', blended, '--out', content, ...pluginsArgs(pluginsPath)]);
@@ -694,46 +770,20 @@ function dailyCollectFacts(args: string[]): void {
694
770
  });
695
771
  }
696
772
 
697
- function dailyPlan(args: string[]): void {
698
- const [workDir = '', runDate = '', pluginsPath = '', planMaxItems = ''] = args;
699
- const planContent = join(workDir, '21-plan', `${runDate}_03_plan_content.json`);
700
- const planDocs = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
701
- const blended = join(workDir, '10-ingest', `${runDate}_02_collect_blended.json`);
702
- timed(workDir, 'plan', () => {
703
- if (isFile(planDocs)) {
704
- console.log(`plan cached: ${planDocs}`);
705
- return;
706
- }
707
- kk(
708
- ['executor', 'run', 'episode-plan-gen', '--in', blended, '--out', planContent, ...pluginsArgs(pluginsPath)],
709
- {
710
- env: { EPISODE_PLAN_MAX_ITEMS: planMaxItems, EPISODE_PLAN_DATE: runDate },
711
- },
712
- );
713
- const docs = metadataOf(readJson(planContent)).docs;
714
- if (!Array.isArray(docs) || docs.length === 0) failWith(1, 'plan: content.metadata.docs missing or empty');
715
- sted('write', planDocs, () => writeJsonText(planDocs, docs));
716
- if (!isFile(planDocs)) failWith(1, `plan: ${planDocs} not written after episode-plan-gen`);
717
- });
718
- }
719
-
720
- /**
721
- * Topic-boundary sidecar (task 0189 R2). Sibling of this file; node-builtins-only read.
722
- */
723
- interface TopicBoundaryRule {
773
+ export interface TopicBoundaryRule {
724
774
  id: string;
725
775
  reason: string;
726
776
  keywords: string[];
727
777
  }
728
778
 
729
- interface TopicBoundarySpec {
779
+ export interface TopicBoundarySpec {
730
780
  version: number;
731
781
  scanChars: number;
732
782
  rules: TopicBoundaryRule[];
733
783
  dateConflict: { reason: string };
734
784
  }
735
785
 
736
- interface TopicBoundaryExclusion {
786
+ export interface TopicBoundaryExclusion {
737
787
  id: string;
738
788
  title: string;
739
789
  rule: string;
@@ -741,7 +791,7 @@ interface TopicBoundaryExclusion {
741
791
  matched: string;
742
792
  }
743
793
 
744
- function readTopicBoundary(): TopicBoundarySpec {
794
+ export function readTopicBoundary(): TopicBoundarySpec {
745
795
  const path = join(SCRIPTS_DIR, 'topic-boundary.json');
746
796
  let parsed: TopicBoundarySpec;
747
797
  try {
@@ -830,86 +880,7 @@ export function applyTopicBoundary(
830
880
  return { kept, excluded };
831
881
  }
832
882
 
833
- function dailyQcContent(args: string[]): void {
834
- const [
835
- workDir = '',
836
- runDate = '',
837
- pluginsPath = '',
838
- qcCategories = '',
839
- minQuality = '',
840
- minImportance = '',
841
- minUrgency = '',
842
- minImpact = '',
843
- minEpisodeItems = '',
844
- ] = args;
845
- const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
846
- const annotated = join(workDir, '21-plan', `${runDate}_04_quality-control-content_annotated.json`);
847
- const exclusions = join(workDir, '21-plan', `${runDate}_04_quality-control-content_exclusions.json`);
848
- const planContent = join(workDir, '21-plan', `${runDate}_03_plan_content.json`);
849
- timed(workDir, 'quality-control-content', () => {
850
- if (isFile(candidates)) {
851
- console.log(`candidates cached: ${candidates}`);
852
- return;
853
- }
854
- sted('write', annotated, () => writeJqText(annotated, metadataOf(readJson(planContent)).docs ?? null));
855
- kk(
856
- [
857
- 'executor',
858
- 'run',
859
- 'episode-plan-gen',
860
- '--in',
861
- annotated,
862
- '--out',
863
- candidates,
864
- ...pluginsArgs(pluginsPath),
865
- ],
866
- {
867
- env: {
868
- EPISODE_PLAN_MODE: 'filter',
869
- QC_CATEGORIES: qcCategories,
870
- QC_MIN_QUALITY: minQuality,
871
- QC_MIN_IMPORTANCE: minImportance,
872
- QC_MIN_URGENCY: minUrgency,
873
- QC_MIN_IMPACT: minImpact,
874
- QC_MIN_EPISODE_ITEMS: minEpisodeItems,
875
- },
876
- },
877
- );
878
- });
879
- // Topic boundary (task 0189 R2) runs on every invocation — re-applying to already-filtered
880
- // candidates is a no-op, so cached reruns stay idempotent while a crash between the candidates
881
- // write and the exclusions write still converges on the next run. Zero kept docs leaves the
882
- // candidates file empty for the workflow's existing done-no-content edge.
883
- const boundaryDocs = metadataOf(readJson(candidates)).docs;
884
- if (!Array.isArray(boundaryDocs)) return;
885
- const { kept, excluded } = applyTopicBoundary(boundaryDocs, runDate, readTopicBoundary());
886
- if (excluded.length === 0) return;
887
- sted('write', exclusions, () =>
888
- writeJsonText(exclusions, {
889
- version: 1,
890
- runDate,
891
- excluded,
892
- kept: kept.map((doc) => (doc as { id: string }).id),
893
- }),
894
- );
895
- const content = requireJson(candidates, 'topic-boundary');
896
- const meta = content.metadata;
897
- if (meta === null || typeof meta !== 'object') {
898
- failWith(1, 'topic-boundary: candidates content carries no metadata object');
899
- }
900
- (meta as Record<string, unknown>).docs = kept;
901
- sted('write', candidates, () => writeJsonText(candidates, content));
902
- console.log(
903
- `topic-boundary: excluded ${excluded.length} item(s): ${excluded.map((entry) => `${entry.id}(${entry.rule})`).join(', ')}`,
904
- );
905
- }
906
-
907
- /**
908
- * Character-bigram Jaccard over whitespace-normalized, lowercased titles — the twin of
909
- * episode-plan-gen's `titleSimilarity`. Duplicated on purpose: this file is node-builtins-only
910
- * (no workspace imports), so it cannot reach that module.
911
- */
912
- function titleSimilarity(a: string, b: string): number {
883
+ export function titleSimilarity(a: string, b: string): number {
913
884
  const bigrams = (text: string): Set<string> => {
914
885
  const normalized = text.replace(/\s+/g, ' ').trim().toLowerCase();
915
886
  const out = new Set<string>();
@@ -959,7 +930,7 @@ export function dedupPlanByTitle<T extends { id?: unknown; title?: unknown }>(
959
930
  * reads. The sidecar's own stderr carries `content check failed: segment <idx> …`; this adds the
960
931
  * stage-level naming so the operator sees which artifact was rejected.
961
932
  */
962
- function assertScriptContent(scriptPath: string): void {
933
+ export function assertScriptContent(scriptPath: string): void {
963
934
  // `spawnSync` + `failWith`, deliberately NOT `delegate`: delegate is `never`-returning (it calls
964
935
  // process.exit), so using it mid-function would abort the calling stage on SUCCESS — dailyWrapDocs
965
936
  // would exit before packaging its docs. A clean exit here falls through to the rest of the stage.
@@ -970,80 +941,7 @@ function assertScriptContent(scriptPath: string): void {
970
941
  }
971
942
  }
972
943
 
973
- function dailyArticle(args: string[]): void {
974
- const [workDir = '', runDate = '', pluginsPath = ''] = args;
975
- const articleMd = join(workDir, '22-article', `${runDate}_06_article_article.md`);
976
- const articleContent = join(workDir, '22-article', `${runDate}_06_article_content.json`);
977
- const planIn = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
978
- const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
979
- timed(workDir, 'article', () => {
980
- let planUsable = true;
981
- const raw = isFile(planIn) ? readFileSync(planIn, 'utf-8') : '';
982
- try {
983
- JSON.parse(raw);
984
- } catch (error) {
985
- console.error(
986
- `article: plan.json invalid JSON — replacing with untranslated plan (downstream states read this file): ${(error as Error).message}`,
987
- );
988
- const docs = metadataOf(readJson(candidates)).docs;
989
- if (!Array.isArray(docs) || docs.length === 0) {
990
- console.error('article: candidates.json metadata.docs missing');
991
- planUsable = false;
992
- } else {
993
- sted('write', planIn, () => writeJsonText(planIn, docs));
994
- }
995
- }
996
- if (!planUsable) failWith(1, 'article: no usable plan input');
997
- // R3 (task 0138): dedup runs BEFORE the cache early-return — the script stage reads this same
998
- // plan.json and must see one survivor even when the article is already cached. Idempotent:
999
- // a no-op once the plan is deduped.
1000
- const planParsed: unknown = readJson(planIn);
1001
- if (Array.isArray(planParsed)) {
1002
- const { kept, dropped } = dedupPlanByTitle(planParsed as Record<string, unknown>[]);
1003
- if (dropped.length > 0) {
1004
- sted('write', planIn, () => writeJsonText(planIn, kept));
1005
- console.error(`article: dropped ${dropped.length} near-duplicate plan item(s): ${dropped.join(', ')}`);
1006
- }
1007
- }
1008
- if (isFile(articleMd)) {
1009
- console.log(`article cached: ${articleMd}`);
1010
- return;
1011
- }
1012
- // Agent-written prose body (workflow `article-write` state) replaces the mechanical
1013
- // per-item render when present — the 3-format split: surfdash gets an article,
1014
- // not a news list (2026-09-16 feedback).
1015
- const proseBody = join(workDir, '22-article', `${runDate}_06_article-write_body.md`);
1016
- kk(
1017
- [
1018
- 'executor',
1019
- 'run',
1020
- 'daily-article-gen',
1021
- '--in',
1022
- planIn,
1023
- '--out',
1024
- articleContent,
1025
- ...pluginsArgs(pluginsPath),
1026
- ],
1027
- {
1028
- env: {
1029
- ARTICLE_DATE: runDate,
1030
- // Plugin entries run with cwd = plugin dir (invoke.ts), so relative
1031
- // paths in env must be resolved or the plugin sees ENOENT.
1032
- ...(isFile(proseBody) ? { ARTICLE_BODY_FILE: resolve(proseBody) } : {}),
1033
- },
1034
- },
1035
- );
1036
- const body = readJson(articleContent)?.body;
1037
- if (typeof body !== 'string') failWith(1, `article: ${articleContent} has no string body`);
1038
- sted('write', articleMd, () => writeRaw(articleMd, body));
1039
- });
1040
- }
1041
-
1042
- /**
1043
- * F614 R2/R4: validate the agent-written cover brief in the order R4 names — the first failing
1044
- * check is the fallback reason; `reason: null` carries the trimmed subject.
1045
- */
1046
- function coverBrief(briefPath: string): { reason: string | null; subject: string } {
944
+ export function coverBrief(briefPath: string): { reason: string | null; subject: string } {
1047
945
  if (!isFile(briefPath)) return { reason: 'missing-brief', subject: '' };
1048
946
  let parsed: unknown;
1049
947
  try {
@@ -1061,71 +959,7 @@ function coverBrief(briefPath: string): { reason: string | null; subject: string
1061
959
  return { reason: null, subject: trimmed };
1062
960
  }
1063
961
 
1064
- /**
1065
- * F614 R3: build the image-gen envelope from the day's cover brief and render it. The subject is
1066
- * the only model-authored input (the workflow's brief agent.run wrote it); format, style,
1067
- * provider and model are pinned by this stage so no caller can drift the look. A missing or
1068
- * invalid brief degrades to the format's default subject (R4) — a plugin failure still fails
1069
- * the stage, matching the pre-F614 cover behavior.
1070
- */
1071
- function dailyCover(args: string[]): void {
1072
- const [workDir = '', runDate = '', pluginsPath = '', provider = '', model = '', fallback = ''] = args;
1073
- const briefPath = join(workDir, '23-cover', `${runDate}_07_cover-brief.json`);
1074
- const envPath = join(workDir, '23-cover', `${runDate}_07_cover_image-gen-in.json`);
1075
- const contentOut = join(workDir, '23-cover', 'content.json');
1076
- timed(workDir, 'cover', () => {
1077
- const brief = coverBrief(briefPath);
1078
- const vars: Record<string, string> = {};
1079
- if (brief.reason !== null) {
1080
- console.error(`cover brief: fallback (${brief.reason})`);
1081
- } else {
1082
- vars.subject = brief.subject;
1083
- }
1084
- const imageGen: Record<string, unknown> = {
1085
- job: 'cover',
1086
- format: COVER_FORMAT,
1087
- style: COVER_STYLE,
1088
- vars,
1089
- maxImages: 1,
1090
- };
1091
- if (provider !== '') imageGen.provider = provider;
1092
- if (model !== '') imageGen.model = model;
1093
- // D6 0166 R7: runtime-fallback opt-in. '' (the shipped var default) keeps strict fail-fast —
1094
- // F614's pinned look stays the default; 'true' = retry down AUTO_ORDER; anything else is a
1095
- // comma-split, trimmed candidate list. Unknown names are rejected by image-gen, not here.
1096
- if (fallback !== '') {
1097
- imageGen.fallback =
1098
- fallback === 'true'
1099
- ? true
1100
- : fallback
1101
- .split(',')
1102
- .map((s) => s.trim())
1103
- .filter(Boolean);
1104
- }
1105
- sted('write', envPath, () =>
1106
- writeJsonText(envPath, [{ id: 'daily-cover', title: runDate, body: '', metadata: { imageGen } }]),
1107
- );
1108
- kk(['executor', 'run', 'image-gen', '--in', envPath, '--out', contentOut, ...pluginsArgs(pluginsPath)]);
1109
- });
1110
- }
1111
-
1112
- function dailyCoverNormalize(args: string[]): void {
1113
- const [workDir = '', runDate = ''] = args;
1114
- // The pre-0133 shell had no timing trap, so this stage is not `timed`; it still fails through
1115
- // `failWith`, which needs the named-failure handling `timed` gives the other stages.
1116
- try {
1117
- coverNormalizeBody(workDir, runDate);
1118
- } catch (error) {
1119
- if (error instanceof StageFailure) {
1120
- if (error.named) console.error(error.message);
1121
- process.exit(error.status);
1122
- }
1123
- console.error((error as Error).message);
1124
- process.exit(1);
1125
- }
1126
- }
1127
-
1128
- function coverNormalizeBody(workDir: string, runDate: string): void {
962
+ export function coverNormalizeBody(workDir: string, runDate: string): void {
1129
963
  // F614 R5: the artifact name follows the format preset (`<stem>-<format>-NN.<ext>`,
1130
964
  // paths.ts), so normalize reads the path the plugin itself recorded in the manifest instead
1131
965
  // of a hard-coded name. The legacy content-cover-01.{png,jpg} paths stay as stale cleanup.
@@ -1165,11 +999,11 @@ function coverNormalizeBody(workDir: string, runDate: string): void {
1165
999
  // `rm -f` is silent when the file is already gone (a renamed source lands here too).
1166
1000
  }
1167
1001
  }
1168
- console.log(`cover normalized: ${runDate}_07_cover_cover.png`);
1002
+ console.error(`cover normalized: ${runDate}_07_cover_cover.png`);
1169
1003
  }
1170
1004
 
1171
1005
  /** `metadata.images[0]` — the artifact the image-gen plugin recorded for one render. */
1172
- function firstArtifact(content: Record<string, unknown> | null): Record<string, unknown> | null {
1006
+ export function firstArtifact(content: Record<string, unknown> | null): Record<string, unknown> | null {
1173
1007
  const images = metadataOf(content).images;
1174
1008
  const first = Array.isArray(images) ? images[0] : undefined;
1175
1009
  return first !== null && typeof first === 'object' ? (first as Record<string, unknown>) : null;
@@ -1181,7 +1015,7 @@ function firstArtifact(content: Record<string, unknown> | null): Record<string,
1181
1015
  * check fails loud naming the field, so an unusable plan skips the XHS target visibly instead of
1182
1016
  * publishing a partial card series (the owning state carries `onError: continue`).
1183
1017
  */
1184
- function xhsPlan(planPath: string, count: number): { title: string; body: string; cards: XhsCard[] } {
1018
+ export function xhsPlan(planPath: string, count: number): { title: string; body: string; cards: XhsCard[] } {
1185
1019
  const parsed = requireJson(planPath, 'xhs plan');
1186
1020
  const title = typeof parsed.title === 'string' ? parsed.title.trim() : '';
1187
1021
  if (title === '' || title.length > XHS_TITLE_CAP) {
@@ -1229,293 +1063,14 @@ function xhsPlan(planPath: string, count: number): { title: string; body: string
1229
1063
  return { title, body, cards };
1230
1064
  }
1231
1065
 
1232
- /**
1233
- * E8: build one `image-gen` envelope per planned card, render them sequentially, and package the
1234
- * ordered card manifest + `Content` that `xhs-pub` consumes. Format, style, provider and model
1235
- * are pinned here (never model-authored), so the series cannot drift from its preset. Card copy
1236
- * is typeset by image-gen's composer; the provider prompt stays text-free (0181). Resume-safe:
1237
- * a card whose recorded artifact is still on disk is not re-rendered — and not re-billed.
1238
- */
1239
- function dailyXhsRender(args: string[]): void {
1240
- const [workDir = '', runDate = '', pluginsPath = '', countArg = '', style = '', provider = '', model = ''] = args;
1241
- const count = Number.parseInt(countArg, 10);
1242
- const planPath = join(workDir, '25-xhs', `${runDate}_18_xhs_plan.json`);
1243
- const cardsDir = join(workDir, '25-xhs', 'cards');
1244
- const manifestPath = join(workDir, '25-xhs', 'cards.json');
1245
- const contentPath = join(workDir, '25-xhs', 'content.json');
1246
- timed(workDir, 'xhs-render', () => {
1247
- if (!Number.isInteger(count) || count < 1 || count > XHS_MAX_CARDS) {
1248
- failWith(1, `xhs render: xhs_cards must be 1..${XHS_MAX_CARDS}, got '${countArg}'`);
1249
- }
1250
- const plan = xhsPlan(planPath, count);
1251
- sted('mkdir', cardsDir, () => mkdirSync(cardsDir, { recursive: true }));
1252
- const images: Record<string, unknown>[] = [];
1253
- const cards: Record<string, unknown>[] = [];
1254
- for (const card of plan.cards) {
1255
- const out = join(cardsDir, `card-${card.index}.json`);
1256
- const promptPath = join(cardsDir, `card-${card.index}.prompt.txt`);
1257
- let artifact = firstArtifact(readJson(out));
1258
- if (artifact === null || !isFile(typeof artifact.path === 'string' ? artifact.path : '')) {
1259
- const envPath = join(cardsDir, `card-${card.index}.in.json`);
1260
- const imageGen: Record<string, unknown> = {
1261
- job: 'free',
1262
- format: XHS_FORMAT,
1263
- style,
1264
- vars: {
1265
- role: card.role,
1266
- layout: card.layout,
1267
- headline: card.copy,
1268
- points: card.points.join('\n'),
1269
- subject: card.subject,
1270
- index: card.index,
1271
- },
1272
- maxImages: 1,
1273
- };
1274
- if (provider !== '') imageGen.provider = provider;
1275
- if (model !== '') imageGen.model = model;
1276
- sted('write', envPath, () =>
1277
- writeJsonText(envPath, [
1278
- { id: `xhs-card-${card.index}`, title: runDate, body: '', metadata: { imageGen } },
1279
- ]),
1280
- );
1281
- // Sequential on purpose: one provider call per card (illustration only —
1282
- // card copy is typeset by image-gen's composer, never prompted), and the
1283
- // first failure stops the spend — later cards stay unrendered for a resume
1284
- // run to pick up.
1285
- kk(['executor', 'run', 'image-gen', '--in', envPath, '--out', out, ...pluginsArgs(pluginsPath)]);
1286
- artifact = firstArtifact(requireJson(out, 'xhs render'));
1287
- }
1288
- if (artifact === null) failWith(1, `xhs render: card ${card.index} recorded no image artifact (${out})`);
1289
- const recorded = artifact.path;
1290
- if (typeof recorded !== 'string' || !isFile(recorded)) {
1291
- failWith(1, `xhs render: card ${card.index} image artifact is not on disk: '${String(recorded)}'`);
1292
- }
1293
- // `image-gen` writes the artifact beside `--out`; the publisher requires absolute paths.
1294
- const imagePath = resolve(recorded);
1295
- // 0181: a composed card may also carry the text-free illustration layer; keep
1296
- // its recorded path so the manifest shows both layers.
1297
- const rawIllustration: unknown = artifact.illustration;
1298
- const illustrationPath =
1299
- typeof rawIllustration === 'object' &&
1300
- rawIllustration !== null &&
1301
- typeof (rawIllustration as Record<string, unknown>).path === 'string'
1302
- ? resolve((rawIllustration as Record<string, unknown>).path as string)
1303
- : undefined;
1304
- sted('write', promptPath, () =>
1305
- writeRaw(promptPath, typeof artifact.prompt === 'string' ? artifact.prompt : ''),
1306
- );
1307
- images.push({ ...artifact, path: imagePath });
1308
- cards.push({
1309
- index: card.index,
1310
- role: card.role,
1311
- sourcePoint: card.sourcePoint,
1312
- copy: card.copy,
1313
- layout: card.layout,
1314
- promptPath,
1315
- imagePath,
1316
- illustrationPath,
1317
- status: 'rendered',
1318
- });
1319
- }
1320
- sted('write', manifestPath, () => writeJsonText(manifestPath, { source: planPath, cards }));
1321
- sted('write', contentPath, () =>
1322
- writeJsonText(contentPath, {
1323
- title: plan.title,
1324
- body: plan.body,
1325
- format: 'text',
1326
- metadata: { images, publishStatus: 'draft' },
1327
- }),
1328
- );
1329
- console.log(`xhs render: ${cards.length} cards packaged in card order (${contentPath})`);
1330
- });
1331
- }
1332
-
1333
- /** A single-target publish Result, not the fan-out aggregate: `ok` plus `metadata.publishStatus`. */
1334
- function resultPublishStatus(path: string): string {
1066
+ export function resultPublishStatus(path: string): string {
1335
1067
  const parsed = readJson(path);
1336
1068
  if (parsed?.ok !== true) return '';
1337
1069
  const status = metadataOf(parsed).publishStatus;
1338
1070
  return status === 'public' || status === 'draft' ? status : '';
1339
1071
  }
1340
1072
 
1341
- /**
1342
- * E8: publish the packaged card series through `xhs-pub`'s script driver. Draft by default; live
1343
- * only on an explicit launch-time `xhs_live=true` (ADR-022 — an unattended run never posts live on
1344
- * its own). Non-blocking by operator decision: a missing/expired XHS session or a changed console
1345
- * logs a loud failure and leaves the run alive, and the outcome never enters `publish_status`
1346
- * (surfdash + podcast remain the episode's publication contract).
1347
- */
1348
- function dailyPublishXhs(args: string[]): void {
1349
- const [workDir = '', runDate = '', pluginsPath = '', live = 'false'] = args;
1350
- const content = join(workDir, '25-xhs', 'content.json');
1351
- const input = join(workDir, '31-publish', `${runDate}_19_publish-xhs_content.json`);
1352
- const result = join(workDir, '31-publish', `${runDate}_19_publish-xhs_result.json`);
1353
- const want = live === 'true' ? 'public' : 'draft';
1354
- timed(workDir, 'publish-xhs', () => {
1355
- // Cache on the *requested* status: a cached draft must not silently satisfy a later
1356
- // xhs_live=true re-run (that would report a live post that never happened).
1357
- if (resultPublishStatus(result) === want) {
1358
- console.log(`publish-xhs cached: ${want} already recorded in ${result}`);
1359
- return;
1360
- }
1361
- if (!isFile(content)) {
1362
- console.log(`publish-xhs: no card series at ${content} (render packaged none) — skip`);
1363
- return;
1364
- }
1365
- const packaged = requireJson(content, 'publish-xhs');
1366
- sted('write', input, () =>
1367
- writeJsonText(input, { ...packaged, metadata: { ...metadataOf(packaged), publishStatus: want } }),
1368
- );
1369
- // `soft` keeps the XHS target non-blocking: the plugin still writes its own failed Result
1370
- // and prints the failing step (login gate, upload, body, submit control), which is what the
1371
- // operator needs to re-run — never a silent skip.
1372
- const status = kk(['executor', 'run', 'xhs-pub', '--in', input, '--out', result, ...pluginsArgs(pluginsPath)], {
1373
- soft: true,
1374
- });
1375
- console.log(
1376
- status === 0
1377
- ? `publish-xhs: ${want} dispatched; outcome recorded in ${result}`
1378
- : `WARNING: publish-xhs — xhs-pub exited ${status}; XHS target skipped (run continues, see ${result})`,
1379
- );
1380
- });
1381
- }
1382
-
1383
- /**
1384
- * WeChat MP: drive the console via `wechatmp-pub`'s browser transport and save the article to the
1385
- * draft box (草稿箱). Draft by default; the 发表 flow runs only on an explicit launch-time
1386
- * `wechat_live=true` (ADR-022 rail, same as xhs). Independent and non-blocking like XHS: the
1387
- * outcome never enters `publish_status` (surfdash + podcast remain the publication contract).
1388
- */
1389
- function dailyPublishWechat(args: string[]): void {
1390
- const [workDirRaw = '', runDate = '', pluginsPath = '', live = 'false', collectionArg = ''] = args;
1391
- // Metadata carries file paths consumed by the plugin child process (cwd = plugin dir) - never emit relatives.
1392
- const workDir = resolve(workDirRaw);
1393
- const article = join(workDir, '22-article', `${runDate}_06_article_content.json`);
1394
- const input = join(workDir, '31-publish', `${runDate}_20_publish-wechat_content.json`);
1395
- const result = join(workDir, '31-publish', `${runDate}_20_publish-wechat_result.json`);
1396
- const want = live === 'true' ? 'public' : 'draft';
1397
- timed(workDir, 'publish-wechat', () => {
1398
- // Cache on the *requested* status: a cached draft must not silently satisfy a later
1399
- // wechat_live=true re-run (that would report a live post that never happened).
1400
- if (resultPublishStatus(result) === want) {
1401
- console.log(`publish-wechat cached: ${want} already recorded in ${result}`);
1402
- return;
1403
- }
1404
- if (!isFile(article)) {
1405
- console.log(`publish-wechat: no article at ${article} — skip`);
1406
- return;
1407
- }
1408
- const packaged = requireJson(article, 'publish-wechat');
1409
- // MP draft enrichment (operator preference 2026-09-29): 原创+作者, 赞赏, 合集, 原文链接
1410
- // (the surfdash post), AI 生成声明, cover. The brand strings are this daily show's constants.
1411
- const cover = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1412
- const audio = join(workDir, '30-audio', `${runDate}_11_generate_content.mp3`);
1413
- const wechatmp: Record<string, unknown> = {
1414
- original: true,
1415
- aiGenerated: true,
1416
- author: '冰原奔狼',
1417
- collection: collectionArg || 'AI 乱炖',
1418
- ...(isFile(cover) ? { coverPath: cover } : {}),
1419
- // Audio block at the article top (operator request 2026-09-29): the daily MP3 goes to
1420
- // the 素材库 under the article title; the plugin reuses an existing same-title material.
1421
- ...(isFile(audio)
1422
- ? {
1423
- audioPath: audio,
1424
- audioTitle: typeof packaged.title === 'string' ? packaged.title : '',
1425
- audioCoverPath: isFile(cover) ? cover : '',
1426
- }
1427
- : {}),
1428
- };
1429
- // WeChat-only closing line (operator request 2026-09-29): point readers/listeners back to
1430
- // the show's platforms. Appended to the wechat input body only, never the master article.
1431
- const outro = '\n\n欲听往期节目,可至小宇宙、Apple Podcast、YouTube 等平台搜索【咸话咸说】。';
1432
- sted('write', input, () =>
1433
- writeJsonText(input, {
1434
- ...packaged,
1435
- body: `${typeof packaged.body === 'string' ? packaged.body : ''}${outro}`,
1436
- metadata: { ...metadataOf(packaged), publishStatus: want, wechatmp },
1437
- }),
1438
- );
1439
- // `soft` keeps the WeChat target non-blocking: the plugin still writes its own failed Result
1440
- // and prints the failing step (session preflight, editor paste, save control), which is what
1441
- // the operator needs to re-run — never a silent skip.
1442
- const status = kk(
1443
- ['executor', 'run', 'wechatmp-pub', '--in', input, '--out', result, ...pluginsArgs(pluginsPath)],
1444
- {
1445
- soft: true,
1446
- },
1447
- );
1448
- console.log(
1449
- status === 0
1450
- ? `publish-wechat: ${want} dispatched; outcome recorded in ${result}`
1451
- : `WARNING: publish-wechat — wechatmp-pub exited ${status}; WeChat target skipped (run continues, see ${result})`,
1452
- );
1453
- });
1454
- }
1455
-
1456
- /**
1457
- * F613 R1: snapshot the shipped style contract into the run's work dir. Spur workflows have no
1458
- * workflow-dir var, so the script-write prompt reads this copy — and the snapshot freezes the
1459
- * exact contract the run's plan was written against. Idempotent (an existing snapshot is kept);
1460
- * a missing sidecar is a broken install, so `requireSidecar` fails loud.
1461
- */
1462
- function dailyScriptStyle(args: string[]): void {
1463
- const [workDir = '', runDate = ''] = args;
1464
- const snapshot = join(workDir, '24-script', `${runDate}_08_script-write_style.md`);
1465
- if (isFile(snapshot)) {
1466
- console.log(`script style cached: ${snapshot}`);
1467
- return;
1468
- }
1469
- const sidecar = requireSidecar('', STYLE_SIDECAR, 'daily-voice-style');
1470
- const scriptDir = join(workDir, '24-script');
1471
- sted('mkdir', scriptDir, () => mkdirSync(scriptDir, { recursive: true }));
1472
- sted('cp', snapshot, () => copyFileSync(sidecar, snapshot));
1473
- console.log(`script style snapshot: ${snapshot}`);
1474
- }
1475
-
1476
- function dailyScript(args: string[]): void {
1477
- const [workDir = '', runDate = '', pluginsPath = '', language = '', title = '', voiceProfile = ''] = args;
1478
- const yamlOut = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
1479
- const contentOut = join(workDir, '24-script', `${runDate}_08_script_content.json`);
1480
- const planIn = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
1481
- timed(workDir, 'script', () => {
1482
- if (isFile(yamlOut)) {
1483
- console.log(`voicescript capped by plan: ${yamlOut}`);
1484
- return;
1485
- }
1486
- kk(['executor', 'run', 'dailynews-gen', '--in', planIn, '--out', contentOut, ...pluginsArgs(pluginsPath)], {
1487
- env: {
1488
- DAILYNEWS_LANGUAGE: language,
1489
- DAILYNEWS_TITLE: title,
1490
- // Task 0189: the broadcast greeting carries the episode's run date, not the render
1491
- // date — a backfilled/retried run must not greet "today".
1492
- DAILYNEWS_DATE: runDate,
1493
- VOICEBOX_DEFAULT_PROFILE: voiceProfile,
1494
- // F613 R4: always passed as absolute paths — spawnPluginEntry runs plugins with
1495
- // cwd = the plugin package dir, so a relative workDir would silently miss the
1496
- // plan; a plan file absent at build time reports missing-plan (template fallback).
1497
- DAILYNEWS_SCRIPT_PLAN: resolve(join(workDir, '24-script', `${runDate}_08_script-write_plan.json`)),
1498
- DAILYNEWS_STYLE_FILE: resolve(resolveSidecar('', STYLE_SIDECAR)),
1499
- },
1500
- });
1501
- const body = readJson(contentOut)?.body;
1502
- if (typeof body !== 'string') failWith(1, `script: ${contentOut} has no string body`);
1503
- sted('write', yamlOut, () => writeRaw(yamlOut, body));
1504
- // R3 (task 0139): fresh writes are validated here; the script cache path is covered by
1505
- // wrap-docs, which runs on every path that reaches audio.
1506
- try {
1507
- assertScriptContent(yamlOut);
1508
- } catch (err) {
1509
- // A rejected fresh write would otherwise poison the isFile(yamlOut) cache: every
1510
- // resume skips regeneration and re-fails at wrap-docs. Drop it so retry regenerates.
1511
- unlinkSync(yamlOut);
1512
- throw err;
1513
- }
1514
- });
1515
- }
1516
-
1517
- /** Task 0189 R10 artifacts of the content-safety verdict gate (24-script, step 09). */
1518
- function safetyPath(workDir: string, runDate: string, name: string): string {
1073
+ export function safetyPath(workDir: string, runDate: string, name: string): string {
1519
1074
  return join(workDir, '24-script', `${runDate}_09_${name}`);
1520
1075
  }
1521
1076
 
@@ -1524,7 +1079,7 @@ function safetyPath(workDir: string, runDate: string, name: string): string {
1524
1079
  * for the bytes it was issued on: the gate and publish-prep both recompute this, so an edit after
1525
1080
  * PASS — including a HITL edit at `review` — invalidates the stamp instead of riding on it.
1526
1081
  */
1527
- function safetyDigest(workDir: string, runDate: string): string {
1082
+ export function safetyDigest(workDir: string, runDate: string): string {
1528
1083
  const hash = createHash('sha256');
1529
1084
  for (const path of safetyDraftPaths(workDir, runDate)) {
1530
1085
  if (!isFile(path)) failWith(1, `safety-review: required draft not found: ${path}`);
@@ -1534,337 +1089,14 @@ function safetyDigest(workDir: string, runDate: string): string {
1534
1089
  }
1535
1090
 
1536
1091
  /** The drafts under review — the digest's bytes, and the quarantine set on a quality block. */
1537
- function safetyDraftPaths(workDir: string, runDate: string): [string, string] {
1092
+ export function safetyDraftPaths(workDir: string, runDate: string): [string, string] {
1538
1093
  return [
1539
1094
  join(workDir, '22-article', `${runDate}_06_article_content.json`),
1540
1095
  join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`),
1541
1096
  ];
1542
1097
  }
1543
1098
 
1544
- /**
1545
- * Open a content-safety review of the current drafts: drop every artifact of an earlier review
1546
- * (a retry must be re-reviewed on its final version) and write the request the two agent.run
1547
- * steps read. The program passes context only — platform policy lives in the kk:content-safety
1548
- * skill's reference files, never here.
1549
- */
1550
- function dailySafetyPrep(args: string[]): void {
1551
- const [workDir = '', runDate = '', platforms = ''] = args;
1552
- timed(workDir, 'safety-review', () => {
1553
- const digest = safetyDigest(workDir, runDate);
1554
- for (const name of [
1555
- 'safety-review_report.json',
1556
- 'safety-judge_verdict.json',
1557
- 'safety-judge_status.txt',
1558
- 'safety-judge_stamp.json',
1559
- ]) {
1560
- const stale = safetyPath(workDir, runDate, name);
1561
- if (isFile(stale)) unlinkSync(stale);
1562
- }
1563
- const request = safetyPath(workDir, runDate, 'safety-review_request.json');
1564
- const targets = platforms
1565
- .split(',')
1566
- .map((platform) => platform.trim())
1567
- .filter(Boolean);
1568
- sted('write', request, () => writeJsonText(request, { digest, platforms: targets, mode: 'detect-only' }));
1569
- });
1570
- }
1571
-
1572
- /**
1573
- * Consume the content-safety report and the content-judge verdict into pass|escalate|block (exit 0
1574
- * in all three — routing is transition-owned). Fail-closed: only an explicit PASS verdict on the
1575
- * unchanged drafts, with a well-formed report carrying no BLOCK, unblocks; a RISK decision or
1576
- * `escalate: true` forces the human review regardless of the verdict score.
1577
- */
1578
- function dailySafetyGate(args: string[]): void {
1579
- const [workDir = '', runDate = ''] = args;
1580
- const statusFile = safetyPath(workDir, runDate, 'safety-judge_status.txt');
1581
- const stampFile = safetyPath(workDir, runDate, 'safety-judge_stamp.json');
1582
- timed(workDir, 'safety-judge', () => {
1583
- const request = readJson(safetyPath(workDir, runDate, 'safety-review_request.json'));
1584
- const report = readJson(safetyPath(workDir, runDate, 'safety-review_report.json'));
1585
- const verdict = readJson(safetyPath(workDir, runDate, 'safety-judge_verdict.json'));
1586
- const platforms = Array.isArray(report?.platforms) ? (report.platforms as Record<string, unknown>[]) : [];
1587
- const decisions = platforms.map((platform) => platform?.decision);
1588
- const digest = safetyDigest(workDir, runDate);
1589
- let status = 'block';
1590
- let reason = 'verdict PASS on unchanged drafts, no BLOCK/RISK';
1591
- // A digest-mismatch block means the drafts were deliberately edited (HITL): keep them so
1592
- // a re-run of safety-prep re-reviews the edited version. Any other block means the
1593
- // content itself is rejected — quarantine the drafts below.
1594
- let edited = false;
1595
- if (request?.digest !== digest) {
1596
- reason = 'drafts changed after the review was requested';
1597
- edited = true;
1598
- } else if (decisions.length === 0 || decisions.some((d) => d !== 'OK' && d !== 'RISK' && d !== 'BLOCK'))
1599
- reason = 'content-safety report missing or malformed';
1600
- else if (decisions.includes('BLOCK')) reason = 'content-safety report carries a BLOCK decision';
1601
- else if (verdict?.verdict !== 'PASS')
1602
- reason = `content-judge verdict is ${typeof verdict?.verdict === 'string' ? verdict.verdict : 'missing or unreadable'}`;
1603
- else if (verdict?.confidence !== 'HIGH' && verdict?.confidence !== 'MEDIUM' && verdict?.confidence !== 'LOW')
1604
- reason = 'content-judge verdict is missing a HIGH/MEDIUM/LOW confidence level';
1605
- else if (decisions.includes('RISK') || report?.escalate === true) {
1606
- status = 'escalate';
1607
- reason = 'RISK decision or escalation — human review required';
1608
- } else status = 'pass';
1609
- if (status !== 'block') sted('write', stampFile, () => writeJsonText(stampFile, { digest, status }));
1610
- if (status === 'block' && !edited) {
1611
- // A quality block poisons the isFile() draft cache: every same-day retry would
1612
- // re-fail on unchanged bytes (20261002 dogfood — two judge-fail runs replayed until
1613
- // the drafts were moved by hand). Quarantine (mv, never rm) so the next run
1614
- // regenerates from scratch; the 09_* verdict artifacts stay in 24-script for audit.
1615
- const q = join(workDir, '24-script', `.quarantine-${runDate}-judge-block`);
1616
- mkdirSync(q, { recursive: true });
1617
- for (const path of safetyDraftPaths(workDir, runDate)) {
1618
- if (!isFile(path)) continue;
1619
- let dest = join(q, basename(path));
1620
- for (let n = 2; isFile(dest); n += 1) dest = join(q, `${n}-${basename(path)}`);
1621
- renameSync(path, dest);
1622
- }
1623
- }
1624
- sted('write', statusFile, () => writeRaw(statusFile, status));
1625
- console.log(`safety: status=${status} (${reason}); routing is transition-owned`);
1626
- });
1627
- }
1628
-
1629
- function dailyWrapDocs(args: string[]): void {
1630
- const [workDir = '', runDate = '', voiceProfile = ''] = args;
1631
- const scriptPath = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
1632
- const docsPath = join(workDir, '30-audio', `${runDate}_10_wrap-docs_docs.json`);
1633
- timed(workDir, 'wrap-docs', () => {
1634
- // R3 (task 0139): the second validator placement — covers a HITL-edited YAML and the
1635
- // script-stage cache path, neither of which re-enters dailyScript's fresh-write branch.
1636
- assertScriptContent(scriptPath);
1637
- const url = new URL(scriptPath, `file://${process.cwd()}/`);
1638
- const body = readFileSync(decodeURIComponent(url.pathname), 'utf-8');
1639
- const profile = voiceProfile || process.env.VOICEBOX_DEFAULT_PROFILE || 'robin-news';
1640
- const doc = {
1641
- id: createHash('sha256').update(url.href).digest('hex').slice(0, 16),
1642
- title: 'Daily AI News VoiceScript',
1643
- sourceUri: url.href,
1644
- body,
1645
- mediaType: 'application/yaml',
1646
- metadata: { voiceProfile: profile },
1647
- };
1648
- sted('write', docsPath, () => writeJsonText(docsPath, [doc]));
1649
- });
1650
- }
1651
-
1652
- function dailyGenerate(args: string[]): void {
1653
- const [workDir = '', runDate = '', pluginsPath = '', transcodeMp3 = '', vdriver = ''] = args;
1654
- // 20260917 review: the workflow picks a voice driver by short name; both drivers' envs are
1655
- // passed unconditionally so switching `vdriver` needs no shell changes. Unknown -> fail loud.
1656
- const VOICE_DRIVERS: Record<string, string> = {
1657
- ominivoice: 'omni-voice-gen',
1658
- voicebox: 'voice-gen',
1659
- gemini: 'gemini-tts',
1660
- };
1661
- const voiceGenerator = VOICE_DRIVERS[vdriver];
1662
- if (!voiceGenerator) {
1663
- failWith(
1664
- 1,
1665
- `generate: unknown vdriver '${vdriver}' (expected one of: ${Object.keys(VOICE_DRIVERS).join(', ')})`,
1666
- );
1667
- }
1668
- const docs = join(workDir, '30-audio', `${runDate}_10_wrap-docs_docs.json`);
1669
- const content = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
1670
- timed(workDir, 'generate', () => {
1671
- if (isFile(content) && metadataOf(readJson(content)).qc !== undefined) {
1672
- const qc = metadataOf(readJson(content)).qc as Record<string, unknown> | undefined;
1673
- if (qc?.passed === true) {
1674
- console.log(`audio cached: ${content} (QC passed)`);
1675
- return;
1676
- }
1677
- }
1678
- kk(['executor', 'run', voiceGenerator, '--in', docs, '--out', content, ...pluginsArgs(pluginsPath)], {
1679
- env: { VOICE_GEN_MP3: transcodeMp3 },
1680
- });
1681
- });
1682
- }
1683
-
1684
- /**
1685
- * 20260917 review #7: prepend the show's 8s intro sting to the generated audio.
1686
- * `intro_music` empty -> skip. Writes `<stem>.intro.wav` (original render kept), points
1687
- * `audioPath` at the merged file, extends `duration`, marks `introMerged` (idempotent).
1688
- */
1689
- /**
1690
- * Episode duration gate (standing requirement, 20261002): a daily episode must run 15–20 minutes
1691
- * (duration is authoritative; the 8–10-item figure is only an estimate). Reads the final audio
1692
- * content's `metadata.duration` — after the intro sting merged, this is the true episode length —
1693
- * and fails loud outside `[min, max]` seconds-worth of minutes. A missing duration is a failure,
1694
- * not a pass: the gate must never silently let an under-length episode through again.
1695
- */
1696
- function dailyEpisodeDuration(args: string[]): void {
1697
- const [workDir = '', runDate = '', minArg = '', maxArg = ''] = args;
1698
- if (!workDir) fail('missing work_dir');
1699
- if (!runDate) fail('missing run_date');
1700
- const minMinutes = Number(minArg);
1701
- const maxMinutes = Number(maxArg);
1702
- if (!Number.isFinite(minMinutes) || minMinutes <= 0) fail(`invalid min_minutes: ${minArg || '(empty)'}`);
1703
- if (!Number.isFinite(maxMinutes) || maxMinutes < minMinutes) fail(`invalid max_minutes: ${maxArg || '(empty)'}`);
1704
- const contentPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
1705
- if (!isFile(contentPath)) failWith(1, `episode-duration: audio content not found: ${contentPath}`);
1706
- let raw: unknown;
1707
- try {
1708
- const parsed = JSON.parse(readFileSync(contentPath, 'utf-8')) as { metadata?: { duration?: unknown } };
1709
- raw = parsed.metadata?.duration;
1710
- } catch (error) {
1711
- if ((error as { code?: string }).code !== undefined) {
1712
- sted('read', contentPath, () => readFileSync(contentPath, 'utf-8'));
1713
- }
1714
- raw = undefined;
1715
- }
1716
- const seconds = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN;
1717
- if (!Number.isFinite(seconds) || seconds <= 0) {
1718
- failWith(1, `episode-duration: audio metadata.duration missing or not a number in ${contentPath}`);
1719
- }
1720
- const minutes = seconds / 60;
1721
- const shown = minutes.toFixed(1);
1722
- if (minutes < minMinutes) {
1723
- failWith(
1724
- 1,
1725
- `episode-duration: episode runs ${shown} min, below the ${minMinutes}–${maxMinutes} min requirement (duration is authoritative). The candidate pool is too thin — deepen per-item treatment or add items; do not ship a short episode silently.`,
1726
- );
1727
- }
1728
- if (minutes > maxMinutes) {
1729
- failWith(
1730
- 1,
1731
- `episode-duration: episode runs ${shown} min, above the ${minMinutes}–${maxMinutes} min requirement. Trim items or per-item treatment.`,
1732
- );
1733
- }
1734
- console.log(`episode-duration ok: ${shown} min within ${minMinutes}–${maxMinutes} min`);
1735
- }
1736
-
1737
- function dailyIntro(args: string[]): void {
1738
- const [workDir = '', runDate = '', introSrc = ''] = args;
1739
- if (!introSrc || introSrc.trim() === '') {
1740
- console.log('intro-music: intro_music var empty — skip');
1741
- return;
1742
- }
1743
- const contentPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
1744
- timed(workDir, 'intro-music', () => {
1745
- const content = requireJson(contentPath, 'intro-music');
1746
- const meta = metadataOf(content);
1747
- if (meta.introMerged === true) {
1748
- console.log(`intro-music cached: ${contentPath}`);
1749
- return;
1750
- }
1751
- // Merge from the raw lossless render; mp3Path is the derived publish artifact.
1752
- // Preferring mp3Path here made `<stem>.mp3` the concat input while the output
1753
- // suffix rewrite (`.wav` -> `.intro.wav`) no longer matched, so ffmpeg saw one
1754
- // path as both input #1 and output and aborted with "same as Input #1".
1755
- const candidates = [meta.audioPath, meta.mp3Path].filter(
1756
- (candidate): candidate is string => typeof candidate === 'string' && candidate !== '',
1757
- );
1758
- const rawFile = candidates.find((candidate) => existsSync(resolve(candidate))) ?? candidates[0];
1759
- if (rawFile === undefined) {
1760
- failWith(1, 'intro-music: voice content carries no mp3Path/audioPath');
1761
- }
1762
- const introPath = resolve(introSrc);
1763
- if (!existsSync(introPath)) failWith(1, `intro-music: intro file not found: ${introPath}`);
1764
- const mainPath = resolve(rawFile);
1765
- if (!existsSync(mainPath)) failWith(1, `intro-music: generated audio not found: ${mainPath}`);
1766
- const mergedPath = `${mainPath.replace(/\.(wav|mp3)$/i, '')}.intro.wav`;
1767
- const probe = (file: string, entries: string): string =>
1768
- spawnSync('ffprobe', [
1769
- '-v',
1770
- 'error',
1771
- '-select_streams',
1772
- 'a:0',
1773
- '-show_entries',
1774
- entries,
1775
- '-of',
1776
- 'csv=p=0',
1777
- file,
1778
- ])
1779
- .stdout?.toString()
1780
- .trim() ?? '';
1781
- const mainDur = Number.parseFloat(probe(mainPath, 'format=duration'));
1782
- const introDur = Number.parseFloat(probe(introPath, 'format=duration'));
1783
- if (!Number.isFinite(mainDur) || !Number.isFinite(introDur)) {
1784
- failWith(1, `intro-music: ffprobe could not read durations (main=${mainDur} intro=${introDur})`);
1785
- }
1786
- const rate = probe(mainPath, 'stream=sample_rate') || '24000'; // match the generated audio's rate; mono s16 both sides
1787
- const norm = (label: string): string =>
1788
- `[${label}:a]aresample=${rate},aformat=sample_fmts=s16:channel_layouts=mono[${label}]`;
1789
- const fc = `${norm('0')};${norm('1')};[0][1]concat=n=2:v=0:a=1[out]`;
1790
- const conv = spawnSync('ffmpeg', [
1791
- '-y',
1792
- '-hide_banner',
1793
- '-loglevel',
1794
- 'error',
1795
- '-i',
1796
- introPath,
1797
- '-i',
1798
- mainPath,
1799
- '-filter_complex',
1800
- fc,
1801
- '-map',
1802
- '[out]',
1803
- '-c:a',
1804
- 'pcm_s16le',
1805
- mergedPath,
1806
- ]);
1807
- if (conv.status !== 0 || !existsSync(mergedPath)) {
1808
- failWith(1, `intro-music: ffmpeg concat failed: ${conv.stderr?.toString().trim()}`);
1809
- }
1810
- if (meta.mp3Path && typeof meta.mp3Path === 'string') {
1811
- // transcode_mp3=true: keep the mp3 path honest by re-transcoding the merged wav.
1812
- const mp3 = spawnSync('ffmpeg', [
1813
- '-y',
1814
- '-hide_banner',
1815
- '-loglevel',
1816
- 'error',
1817
- '-i',
1818
- mergedPath,
1819
- '-codec:a',
1820
- 'libmp3lame',
1821
- '-qscale:a',
1822
- '2',
1823
- resolve(meta.mp3Path),
1824
- ]);
1825
- if (mp3.status !== 0) failWith(1, `intro-music: mp3 re-transcode failed: ${mp3.stderr?.toString().trim()}`);
1826
- }
1827
- const mergedDur = Number.parseFloat(probe(mergedPath, 'format=duration')) || mainDur + introDur;
1828
- const updated = {
1829
- ...content,
1830
- metadata: {
1831
- ...meta,
1832
- audioPath: mergedPath,
1833
- duration: mergedDur,
1834
- introMerged: true,
1835
- },
1836
- };
1837
- sted('write', contentPath, () => writeJsonText(contentPath, updated));
1838
- console.log(
1839
- `intro-music: merged ${introDur.toFixed(1)}s intro -> ${mergedPath} (${mergedDur.toFixed(1)}s total)`,
1840
- );
1841
- });
1842
- }
1843
-
1844
- function dailyQualityReport(args: string[]): void {
1845
- const [workDir = '', runDate = ''] = args;
1846
- const content = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
1847
- timed(workDir, 'quality-control', () => {
1848
- const qc = metadataOf(readJson(content)).qc as Record<string, unknown> | undefined;
1849
- console.log('=== Voice Quality Control Report ===');
1850
- console.log(`Overall Score: ${qc ? qc.overallScore : 100}/100 | Passed: ${qc ? qc.passed : true}`);
1851
- const issues = qc?.criticalIssues;
1852
- if (Array.isArray(issues) && issues.length > 0) {
1853
- console.warn(`QC Issues:\n${issues.map((issue) => ` - ${issue}`).join('\n')}`);
1854
- } else {
1855
- console.log('All audio segments passed quality control checks cleanly.');
1856
- }
1857
- });
1858
- }
1859
-
1860
- /**
1861
- * 0171 R3: Apple Podcasts reads the enclosure's ID3 tags, so the published MP3 carries
1862
- * title/artist/album/date. `-c copy` moves the audio frames without a re-encode, and the temp
1863
- * file + rename keeps a failed ffmpeg from truncating the episode in place. An empty value
1864
- * omits that tag rather than failing. The caller runs this after intro-music, whose mp3
1865
- * re-transcode would otherwise strip tags stamped earlier.
1866
- */
1867
- function stampId3Tags(audioFile: string, title: string, runDate: string, artist: string, album: string): void {
1099
+ export function stampId3Tags(audioFile: string, title: string, runDate: string, artist: string, album: string): void {
1868
1100
  const tmpFile = `${audioFile}.id3.tmp.mp3`;
1869
1101
  const metadata: string[] = [];
1870
1102
  const addTag = (name: string, value: string): void => {
@@ -1899,169 +1131,7 @@ function stampId3Tags(audioFile: string, title: string, runDate: string, artist:
1899
1131
  sted('rename', audioFile, () => renameSync(tmpFile, audioFile));
1900
1132
  }
1901
1133
 
1902
- function dailyPublishPrep(args: string[]): void {
1903
- const [workDir = '', runDate = '', artist = '', album = ''] = args;
1904
- const articlePath = join(workDir, '22-article', `${runDate}_06_article_content.json`);
1905
- const audioPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
1906
- const prepPath = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
1907
- const coverPath = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1908
- timed(workDir, 'publish-prep', () => {
1909
- const article = requireJson(articlePath, 'publish-prep');
1910
- const audioMeta = metadataOf(readJson(audioPath));
1911
- const rawFile = audioMeta.mp3Path ?? audioMeta.audioPath;
1912
- if (typeof rawFile !== 'string' || rawFile === '') {
1913
- failWith(1, 'publish-prep: voice content carries no mp3Path/audioPath');
1914
- }
1915
- const audioFile = resolve(rawFile);
1916
- // 0171 R2: publish-prep publishes metadata.mp3Path ?? audioPath, so a WAV here means
1917
- // transcode_mp3 was turned off — the run would ship an enclosure Apple Podcasts rejects.
1918
- if (!audioFile.endsWith('.mp3')) {
1919
- failWith(1, `publish-prep: resolved audio is not an MP3 (transcode_mp3 must be "true"): ${audioFile}`);
1920
- }
1921
- stampId3Tags(audioFile, typeof article.title === 'string' ? article.title : '', runDate, artist, album);
1922
- const resolvedCover = resolve(coverPath);
1923
- const hasCover = existsSync(resolvedCover);
1924
- // Daily-show itunes convention: season = year, episode = day-of-year. run_date arrives
1925
- // dashed (`$(date +%F)`) or digits-only (formatRunDate); map.ts forwards numbers only,
1926
- // so a malformed run_date omits both tags instead of failing the publish.
1927
- const digits = runDate.replaceAll(/\D/g, '');
1928
- const season = Number(digits.slice(0, 4));
1929
- const month = Number(digits.slice(4, 6));
1930
- const day = Number(digits.slice(6, 8));
1931
- const episode =
1932
- digits.length === 8 && month >= 1 && month <= 12 && day >= 1 && day <= 31
1933
- ? Math.round((Date.UTC(season, month - 1, day) - Date.UTC(season, 0, 1)) / 86_400_000) + 1
1934
- : 0;
1935
- // podcast-pub's documented contract reads metadata.coverImage (map.ts); options.coverPath
1936
- // alone was silently dropped by the mapper — episodes published without a cover.
1937
- const merged = {
1938
- ...article,
1939
- metadata: {
1940
- ...metadataOf(article),
1941
- audioFile,
1942
- runDate,
1943
- ...(hasCover ? { coverImage: resolvedCover } : {}),
1944
- ...(episode > 0 ? { itunes: { season, episode } } : {}),
1945
- },
1946
- options: {
1947
- audioFile,
1948
- durationSec: audioMeta.duration,
1949
- coverPath: hasCover ? resolvedCover : undefined,
1950
- },
1951
- };
1952
- // Task 0189 R10: the last program seam before anything is dispatched. No stamp, or a
1953
- // stamp issued on different bytes (edited after PASS, or an older draft), never publishes.
1954
- const stamp = readJson(safetyPath(workDir, runDate, 'safety-judge_stamp.json'));
1955
- if (stamp === null) failWith(1, 'publish-prep: no content-safety verdict stamp for this run — not publishing');
1956
- if (stamp.digest !== safetyDigest(workDir, runDate)) {
1957
- failWith(1, 'publish-prep: drafts changed after the content-safety verdict — re-run the review');
1958
- }
1959
- sted('write', prepPath, () => writeJsonText(prepPath, merged));
1960
- });
1961
- }
1962
-
1963
- function dailyPublishSurfdash(args: string[]): void {
1964
- const [workDir = '', runDate = '', pluginsPath = ''] = args;
1965
- const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
1966
- const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
1967
- timed(workDir, 'publish-surfdash', () => {
1968
- if (targetPublished(result, 'surfdash-pub')) {
1969
- console.log(`publish-surfdash cached: surfdash target ok in ${result}`);
1970
- return;
1971
- }
1972
- kk(
1973
- [
1974
- 'executor',
1975
- 'fan-out',
1976
- '--content',
1977
- prep,
1978
- '--target',
1979
- 'surfdash-pub',
1980
- '--out',
1981
- result,
1982
- ...pluginsArgs(pluginsPath),
1983
- ],
1984
- {
1985
- unset: NESTED_SPUR_UNSET,
1986
- soft: true,
1987
- },
1988
- );
1989
- console.log('publish-surfdash: surfdash target dispatched; classification happens after podcast publish');
1990
- });
1991
- }
1992
-
1993
- function dailyShowNotes(args: string[]): void {
1994
- const [workDir = '', runDate = '', pluginsPath = '', title = ''] = args;
1995
- const article = join(workDir, '22-article', `${runDate}_06_article_content.json`);
1996
- // The preceding `publish-surfdash` state writes this artifact; `_17_publish_result.json` does
1997
- // not exist yet at `show-notes` time (the later `publish` state merges it), so reading it left
1998
- // PODCAST_PUB_ARTICLE_POST_PATH permanently empty (task 0137).
1999
- const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
2000
- const out = join(workDir, '31-publish', `${runDate}_15_show-notes_show-notes.md`);
2001
- const plan = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
2002
- // Generated-episode content carries `metadata.segments[]` — the source of real
2003
- // audio offsets for the show-notes timeline (jump-to-item timestamps).
2004
- const audioContent = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
2005
- timed(workDir, 'show-notes', () => {
2006
- const effective = readJson(article)?.title;
2007
- const titleArg = title !== '' ? title : typeof effective === 'string' ? effective : '';
2008
- // Boundary-excluded docs must not render into show notes (judge finding 2026-10-02): the
2009
- // article body's 未写入 note names the dropped ids; filter the plan copy podcast-pub sees.
2010
- // Inlined parse — this file must not import @gobing-ai/* (header contract); the same note
2011
- // grammar lives in daily-article-gen's excludedIdsFromNote.
2012
- const postPath = firstPostPath(readJson(result), 'surfdash-pub');
2013
- const articleBody = readJson(article)?.body;
2014
- const excluded = new Set<string>();
2015
- if (typeof articleBody === 'string') {
2016
- for (const note of articleBody.matchAll(/未写入[::](.*)/g)) {
2017
- for (const id of note[1].matchAll(/(?:[a-zA-Z0-9_-]+:)?[0-9a-f]{16}/g)) {
2018
- excluded.add(id[0]);
2019
- const colon = id[0].indexOf(':');
2020
- if (colon !== -1) excluded.add(id[0].slice(colon + 1));
2021
- }
2022
- }
2023
- }
2024
- let planIn = plan;
2025
- if (excluded.size > 0) {
2026
- const parsed: unknown = readJson(plan);
2027
- if (Array.isArray(parsed)) {
2028
- const kept = parsed.filter(
2029
- (d) =>
2030
- !(
2031
- d !== null &&
2032
- typeof d === 'object' &&
2033
- (excluded.has(String((d as Record<string, unknown>).id)) ||
2034
- (String((d as Record<string, unknown>).id).includes(':') &&
2035
- excluded.has(
2036
- String((d as Record<string, unknown>).id).slice(
2037
- String((d as Record<string, unknown>).id).indexOf(':') + 1,
2038
- ),
2039
- )))
2040
- ),
2041
- );
2042
- if (kept.length !== parsed.length) {
2043
- planIn = plan.replace(/\.json$/, '-shownotes.json');
2044
- writeJsonText(planIn, kept);
2045
- }
2046
- }
2047
- }
2048
- kk(['executor', 'run', 'podcast-pub', '--in', planIn, '--out', out, ...pluginsArgs(pluginsPath)], {
2049
- env: {
2050
- PODCAST_PUB_TITLE: titleArg,
2051
- PODCAST_PUB_RUN_DATE: runDate,
2052
- PODCAST_PUB_BRIEF_MODE: 'true',
2053
- PODCAST_PUB_ARTICLE_POST_PATH: postPath,
2054
- // Task 0189 R1 / Apple §1.11: AI-voice disclosure in the episode metadata, not only
2055
- // in the audio sign-off and the article footer.
2056
- PODCAST_PUB_FOOTER_NOTE: '本期文字由 AI 辅助整理,音频为 AI 合成配音。',
2057
- ...(isFile(audioContent) ? { PODCAST_PUB_AUDIO_CONTENT: audioContent } : {}),
2058
- },
2059
- });
2060
- });
2061
- }
2062
-
2063
- /** `jq -r '[…select(.target == t and .ok == true) | .metadata.postPath // empty] | first // ""'`. */
2064
- function firstPostPath(parsed: Record<string, unknown> | null, target: string): string {
1134
+ export function firstPostPath(parsed: Record<string, unknown> | null, target: string): string {
2065
1135
  const targets = parsed?.targets;
2066
1136
  if (!Array.isArray(targets)) return '';
2067
1137
  for (const entry of targets) {
@@ -2074,48 +1144,6 @@ function firstPostPath(parsed: Record<string, unknown> | null, target: string):
2074
1144
  return '';
2075
1145
  }
2076
1146
 
2077
- function dailyPublishPodcast(args: string[]): void {
2078
- const [workDir = '', runDate = '', pluginsPath = ''] = args;
2079
- const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
2080
- const notes = join(workDir, '31-publish', `${runDate}_15_show-notes_show-notes.md`);
2081
- const result = join(workDir, '31-publish', `${runDate}_16_publish-podcast_result.json`);
2082
- timed(workDir, 'publish-podcast', () => {
2083
- if (targetPublished(result, 'podcast-pub')) {
2084
- console.log(`publish-podcast cached: podcast target ok in ${result}`);
2085
- return;
2086
- }
2087
- try {
2088
- const content = requireJson(prep, 'publish-podcast');
2089
- const showNotes = readFileSync(notes, 'utf-8');
2090
- const merged = { ...content, metadata: { ...metadataOf(content), showNotes } };
2091
- sted('write', prep, () => writeJsonText(prep, merged));
2092
- kk(
2093
- [
2094
- 'executor',
2095
- 'fan-out',
2096
- '--content',
2097
- prep,
2098
- '--target',
2099
- 'podcast-pub',
2100
- '--out',
2101
- result,
2102
- ...pluginsArgs(pluginsPath),
2103
- ],
2104
- {
2105
- unset: ['SPUR_WORKFLOW_RUN_ACTIVE'],
2106
- soft: true,
2107
- },
2108
- );
2109
- } catch (error) {
2110
- // The shell's `… && … || true` swallowed both steps' failures — but an unusable `kk`
2111
- // (exit 127) is an environment failure, not a tolerated plugin failure.
2112
- if (error instanceof StageFailure && error.status === 127) throw error;
2113
- console.error((error as Error).message);
2114
- }
2115
- console.log('publish-podcast: podcast target dispatched');
2116
- });
2117
- }
2118
-
2119
1147
  function dailyPublishClassify(args: string[]): void {
2120
1148
  const [workDir = '', runDate = ''] = args;
2121
1149
  const result = join(workDir, '31-publish', `${runDate}_17_publish_result.json`);
@@ -2134,11 +1162,27 @@ function dailyPublishClassify(args: string[]): void {
2134
1162
  const status = okCount > 0 && okCount === total ? 'full' : okCount > 0 ? 'partial' : 'none';
2135
1163
  sted('write', result, () => writeJqText(result, { ok: okCount === total && total > 0, targets }));
2136
1164
  sted('write', statusFile, () => writeRaw(statusFile, status));
1165
+ // The ledger's Result cell for the legacy `publish` node (task 0212 R1): same target-set
1166
+ // shape the voice2 classify writes to publish-classify.summary.txt, so cutover-check can
1167
+ // compare baseline and candidate ledgers during the cutover window.
1168
+ const summaryFile = join(workDir, 'publish.summary.txt');
1169
+ sted('write', summaryFile, () =>
1170
+ writeRaw(summaryFile, `${JSON.stringify({ status, published: okCount, targets: total })}\n`),
1171
+ );
1172
+ // 0220: completeness moved to terminal daily-publish-completeness stage
1173
+ console.error(`publish: status=${status} (${okCount}/${total} targets published); routing is transition-owned`);
1174
+ });
1175
+ }
1176
+
1177
+ function dailyPublishCompleteness(args: string[]): void {
1178
+ const [workDir = '', runDate = ''] = args;
1179
+ timed(workDir, 'publish-completeness', () => {
2137
1180
  const completeness = publishCompleteness(workDir, runDate);
2138
1181
  const completenessFile = join(workDir, '31-publish', `${runDate}_17_publish_completeness.json`);
2139
1182
  sted('write', completenessFile, () => writeJqText(completenessFile, { runDate, ...completeness }));
2140
- console.log(`publish: status=${status} (${okCount}/${total} targets published); routing is transition-owned`);
2141
- console.log(`publish-completeness: ${completeness.rows.map((row) => `${row.channel}=${row.state}`).join(' ')}`);
1183
+ console.error(
1184
+ `publish-completeness: ${completeness.rows.map((row) => `${row.channel}=${row.state}`).join(' ')}`,
1185
+ );
2142
1186
  if (completeness.missing.length > 0) {
2143
1187
  const details = completeness.rows
2144
1188
  .filter((row) => row.state !== 'done')
@@ -2174,14 +1218,38 @@ function publishCompleteness(
2174
1218
  const at = (name: string): string => join(workDir, '31-publish', `${runDate}_${name}`);
2175
1219
  const markerFilled = (path: string): boolean => isFile(path) && readFileSync(path, 'utf-8').trim() !== '';
2176
1220
  const rows: { channel: string; state: string; detail: string }[] = [];
2177
- const localesDone = ['en', 'ja'].every((locale) =>
1221
+ const localesScaffolded = ['en', 'ja'].every((locale) =>
2178
1222
  markerFilled(at(`20_translate-localize_${locale}-post-path.txt`)),
2179
1223
  );
2180
- rows.push({
2181
- channel: 'locales',
2182
- state: localesDone ? 'done' : 'not-run',
2183
- detail: localesDone ? 'en/ja drafts scaffolded' : 'translate markers absent (en/ja never scaffolded)',
2184
- });
1224
+ if (!localesScaffolded) {
1225
+ rows.push({
1226
+ channel: 'locales',
1227
+ state: 'not-run',
1228
+ detail: 'translate markers absent (en/ja never scaffolded)',
1229
+ });
1230
+ } else {
1231
+ const syncResultFile = at('24_translate-sync_sync-result.json');
1232
+ const syncResult = isFile(syncResultFile) ? readJson(syncResultFile) : null;
1233
+ if (syncResult === null) {
1234
+ rows.push({
1235
+ channel: 'locales',
1236
+ state: 'not-run',
1237
+ detail: 'en/ja scaffolded, never synced',
1238
+ });
1239
+ } else if (syncResult.ok === true) {
1240
+ rows.push({
1241
+ channel: 'locales',
1242
+ state: 'done',
1243
+ detail: 'en/ja drafts scaffolded',
1244
+ });
1245
+ } else {
1246
+ rows.push({
1247
+ channel: 'locales',
1248
+ state: 'failed',
1249
+ detail: String(syncResult.error ?? 'ok=false'),
1250
+ });
1251
+ }
1252
+ }
2185
1253
  const resultChannels: [string, string, string][] = [
2186
1254
  ['xhs', '19_publish-xhs_result.json', ''],
2187
1255
  ['wechat', '20_publish-wechat_result.json', ''],
@@ -2242,7 +1310,7 @@ function dailyPublishPartial(args: string[]): void {
2242
1310
  function dailyPatchSurfdash(args: string[]): void {
2243
1311
  const [workDir = '', runDate = '', surfdashRoot = '', pluginsPath = ''] = args;
2244
1312
  if (!surfdashRoot || !existsSync(surfdashRoot)) {
2245
- console.log(`patch-surfdash: surfdash root '${surfdashRoot}' not found — skip`);
1313
+ console.error(`patch-surfdash: surfdash root '${surfdashRoot}' not found — skip`);
2246
1314
  return;
2247
1315
  }
2248
1316
  timed(workDir, 'patch-surfdash', () => {
@@ -2294,7 +1362,7 @@ function dailyPatchSurfdash(args: string[]): void {
2294
1362
  ] as const) {
2295
1363
  const indexMd = join(dir, 'index.md');
2296
1364
  if (!existsSync(indexMd)) {
2297
- console.log(`patch-surfdash: ${locale} post not published (${indexMd}) — skip`);
1365
+ console.error(`patch-surfdash: ${locale} post not published (${indexMd}) — skip`);
2298
1366
  continue;
2299
1367
  }
2300
1368
  let md = readFileSync(indexMd, 'utf-8');
@@ -2357,7 +1425,7 @@ function dailyPatchSurfdash(args: string[]): void {
2357
1425
  // cover this way). Leave a marker for the classify step's completeness report.
2358
1426
  const skipMarker = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_emdash-resync.txt`);
2359
1427
  sted('write', skipMarker, () => writeRaw(skipMarker, 'skipped: no plugins-path\n'));
2360
- console.log('patch-surfdash: no plugins-path — skip emdash re-sync');
1428
+ console.error('patch-surfdash: no plugins-path — skip emdash re-sync');
2361
1429
  } else {
2362
1430
  const syncIn = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync.json`);
2363
1431
  const syncResult = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync-result.json`);
@@ -2374,15 +1442,15 @@ function dailyPatchSurfdash(args: string[]): void {
2374
1442
  result !== null &&
2375
1443
  (result.ok === true || (result.targets as Array<{ ok?: boolean }> | undefined)?.[0]?.ok === true);
2376
1444
  if (!ok) {
2377
- console.log(
1445
+ console.error(
2378
1446
  `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}`,
2379
1447
  );
2380
1448
  } else {
2381
- console.log('patch-surfdash: emdash rows re-synced from patched files');
1449
+ console.error('patch-surfdash: emdash rows re-synced from patched files');
2382
1450
  }
2383
1451
  }
2384
1452
  if (changed === 0) {
2385
- console.log('patch-surfdash: nothing to change');
1453
+ console.error('patch-surfdash: nothing to change');
2386
1454
  return;
2387
1455
  }
2388
1456
  const git = (...a: string[]): void => {
@@ -2398,7 +1466,7 @@ function dailyPatchSurfdash(args: string[]): void {
2398
1466
  ...written,
2399
1467
  );
2400
1468
  if (process.env.SURFDASH_AUTO_PUSH !== '0') git('push', 'origin', 'main');
2401
- console.log(
1469
+ console.error(
2402
1470
  `patch-surfdash: patched ${changed} post(s), committed${process.env.SURFDASH_AUTO_PUSH !== '0' ? ' + pushed' : ' (push skipped: SURFDASH_AUTO_PUSH=0)'}`,
2403
1471
  );
2404
1472
  });
@@ -2420,11 +1488,11 @@ function dailyRunReport(args: string[]): void {
2420
1488
  const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
2421
1489
  timed(workDir, 'run-report', () => {
2422
1490
  if (isFile(reportMd)) {
2423
- console.log(`run report cached: ${reportMd}`);
1491
+ console.error(`run report cached: ${reportMd}`);
2424
1492
  return;
2425
1493
  }
2426
1494
  if (!isFile(candidates)) {
2427
- console.log('run-report: no candidates.json (run ended below plan step) — skip');
1495
+ console.error('run-report: no candidates.json (run ended below plan step) — skip');
2428
1496
  return;
2429
1497
  }
2430
1498
  sted('write', cands, () => writeJqText(cands, metadataOf(readJson(candidates)).docs ?? []));
@@ -2459,92 +1527,7 @@ function dailyRunReport(args: string[]): void {
2459
1527
  const body = readJson(reportJson)?.body;
2460
1528
  if (typeof body !== 'string') failWith(1, `run-report: ${reportJson} has no string body`);
2461
1529
  sted('write', reportMd, () => writeRaw(reportMd, body));
2462
- console.log(`run report written: ${reportMd}`);
2463
- });
2464
- }
2465
-
2466
- /**
2467
- * Collapsed translate scaffolding (2026-09-16 feedback): surfdash-pub owns the
2468
- * deterministic mechanics — `scaffold-locale` for both target locales — while the
2469
- * workflow `translate` state's agent step owns the LLM translation of the drafts.
2470
- * Writes one marker file per locale (zh post path + en/ja draft paths); empty
2471
- * marker = locale absent (callers skip). Reuses an existing on-disk draft as the
2472
- * cache across run retries — re-scaffolding would erase the agent's translation.
2473
- */
2474
- function dailyTranslateLocalize(args: string[]): void {
2475
- const [workDir = '', runDate = '', pluginsPath = ''] = args;
2476
- const result = join(workDir, '31-publish', `${runDate}_17_publish_result.json`);
2477
- const zhFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
2478
- timed(workDir, 'translate-localize', () => {
2479
- const postPath = firstPostPath(readJson(result), 'surfdash-pub');
2480
- const zh = postPath.endsWith('index.md') ? postPath : '';
2481
- sted('write', zhFile, () => writeRaw(zhFile, zh));
2482
- if (zh === '') {
2483
- for (const target of ['en', 'ja'] as const) {
2484
- sted(
2485
- 'write',
2486
- join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`),
2487
- () =>
2488
- writeRaw(
2489
- join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`),
2490
- '',
2491
- ),
2492
- );
2493
- }
2494
- console.log('no zh post path — skip localize');
2495
- return;
2496
- }
2497
- for (const target of ['en', 'ja'] as const) {
2498
- const markerFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`);
2499
- const existing = zh.replace('/zh/', `/${target}/`);
2500
- if (isFile(existing)) {
2501
- sted('write', markerFile, () => writeRaw(markerFile, existing));
2502
- console.log(`${target} draft cached: ${existing}`);
2503
- continue;
2504
- }
2505
- const scaffold = join(workDir, '31-publish', `${runDate}_20_translate-localize_scaffold-${target}.json`);
2506
- const scaffoldResult = join(
2507
- workDir,
2508
- '31-publish',
2509
- `${runDate}_20_translate-localize_scaffold-${target}-result.json`,
2510
- );
2511
- sted('write', scaffold, () =>
2512
- writeJqText(scaffold, { body: '', options: { operation: 'scaffold-locale', postPath: zh, target } }),
2513
- );
2514
- kk([
2515
- 'executor',
2516
- 'run',
2517
- 'surfdash-pub',
2518
- '--in',
2519
- scaffold,
2520
- '--out',
2521
- scaffoldResult,
2522
- ...pluginsArgs(pluginsPath),
2523
- ]);
2524
- const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
2525
- sted('write', markerFile, () => writeRaw(markerFile, typeof draftPath === 'string' ? draftPath : ''));
2526
- }
2527
- });
2528
- }
2529
-
2530
- function dailyTranslateSync(args: string[]): void {
2531
- const [workDir = '', runDate = '', pluginsPath = '', zhPathFile = ''] = args;
2532
- const sync = join(workDir, '31-publish', `${runDate}_24_translate-sync_sync.json`);
2533
- const syncResult = join(workDir, '31-publish', `${runDate}_24_translate-sync_sync-result.json`);
2534
- timed(workDir, 'translate-sync', () => {
2535
- // Reads the marker file itself — the collapsed `translate` state has no
2536
- // cross-state var to hand the path through.
2537
- const zhPostPath = isFile(zhPathFile) ? readFileSync(zhPathFile, 'utf-8') : '';
2538
- if (zhPostPath === '') {
2539
- console.log('no zh post path — skip translate-sync');
2540
- return;
2541
- }
2542
- sted('write', sync, () =>
2543
- writeJqText(sync, { body: '', options: { operation: 'sync', postPath: zhPostPath } }),
2544
- );
2545
- kk(['executor', 'run', 'surfdash-pub', '--in', sync, '--out', syncResult, ...pluginsArgs(pluginsPath)], {
2546
- unset: NESTED_SPUR_UNSET,
2547
- });
1530
+ console.error(`run report written: ${reportMd}`);
2548
1531
  });
2549
1532
  }
2550
1533
 
@@ -2610,72 +1593,125 @@ if (import.meta.main)
2610
1593
  case 'daily-collect-facts':
2611
1594
  dailyCollectFacts(rest);
2612
1595
  break;
2613
- case 'daily-plan':
1596
+ case 'collect-prepare':
1597
+ collectPrepare(rest);
1598
+ break;
1599
+ case 'daily-plan': {
1600
+ const { dailyPlan } = await loadDailyStages();
2614
1601
  dailyPlan(rest);
2615
1602
  break;
2616
- case 'daily-qc-content':
1603
+ }
1604
+ case 'daily-qc-content': {
1605
+ const { dailyQcContent } = await loadDailyStages();
2617
1606
  dailyQcContent(rest);
2618
1607
  break;
2619
- case 'daily-article':
1608
+ }
1609
+ case 'daily-article': {
1610
+ const { dailyArticle } = await loadDailyStages();
2620
1611
  dailyArticle(rest);
2621
1612
  break;
2622
- case 'daily-cover':
1613
+ }
1614
+ case 'daily-cover': {
1615
+ const { dailyCover } = await loadDailyStages();
2623
1616
  dailyCover(rest);
2624
1617
  break;
2625
- case 'daily-cover-normalize':
1618
+ }
1619
+ case 'daily-cover-normalize': {
1620
+ const { dailyCoverNormalize } = await loadDailyStages();
2626
1621
  dailyCoverNormalize(rest);
2627
1622
  break;
2628
- case 'daily-xhs-render':
1623
+ }
1624
+ case 'daily-xhs-render': {
1625
+ const { dailyXhsRender } = await loadDailyStages();
2629
1626
  dailyXhsRender(rest);
2630
1627
  break;
2631
- case 'daily-script-style':
1628
+ }
1629
+ case 'daily-script-style': {
1630
+ const { dailyScriptStyle } = await loadDailyStages();
2632
1631
  dailyScriptStyle(rest);
2633
1632
  break;
2634
- case 'daily-script':
1633
+ }
1634
+ case 'daily-script': {
1635
+ const { dailyScript } = await loadDailyStages();
2635
1636
  dailyScript(rest);
2636
1637
  break;
2637
- case 'daily-safety-prep':
1638
+ }
1639
+ case 'daily-safety-prep': {
1640
+ const { dailySafetyPrep } = await loadDailyStages();
2638
1641
  dailySafetyPrep(rest);
2639
1642
  break;
2640
- case 'daily-safety-gate':
1643
+ }
1644
+ case 'daily-safety-gate': {
1645
+ const { dailySafetyGate } = await loadDailyStages();
2641
1646
  dailySafetyGate(rest);
2642
1647
  break;
2643
- case 'daily-wrap-docs':
1648
+ }
1649
+ case 'daily-safety-revise': {
1650
+ const { dailySafetyRevise } = await loadDailyStages();
1651
+ dailySafetyRevise(rest);
1652
+ break;
1653
+ }
1654
+ case 'daily-wrap-docs': {
1655
+ const { dailyWrapDocs } = await loadDailyStages();
2644
1656
  dailyWrapDocs(rest);
2645
1657
  break;
2646
- case 'daily-generate':
1658
+ }
1659
+ case 'daily-generate': {
1660
+ const { dailyGenerate } = await loadDailyStages();
2647
1661
  dailyGenerate(rest);
2648
1662
  break;
2649
- case 'daily-intro':
1663
+ }
1664
+ case 'daily-intro': {
1665
+ const { dailyIntro } = await loadDailyStages();
2650
1666
  dailyIntro(rest);
2651
1667
  break;
2652
- case 'daily-episode-duration':
1668
+ }
1669
+ case 'daily-episode-duration': {
1670
+ const { dailyEpisodeDuration } = await loadDailyStages();
2653
1671
  dailyEpisodeDuration(rest);
2654
1672
  break;
2655
- case 'daily-quality-report':
1673
+ }
1674
+ case 'daily-quality-report': {
1675
+ const { dailyQualityReport } = await loadDailyStages();
2656
1676
  dailyQualityReport(rest);
2657
1677
  break;
2658
- case 'daily-publish-prep':
1678
+ }
1679
+ case 'daily-publish-prep': {
1680
+ const { dailyPublishPrep } = await loadDailyStages();
2659
1681
  dailyPublishPrep(rest);
2660
1682
  break;
2661
- case 'daily-publish-surfdash':
1683
+ }
1684
+ case 'daily-publish-surfdash': {
1685
+ const { dailyPublishSurfdash } = await loadDailyStages();
2662
1686
  dailyPublishSurfdash(rest);
2663
1687
  break;
2664
- case 'daily-show-notes':
1688
+ }
1689
+ case 'daily-show-notes': {
1690
+ const { dailyShowNotes } = await loadDailyStages();
2665
1691
  dailyShowNotes(rest);
2666
1692
  break;
2667
- case 'daily-publish-podcast':
1693
+ }
1694
+ case 'daily-publish-podcast': {
1695
+ const { dailyPublishPodcast } = await loadDailyStages();
2668
1696
  dailyPublishPodcast(rest);
2669
1697
  break;
2670
- case 'daily-publish-xhs':
1698
+ }
1699
+ case 'daily-publish-xhs': {
1700
+ const { dailyPublishXhs } = await loadDailyStages();
2671
1701
  dailyPublishXhs(rest);
2672
1702
  break;
2673
- case 'daily-publish-wechat':
1703
+ }
1704
+ case 'daily-publish-wechat': {
1705
+ const { dailyPublishWechat } = await loadDailyStages();
2674
1706
  dailyPublishWechat(rest);
2675
1707
  break;
1708
+ }
2676
1709
  case 'daily-publish-classify':
2677
1710
  dailyPublishClassify(rest);
2678
1711
  break;
1712
+ case 'daily-publish-completeness':
1713
+ dailyPublishCompleteness(rest);
1714
+ break;
2679
1715
  case 'daily-patch-surfdash':
2680
1716
  dailyPatchSurfdash(rest);
2681
1717
  break;
@@ -2685,12 +1721,16 @@ if (import.meta.main)
2685
1721
  case 'daily-run-report':
2686
1722
  dailyRunReport(rest);
2687
1723
  break;
2688
- case 'daily-translate-localize':
1724
+ case 'daily-translate-localize': {
1725
+ const { dailyTranslateLocalize } = await loadDailyStages();
2689
1726
  dailyTranslateLocalize(rest);
2690
1727
  break;
2691
- case 'daily-translate-sync':
1728
+ }
1729
+ case 'daily-translate-sync': {
1730
+ const { dailyTranslateSync } = await loadDailyStages();
2692
1731
  dailyTranslateSync(rest);
2693
1732
  break;
1733
+ }
2694
1734
  case 'itc-outline-pick': {
2695
1735
  const { itcOutlinePick } = await loadItcStages();
2696
1736
  itcOutlinePick(rest);