@gobing-ai/knowledge-kit 0.0.32 → 0.0.34

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 (72) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +43047 -24724
  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 +16 -21
  7. package/plugins/generations/daily-article-gen/src/index.ts +8 -12
  8. package/plugins/generations/dailynews-gen/dist/index.js +23 -21
  9. package/plugins/generations/dailynews-gen/src/script-builder.ts +25 -26
  10. package/plugins/generations/episode-plan-gen/dist/index.js +58 -15
  11. package/plugins/generations/episode-plan-gen/src/index.ts +76 -6
  12. package/plugins/generations/gemini-tts/README.md +11 -0
  13. package/plugins/generations/gemini-tts/dist/index.js +10 -9
  14. package/plugins/generations/image-gen/dist/index.js +171 -153
  15. package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +16 -14
  16. package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +2 -2
  17. package/plugins/generations/image-gen/src/compose/card.ts +17 -2
  18. package/plugins/generations/image-gen/src/compose/chromium.ts +60 -42
  19. package/plugins/generations/news-report-gen/dist/index.js +13 -12
  20. package/plugins/generations/voice-gen/dist/index.js +10 -9
  21. package/plugins/ingestions/aihot-ingest/dist/index.js +13 -12
  22. package/plugins/ingestions/horizon-ingest/dist/index.js +13 -12
  23. package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
  24. package/plugins/ingestions/last30days-ingest/dist/index.js +13 -12
  25. package/plugins/ingestions/web-search/dist/index.js +11 -10
  26. package/plugins/kk/README.md +38 -0
  27. package/plugins/kk/decisions/extract/ai-news.yaml +27 -0
  28. package/plugins/kk/decisions/kk-items.yaml +179 -0
  29. package/plugins/kk/plugin.json +1 -1
  30. package/plugins/kk/schedules/cron.tmpl +15 -0
  31. package/plugins/kk/schedules/launchd.plist.tmpl +77 -0
  32. package/plugins/kk/scripts/daily-stages.ts +2023 -0
  33. package/plugins/kk/scripts/daily-voice-style.zh.md +15 -10
  34. package/plugins/kk/scripts/kk-workflow-stages.ts +253 -1368
  35. package/plugins/kk/skills/content-safety/SKILL.md +39 -8
  36. package/plugins/kk/skills/content-safety/references/platforms/apple-podcasts.md +2 -2
  37. package/plugins/kk/skills/web-automation/SKILL.md +100 -0
  38. package/plugins/kk/skills/web-automation/references/browser-launch.md +86 -0
  39. package/plugins/kk/workflows/kk-collect.yaml +256 -0
  40. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +9 -3
  41. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +774 -0
  42. package/plugins/kk/workflows/kk-daily-safety.yaml +167 -0
  43. package/plugins/publishings/emdash-pub/dist/index.js +10 -9
  44. package/plugins/publishings/infoq-pub/dist/index.js +152 -59
  45. package/plugins/publishings/infoq-pub/plugin.json +1 -0
  46. package/plugins/publishings/infoq-pub/src/index.ts +167 -55
  47. package/plugins/publishings/infoq-pub/src/profile-check.ts +4 -2
  48. package/plugins/publishings/juejin-pub/dist/index.js +111 -30
  49. package/plugins/publishings/juejin-pub/plugin.json +1 -0
  50. package/plugins/publishings/juejin-pub/src/index.ts +49 -9
  51. package/plugins/publishings/medium-pub/dist/index.js +10 -9
  52. package/plugins/publishings/podcast-pub/dist/index.js +10 -9
  53. package/plugins/publishings/qiita-pub/dist/index.js +10 -9
  54. package/plugins/publishings/substack-pub/dist/index.js +105 -28
  55. package/plugins/publishings/substack-pub/plugin.json +1 -0
  56. package/plugins/publishings/substack-pub/src/index.ts +39 -8
  57. package/plugins/publishings/substack-pub/src/profile-check.ts +2 -0
  58. package/plugins/publishings/surfdash-pub/dist/index.js +10 -9
  59. package/plugins/publishings/wechatmp-pub/dist/index.js +115 -31
  60. package/plugins/publishings/wechatmp-pub/plugin.json +1 -0
  61. package/plugins/publishings/wechatmp-pub/src/index.ts +42 -9
  62. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +84 -40
  63. package/plugins/publishings/x-pub/dist/index.js +114 -35
  64. package/plugins/publishings/x-pub/plugin.json +1 -0
  65. package/plugins/publishings/x-pub/src/index.ts +68 -14
  66. package/plugins/publishings/xhs-pub/dist/index.js +154 -59
  67. package/plugins/publishings/xhs-pub/plugin.json +1 -0
  68. package/plugins/publishings/xhs-pub/src/flow.ts +9 -3
  69. package/plugins/publishings/xhs-pub/src/index.ts +119 -9
  70. package/plugins/publishings/xhs-pub/src/page.ts +4 -17
  71. package/plugins/publishings/xhs-pub/src/profile-check.ts +28 -15
  72. package/plugins/publishings/zenn-pub/dist/index.js +10 -9
@@ -0,0 +1,2023 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * daily-* stage bodies for the kk-daily-ai-voice2 DAG (task 0211, R7).
4
+ *
5
+ * Moved out of kk-workflow-stages.ts: every stage the voice2 graph touches is owned here so the
6
+ * plugin ships its stages beside the runner. kk-workflow-stages.ts lazy-loads this module through
7
+ * loadDailyStages() and keeps the same `kk stage <name>` surface; the old-voice-only stages stay
8
+ * in the runner until the legacy workflow retires.
9
+ *
10
+ * Shared helpers, sidecar paths and constants still live in kk-workflow-stages.ts and are
11
+ * imported from there — the lazy load keeps the module cycle safe.
12
+ */
13
+ /**
14
+ * Deterministic stage runner shared by the kk product workflows (ADR-115 composition owner).
15
+ *
16
+ * Each YAML action keeps one short package-root resolution block and delegates its multi-command
17
+ * body here, so a shell action stays a single logical call:
18
+ *
19
+ * prepare-itc <work_dir> <topic> <input_file> <force>
20
+ * prepare-solo <work_dir> <topic> <input_file> <voice_profile> <force>
21
+ * prepare-storm <work_dir> <topic> <input_file> <max_results> <fixture>
22
+ * validate [<override>] --in <voicescript.yaml>
23
+ * wrap [<override>] --in <voicescript.yaml> --out <docs.json> [--profile <name>]
24
+ * duration <work_dir> <target_duration_min>
25
+ * render-storm [<override>] --in <content.json> --out <content.md>
26
+ * itc-outline-pick <work_dir> (0149, body in the sibling itc-stages.ts)
27
+ * itc-report <work_dir> [max_revisions] (0149, body in the sibling itc-stages.ts)
28
+ * itc-materialize <work_dir> <publish_live> (0150, sibling)
29
+ * itc-publish-plan <work_dir> <targets> <source_locale> <publish_live> (0150, sibling)
30
+ * itc-publish-fanout <work_dir> [--retry] (0151, sibling)
31
+ * itc-publish-next <work_dir> (0151, sibling)
32
+ *
33
+ * `validate` / `wrap` / `render-storm` resolve the shipped sibling sidecars and delegate to them;
34
+ * nothing about those sidecars changed. The `prepare-*` verbs and `duration` keep the logic that
35
+ * used to live inline in `plugins/kk/workflows/*.yaml`, message-for-message.
36
+ *
37
+ * Node/Bun builtins only — no @gobing-ai/* and no cross-directory relative imports (the file is
38
+ * resolved from the installed package or from a checkout). The itc-* stages delegate to their
39
+ * same-directory sibling `itc-stages.ts`, imported lazily so a runner copy without the sibling
40
+ * still loads and fails loud with the remediation path.
41
+ *
42
+ * The YAML prelude resolves this file as `$pkg/plugins/kk/scripts/kk-workflow-stages.ts` where `pkg`
43
+ * comes from `realpath "$(command -v kk)"` (the installed package root), and falls back to the
44
+ * cwd-relative `plugins/kk/scripts/kk-workflow-stages.ts` when that copy is absent — the checkout
45
+ * tier kk-storm-research's render has always relied on, preserved on purpose. The daily-voice
46
+ * workflow reaches the same script through `kk stage` (apps/cli/src/stage.ts), which resolves a
47
+ * recognized checkout entry to this checked-in copy (never a stale `apps/cli/plugins/` stage) and an
48
+ * installed entry to its own package-shipped copy.
49
+ *
50
+ * SYNC: plugins/kk/workflows/{kk-solo-podcast,kk-itc,kk-storm-research}.yaml shell this script
51
+ * (kk-daily-ai-voice goes through `kk stage`). Keep message substrings stable —
52
+ * apps/cli/tests/kk-workflow-stages.test.ts and the four workflow static tests match them.
53
+ */
54
+ import { spawnSync } from 'node:child_process';
55
+ import { createHash } from 'node:crypto';
56
+ import {
57
+ copyFileSync,
58
+ existsSync,
59
+ mkdirSync,
60
+ readFileSync,
61
+ renameSync,
62
+ rmSync,
63
+ unlinkSync,
64
+ writeFileSync,
65
+ } from 'node:fs';
66
+ import { basename, join, resolve } from 'node:path';
67
+ import {
68
+ ADMISSION_CRITERIA,
69
+ applyTopicBoundary,
70
+ assertScriptContent,
71
+ COVER_FORMAT,
72
+ COVER_STYLE,
73
+ coverBrief,
74
+ coverNormalizeBody,
75
+ dedupPlanByTitle,
76
+ fail,
77
+ failWith,
78
+ firstArtifact,
79
+ firstPostPath,
80
+ isFile,
81
+ kk,
82
+ metadataOf,
83
+ NESTED_SPUR_UNSET,
84
+ PII_FINDING_CATEGORY,
85
+ pluginsArgs,
86
+ readJson,
87
+ readSafetyReviseCount,
88
+ readTopicBoundary,
89
+ requireJson,
90
+ requireSidecar,
91
+ resolveSidecar,
92
+ resultPublishStatus,
93
+ SAFETY_PLATFORM_DECISIONS,
94
+ SAFETY_REVISE_COUNT,
95
+ STYLE_SIDECAR,
96
+ StageFailure,
97
+ safetyDigest,
98
+ safetyDraftPaths,
99
+ safetyPath,
100
+ stampId3Tags,
101
+ sted,
102
+ targetPublished,
103
+ timed,
104
+ writeJqText,
105
+ writeJsonText,
106
+ writeRaw,
107
+ XHS_BODY_CAP,
108
+ XHS_FORMAT,
109
+ XHS_MAX_CARDS,
110
+ xhsPlan,
111
+ } from './kk-workflow-stages.ts';
112
+
113
+ export function dailyQcContent(args: string[]): void {
114
+ const [
115
+ workDir = '',
116
+ runDate = '',
117
+ pluginsPath = '',
118
+ qcCategories = '',
119
+ minQuality = '',
120
+ minImportance = '',
121
+ minUrgency = '',
122
+ minImpact = '',
123
+ minEpisodeItems = '',
124
+ ] = args;
125
+ const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
126
+ const annotated = join(workDir, '21-plan', `${runDate}_04_quality-control-content_annotated.json`);
127
+ const exclusions = join(workDir, '21-plan', `${runDate}_04_quality-control-content_exclusions.json`);
128
+ const planContent = join(workDir, '21-plan', `${runDate}_03_plan_content.json`);
129
+ timed(workDir, 'quality-control-content', () => {
130
+ if (isFile(candidates)) {
131
+ console.log(`candidates cached: ${candidates}`);
132
+ return;
133
+ }
134
+ sted('write', annotated, () => writeJqText(annotated, metadataOf(readJson(planContent)).docs ?? null));
135
+ kk(
136
+ [
137
+ 'executor',
138
+ 'run',
139
+ 'episode-plan-gen',
140
+ '--in',
141
+ annotated,
142
+ '--out',
143
+ candidates,
144
+ ...pluginsArgs(pluginsPath),
145
+ ],
146
+ {
147
+ env: {
148
+ EPISODE_PLAN_MODE: 'filter',
149
+ QC_CATEGORIES: qcCategories,
150
+ QC_MIN_QUALITY: minQuality,
151
+ QC_MIN_IMPORTANCE: minImportance,
152
+ QC_MIN_URGENCY: minUrgency,
153
+ QC_MIN_IMPACT: minImpact,
154
+ QC_MIN_EPISODE_ITEMS: minEpisodeItems,
155
+ },
156
+ },
157
+ );
158
+ });
159
+ // Topic boundary (task 0189 R2) runs on every invocation — re-applying to already-filtered
160
+ // candidates is a no-op, so cached reruns stay idempotent while a crash between the candidates
161
+ // write and the exclusions write still converges on the next run. Zero kept docs leaves the
162
+ // candidates file empty for the workflow's existing done-no-content edge.
163
+ const boundaryDocs = metadataOf(readJson(candidates)).docs;
164
+ if (!Array.isArray(boundaryDocs)) return;
165
+ const { kept, excluded } = applyTopicBoundary(boundaryDocs, runDate, readTopicBoundary());
166
+ if (excluded.length === 0) return;
167
+ sted('write', exclusions, () =>
168
+ writeJsonText(exclusions, {
169
+ version: 1,
170
+ runDate,
171
+ excluded,
172
+ kept: kept.map((doc) => (doc as { id: string }).id),
173
+ }),
174
+ );
175
+ const content = requireJson(candidates, 'topic-boundary');
176
+ const meta = content.metadata;
177
+ if (meta === null || typeof meta !== 'object') {
178
+ failWith(1, 'topic-boundary: candidates content carries no metadata object');
179
+ }
180
+ (meta as Record<string, unknown>).docs = kept;
181
+ sted('write', candidates, () => writeJsonText(candidates, content));
182
+ console.log(
183
+ `topic-boundary: excluded ${excluded.length} item(s): ${excluded.map((entry) => `${entry.id}(${entry.rule})`).join(', ')}`,
184
+ );
185
+ }
186
+
187
+ /**
188
+ * Character-bigram Jaccard over whitespace-normalized, lowercased titles — the twin of
189
+ * episode-plan-gen's `titleSimilarity`. Duplicated on purpose: this file is node-builtins-only
190
+ * (no workspace imports), so it cannot reach that module.
191
+ */
192
+ export function dailyArticle(args: string[]): void {
193
+ const [workDir = '', runDate = '', pluginsPath = ''] = args;
194
+ const articleMd = join(workDir, '22-article', `${runDate}_06_article_article.md`);
195
+ const articleContent = join(workDir, '22-article', `${runDate}_06_article_content.json`);
196
+ const planIn = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
197
+ const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
198
+ timed(workDir, 'article', () => {
199
+ let planUsable = true;
200
+ const raw = isFile(planIn) ? readFileSync(planIn, 'utf-8') : '';
201
+ // The plan file is canonically the plan `Doc[]` — `dailyPlan` writes the generator's
202
+ // `metadata.docs` — and every downstream stage reads it as one. `plan-translate`'s executor
203
+ // has been observed writing the generator's Content ENVELOPE back instead: valid JSON of the
204
+ // wrong shape, which the article generator then refuses with `expected array, received
205
+ // object`. Unwrap that envelope rather than discard the translation; anything else unusable
206
+ // still falls back to the untranslated candidates.
207
+ let planShape: unknown;
208
+ try {
209
+ planShape = JSON.parse(raw);
210
+ } catch (error) {
211
+ console.error(
212
+ `article: plan.json invalid JSON — replacing with untranslated plan (downstream states read this file): ${(error as Error).message}`,
213
+ );
214
+ }
215
+ if (planShape !== undefined && !Array.isArray(planShape)) {
216
+ const enveloped = metadataOf(planShape as Record<string, unknown>).docs;
217
+ if (Array.isArray(enveloped) && enveloped.length > 0) {
218
+ console.error(
219
+ `article: plan.json carries the Content envelope, not the Doc[] — unwrapping metadata.docs (${enveloped.length} doc(s))`,
220
+ );
221
+ sted('write', planIn, () => writeJsonText(planIn, enveloped));
222
+ planShape = enveloped;
223
+ }
224
+ }
225
+ if (!Array.isArray(planShape)) {
226
+ const docs = metadataOf(readJson(candidates)).docs;
227
+ if (!Array.isArray(docs) || docs.length === 0) {
228
+ console.error('article: candidates.json metadata.docs missing');
229
+ planUsable = false;
230
+ } else {
231
+ sted('write', planIn, () => writeJsonText(planIn, docs));
232
+ }
233
+ }
234
+ if (!planUsable) failWith(1, 'article: no usable plan input');
235
+ // R3 (task 0138): dedup runs BEFORE the cache early-return — the script stage reads this same
236
+ // plan.json and must see one survivor even when the article is already cached. Idempotent:
237
+ // a no-op once the plan is deduped.
238
+ const planParsed: unknown = readJson(planIn);
239
+ if (Array.isArray(planParsed)) {
240
+ const { kept, dropped } = dedupPlanByTitle(planParsed as Record<string, unknown>[]);
241
+ if (dropped.length > 0) {
242
+ sted('write', planIn, () => writeJsonText(planIn, kept));
243
+ console.error(`article: dropped ${dropped.length} near-duplicate plan item(s): ${dropped.join(', ')}`);
244
+ }
245
+ }
246
+ // The cache pair must be COMPLETE: markdown without its content.json means the generator
247
+ // half-ran (or the json was pruned), and returning here starves every downstream stage
248
+ // that reads content.json — publish-prep refuses to merge a draft it cannot read. Requiring
249
+ // both artifacts makes the pair self-healing instead of permanently stuck.
250
+ if (isFile(articleMd) && isFile(articleContent)) {
251
+ console.log(`article cached: ${articleMd}`);
252
+ return;
253
+ }
254
+ // Agent-written prose body (workflow `article-write` state) replaces the mechanical
255
+ // per-item render when present — the 3-format split: surfdash gets an article,
256
+ // not a news list (2026-09-16 feedback).
257
+ const proseBody = join(workDir, '22-article', `${runDate}_06_article-write_body.md`);
258
+ kk(
259
+ [
260
+ 'executor',
261
+ 'run',
262
+ 'daily-article-gen',
263
+ '--in',
264
+ planIn,
265
+ '--out',
266
+ articleContent,
267
+ ...pluginsArgs(pluginsPath),
268
+ ],
269
+ {
270
+ env: {
271
+ ARTICLE_DATE: runDate,
272
+ // Plugin entries run with cwd = plugin dir (invoke.ts), so relative
273
+ // paths in env must be resolved or the plugin sees ENOENT.
274
+ ...(isFile(proseBody) ? { ARTICLE_BODY_FILE: resolve(proseBody) } : {}),
275
+ },
276
+ },
277
+ );
278
+ const body = readJson(articleContent)?.body;
279
+ if (typeof body !== 'string') failWith(1, `article: ${articleContent} has no string body`);
280
+ sted('write', articleMd, () => writeRaw(articleMd, body));
281
+ });
282
+ }
283
+
284
+ // Shared opt-in fallback parse (D6 0166 R7; 0219 R1): '' keeps strict fail-fast (the caller
285
+ // omits the key), 'true' retries down image-gen's AUTO_ORDER, anything else is a comma-split,
286
+ // trimmed candidate list. Unknown names are rejected by image-gen, not here.
287
+ function imageFallback(fallback: string): true | string[] {
288
+ return fallback === 'true'
289
+ ? true
290
+ : fallback
291
+ .split(',')
292
+ .map((s) => s.trim())
293
+ .filter(Boolean);
294
+ }
295
+
296
+ /**
297
+ * F614 R2/R4: validate the agent-written cover brief in the order R4 names — the first failing
298
+ * check is the fallback reason; `reason: null` carries the trimmed subject.
299
+ */
300
+ /**
301
+ * F614 R3: build the image-gen envelope from the day's cover brief and render it. The subject is
302
+ * the only model-authored input (the workflow's brief agent.run wrote it); format, style,
303
+ * provider and model are pinned by this stage so no caller can drift the look. A missing or
304
+ * invalid brief degrades to the format's default subject (R4) — a plugin failure still fails
305
+ * the stage, matching the pre-F614 cover behavior.
306
+ */
307
+ export function dailyCover(args: string[]): void {
308
+ const [workDir = '', runDate = '', pluginsPath = '', provider = '', model = '', fallback = ''] = args;
309
+ const briefPath = join(workDir, '23-cover', `${runDate}_07_cover-brief.json`);
310
+ const envPath = join(workDir, '23-cover', `${runDate}_07_cover_image-gen-in.json`);
311
+ const contentOut = join(workDir, '23-cover', 'content.json');
312
+ timed(workDir, 'cover', () => {
313
+ const brief = coverBrief(briefPath);
314
+ const vars: Record<string, string> = {};
315
+ if (brief.reason !== null) {
316
+ console.error(`cover brief: fallback (${brief.reason})`);
317
+ } else {
318
+ vars.subject = brief.subject;
319
+ }
320
+ const imageGen: Record<string, unknown> = {
321
+ job: 'cover',
322
+ format: COVER_FORMAT,
323
+ style: COVER_STYLE,
324
+ vars,
325
+ maxImages: 1,
326
+ };
327
+ if (provider !== '') imageGen.provider = provider;
328
+ if (model !== '') imageGen.model = model;
329
+ // D6 0166 R7: runtime-fallback opt-in. '' (the shipped var default) keeps strict fail-fast —
330
+ // F614's pinned look stays the default; 'true' = retry down AUTO_ORDER; anything else is a
331
+ // comma-split, trimmed candidate list. Unknown names are rejected by image-gen, not here.
332
+ if (fallback !== '') imageGen.fallback = imageFallback(fallback);
333
+ sted('write', envPath, () =>
334
+ writeJsonText(envPath, [{ id: 'daily-cover', title: runDate, body: '', metadata: { imageGen } }]),
335
+ );
336
+ kk(['executor', 'run', 'image-gen', '--in', envPath, '--out', contentOut, ...pluginsArgs(pluginsPath)]);
337
+ });
338
+ }
339
+
340
+ export function dailyCoverNormalize(args: string[]): void {
341
+ const [workDir = '', runDate = ''] = args;
342
+ // The pre-0133 shell had no timing trap, so this stage is not `timed`; it still fails through
343
+ // `failWith`, which needs the named-failure handling `timed` gives the other stages.
344
+ try {
345
+ coverNormalizeBody(workDir, runDate);
346
+ } catch (error) {
347
+ if (error instanceof StageFailure) {
348
+ if (error.named) console.error(error.message);
349
+ process.exit(error.status);
350
+ }
351
+ console.error((error as Error).message);
352
+ process.exit(1);
353
+ }
354
+ }
355
+
356
+ /**
357
+ * 20260917 review #7: prepend the show's 8s intro sting to the generated audio.
358
+ * `intro_music` empty -> skip. Writes `<stem>.intro.wav` (original render kept), points
359
+ * `audioPath` at the merged file, extends `duration`, marks `introMerged` (idempotent).
360
+ */
361
+ /**
362
+ * Episode duration gate (standing requirement, 20261002): a daily episode must run 15–20 minutes
363
+ * (duration is authoritative; the 8–10-item figure is only an estimate). Reads the final audio
364
+ * content's `metadata.duration` — after the intro sting merged, this is the true episode length —
365
+ * and fails loud outside `[min, max]` seconds-worth of minutes. A missing duration is a failure,
366
+ * not a pass: the gate must never silently let an under-length episode through again.
367
+ */
368
+ export function dailyEpisodeDuration(args: string[]): void {
369
+ const [workDir = '', runDate = '', minArg = '', maxArg = ''] = args;
370
+ if (!workDir) fail('missing work_dir');
371
+ if (!runDate) fail('missing run_date');
372
+ const minMinutes = Number(minArg);
373
+ const maxMinutes = Number(maxArg);
374
+ if (!Number.isFinite(minMinutes) || minMinutes <= 0) fail(`invalid min_minutes: ${minArg || '(empty)'}`);
375
+ if (!Number.isFinite(maxMinutes) || maxMinutes < minMinutes) fail(`invalid max_minutes: ${maxArg || '(empty)'}`);
376
+ const contentPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
377
+ if (!isFile(contentPath)) failWith(1, `episode-duration: audio content not found: ${contentPath}`);
378
+ let raw: unknown;
379
+ try {
380
+ const parsed = JSON.parse(readFileSync(contentPath, 'utf-8')) as { metadata?: { duration?: unknown } };
381
+ raw = parsed.metadata?.duration;
382
+ } catch (error) {
383
+ if ((error as { code?: string }).code !== undefined) {
384
+ sted('read', contentPath, () => readFileSync(contentPath, 'utf-8'));
385
+ }
386
+ raw = undefined;
387
+ }
388
+ const seconds = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN;
389
+ if (!Number.isFinite(seconds) || seconds <= 0) {
390
+ failWith(1, `episode-duration: audio metadata.duration missing or not a number in ${contentPath}`);
391
+ }
392
+ const minutes = seconds / 60;
393
+ const shown = minutes.toFixed(1);
394
+ if (minutes < minMinutes) {
395
+ failWith(
396
+ 1,
397
+ `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.`,
398
+ );
399
+ }
400
+ if (minutes > maxMinutes) {
401
+ failWith(
402
+ 1,
403
+ `episode-duration: episode runs ${shown} min, above the ${minMinutes}–${maxMinutes} min requirement. Trim items or per-item treatment.`,
404
+ );
405
+ }
406
+ console.log(`episode-duration ok: ${shown} min within ${minMinutes}–${maxMinutes} min`);
407
+ }
408
+
409
+ /** Ceiling for the pre-spend estimate when the caller passes no `episode_max_minutes`. */
410
+ const DEFAULT_EPISODE_MAX_MINUTES = 20;
411
+
412
+ /** `kk executor run` argv shared by the quota probe, the voice1 one-shot, and each section. */
413
+ function voiceExecutorArgs(generator: string, input: string, output: string, pluginsPath: string): string[] {
414
+ return ['executor', 'run', generator, '--in', input, '--out', output, ...pluginsArgs(pluginsPath)];
415
+ }
416
+
417
+ /**
418
+ * One tiny gemini-tts render before any episode speech. A non-zero exit (quota, key, network)
419
+ * means this run should use voicebox. Probe files are removed either way.
420
+ * Returns false when the caller should fall back.
421
+ */
422
+ function geminiQuotaProbeOk(workDir: string, pluginsPath: string, transcodeMp3: string, probeDocs: unknown): boolean {
423
+ const probeDir = join(workDir, '30-audio');
424
+ const probeIn = join(probeDir, '.gemini-probe-in.json');
425
+ const probeOut = join(probeDir, '.gemini-probe-out.json');
426
+ writeJsonText(probeIn, probeDocs);
427
+ const probe = spawnSync('kk', voiceExecutorArgs('gemini-tts', probeIn, probeOut, pluginsPath), {
428
+ encoding: 'utf8',
429
+ timeout: 120_000,
430
+ env: { ...process.env, VOICE_GEN_MP3: transcodeMp3 },
431
+ });
432
+ for (const stale of [probeIn, probeOut, probeOut.replace(/\.json$/, '.wav'), probeOut.replace(/\.json$/, '.mp3')]) {
433
+ rmSync(stale, { force: true });
434
+ }
435
+ if (probe.status !== 0) {
436
+ const reason = (probe.stderr ?? '').trim().split('\n').slice(-1)[0] || `exit ${probe.status}`;
437
+ console.log(`generate: gemini quota probe failed (${reason}) — falling back to voicebox for this run`);
438
+ return false;
439
+ }
440
+ console.log('generate: gemini quota probe ok');
441
+ return true;
442
+ }
443
+
444
+ export function dailyGenerate(args: string[]): void {
445
+ const [
446
+ workDir = '',
447
+ runDate = '',
448
+ pluginsPath = '',
449
+ transcodeMp3 = '',
450
+ vdriver = '',
451
+ sectionConcurrency = '',
452
+ episodeMaxMinutes = '',
453
+ ] = args;
454
+ // 20260917 review: the workflow picks a voice driver by short name; both drivers' envs are
455
+ // passed unconditionally so switching `vdriver` needs no shell changes. Unknown -> fail loud.
456
+ const VOICE_DRIVERS: Record<string, string> = {
457
+ ominivoice: 'omni-voice-gen',
458
+ voicebox: 'voice-gen',
459
+ gemini: 'gemini-tts',
460
+ };
461
+ let voiceGenerator = VOICE_DRIVERS[vdriver];
462
+ if (!voiceGenerator) {
463
+ failWith(
464
+ 1,
465
+ `generate: unknown vdriver '${vdriver}' (expected one of: ${Object.keys(VOICE_DRIVERS).join(', ')})`,
466
+ );
467
+ }
468
+ // Strict `Number`, not `parseFloat`: a typo'd var ('2O') must refuse, not cap the episode at 2 min.
469
+ const maxMinutes = episodeMaxMinutes === '' ? DEFAULT_EPISODE_MAX_MINUTES : Number(episodeMaxMinutes);
470
+ if (!Number.isFinite(maxMinutes) || maxMinutes <= 0) {
471
+ failWith(1, `generate: invalid episode_max_minutes: ${episodeMaxMinutes}`);
472
+ }
473
+ const docs = join(workDir, '30-audio', `${runDate}_10_wrap-docs_docs.json`);
474
+ const content = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
475
+
476
+ if (!sectionConcurrency) {
477
+ timed(workDir, 'generate', () => {
478
+ if (isFile(content) && metadataOf(readJson(content)).qc !== undefined) {
479
+ const qc = metadataOf(readJson(content)).qc as Record<string, unknown> | undefined;
480
+ if (qc?.passed === true) {
481
+ console.log(`audio cached: ${content} (QC passed)`);
482
+ return;
483
+ }
484
+ }
485
+ // Gemini quota probe (20261006): the 06:00 cron hit gemini-3.8-flash-tts 429 with a
486
+ // multi-hour retry-after after the shared key's window drained overnight. One tiny
487
+ // render (~1 request) before the real episode; ANY probe failure (quota, key, network)
488
+ // flips this run to the local voicebox driver instead of dying mid-episode — voicebox
489
+ // needs no key. Operator decision 20261006: keep the gemini default + auto-fallback.
490
+ if (vdriver === 'gemini') {
491
+ const docsList = readJson(docs) as Array<Record<string, unknown>>;
492
+ if (
493
+ docsList.length > 0 &&
494
+ !geminiQuotaProbeOk(workDir, pluginsPath, transcodeMp3, [{ ...docsList[0], body: 'Hello.' }])
495
+ ) {
496
+ voiceGenerator = VOICE_DRIVERS.voicebox;
497
+ }
498
+ }
499
+ kk(voiceExecutorArgs(voiceGenerator, docs, content, pluginsPath), {
500
+ env: { VOICE_GEN_MP3: transcodeMp3 },
501
+ });
502
+ });
503
+ return;
504
+ }
505
+
506
+ if (sectionConcurrency !== '1') {
507
+ failWith(1, `generate: section_concurrency '${sectionConcurrency}' not supported (must be 1)`);
508
+ }
509
+
510
+ timed(workDir, 'generate', () => {
511
+ const scriptPath = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
512
+ if (!existsSync(scriptPath)) {
513
+ failWith(1, `generate: voicescript not found: ${scriptPath}`);
514
+ }
515
+ const scriptRaw = readFileSync(scriptPath, 'utf8');
516
+ const script = Bun.YAML.parse(scriptRaw) as Record<string, unknown>;
517
+ const segments = (script.segments ?? []) as Array<Record<string, unknown>>;
518
+ if (!Array.isArray(segments) || segments.length < 2) {
519
+ failWith(1, 'generate: invalid voicescript (expected at least opening and closing segments)');
520
+ }
521
+
522
+ // Grouping: first is opening, last is closing, middle segments split after gap_ms: 1200
523
+ const middleSegments = segments.slice(1, -1);
524
+ const itemSections: Array<Array<Record<string, unknown>>> = [];
525
+ let currentItem: Array<Record<string, unknown>> = [];
526
+ for (const seg of middleSegments) {
527
+ currentItem.push(seg);
528
+ if (seg.gap_ms === 1200) {
529
+ itemSections.push(currentItem);
530
+ currentItem = [];
531
+ }
532
+ }
533
+ if (currentItem.length > 0) {
534
+ itemSections.push(currentItem);
535
+ }
536
+
537
+ const planPath = join(workDir, '24-script', `${runDate}_08_script-write_plan.json`);
538
+ if (existsSync(planPath)) {
539
+ try {
540
+ const plan = readJson(planPath) as { items?: unknown[] };
541
+ if (Array.isArray(plan.items) && plan.items.length !== itemSections.length) {
542
+ failWith(
543
+ 1,
544
+ `generate: plan item count (${plan.items.length}) does not match script item sections (${itemSections.length})`,
545
+ );
546
+ }
547
+ } catch (e: unknown) {
548
+ if (e instanceof StageFailure) throw e;
549
+ }
550
+ }
551
+
552
+ interface SectionDef {
553
+ index: number;
554
+ role: string;
555
+ stem: string;
556
+ name: string;
557
+ segments: Array<Record<string, unknown>>;
558
+ characters: number;
559
+ gap_ms: number;
560
+ text: string;
561
+ }
562
+
563
+ const sectionDefs: SectionDef[] = [];
564
+ // 00-opening
565
+ const openSegs = [segments[0]];
566
+ const openText = openSegs.map((s) => String(s.text ?? '')).join('');
567
+ sectionDefs.push({
568
+ index: 0,
569
+ role: 'opening',
570
+ stem: '00-opening',
571
+ name: '00-opening',
572
+ segments: openSegs,
573
+ characters: [...openText].length,
574
+ gap_ms:
575
+ typeof openSegs[openSegs.length - 1].gap_ms === 'number'
576
+ ? (openSegs[openSegs.length - 1].gap_ms as number)
577
+ : 1200,
578
+ text: openText,
579
+ });
580
+
581
+ // 01-item ...
582
+ itemSections.forEach((segs, idx) => {
583
+ const itemIdx = idx + 1;
584
+ const pad = String(itemIdx).padStart(2, '0');
585
+ const stem = `${pad}-item`;
586
+ const itemText = segs.map((s) => String(s.text ?? '')).join('');
587
+ const lastSeg = segs[segs.length - 1];
588
+ sectionDefs.push({
589
+ index: itemIdx,
590
+ role: 'item',
591
+ stem,
592
+ name: stem,
593
+ segments: segs,
594
+ characters: [...itemText].length,
595
+ gap_ms: typeof lastSeg?.gap_ms === 'number' ? (lastSeg.gap_ms as number) : 1200,
596
+ text: itemText,
597
+ });
598
+ });
599
+
600
+ // closing
601
+ const closeIdx = sectionDefs.length;
602
+ const closePad = String(closeIdx).padStart(2, '0');
603
+ const closeSegs = [segments[segments.length - 1]];
604
+ const closeText = closeSegs.map((s) => String(s.text ?? '')).join('');
605
+ sectionDefs.push({
606
+ index: closeIdx,
607
+ role: 'closing',
608
+ stem: `${closePad}-closing`,
609
+ name: `${closePad}-closing`,
610
+ segments: closeSegs,
611
+ characters: [...closeText].length,
612
+ gap_ms:
613
+ typeof closeSegs[closeSegs.length - 1].gap_ms === 'number'
614
+ ? (closeSegs[closeSegs.length - 1].gap_ms as number)
615
+ : 300,
616
+ text: closeText,
617
+ });
618
+
619
+ // Character-rate estimate: section characters / driver rate + 8 s sting > the workflow's
620
+ // ceiling (`episode_max_minutes`, arg 8; default 20 for a bare five/six-argument call).
621
+ // The rates are MEASURED from shipped renders, not the style contract's prose. gemini-tts:
622
+ // 20261004 5466 chars / 1026 s = 5.33, 20261007 5970 / 1096 = 5.45, and the 0237 live dry run
623
+ // 4645 / 789.9 s of section speech = 5.88 — a 5.3–5.9 range, so 5.4 deliberately errs low (a
624
+ // slightly early refusal is cheaper than a rendered episode the duration gate then rejects).
625
+ // voice-gen: 20261006 6376 / 1135 = 5.62. A 20-minute ceiling is therefore ~6400 characters.
626
+ // The style sidecar's 3.3 / 6.3 figures are stale and mutually inconsistent with its own
627
+ // 4300–5800 字 budget (which is 13–29 min at 3.3); using them refused a script that really
628
+ // renders in 17 min. Recalibrate from a fresh pair of `metadata.duration` readings when a
629
+ // driver changes model or speed.
630
+ const charRate = voiceGenerator === 'gemini-tts' ? 5.4 : 5.6;
631
+ const totalChars = sectionDefs.reduce((acc, s) => acc + s.characters, 0);
632
+ const estimatedSeconds = 8 + totalChars / charRate;
633
+ if (estimatedSeconds > maxMinutes * 60) {
634
+ const longest3 = [...sectionDefs].sort((a, b) => b.characters - a.characters).slice(0, 3);
635
+ failWith(
636
+ 1,
637
+ `generate: character-rate estimate ${Math.round(estimatedSeconds / 60)} min exceeds ${maxMinutes} min limit (longest sections: ${longest3.map((s) => s.name).join(', ')})`,
638
+ );
639
+ }
640
+
641
+ const voiceProfile =
642
+ (typeof script.default_profile_id === 'string' && script.default_profile_id) ||
643
+ (typeof script.default_profile === 'string' && script.default_profile) ||
644
+ process.env.VOICEBOX_DEFAULT_PROFILE ||
645
+ 'robin-news';
646
+
647
+ const secDir = join(workDir, '30-audio', 'sections', runDate);
648
+ mkdirSync(secDir, { recursive: true });
649
+
650
+ const joinedWavPath = join(workDir, '30-audio', `${runDate}_11_generate_content.wav`);
651
+ const joinedContentPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
652
+
653
+ interface SectionPlan {
654
+ def: SectionDef;
655
+ hash: string;
656
+ wavPath: string;
657
+ jsonPath: string;
658
+ needsSynthesis: boolean;
659
+ }
660
+
661
+ const computePlans = (driver: string): SectionPlan[] => {
662
+ return sectionDefs.map((def) => {
663
+ const hash = createHash('sha256').update(`${def.text}\0${voiceProfile}\0${driver}`).digest('hex');
664
+ const wavPath = join(secDir, `${def.stem}.wav`);
665
+ const jsonPath = join(secDir, `${def.stem}.json`);
666
+ let cached = false;
667
+ if (existsSync(wavPath) && existsSync(jsonPath)) {
668
+ try {
669
+ const sidecar = readJson(jsonPath) as Record<string, unknown>;
670
+ const sidecarMeta = metadataOf(sidecar) as Record<string, unknown>;
671
+ if (sidecar.hash === hash || sidecarMeta.hash === hash) {
672
+ cached = true;
673
+ }
674
+ } catch {}
675
+ }
676
+ return {
677
+ def,
678
+ hash,
679
+ wavPath,
680
+ jsonPath,
681
+ needsSynthesis: !cached,
682
+ };
683
+ });
684
+ };
685
+
686
+ let plans = computePlans(voiceGenerator);
687
+ const anySynthesisNeeded = plans.some((p) => p.needsSynthesis);
688
+
689
+ if (!anySynthesisNeeded && existsSync(joinedWavPath) && existsSync(joinedContentPath)) {
690
+ console.log('generate: all sections cached, joined speech reused');
691
+ return;
692
+ }
693
+
694
+ // Same quota probe as the one-shot path, before any section request. A fallback changes
695
+ // the cache key, so plans are recomputed and no Gemini file is played as voicebox speech.
696
+ if (
697
+ anySynthesisNeeded &&
698
+ vdriver === 'gemini' &&
699
+ !geminiQuotaProbeOk(workDir, pluginsPath, transcodeMp3, [
700
+ {
701
+ id: 'probe',
702
+ title: 'Probe',
703
+ body: Bun.YAML.stringify({
704
+ title: 'Probe',
705
+ segments: [{ text: 'Hello.' }],
706
+ }),
707
+ sourceUri: 'file://probe',
708
+ mediaType: 'application/yaml',
709
+ metadata: { voiceProfile },
710
+ },
711
+ ])
712
+ ) {
713
+ voiceGenerator = VOICE_DRIVERS.voicebox;
714
+ plans = computePlans(voiceGenerator);
715
+ }
716
+
717
+ // Synthesize sections sequentially (concurrency 1)
718
+ for (const plan of plans) {
719
+ if (!plan.needsSynthesis) {
720
+ console.log(`generate: section cached: ${plan.def.stem}`);
721
+ continue;
722
+ }
723
+ console.log(`generate: synthesizing section: ${plan.def.stem}`);
724
+ const secScript = { ...script, segments: plan.def.segments };
725
+ const secDocIn = join(secDir, `.${plan.def.stem}_in.json`);
726
+ const doc = {
727
+ id: createHash('sha256').update(`section-${plan.def.stem}`).digest('hex').slice(0, 16),
728
+ title: `Daily AI News VoiceScript - ${plan.def.stem}`,
729
+ sourceUri: `file://${resolve(scriptPath)}#${plan.def.stem}`,
730
+ body: Bun.YAML.stringify(secScript),
731
+ mediaType: 'application/yaml',
732
+ metadata: { voiceProfile },
733
+ };
734
+ writeJsonText(secDocIn, [doc]);
735
+ try {
736
+ const status = kk(voiceExecutorArgs(voiceGenerator, secDocIn, plan.jsonPath, pluginsPath), {
737
+ env: { VOICE_GEN_MP3: transcodeMp3 },
738
+ soft: true,
739
+ });
740
+ if (status !== 0) {
741
+ failWith(status, `generate: section synthesis failed for ${plan.def.stem}`);
742
+ }
743
+ } finally {
744
+ rmSync(secDocIn, { force: true });
745
+ }
746
+ try {
747
+ const sidecar = readJson(plan.jsonPath) as Record<string, unknown>;
748
+ sidecar.hash = plan.hash;
749
+ if (sidecar.metadata && typeof sidecar.metadata === 'object') {
750
+ (sidecar.metadata as Record<string, unknown>).hash = plan.hash;
751
+ }
752
+ writeJsonText(plan.jsonPath, sidecar);
753
+ } catch {}
754
+ }
755
+
756
+ // Join sections with ffmpeg
757
+ const probeHelper = (file: string, entries: string): string =>
758
+ spawnSync('ffprobe', [
759
+ '-v',
760
+ 'error',
761
+ '-select_streams',
762
+ 'a:0',
763
+ '-show_entries',
764
+ entries,
765
+ '-of',
766
+ 'csv=p=0',
767
+ file,
768
+ ])
769
+ .stdout?.toString()
770
+ .trim() ?? '';
771
+
772
+ const sampleRate = probeHelper(plans[0].wavPath, 'stream=sample_rate') || '24000';
773
+ const ffmpegArgs = ['-y', '-hide_banner', '-loglevel', 'error'];
774
+ const filterParts: string[] = [];
775
+ const concatInputs: string[] = [];
776
+
777
+ plans.forEach((plan, i) => {
778
+ ffmpegArgs.push('-i', plan.wavPath);
779
+ filterParts.push(`[${i}:a]aresample=${sampleRate},aformat=sample_fmts=s16:channel_layouts=mono[a${i}]`);
780
+ concatInputs.push(`[a${i}]`);
781
+ if (i < plans.length - 1) {
782
+ const gapMs = plan.def.gap_ms;
783
+ const gapS = gapMs > 0 ? (gapMs / 1000).toFixed(4) : '0';
784
+ filterParts.push(`anullsrc=r=${sampleRate}:cl=mono,atrim=duration=${gapS}[sil${i}]`);
785
+ concatInputs.push(`[sil${i}]`);
786
+ }
787
+ });
788
+
789
+ const filterComplex = `${filterParts.join(';')};${concatInputs.join('')}concat=n=${concatInputs.length}:v=0:a=1[out]`;
790
+ ffmpegArgs.push('-filter_complex', filterComplex, '-map', '[out]', '-c:a', 'pcm_s16le', joinedWavPath);
791
+
792
+ const conv = spawnSync('ffmpeg', ffmpegArgs);
793
+ if (conv.status !== 0 || !existsSync(joinedWavPath)) {
794
+ failWith(1, `generate: ffmpeg join failed: ${conv.stderr?.toString().trim()}`);
795
+ }
796
+
797
+ const joinedDuration = Number.parseFloat(probeHelper(joinedWavPath, 'format=duration'));
798
+
799
+ let contentObj: Record<string, unknown> = {};
800
+ if (existsSync(joinedContentPath)) {
801
+ try {
802
+ contentObj = readJson(joinedContentPath) as Record<string, unknown>;
803
+ } catch {}
804
+ }
805
+ const meta = (contentObj.metadata ?? {}) as Record<string, unknown>;
806
+ delete meta.introMerged;
807
+ meta.audioPath = joinedWavPath;
808
+ meta.duration = joinedDuration;
809
+ contentObj.audioPath = joinedWavPath;
810
+ contentObj.mediaType = 'audio/wav';
811
+ contentObj.metadata = meta;
812
+ writeJsonText(joinedContentPath, contentObj);
813
+ });
814
+ }
815
+
816
+ /**
817
+ * 20260917 review #7: prepend the show's 8s intro sting to the generated audio.
818
+ * `intro_music` empty -> skip. Writes `<stem>.intro.wav` (original render kept), points
819
+ * `audioPath` at the merged file, extends `duration`, marks `introMerged` (idempotent).
820
+ */
821
+ /**
822
+ * Episode duration gate (standing requirement, 20261002): a daily episode must run 15–20 minutes
823
+ * (duration is authoritative; the 8–10-item figure is only an estimate). Reads the final audio
824
+ * content's `metadata.duration` — after the intro sting merged, this is the true episode length —
825
+ * and fails loud outside `[min, max]` seconds-worth of minutes. A missing duration is a failure,
826
+ * not a pass: the gate must never silently let an under-length episode through again.
827
+ */
828
+ export function dailyIntro(args: string[]): void {
829
+ const [workDir = '', runDate = '', introSrc = ''] = args;
830
+ if (!introSrc || introSrc.trim() === '') {
831
+ console.log('intro-music: intro_music var empty — skip');
832
+ return;
833
+ }
834
+ const contentPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
835
+ timed(workDir, 'intro-music', () => {
836
+ const content = requireJson(contentPath, 'intro-music');
837
+ const meta = metadataOf(content);
838
+ if (meta.introMerged === true) {
839
+ console.log(`intro-music cached: ${contentPath}`);
840
+ return;
841
+ }
842
+ // Merge from the raw lossless render; mp3Path is the derived publish artifact.
843
+ // Preferring mp3Path here made `<stem>.mp3` the concat input while the output
844
+ // suffix rewrite (`.wav` -> `.intro.wav`) no longer matched, so ffmpeg saw one
845
+ // path as both input #1 and output and aborted with "same as Input #1".
846
+ const candidates = [meta.audioPath, meta.mp3Path].filter(
847
+ (candidate): candidate is string => typeof candidate === 'string' && candidate !== '',
848
+ );
849
+ const rawFile = candidates.find((candidate) => existsSync(resolve(candidate))) ?? candidates[0];
850
+ if (rawFile === undefined) {
851
+ failWith(1, 'intro-music: voice content carries no mp3Path/audioPath');
852
+ }
853
+ const introPath = resolve(introSrc);
854
+ if (!existsSync(introPath)) failWith(1, `intro-music: intro file not found: ${introPath}`);
855
+ const mainPath = resolve(rawFile);
856
+ if (!existsSync(mainPath)) failWith(1, `intro-music: generated audio not found: ${mainPath}`);
857
+ const mergedPath = `${mainPath.replace(/\.(wav|mp3)$/i, '')}.intro.wav`;
858
+ const probe = (file: string, entries: string): string =>
859
+ spawnSync('ffprobe', [
860
+ '-v',
861
+ 'error',
862
+ '-select_streams',
863
+ 'a:0',
864
+ '-show_entries',
865
+ entries,
866
+ '-of',
867
+ 'csv=p=0',
868
+ file,
869
+ ])
870
+ .stdout?.toString()
871
+ .trim() ?? '';
872
+ const mainDur = Number.parseFloat(probe(mainPath, 'format=duration'));
873
+ const introDur = Number.parseFloat(probe(introPath, 'format=duration'));
874
+ if (!Number.isFinite(mainDur) || !Number.isFinite(introDur)) {
875
+ failWith(1, `intro-music: ffprobe could not read durations (main=${mainDur} intro=${introDur})`);
876
+ }
877
+ const rate = probe(mainPath, 'stream=sample_rate') || '24000'; // match the generated audio's rate; mono s16 both sides
878
+ const norm = (label: string): string =>
879
+ `[${label}:a]aresample=${rate},aformat=sample_fmts=s16:channel_layouts=mono[${label}]`;
880
+ const fc = `${norm('0')};${norm('1')};[0][1]concat=n=2:v=0:a=1[out]`;
881
+ const conv = spawnSync('ffmpeg', [
882
+ '-y',
883
+ '-hide_banner',
884
+ '-loglevel',
885
+ 'error',
886
+ '-i',
887
+ introPath,
888
+ '-i',
889
+ mainPath,
890
+ '-filter_complex',
891
+ fc,
892
+ '-map',
893
+ '[out]',
894
+ '-c:a',
895
+ 'pcm_s16le',
896
+ mergedPath,
897
+ ]);
898
+ if (conv.status !== 0 || !existsSync(mergedPath)) {
899
+ failWith(1, `intro-music: ffmpeg concat failed: ${conv.stderr?.toString().trim()}`);
900
+ }
901
+ if (meta.mp3Path && typeof meta.mp3Path === 'string') {
902
+ // transcode_mp3=true: keep the mp3 path honest by re-transcoding the merged wav.
903
+ const mp3 = spawnSync('ffmpeg', [
904
+ '-y',
905
+ '-hide_banner',
906
+ '-loglevel',
907
+ 'error',
908
+ '-i',
909
+ mergedPath,
910
+ '-codec:a',
911
+ 'libmp3lame',
912
+ '-qscale:a',
913
+ '2',
914
+ resolve(meta.mp3Path),
915
+ ]);
916
+ if (mp3.status !== 0) failWith(1, `intro-music: mp3 re-transcode failed: ${mp3.stderr?.toString().trim()}`);
917
+ }
918
+ const mergedDur = Number.parseFloat(probe(mergedPath, 'format=duration')) || mainDur + introDur;
919
+ const updated = {
920
+ ...content,
921
+ metadata: {
922
+ ...meta,
923
+ audioPath: mergedPath,
924
+ duration: mergedDur,
925
+ introMerged: true,
926
+ },
927
+ };
928
+ sted('write', contentPath, () => writeJsonText(contentPath, updated));
929
+ console.log(
930
+ `intro-music: merged ${introDur.toFixed(1)}s intro -> ${mergedPath} (${mergedDur.toFixed(1)}s total)`,
931
+ );
932
+ });
933
+ }
934
+
935
+ export function dailyPlan(args: string[]): void {
936
+ const [workDir = '', runDate = '', pluginsPath = '', planMaxItems = ''] = args;
937
+ const planContent = join(workDir, '21-plan', `${runDate}_03_plan_content.json`);
938
+ const planDocs = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
939
+ // voice2's extract node writes 10-collect/pool.json; voice1's fan-in still writes the blended
940
+ // file. Prefer the pool when it exists so both DAGs share this stage (0212 dogfood finding).
941
+ const pool = join(workDir, '10-collect', 'pool.json');
942
+ const blended = isFile(pool) ? pool : join(workDir, '10-ingest', `${runDate}_02_collect_blended.json`);
943
+ timed(workDir, 'plan', () => {
944
+ if (isFile(planDocs)) {
945
+ console.log(`plan cached: ${planDocs}`);
946
+ return;
947
+ }
948
+ kk(
949
+ ['executor', 'run', 'episode-plan-gen', '--in', blended, '--out', planContent, ...pluginsArgs(pluginsPath)],
950
+ {
951
+ env: { EPISODE_PLAN_MAX_ITEMS: planMaxItems, EPISODE_PLAN_DATE: runDate },
952
+ },
953
+ );
954
+ const docs = metadataOf(readJson(planContent)).docs;
955
+ if (!Array.isArray(docs) || docs.length === 0) failWith(1, 'plan: content.metadata.docs missing or empty');
956
+ sted('write', planDocs, () => writeJsonText(planDocs, docs));
957
+ if (!isFile(planDocs)) failWith(1, `plan: ${planDocs} not written after episode-plan-gen`);
958
+ });
959
+ }
960
+
961
+ /**
962
+ * Topic-boundary sidecar (task 0189 R2). Sibling of this file; node-builtins-only read.
963
+ */
964
+ export function dailyPublishPodcast(args: string[]): void {
965
+ const [workDir = '', runDate = '', pluginsPath = ''] = args;
966
+ const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
967
+ const notes = join(workDir, '31-publish', `${runDate}_15_show-notes_show-notes.md`);
968
+ const result = join(workDir, '31-publish', `${runDate}_16_publish-podcast_result.json`);
969
+ timed(workDir, 'publish-podcast', () => {
970
+ if (targetPublished(result, 'podcast-pub')) {
971
+ console.log(`publish-podcast cached: podcast target ok in ${result}`);
972
+ return;
973
+ }
974
+ try {
975
+ const content = requireJson(prep, 'publish-podcast');
976
+ const showNotes = readFileSync(notes, 'utf-8');
977
+ const merged = { ...content, metadata: { ...metadataOf(content), showNotes } };
978
+ sted('write', prep, () => writeJsonText(prep, merged));
979
+ kk(
980
+ [
981
+ 'executor',
982
+ 'fan-out',
983
+ '--content',
984
+ prep,
985
+ '--target',
986
+ 'podcast-pub',
987
+ '--out',
988
+ result,
989
+ ...pluginsArgs(pluginsPath),
990
+ ],
991
+ {
992
+ unset: ['SPUR_WORKFLOW_RUN_ACTIVE'],
993
+ soft: true,
994
+ },
995
+ );
996
+ } catch (error) {
997
+ // The shell's `… && … || true` swallowed both steps' failures — but an unusable `kk`
998
+ // (exit 127) is an environment failure, not a tolerated plugin failure.
999
+ if (error instanceof StageFailure && error.status === 127) throw error;
1000
+ console.error((error as Error).message);
1001
+ }
1002
+ console.log('publish-podcast: podcast target dispatched');
1003
+ });
1004
+ }
1005
+
1006
+ export function dailyPublishPrep(args: string[]): void {
1007
+ const [workDir = '', runDate = '', artist = '', album = ''] = args;
1008
+ const articlePath = join(workDir, '22-article', `${runDate}_06_article_content.json`);
1009
+ const audioPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
1010
+ const prepPath = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
1011
+ const coverPath = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1012
+ timed(workDir, 'publish-prep', () => {
1013
+ const article = requireJson(articlePath, 'publish-prep');
1014
+ const audioMeta = metadataOf(readJson(audioPath));
1015
+ const rawFile = audioMeta.mp3Path ?? audioMeta.audioPath;
1016
+ if (typeof rawFile !== 'string' || rawFile === '') {
1017
+ failWith(1, 'publish-prep: voice content carries no mp3Path/audioPath');
1018
+ }
1019
+ const audioFile = resolve(rawFile);
1020
+ // 0171 R2: publish-prep publishes metadata.mp3Path ?? audioPath, so a WAV here means
1021
+ // transcode_mp3 was turned off — the run would ship an enclosure Apple Podcasts rejects.
1022
+ if (!audioFile.endsWith('.mp3')) {
1023
+ failWith(1, `publish-prep: resolved audio is not an MP3 (transcode_mp3 must be "true"): ${audioFile}`);
1024
+ }
1025
+ stampId3Tags(audioFile, typeof article.title === 'string' ? article.title : '', runDate, artist, album);
1026
+ const resolvedCover = resolve(coverPath);
1027
+ const hasCover = existsSync(resolvedCover);
1028
+ // Daily-show itunes convention: season = year, episode = day-of-year. run_date arrives
1029
+ // dashed (`$(date +%F)`) or digits-only (formatRunDate); map.ts forwards numbers only,
1030
+ // so a malformed run_date omits both tags instead of failing the publish.
1031
+ const digits = runDate.replaceAll(/\D/g, '');
1032
+ const season = Number(digits.slice(0, 4));
1033
+ const month = Number(digits.slice(4, 6));
1034
+ const day = Number(digits.slice(6, 8));
1035
+ const episode =
1036
+ digits.length === 8 && month >= 1 && month <= 12 && day >= 1 && day <= 31
1037
+ ? Math.round((Date.UTC(season, month - 1, day) - Date.UTC(season, 0, 1)) / 86_400_000) + 1
1038
+ : 0;
1039
+ // podcast-pub's documented contract reads metadata.coverImage (map.ts); options.coverPath
1040
+ // alone was silently dropped by the mapper — episodes published without a cover.
1041
+ const merged = {
1042
+ ...article,
1043
+ metadata: {
1044
+ ...metadataOf(article),
1045
+ audioFile,
1046
+ runDate,
1047
+ ...(hasCover ? { coverImage: resolvedCover } : {}),
1048
+ ...(episode > 0 ? { itunes: { season, episode } } : {}),
1049
+ },
1050
+ options: {
1051
+ audioFile,
1052
+ durationSec: audioMeta.duration,
1053
+ coverPath: hasCover ? resolvedCover : undefined,
1054
+ },
1055
+ };
1056
+ // Task 0189 R10: the last program seam before anything is dispatched. No stamp, or a
1057
+ // stamp issued on different bytes (edited after PASS, or an older draft), never publishes.
1058
+ const stamp = readJson(safetyPath(workDir, runDate, 'safety-judge_stamp.json'));
1059
+ if (stamp === null) failWith(1, 'publish-prep: no content-safety verdict stamp for this run — not publishing');
1060
+ // 20261005: pass/escalate/exclude all stamp the drafts as publishable; only `block` (draft
1061
+ // integrity) has no stamp. An `exclude` stamp carries the admission-blocked platforms so
1062
+ // the per-target stages can skip those targets instead of failing the run.
1063
+ if (stamp.status !== 'pass' && stamp.status !== 'escalate' && stamp.status !== 'exclude') {
1064
+ failWith(1, `publish-prep: content-safety status ${String(stamp.status)} does not publish`);
1065
+ }
1066
+ if (stamp.digest !== safetyDigest(workDir, runDate)) {
1067
+ failWith(1, 'publish-prep: drafts changed after the content-safety verdict — re-run the review');
1068
+ }
1069
+ const excludedPlatforms = Array.isArray(stamp.excludedPlatforms) ? (stamp.excludedPlatforms as string[]) : [];
1070
+ if (excludedPlatforms.length > 0) {
1071
+ console.log(`publish-prep: content-safety excluded platforms: ${excludedPlatforms.join(', ')}`);
1072
+ }
1073
+ sted('write', prepPath, () =>
1074
+ writeJsonText(prepPath, {
1075
+ ...merged,
1076
+ metadata: { ...metadataOf(merged), safetyExcludedPlatforms: excludedPlatforms },
1077
+ }),
1078
+ );
1079
+ });
1080
+ }
1081
+
1082
+ export function dailyPublishSurfdash(args: string[]): void {
1083
+ const [workDir = '', runDate = '', pluginsPath = ''] = args;
1084
+ const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
1085
+ const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
1086
+ timed(workDir, 'publish-surfdash', () => {
1087
+ if (targetPublished(result, 'surfdash-pub')) {
1088
+ console.log(`publish-surfdash cached: surfdash target ok in ${result}`);
1089
+ return;
1090
+ }
1091
+ kk(
1092
+ [
1093
+ 'executor',
1094
+ 'fan-out',
1095
+ '--content',
1096
+ prep,
1097
+ '--target',
1098
+ 'surfdash-pub',
1099
+ '--out',
1100
+ result,
1101
+ ...pluginsArgs(pluginsPath),
1102
+ ],
1103
+ {
1104
+ unset: NESTED_SPUR_UNSET,
1105
+ soft: true,
1106
+ },
1107
+ );
1108
+ console.log('publish-surfdash: surfdash target dispatched; classification happens after podcast publish');
1109
+ });
1110
+ }
1111
+
1112
+ /**
1113
+ * WeChat MP: drive the console via `wechatmp-pub`'s browser transport and save the article to the
1114
+ * draft box (草稿箱). Draft by default; the 发表 flow runs only on an explicit launch-time
1115
+ * `wechat_live=true` (ADR-022 rail, same as xhs). Independent and non-blocking like XHS: the
1116
+ * outcome never enters `publish_status` (surfdash + podcast remain the publication contract).
1117
+ */
1118
+ export function dailyPublishWechat(args: string[]): void {
1119
+ const [workDirRaw = '', runDate = '', pluginsPath = '', live = 'false', collectionArg = ''] = args;
1120
+ // Metadata carries file paths consumed by the plugin child process (cwd = plugin dir) - never emit relatives.
1121
+ const workDir = resolve(workDirRaw);
1122
+ const article = join(workDir, '22-article', `${runDate}_06_article_content.json`);
1123
+ const input = join(workDir, '31-publish', `${runDate}_20_publish-wechat_content.json`);
1124
+ const result = join(workDir, '31-publish', `${runDate}_20_publish-wechat_result.json`);
1125
+ const want = live === 'true' ? 'public' : 'draft';
1126
+ timed(workDir, 'publish-wechat', () => {
1127
+ // Cache on the *requested* status: a cached draft must not silently satisfy a later
1128
+ // wechat_live=true re-run (that would report a live post that never happened).
1129
+ if (resultPublishStatus(result) === want) {
1130
+ console.log(`publish-wechat cached: ${want} already recorded in ${result}`);
1131
+ return;
1132
+ }
1133
+ // 20261005: a platform the content-safety gate excluded never gets a draft. Skipping here
1134
+ // keeps the run alive for the targets that did pass, which is the point of the gate.
1135
+ const safety = readJson(safetyPath(workDir, runDate, 'safety-judge_stamp.json'));
1136
+ const excluded = Array.isArray(safety?.excludedPlatforms) ? (safety.excludedPlatforms as string[]) : [];
1137
+ if (excluded.includes('wechatmp')) {
1138
+ console.log(`publish-wechat: SKIPPED — content-safety excluded wechatmp for this run`);
1139
+ return;
1140
+ }
1141
+ if (!isFile(article)) {
1142
+ console.log(`publish-wechat: no article at ${article} — skip`);
1143
+ return;
1144
+ }
1145
+ const packaged = requireJson(article, 'publish-wechat');
1146
+ // MP draft enrichment (operator preference 2026-09-29): 原创+作者, 赞赏, 合集, 原文链接
1147
+ // (the surfdash post), AI 生成声明, cover. The brand strings are this daily show's constants.
1148
+ const cover = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1149
+ const audio = join(workDir, '30-audio', `${runDate}_11_generate_content.mp3`);
1150
+ const wechatmp: Record<string, unknown> = {
1151
+ original: true,
1152
+ aiGenerated: true,
1153
+ author: '冰原奔狼',
1154
+ collection: collectionArg || '每日AI乱炖',
1155
+ ...(isFile(cover) ? { coverPath: cover } : {}),
1156
+ // Audio block at the article top (operator request 2026-09-29): the daily MP3 goes to
1157
+ // the 素材库 under the article title; the plugin reuses an existing same-title material.
1158
+ ...(isFile(audio)
1159
+ ? {
1160
+ audioPath: audio,
1161
+ audioTitle: typeof packaged.title === 'string' ? packaged.title : '',
1162
+ audioCoverPath: isFile(cover) ? cover : '',
1163
+ }
1164
+ : {}),
1165
+ };
1166
+ // WeChat-only closing line (operator request 2026-09-29): point readers/listeners back to
1167
+ // the show's platforms. Appended to the wechat input body only, never the master article.
1168
+ const outro = '\n\n欲听往期节目,可至小宇宙、Apple Podcast、YouTube 等平台搜索【咸话咸说】。';
1169
+ sted('write', input, () =>
1170
+ writeJsonText(input, {
1171
+ ...packaged,
1172
+ body: `${typeof packaged.body === 'string' ? packaged.body : ''}${outro}`,
1173
+ metadata: { ...metadataOf(packaged), publishStatus: want, wechatmp },
1174
+ }),
1175
+ );
1176
+ // `soft` keeps the WeChat target non-blocking: the plugin still writes its own failed Result
1177
+ // and prints the failing step (session preflight, editor paste, save control), which is what
1178
+ // the operator needs to re-run — never a silent skip.
1179
+ const status = kk(
1180
+ ['executor', 'run', 'wechatmp-pub', '--in', input, '--out', result, ...pluginsArgs(pluginsPath)],
1181
+ {
1182
+ soft: true,
1183
+ },
1184
+ );
1185
+ console.log(
1186
+ status === 0
1187
+ ? `publish-wechat: ${want} dispatched; outcome recorded in ${result}`
1188
+ : `WARNING: publish-wechat — wechatmp-pub exited ${status}; WeChat target skipped (run continues, see ${result})`,
1189
+ );
1190
+ });
1191
+ }
1192
+
1193
+ /**
1194
+ * F613 R1: snapshot the shipped style contract into the run's work dir. Spur workflows have no
1195
+ * workflow-dir var, so the script-write prompt reads this copy — and the snapshot freezes the
1196
+ * exact contract the run's plan was written against. Idempotent (an existing snapshot is kept);
1197
+ * a missing sidecar is a broken install, so `requireSidecar` fails loud.
1198
+ */
1199
+ /**
1200
+ * E8: publish the packaged card series through `xhs-pub`'s script driver. Draft by default; live
1201
+ * only on an explicit launch-time `xhs_live=true` (ADR-022 — an unattended run never posts live on
1202
+ * its own). Non-blocking by operator decision: a missing/expired XHS session or a changed console
1203
+ * logs a loud failure and leaves the run alive, and the outcome never enters `publish_status`
1204
+ * (surfdash + podcast remain the episode's publication contract).
1205
+ *
1206
+ * 0236: the optional trailing `<podcast>` arg (`'true'` from voice2 only) attaches the episode's
1207
+ * 发播客 note to the same visit as `metadata.followUps` — one console visit, two notes, one cap entry
1208
+ * (ADR-025 amendment 2026-10-09).
1209
+ */
1210
+ export function dailyPublishXhs(args: string[]): void {
1211
+ const [workDir = '', runDate = '', pluginsPath = '', live = 'false', podcast = ''] = args;
1212
+ const content = join(workDir, '25-xhs', 'content.json');
1213
+ const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
1214
+ const input = join(workDir, '31-publish', `${runDate}_19_publish-xhs_content.json`);
1215
+ const result = join(workDir, '31-publish', `${runDate}_19_publish-xhs_result.json`);
1216
+ const want = live === 'true' ? 'public' : 'draft';
1217
+ timed(workDir, 'publish-xhs', () => {
1218
+ // Cache on the *requested* status: a cached draft must not silently satisfy a later
1219
+ // xhs_live=true re-run (that would report a live post that never happened).
1220
+ if (resultPublishStatus(result) === want) {
1221
+ console.log(`publish-xhs cached: ${want} already recorded in ${result}`);
1222
+ // 0236: the cache keys on the visit's primary note, so a 发播客 follow-up that failed in
1223
+ // that visit is named here instead of hidden. It is not retried: a re-visit would post
1224
+ // a second 图文 draft (the console has no idempotency key).
1225
+ const followUps = metadataOf(readJson(result)).followUps;
1226
+ for (const entry of Array.isArray(followUps) ? followUps : []) {
1227
+ const step = (entry ?? {}) as Record<string, unknown>;
1228
+ if (step.ok === true) continue;
1229
+ console.log(
1230
+ `publish-xhs cached: follow-up ${String(metadataOf(step).operation ?? '?')} did not complete: ${String(step.error ?? 'no error recorded')}`,
1231
+ );
1232
+ }
1233
+ return;
1234
+ }
1235
+ if (!isFile(content)) {
1236
+ console.log(`publish-xhs: no card series at ${content} (render packaged none) — skip`);
1237
+ return;
1238
+ }
1239
+ const packaged = requireJson(content, 'publish-xhs');
1240
+ const followUp = podcast === 'true' ? xhsPodcastNote(prep, want) : undefined;
1241
+ sted('write', input, () =>
1242
+ writeJsonText(input, {
1243
+ ...packaged,
1244
+ metadata: {
1245
+ ...metadataOf(packaged),
1246
+ publishStatus: want,
1247
+ ...(followUp === undefined ? {} : { followUps: [followUp] }),
1248
+ },
1249
+ }),
1250
+ );
1251
+ // `soft` keeps the XHS target non-blocking: the plugin still writes its own failed Result
1252
+ // and prints the failing step (login gate, upload, body, submit control), which is what the
1253
+ // operator needs to re-run — never a silent skip.
1254
+ const status = kk(['executor', 'run', 'xhs-pub', '--in', input, '--out', result, ...pluginsArgs(pluginsPath)], {
1255
+ soft: true,
1256
+ });
1257
+ console.log(
1258
+ status === 0
1259
+ ? `publish-xhs: ${want} dispatched; outcome recorded in ${result}`
1260
+ : `WARNING: publish-xhs — xhs-pub exited ${status}; XHS target skipped (run continues, see ${result})`,
1261
+ );
1262
+ });
1263
+ }
1264
+
1265
+ /**
1266
+ * 0236 R1: the 发播客 Content for the same episode — the run's publish-prep Content (the final mp3
1267
+ * with the intro, `23-cover`'s PNG, title and body) plus `metadata.operation` and the run's own
1268
+ * `publishStatus`. No artifact is produced here: both files already exist.
1269
+ *
1270
+ * `metadata.audio` repeats the prep's `metadata.audioFile` under the key the xhs channel's 发播客 mode
1271
+ * reads (`xhs-pub/src/modes.ts`); `metadata.coverImage` is already the shared spelling. A run with no
1272
+ * publish-prep output names the missing episode drafts and carries the 图文 note alone — the 发播客
1273
+ * note is never invented.
1274
+ */
1275
+ function xhsPodcastNote(prepPath: string, publishStatus: 'draft' | 'public'): Record<string, unknown> | undefined {
1276
+ const prep = readJson(prepPath);
1277
+ if (prep === null) {
1278
+ console.log(`publish-xhs: no publish-prep Content at ${prepPath} — the 发播客 note is skipped`);
1279
+ return undefined;
1280
+ }
1281
+ const metadata = metadataOf(prep);
1282
+ return {
1283
+ ...prep,
1284
+ metadata: {
1285
+ ...metadata,
1286
+ operation: '发播客',
1287
+ audio: metadata.audioFile,
1288
+ publishStatus,
1289
+ },
1290
+ };
1291
+ }
1292
+
1293
+ /**
1294
+ * WeChat MP: drive the console via `wechatmp-pub`'s browser transport and save the article to the
1295
+ * draft box (草稿箱). Draft by default; the 发表 flow runs only on an explicit launch-time
1296
+ * `wechat_live=true` (ADR-022 rail, same as xhs). Independent and non-blocking like XHS: the
1297
+ * outcome never enters `publish_status` (surfdash + podcast remain the publication contract).
1298
+ */
1299
+ export function dailyQualityReport(args: string[]): void {
1300
+ const [workDir = '', runDate = ''] = args;
1301
+ const content = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
1302
+ timed(workDir, 'quality-control', () => {
1303
+ const qc = metadataOf(readJson(content)).qc as Record<string, unknown> | undefined;
1304
+ console.log('=== Voice Quality Control Report ===');
1305
+ console.log(`Overall Score: ${qc ? qc.overallScore : 100}/100 | Passed: ${qc ? qc.passed : true}`);
1306
+ const issues = qc?.criticalIssues;
1307
+ if (Array.isArray(issues) && issues.length > 0) {
1308
+ console.warn(`QC Issues:\n${issues.map((issue) => ` - ${issue}`).join('\n')}`);
1309
+ } else {
1310
+ console.log('All audio segments passed quality control checks cleanly.');
1311
+ }
1312
+ });
1313
+ }
1314
+
1315
+ /**
1316
+ * 0171 R3: Apple Podcasts reads the enclosure's ID3 tags, so the published MP3 carries
1317
+ * title/artist/album/date. `-c copy` moves the audio frames without a re-encode, and the temp
1318
+ * file + rename keeps a failed ffmpeg from truncating the episode in place. An empty value
1319
+ * omits that tag rather than failing. The caller runs this after intro-music, whose mp3
1320
+ * re-transcode would otherwise strip tags stamped earlier.
1321
+ */
1322
+ /**
1323
+ * Consume the content-safety report and the content-judge verdict into a publish decision (exit 0 in
1324
+ * every case — routing is transition-owned). The gate's job is to *adjust*, not to kill the run
1325
+ * (20261005 operator decision): a non-PASS verdict first routes back through one bounded
1326
+ * draft-remediation loop (`revise`), and what survives the loop is classified by what it is:
1327
+ *
1328
+ * - `revise` — non-PASS and the remediation budget is not spent: re-review after the drafts change.
1329
+ * - `exclude` — admission-only residue (the CN 资质口径 / 播报形态 findings): the platforms that
1330
+ * BLOCKed are dropped from this run and every other target publishes normally. Also
1331
+ * the 20261007 narrowing: RISK whose findings are all gap/note/fix publishes at
1332
+ * recorded risk with nothing dropped (empty `excludedPlatforms`).
1333
+ * - `escalate` — a finding the reviewer marked `disposition: "escalate"` on an otherwise clean
1334
+ * pass: human review. Legacy reports (no `disposition` field anywhere) keep the old
1335
+ * rule — any RISK decision or report-level `escalate: true` pauses (20261007 ADR).
1336
+ * - `pass` — explicit PASS verdict, unchanged drafts, no BLOCK and no RISK finding at all.
1337
+ * - `block` — draft integrity: PII reported on a blocked platform, or a judge must-pass `fail`
1338
+ * outside `jurisdictional-rules` (prohibited claims / disclosure / data-handling);
1339
+ * also an unreadable or out-of-contract report/verdict, a non-PASS that documents
1340
+ * nothing, or drafts edited after the review. Never published.
1341
+ *
1342
+ * Fail-closed where it matters: a missing report, verdict or confidence still blocks.
1343
+ */
1344
+ export function dailySafetyGate(args: string[]): void {
1345
+ const [workDir = '', runDate = '', maxReviseArg = '0'] = args;
1346
+ const maxRevise = Number.parseInt(maxReviseArg, 10);
1347
+ const statusFile = safetyPath(workDir, runDate, 'safety-judge_status.txt');
1348
+ const stampFile = safetyPath(workDir, runDate, 'safety-judge_stamp.json');
1349
+ timed(workDir, 'safety-judge', () => {
1350
+ const request = readJson(safetyPath(workDir, runDate, 'safety-review_request.json'));
1351
+ const report = readJson(safetyPath(workDir, runDate, 'safety-review_report.json'));
1352
+ const verdict = readJson(safetyPath(workDir, runDate, 'safety-judge_verdict.json'));
1353
+ const platforms = Array.isArray(report?.platforms) ? (report.platforms as Record<string, unknown>[]) : [];
1354
+ const decisions = platforms.map((platform) => platform?.decision);
1355
+ const digest = safetyDigest(workDir, runDate);
1356
+ const revisions = readSafetyReviseCount(workDir);
1357
+ const platformFindings = (platform: Record<string, unknown>): Record<string, unknown>[] =>
1358
+ Array.isArray(platform?.findings) ? (platform.findings as Record<string, unknown>[]) : [];
1359
+ // 20261007 narrowing (operator decision): on a clean pass, only a finding the reviewer
1360
+ // explicitly marked `disposition: "escalate"` is a human decision. Evidence gaps, note-class
1361
+ // and fix-class RISK are recorded but route to `exclude` at recorded risk instead of pausing
1362
+ // an unattended run. Back-compat, fail-closed: a report with no `disposition` field keeps the
1363
+ // legacy rule — any RISK decision or report-level escalate pauses.
1364
+ const allFindings = platforms.flatMap(platformFindings);
1365
+ const usesDisposition = allFindings.some((finding) => typeof finding?.disposition === 'string');
1366
+ const escalatedFindings = allFindings.filter((finding) => finding?.disposition === 'escalate');
1367
+ const risky = usesDisposition
1368
+ ? escalatedFindings.length > 0
1369
+ : decisions.includes('RISK') || report?.escalate === true;
1370
+ // 20261007 amendment: a report that self-classifies with dispositions must also self-grade
1371
+ // its confidence (HIGH|MEDIUM|LOW), mirroring the 20261003 verdict-confidence hardening.
1372
+ // Legacy reports (no disposition field) stay exempt — R3 back-compat unchanged.
1373
+ const reportConfidence = report?.confidence;
1374
+ const reportConfidenceOk =
1375
+ reportConfidence === 'HIGH' || reportConfidence === 'MEDIUM' || reportConfidence === 'LOW';
1376
+ const verdictFindings = Array.isArray(verdict?.findings) ? (verdict.findings as Record<string, unknown>[]) : [];
1377
+ const verdictFails = verdictFindings.filter((finding) => finding?.status === 'fail');
1378
+ // What a BLOCK *is* decides who can answer it. Dropping a blocked platform answers the
1379
+ // platform's own admission rules (资质口径/播报形态 …) — but the drafts' bytes ship to every
1380
+ // other target too, so a *draft* defect reported on that platform survives the drop. The
1381
+ // one finding class that is unambiguously a draft property is category 3 (可识别个人信息):
1382
+ // PII cannot be answered by choosing targets. The rest of the draft-integrity class is owned
1383
+ // by the judge's must-pass criteria below (no-prohibited-claims / disclosure-presence /
1384
+ // data-handling), which is where an independent reviewer can grade it.
1385
+ const blockedPlatforms = platforms.filter((platform) => platform?.decision === 'BLOCK');
1386
+ const excludedPlatforms = blockedPlatforms.map((platform) => String(platform?.platform ?? '')).filter(Boolean);
1387
+ const piiBlock = blockedPlatforms.some((platform) =>
1388
+ platformFindings(platform).some((finding) => Number(finding?.category) === PII_FINDING_CATEGORY),
1389
+ );
1390
+ // A BLOCK that names no finding is a contract violation, not a signal: there is nothing to
1391
+ // remediate and nothing to exclude, so it blocks (fail-closed).
1392
+ const unnamedBlock =
1393
+ blockedPlatforms.length > 0 && blockedPlatforms.every((p) => platformFindings(p).length === 0);
1394
+ const malformed =
1395
+ decisions.length === 0 || decisions.some((decision) => !SAFETY_PLATFORM_DECISIONS.has(String(decision)));
1396
+ const confidence = verdict?.confidence;
1397
+ const confidenceOk = confidence === 'HIGH' || confidence === 'MEDIUM' || confidence === 'LOW';
1398
+ const verdictValue = String(verdict?.verdict ?? '');
1399
+ const outOfContract =
1400
+ (verdictValue !== 'PASS' && verdictValue !== 'FAIL' && verdictValue !== 'NEEDS_REVISION') ||
1401
+ !confidenceOk ||
1402
+ (usesDisposition && !reportConfidenceOk);
1403
+ const nonPass = verdictValue !== 'PASS' || !confidenceOk;
1404
+ // Integrity = the finding survives any target choice: PII reported on a blocked platform, an
1405
+ // out-of-contract verdict, a non-PASS that documents nothing, or a `fail` outside the
1406
+ // admission criteria.
1407
+ const integrity =
1408
+ piiBlock ||
1409
+ unnamedBlock ||
1410
+ (nonPass && verdictFindings.length === 0) ||
1411
+ verdictFails.some((finding) => !ADMISSION_CRITERIA.has(String(finding?.criterion ?? '')));
1412
+ let status = 'block';
1413
+ let reason = 'verdict PASS on unchanged drafts, no BLOCK/RISK';
1414
+ // A digest-mismatch block means the drafts were deliberately edited (HITL): keep them so
1415
+ // a re-run of safety-prep re-reviews the edited version. Any other block means the
1416
+ // content itself is rejected — quarantine the drafts below.
1417
+ let edited = false;
1418
+ if (request?.digest !== digest) {
1419
+ reason = 'drafts changed after the review was requested';
1420
+ edited = true;
1421
+ } else if (malformed) {
1422
+ reason = 'content-safety report missing or malformed';
1423
+ } else if (outOfContract) {
1424
+ // 20261003 hardening (verdict) + 20261007 amendment (report): a verdict outside
1425
+ // PASS|FAIL|NEEDS_REVISION, any verdict without a HIGH/MEDIUM/LOW confidence, or a
1426
+ // disposition-contract report without one, is not reviewable — block, never revise
1427
+ // against it (there is no contract to read findings from).
1428
+ reason = `content contract violation: ${[
1429
+ verdictValue !== 'PASS' && verdictValue !== 'FAIL' && verdictValue !== 'NEEDS_REVISION'
1430
+ ? `verdict ${verdictValue || 'missing'}`
1431
+ : '',
1432
+ !confidenceOk
1433
+ ? `verdict confidence ${String(confidence ?? 'missing')} (no confidence contract: need HIGH/MEDIUM/LOW)`
1434
+ : '',
1435
+ usesDisposition && !reportConfidenceOk
1436
+ ? `report confidence ${String(reportConfidence ?? 'missing')} (need HIGH/MEDIUM/LOW)`
1437
+ : '',
1438
+ ]
1439
+ .filter(Boolean)
1440
+ .join(', ')}`;
1441
+ } else if (nonPass || blockedPlatforms.length > 0) {
1442
+ const trigger =
1443
+ blockedPlatforms.length > 0
1444
+ ? `content-safety BLOCK on ${blockedPlatforms.map((p) => String(p?.platform ?? '')).join(', ')}`
1445
+ : `content-judge verdict ${verdictValue}`;
1446
+ if (revisions < maxRevise) {
1447
+ status = 'revise';
1448
+ reason = `${trigger} — remediation pass ${revisions + 1}/${maxRevise} (drafts re-reviewed after the edit)`;
1449
+ } else if (integrity) {
1450
+ status = 'block';
1451
+ const named = blockedPlatforms
1452
+ .flatMap((platform) => platformFindings(platform))
1453
+ .filter((finding) => Number(finding?.category) === PII_FINDING_CATEGORY)
1454
+ .map((finding) => `${String(finding?.item ?? '?')}(category ${String(finding?.category)})`)
1455
+ .join(', ');
1456
+ const criteria = verdictFails.map((finding) => String(finding?.criterion)).join(', ');
1457
+ reason = `${trigger} — draft-integrity findings survive ${revisions} remediation passes (${named || criteria || 'the verdict names no criterion'}); exclusion cannot fix these`;
1458
+ } else {
1459
+ status = 'exclude';
1460
+ reason =
1461
+ excludedPlatforms.length > 0
1462
+ ? `${trigger} — admission-only, ${revisions} remediation passes spent: ${excludedPlatforms.join(', ')} dropped from this run, other targets publish${risky ? ' (RISK/escalation recorded)' : ''}`
1463
+ : `${trigger} — admission-only, no platform BLOCK: submitting to the remaining targets at recorded risk`;
1464
+ }
1465
+ } else if (risky) {
1466
+ status = 'escalate';
1467
+ reason = usesDisposition
1468
+ ? `a finding is marked disposition:"escalate" (${escalatedFindings.map((f) => String(f?.item ?? '?')).join(', ')}) — human review required`
1469
+ : 'RISK decision or escalation — human review required';
1470
+ } else if (usesDisposition && decisions.includes('RISK')) {
1471
+ // 20261007 narrowing: RISK whose findings are all gap/note/fix publishes at recorded
1472
+ // risk instead of pausing an unattended run; the exclude stamp keeps the residual-risk
1473
+ // record and the FSM routes it to wrap-docs like any admission-only exclusion.
1474
+ status = 'exclude';
1475
+ reason = 'RISK decisions carry only gap/note/fix dispositions — recorded, publishing at recorded risk';
1476
+ } else status = 'pass';
1477
+ if (status === 'pass' || status === 'escalate' || status === 'exclude') {
1478
+ sted('write', stampFile, () => writeJsonText(stampFile, { digest, status, excludedPlatforms }));
1479
+ }
1480
+ if (status === 'block' && !edited) {
1481
+ // A quality block poisons the isFile() draft cache: every same-day retry would
1482
+ // re-fail on unchanged bytes (20261002 dogfood — two judge-fail runs replayed until
1483
+ // the drafts were moved by hand). Quarantine (mv, never rm) so the next run
1484
+ // regenerates from scratch; the 09_* verdict artifacts stay in 24-script for audit.
1485
+ const q = join(workDir, '24-script', `.quarantine-${runDate}-judge-block`);
1486
+ mkdirSync(q, { recursive: true });
1487
+ for (const path of safetyDraftPaths(workDir, runDate)) {
1488
+ if (!isFile(path)) continue;
1489
+ let dest = join(q, basename(path));
1490
+ for (let n = 2; isFile(dest); n += 1) dest = join(q, `${n}-${basename(path)}`);
1491
+ renameSync(path, dest);
1492
+ }
1493
+ }
1494
+ sted('write', statusFile, () => writeRaw(statusFile, status));
1495
+ console.log(`safety: status=${status} (${reason}); routing is transition-owned`);
1496
+ });
1497
+ }
1498
+
1499
+ /**
1500
+ * Post-remediation bookkeeping for the `safety-revise` state: the agent edited the drafts, this
1501
+ * stage checks they are still the artifacts the rest of the pipeline consumes and spends one unit
1502
+ * of the remediation budget (`daily-safety-gate` reads it to choose revise-vs-exclude). Validating
1503
+ * here fails inside the loop instead of at `wrap-docs`, after the run has moved on. A broken
1504
+ * revision spends the budget too: an unparsable draft must not be retried forever.
1505
+ */
1506
+ /**
1507
+ * Open a content-safety review of the current drafts: drop every artifact of an earlier review
1508
+ * (a retry must be re-reviewed on its final version) and write the request the two agent.run
1509
+ * steps read. The program passes context only — platform policy lives in the kk:content-safety
1510
+ * skill's reference files, never here.
1511
+ */
1512
+ export function dailySafetyPrep(args: string[]): void {
1513
+ const [workDir = '', runDate = '', platforms = ''] = args;
1514
+ timed(workDir, 'safety-review', () => {
1515
+ const digest = safetyDigest(workDir, runDate);
1516
+ for (const name of [
1517
+ 'safety-review_report.json',
1518
+ 'safety-judge_verdict.json',
1519
+ 'safety-judge_status.txt',
1520
+ 'safety-judge_stamp.json',
1521
+ ]) {
1522
+ const stale = safetyPath(workDir, runDate, name);
1523
+ if (isFile(stale)) unlinkSync(stale);
1524
+ }
1525
+ const request = safetyPath(workDir, runDate, 'safety-review_request.json');
1526
+ const targets = platforms
1527
+ .split(',')
1528
+ .map((platform) => platform.trim())
1529
+ .filter(Boolean);
1530
+ sted('write', request, () => writeJsonText(request, { digest, platforms: targets, mode: 'detect-only' }));
1531
+ });
1532
+ }
1533
+
1534
+ /**
1535
+ * Consume the content-safety report and the content-judge verdict into a publish decision (exit 0 in
1536
+ * every case — routing is transition-owned). The gate's job is to *adjust*, not to kill the run
1537
+ * (20261005 operator decision): a non-PASS verdict first routes back through one bounded
1538
+ * draft-remediation loop (`revise`), and what survives the loop is classified by what it is:
1539
+ *
1540
+ * - `revise` — non-PASS and the remediation budget is not spent: re-review after the drafts change.
1541
+ * - `exclude` — admission-only residue (the CN 资质口径 / 播报形态 findings): the platforms that
1542
+ * BLOCKed are dropped from this run and every other target publishes normally. Also
1543
+ * the 20261007 narrowing: RISK whose findings are all gap/note/fix publishes at
1544
+ * recorded risk with nothing dropped (empty `excludedPlatforms`).
1545
+ * - `escalate` — a finding the reviewer marked `disposition: "escalate"` on an otherwise clean
1546
+ * pass: human review. Legacy reports (no `disposition` field anywhere) keep the old
1547
+ * rule — any RISK decision or report-level `escalate: true` pauses (20261007 ADR).
1548
+ * - `pass` — explicit PASS verdict, unchanged drafts, no BLOCK and no RISK finding at all.
1549
+ * - `block` — draft integrity: PII reported on a blocked platform, or a judge must-pass `fail`
1550
+ * outside `jurisdictional-rules` (prohibited claims / disclosure / data-handling);
1551
+ * also an unreadable or out-of-contract report/verdict, a non-PASS that documents
1552
+ * nothing, or drafts edited after the review. Never published.
1553
+ *
1554
+ * Fail-closed where it matters: a missing report, verdict or confidence still blocks.
1555
+ */
1556
+ /**
1557
+ * Post-remediation bookkeeping for the `safety-revise` state: the agent edited the drafts, this
1558
+ * stage checks they are still the artifacts the rest of the pipeline consumes and spends one unit
1559
+ * of the remediation budget (`daily-safety-gate` reads it to choose revise-vs-exclude). Validating
1560
+ * here fails inside the loop instead of at `wrap-docs`, after the run has moved on. A broken
1561
+ * revision spends the budget too: an unparsable draft must not be retried forever.
1562
+ */
1563
+ export function dailySafetyRevise(args: string[]): void {
1564
+ const [workDir = '', runDate = ''] = args;
1565
+ timed(workDir, 'safety-revise', () => {
1566
+ const [articlePath, scriptPath] = safetyDraftPaths(workDir, runDate);
1567
+ const article = requireJson(articlePath, 'safety-revise');
1568
+ for (const field of ['title', 'body'] as const) {
1569
+ const value = article[field];
1570
+ if (typeof value !== 'string' || value.trim() === '') {
1571
+ failWith(1, `safety-revise: article ${field} must be a non-empty string in ${articlePath}`);
1572
+ }
1573
+ }
1574
+ assertScriptContent(scriptPath);
1575
+ const path = join(workDir, SAFETY_REVISE_COUNT);
1576
+ const next = readSafetyReviseCount(workDir) + 1;
1577
+ sted('write', path, () => writeFileSync(path, `${next}\n`));
1578
+ console.log(`safety-revise: remediation pass ${next} applied; drafts re-validated`);
1579
+ });
1580
+ }
1581
+
1582
+ export function dailyScript(args: string[]): void {
1583
+ const [workDir = '', runDate = '', pluginsPath = '', language = '', title = '', voiceProfile = ''] = args;
1584
+ const yamlOut = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
1585
+ const contentOut = join(workDir, '24-script', `${runDate}_08_script_content.json`);
1586
+ const planIn = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
1587
+ timed(workDir, 'script', () => {
1588
+ if (isFile(yamlOut)) {
1589
+ console.log(`voicescript capped by plan: ${yamlOut}`);
1590
+ return;
1591
+ }
1592
+ kk(['executor', 'run', 'dailynews-gen', '--in', planIn, '--out', contentOut, ...pluginsArgs(pluginsPath)], {
1593
+ env: {
1594
+ DAILYNEWS_LANGUAGE: language,
1595
+ DAILYNEWS_TITLE: title,
1596
+ // Task 0189: the broadcast greeting carries the episode's run date, not the render
1597
+ // date — a backfilled/retried run must not greet "today".
1598
+ DAILYNEWS_DATE: runDate,
1599
+ VOICEBOX_DEFAULT_PROFILE: voiceProfile,
1600
+ // F613 R4: always passed as absolute paths — spawnPluginEntry runs plugins with
1601
+ // cwd = the plugin package dir, so a relative workDir would silently miss the
1602
+ // plan; a plan file absent at build time reports missing-plan (template fallback).
1603
+ DAILYNEWS_SCRIPT_PLAN: resolve(join(workDir, '24-script', `${runDate}_08_script-write_plan.json`)),
1604
+ DAILYNEWS_STYLE_FILE: resolve(resolveSidecar('', STYLE_SIDECAR)),
1605
+ },
1606
+ });
1607
+ const body = readJson(contentOut)?.body;
1608
+ if (typeof body !== 'string') failWith(1, `script: ${contentOut} has no string body`);
1609
+ sted('write', yamlOut, () => writeRaw(yamlOut, body));
1610
+ // R3 (task 0139): fresh writes are validated here; the script cache path is covered by
1611
+ // wrap-docs, which runs on every path that reaches audio.
1612
+ try {
1613
+ assertScriptContent(yamlOut);
1614
+ } catch (err) {
1615
+ // A rejected fresh write would otherwise poison the isFile(yamlOut) cache: every
1616
+ // resume skips regeneration and re-fails at wrap-docs. Drop it so retry regenerates.
1617
+ unlinkSync(yamlOut);
1618
+ throw err;
1619
+ }
1620
+ });
1621
+ }
1622
+
1623
+ /** Task 0189 R10 artifacts of the content-safety verdict gate (24-script, step 09). */
1624
+ /**
1625
+ * F613 R1: snapshot the shipped style contract into the run's work dir. Spur workflows have no
1626
+ * workflow-dir var, so the script-write prompt reads this copy — and the snapshot freezes the
1627
+ * exact contract the run's plan was written against. Idempotent (an existing snapshot is kept);
1628
+ * a missing sidecar is a broken install, so `requireSidecar` fails loud.
1629
+ */
1630
+ export function dailyScriptStyle(args: string[]): void {
1631
+ const [workDir = '', runDate = ''] = args;
1632
+ const snapshot = join(workDir, '24-script', `${runDate}_08_script-write_style.md`);
1633
+ if (isFile(snapshot)) {
1634
+ console.log(`script style cached: ${snapshot}`);
1635
+ return;
1636
+ }
1637
+ const sidecar = requireSidecar('', STYLE_SIDECAR, 'daily-voice-style');
1638
+ const scriptDir = join(workDir, '24-script');
1639
+ sted('mkdir', scriptDir, () => mkdirSync(scriptDir, { recursive: true }));
1640
+ sted('cp', snapshot, () => copyFileSync(sidecar, snapshot));
1641
+ console.log(`script style snapshot: ${snapshot}`);
1642
+ }
1643
+
1644
+ export function dailyShowNotes(args: string[]): void {
1645
+ const [workDir = '', runDate = '', pluginsPath = '', title = ''] = args;
1646
+ const article = join(workDir, '22-article', `${runDate}_06_article_content.json`);
1647
+ // The preceding `publish-surfdash` state writes this artifact; `_17_publish_result.json` does
1648
+ // not exist yet at `show-notes` time (the later `publish` state merges it), so reading it left
1649
+ // PODCAST_PUB_ARTICLE_POST_PATH permanently empty (task 0137).
1650
+ const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
1651
+ const out = join(workDir, '31-publish', `${runDate}_15_show-notes_show-notes.md`);
1652
+ const plan = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
1653
+ // Generated-episode content carries `metadata.segments[]` — the source of real
1654
+ // audio offsets for the show-notes timeline (jump-to-item timestamps).
1655
+ const audioContent = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
1656
+ timed(workDir, 'show-notes', () => {
1657
+ const effective = readJson(article)?.title;
1658
+ const titleArg = title !== '' ? title : typeof effective === 'string' ? effective : '';
1659
+ // Boundary-excluded docs must not render into show notes (judge finding 2026-10-02): the
1660
+ // article body's 未写入 note names the dropped ids; filter the plan copy podcast-pub sees.
1661
+ // Inlined parse — this file must not import @gobing-ai/* (header contract); the same note
1662
+ // grammar lives in daily-article-gen's excludedIdsFromNote.
1663
+ const postPath = firstPostPath(readJson(result), 'surfdash-pub');
1664
+ const articleBody = readJson(article)?.body;
1665
+ const excluded = new Set<string>();
1666
+ if (typeof articleBody === 'string') {
1667
+ for (const note of articleBody.matchAll(/未写入[::](.*)/g)) {
1668
+ for (const id of note[1].matchAll(/(?:[a-zA-Z0-9_-]+:)?[0-9a-f]{16,64}/g)) {
1669
+ excluded.add(id[0]);
1670
+ const colon = id[0].indexOf(':');
1671
+ if (colon !== -1) excluded.add(id[0].slice(colon + 1));
1672
+ }
1673
+ }
1674
+ }
1675
+ let planIn = plan;
1676
+ if (excluded.size > 0) {
1677
+ const parsed: unknown = readJson(plan);
1678
+ if (Array.isArray(parsed)) {
1679
+ const kept = parsed.filter(
1680
+ (d) =>
1681
+ !(
1682
+ d !== null &&
1683
+ typeof d === 'object' &&
1684
+ (excluded.has(String((d as Record<string, unknown>).id)) ||
1685
+ (String((d as Record<string, unknown>).id).includes(':') &&
1686
+ excluded.has(
1687
+ String((d as Record<string, unknown>).id).slice(
1688
+ String((d as Record<string, unknown>).id).indexOf(':') + 1,
1689
+ ),
1690
+ )))
1691
+ ),
1692
+ );
1693
+ if (kept.length !== parsed.length) {
1694
+ planIn = plan.replace(/\.json$/, '-shownotes.json');
1695
+ writeJsonText(planIn, kept);
1696
+ }
1697
+ }
1698
+ }
1699
+ kk(['executor', 'run', 'podcast-pub', '--in', planIn, '--out', out, ...pluginsArgs(pluginsPath)], {
1700
+ env: {
1701
+ PODCAST_PUB_TITLE: titleArg,
1702
+ PODCAST_PUB_RUN_DATE: runDate,
1703
+ PODCAST_PUB_BRIEF_MODE: 'true',
1704
+ PODCAST_PUB_ARTICLE_POST_PATH: postPath,
1705
+ // 2026-10-08: the column settings carry the AI-production note. Do not append the
1706
+ // stock closer to show notes (or the article, or the spoken sign-off).
1707
+ ...(isFile(audioContent) ? { PODCAST_PUB_AUDIO_CONTENT: audioContent } : {}),
1708
+ },
1709
+ });
1710
+ });
1711
+ }
1712
+
1713
+ /** `jq -r '[…select(.target == t and .ok == true) | .metadata.postPath // empty] | first // ""'`. */
1714
+ /**
1715
+ * Collapsed translate scaffolding (2026-09-16 feedback): surfdash-pub owns the
1716
+ * deterministic mechanics — `scaffold-locale` for both target locales — while the
1717
+ * workflow `translate` state's agent step owns the LLM translation of the drafts.
1718
+ * Writes one marker file per locale (zh post path + en/ja draft paths); empty
1719
+ * marker = locale absent (callers skip). Reuses an existing on-disk draft as the
1720
+ * cache across run retries — re-scaffolding would erase the agent's translation.
1721
+ */
1722
+ export function dailyTranslateLocalize(args: string[]): void {
1723
+ const [workDir = '', runDate = '', pluginsPath = ''] = args;
1724
+ // Same seam as show-notes (task 0137): `_17_publish_result.json` is written later by
1725
+ // publish-classify, which depends on translate-sync. Reading it here always saw no zh
1726
+ // path, so en/ja were never scaffolded (2026-10-08 dogfood).
1727
+ const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
1728
+ const zhFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
1729
+ timed(workDir, 'translate-localize', () => {
1730
+ const postPath = firstPostPath(readJson(result), 'surfdash-pub');
1731
+ const zh = postPath.endsWith('index.md') ? postPath : '';
1732
+ sted('write', zhFile, () => writeRaw(zhFile, zh));
1733
+ if (zh === '') {
1734
+ for (const target of ['en', 'ja'] as const) {
1735
+ sted(
1736
+ 'write',
1737
+ join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`),
1738
+ () =>
1739
+ writeRaw(
1740
+ join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`),
1741
+ '',
1742
+ ),
1743
+ );
1744
+ }
1745
+ console.log('no zh post path — skip localize');
1746
+ return;
1747
+ }
1748
+ for (const target of ['en', 'ja'] as const) {
1749
+ const markerFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`);
1750
+ const existing = zh.replace('/zh/', `/${target}/`);
1751
+ if (isFile(existing)) {
1752
+ sted('write', markerFile, () => writeRaw(markerFile, existing));
1753
+ console.log(`${target} draft cached: ${existing}`);
1754
+ continue;
1755
+ }
1756
+ const scaffold = join(workDir, '31-publish', `${runDate}_20_translate-localize_scaffold-${target}.json`);
1757
+ const scaffoldResult = join(
1758
+ workDir,
1759
+ '31-publish',
1760
+ `${runDate}_20_translate-localize_scaffold-${target}-result.json`,
1761
+ );
1762
+ sted('write', scaffold, () =>
1763
+ writeJqText(scaffold, { body: '', options: { operation: 'scaffold-locale', postPath: zh, target } }),
1764
+ );
1765
+ kk([
1766
+ 'executor',
1767
+ 'run',
1768
+ 'surfdash-pub',
1769
+ '--in',
1770
+ scaffold,
1771
+ '--out',
1772
+ scaffoldResult,
1773
+ ...pluginsArgs(pluginsPath),
1774
+ ]);
1775
+ const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
1776
+ sted('write', markerFile, () => writeRaw(markerFile, typeof draftPath === 'string' ? draftPath : ''));
1777
+ }
1778
+ });
1779
+ }
1780
+
1781
+ export function dailyTranslateSync(args: string[]): void {
1782
+ const [workDir = '', runDate = '', pluginsPath = '', zhPathFile = ''] = args;
1783
+ const sync = join(workDir, '31-publish', `${runDate}_24_translate-sync_sync.json`);
1784
+ const syncResult = join(workDir, '31-publish', `${runDate}_24_translate-sync_sync-result.json`);
1785
+ timed(workDir, 'translate-sync', () => {
1786
+ // Reads the marker file itself — the collapsed `translate` state has no
1787
+ // cross-state var to hand the path through.
1788
+ const zhPostPath = isFile(zhPathFile) ? readFileSync(zhPathFile, 'utf-8') : '';
1789
+ if (zhPostPath === '') {
1790
+ console.log('no zh post path — skip translate-sync');
1791
+ return;
1792
+ }
1793
+ sted('write', sync, () =>
1794
+ writeJqText(sync, { body: '', options: { operation: 'sync', postPath: zhPostPath } }),
1795
+ );
1796
+ kk(['executor', 'run', 'surfdash-pub', '--in', sync, '--out', syncResult, ...pluginsArgs(pluginsPath)], {
1797
+ unset: NESTED_SPUR_UNSET,
1798
+ });
1799
+ });
1800
+ }
1801
+
1802
+ export function dailyWrapDocs(args: string[]): void {
1803
+ const [workDir = '', runDate = '', voiceProfile = ''] = args;
1804
+ const scriptPath = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
1805
+ const docsPath = join(workDir, '30-audio', `${runDate}_10_wrap-docs_docs.json`);
1806
+ timed(workDir, 'wrap-docs', () => {
1807
+ // R3 (task 0139): the second validator placement — covers a HITL-edited YAML and the
1808
+ // script-stage cache path, neither of which re-enters dailyScript's fresh-write branch.
1809
+ assertScriptContent(scriptPath);
1810
+ const url = new URL(scriptPath, `file://${process.cwd()}/`);
1811
+ const body = readFileSync(decodeURIComponent(url.pathname), 'utf-8');
1812
+ const profile = voiceProfile || process.env.VOICEBOX_DEFAULT_PROFILE || 'robin-news';
1813
+ const doc = {
1814
+ id: createHash('sha256').update(url.href).digest('hex').slice(0, 16),
1815
+ title: 'Daily AI News VoiceScript',
1816
+ sourceUri: url.href,
1817
+ body,
1818
+ mediaType: 'application/yaml',
1819
+ metadata: { voiceProfile: profile },
1820
+ };
1821
+ sted('write', docsPath, () => writeJsonText(docsPath, [doc]));
1822
+ });
1823
+ }
1824
+
1825
+ /** 0235 R4: `note_scope`'s one opt-in value. Anything else — absent included — is today's path. */
1826
+ const XHS_NOTE_SCOPE_EPISODE = 'episode';
1827
+
1828
+ /**
1829
+ * 0235 R2: `①` — the circled digits ①…⑳ are contiguous from here, and the episode note attaches
1830
+ * `XHS_MAX_CARDS` cards plus at most one cover, so no bare number can ever be needed.
1831
+ */
1832
+ const CIRCLED_DIGIT_ONE = 0x2460;
1833
+
1834
+ /** The attach-order marker of line `position` (0-based). */
1835
+ const markerFor = (position: number): string => String.fromCodePoint(CIRCLED_DIGIT_ONE + position);
1836
+
1837
+ /**
1838
+ * 0235 R2: split the agent's note body into its lead paragraph and its trailing hashtag run. The
1839
+ * prompt asks for 3-5 hashtags at the end, so the run is read off the end of the body; a body
1840
+ * without hashtags is entirely lead.
1841
+ */
1842
+ function splitNoteBody(body: string): { lead: string; hashtags: string } {
1843
+ const trimmed = body.trim();
1844
+ const match = /(?:^|\s)(#\S+(?:\s+#\S+)*)\s*$/.exec(trimmed);
1845
+ if (match === null) return { lead: trimmed, hashtags: '' };
1846
+ return { lead: trimmed.slice(0, match.index).trimEnd(), hashtags: match[1] ?? '' };
1847
+ }
1848
+
1849
+ /**
1850
+ * 0235 R2: the episode note body — the agent's lead paragraph, then one numbered line per attached
1851
+ * image in attach order, then the plan's hashtags. `cap` is the console's own note limit
1852
+ * (`XHS_NOTE_BODY_MAX`): only the lead is trimmed to fit, so an image never loses its line. A body
1853
+ * that overflows even without a lead fails loud rather than shipping a note the editor would cut.
1854
+ */
1855
+ function episodeNoteBody(body: string, lines: string[], cap: number): string {
1856
+ const { lead, hashtags } = splitNoteBody(body);
1857
+ const tail = [lines.join('\n'), hashtags].filter((part) => part !== '').join('\n\n');
1858
+ // `\n\n` joins the lead to the tail; a lead trimmed away drops the separator with it.
1859
+ const budget = cap - tail.length - (lead === '' ? 0 : 2);
1860
+ const head = budget >= lead.length ? lead : lead.slice(0, Math.max(budget, 0)).trimEnd();
1861
+ const note = head === '' ? tail : `${head}\n\n${tail}`;
1862
+ if (note.length > cap) {
1863
+ failWith(
1864
+ 1,
1865
+ `xhs render: episode note body is ${note.length} chars for ${lines.length} image lines — the cap is ${cap} and a line is never dropped`,
1866
+ );
1867
+ }
1868
+ return note;
1869
+ }
1870
+
1871
+ /**
1872
+ * E8: build one `image-gen` envelope per planned card, render them sequentially, and package the
1873
+ * ordered card manifest + `Content` that `xhs-pub` consumes. Format, style, provider and model
1874
+ * are pinned here (never model-authored), so the series cannot drift from its preset. Card copy
1875
+ * is typeset by image-gen's composer; the provider prompt stays text-free (0181). Resume-safe:
1876
+ * a card whose recorded artifact is still on disk is not re-rendered — and not re-billed.
1877
+ *
1878
+ * `note_scope` (`episode`, 0235) opts into the episode note for voice2: the packaged set then also
1879
+ * attaches the episode cover last, and the body is rebuilt as the lead paragraph plus one numbered
1880
+ * line per attached image. Absent or any other value: the lead-item series and the agent's body are
1881
+ * packaged verbatim (voice1's contract).
1882
+ */
1883
+ export function dailyXhsRender(args: string[]): void {
1884
+ const [
1885
+ workDir = '',
1886
+ runDate = '',
1887
+ pluginsPath = '',
1888
+ countArg = '',
1889
+ style = '',
1890
+ provider = '',
1891
+ model = '',
1892
+ fallback = '',
1893
+ noteScope = '',
1894
+ ] = args;
1895
+ const count = Number.parseInt(countArg, 10);
1896
+ const episode = noteScope === XHS_NOTE_SCOPE_EPISODE;
1897
+ const planPath = join(workDir, '25-xhs', `${runDate}_18_xhs_plan.json`);
1898
+ const coverPath = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1899
+ const cardsDir = join(workDir, '25-xhs', 'cards');
1900
+ const manifestPath = join(workDir, '25-xhs', 'cards.json');
1901
+ const contentPath = join(workDir, '25-xhs', 'content.json');
1902
+ timed(workDir, 'xhs-render', () => {
1903
+ if (!Number.isInteger(count) || count < 1 || count > XHS_MAX_CARDS) {
1904
+ failWith(1, `xhs render: xhs_cards must be 1..${XHS_MAX_CARDS}, got '${countArg}'`);
1905
+ }
1906
+ const plan = xhsPlan(planPath, count);
1907
+ sted('mkdir', cardsDir, () => mkdirSync(cardsDir, { recursive: true }));
1908
+ const images: Record<string, unknown>[] = [];
1909
+ const cards: Record<string, unknown>[] = [];
1910
+ // 0235 R2: the numbered lines are a pure function of the plan and the cover, in attach order
1911
+ // (cards in plan order, the cover last), so the note — and its cap — is settled before the
1912
+ // first card is billed: an over-cap tail fails here with nothing rendered.
1913
+ const withCover = episode && isFile(coverPath);
1914
+ const lines = plan.cards.map((card, i) => `${markerFor(i)} ${card.copy}:${card.points[0] ?? ''}`);
1915
+ if (withCover) lines.push(`${markerFor(lines.length)} 每日AI乱炖 ${runDate} 本期封面`);
1916
+ const body = episode ? episodeNoteBody(plan.body, lines, XHS_BODY_CAP) : plan.body;
1917
+ for (const card of plan.cards) {
1918
+ const out = join(cardsDir, `card-${card.index}.json`);
1919
+ const promptPath = join(cardsDir, `card-${card.index}.prompt.txt`);
1920
+ let artifact = firstArtifact(readJson(out));
1921
+ if (artifact === null || !isFile(typeof artifact.path === 'string' ? artifact.path : '')) {
1922
+ const envPath = join(cardsDir, `card-${card.index}.in.json`);
1923
+ const imageGen: Record<string, unknown> = {
1924
+ job: 'free',
1925
+ format: XHS_FORMAT,
1926
+ style,
1927
+ vars: {
1928
+ role: card.role,
1929
+ layout: card.layout,
1930
+ headline: card.copy,
1931
+ points: card.points.join('\n'),
1932
+ subject: card.subject,
1933
+ index: card.index,
1934
+ },
1935
+ maxImages: 1,
1936
+ };
1937
+ if (provider !== '') imageGen.provider = provider;
1938
+ if (model !== '') imageGen.model = model;
1939
+ // 0219 R1: same opt-in as the cover stage — '' keeps strict fail-fast.
1940
+ if (fallback !== '') imageGen.fallback = imageFallback(fallback);
1941
+ sted('write', envPath, () =>
1942
+ writeJsonText(envPath, [
1943
+ { id: `xhs-card-${card.index}`, title: runDate, body: '', metadata: { imageGen } },
1944
+ ]),
1945
+ );
1946
+ // Sequential on purpose: one provider call per card (illustration only —
1947
+ // card copy is typeset by image-gen's composer, never prompted), and the
1948
+ // first failure stops the spend — later cards stay unrendered for a resume
1949
+ // run to pick up.
1950
+ const status = kk(
1951
+ ['executor', 'run', 'image-gen', '--in', envPath, '--out', out, ...pluginsArgs(pluginsPath)],
1952
+ { soft: true },
1953
+ );
1954
+ // 0219 R2: name the failed card so a hand re-run resumes without re-billing.
1955
+ if (status !== 0) {
1956
+ failWith(
1957
+ status,
1958
+ `xhs render: card ${card.index} failed (image-gen exit ${status}); cards before it are kept — re-run daily-xhs-render to resume from card ${card.index}`,
1959
+ );
1960
+ }
1961
+ artifact = firstArtifact(requireJson(out, 'xhs render'));
1962
+ }
1963
+ if (artifact === null) failWith(1, `xhs render: card ${card.index} recorded no image artifact (${out})`);
1964
+ const recorded = artifact.path;
1965
+ if (typeof recorded !== 'string' || !isFile(recorded)) {
1966
+ failWith(1, `xhs render: card ${card.index} image artifact is not on disk: '${String(recorded)}'`);
1967
+ }
1968
+ // `image-gen` writes the artifact beside `--out`; the publisher requires absolute paths.
1969
+ const imagePath = resolve(recorded);
1970
+ // 0181: a composed card may also carry the text-free illustration layer; keep
1971
+ // its recorded path so the manifest shows both layers.
1972
+ const rawIllustration: unknown = artifact.illustration;
1973
+ const illustrationPath =
1974
+ typeof rawIllustration === 'object' &&
1975
+ rawIllustration !== null &&
1976
+ typeof (rawIllustration as Record<string, unknown>).path === 'string'
1977
+ ? resolve((rawIllustration as Record<string, unknown>).path as string)
1978
+ : undefined;
1979
+ sted('write', promptPath, () =>
1980
+ writeRaw(promptPath, typeof artifact.prompt === 'string' ? artifact.prompt : ''),
1981
+ );
1982
+ images.push({ ...artifact, path: imagePath });
1983
+ cards.push({
1984
+ index: card.index,
1985
+ role: card.role,
1986
+ sourcePoint: card.sourcePoint,
1987
+ copy: card.copy,
1988
+ layout: card.layout,
1989
+ promptPath,
1990
+ imagePath,
1991
+ illustrationPath,
1992
+ ...(card.itemId === undefined ? {} : { itemId: card.itemId }),
1993
+ status: 'rendered',
1994
+ });
1995
+ }
1996
+ // 0235 R1: the episode cover closes the set — it is ultrawide, and image 1 (card 01) is what
1997
+ // sets the note's grid ratio. It is `daily-cover`'s own artifact (no new image kind), and
1998
+ // the set is ≤ XHS_MAX_CARDS + 1 = 11, inside `xhs-pub`'s XHS_IMAGE_MAX (18).
1999
+ if (withCover) {
2000
+ images.push({ path: resolve(coverPath) });
2001
+ } else if (episode) {
2002
+ console.log(
2003
+ `xhs render: no episode cover at ${coverPath} — the note carries the ${cards.length} cards only`,
2004
+ );
2005
+ }
2006
+ sted('write', manifestPath, () => writeJsonText(manifestPath, { source: planPath, cards }));
2007
+ sted('write', contentPath, () =>
2008
+ writeJsonText(contentPath, {
2009
+ title: plan.title,
2010
+ body,
2011
+ format: 'text',
2012
+ metadata: { images, publishStatus: 'draft' },
2013
+ }),
2014
+ );
2015
+ console.log(
2016
+ episode
2017
+ ? `xhs render: ${images.length} images packaged in image order (${contentPath})`
2018
+ : `xhs render: ${cards.length} cards packaged in card order (${contentPath})`,
2019
+ );
2020
+ });
2021
+ }
2022
+
2023
+ /** A single-target publish Result, not the fan-out aggregate: `ok` plus `metadata.publishStatus`. */