@gobing-ai/knowledge-kit 0.0.14 → 0.0.16

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 (26) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/package.json +1 -1
  3. package/plugins/generations/omni-voice-gen/README.md +9 -3
  4. package/plugins/generations/omni-voice-gen/profiles.json +5 -1
  5. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/backend.py +12 -1
  6. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +18 -2
  7. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/profiles.py +8 -0
  8. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/voicescript.py +0 -12
  9. package/plugins/generations/omni-voice-gen/voices/robin-news.wav +0 -0
  10. package/plugins/kk/commands/workflow-run.md +54 -22
  11. package/plugins/kk/config.example.yaml +5 -0
  12. package/plugins/kk/plugin.json +1 -1
  13. package/plugins/kk/scripts/kk-workflow-stages.ts +1226 -0
  14. package/plugins/kk/{workflows → scripts}/validate-voicescript.ts +4 -4
  15. package/plugins/kk/{workflows → scripts}/wrap-voicescript-doc.ts +7 -6
  16. package/plugins/kk/skills/audio-authoring/SKILL.md +11 -12
  17. package/plugins/kk/skills/itc-generating/SKILL.md +2 -2
  18. package/plugins/kk/skills/itc-generating/references/generic-craft.md +1 -1
  19. package/plugins/kk/skills/itc-generating/references/platform-english.md +1 -1
  20. package/plugins/kk/skills/itc-generating/references/platform-wechat.md +1 -1
  21. package/plugins/kk/skills/storm-research/SKILL.md +5 -5
  22. package/plugins/kk/skills/topic/SKILL.md +7 -7
  23. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +149 -163
  24. package/plugins/kk/workflows/kk-itc.yaml +12 -29
  25. package/plugins/kk/workflows/kk-solo-podcast.yaml +72 -131
  26. package/plugins/kk/workflows/kk-storm-research.yaml +12 -62
@@ -0,0 +1,1226 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Deterministic stage runner shared by the kk product workflows (ADR-115 composition owner).
4
+ *
5
+ * Each YAML action keeps one short package-root resolution block and delegates its multi-command
6
+ * body here, so a shell action stays a single logical call:
7
+ *
8
+ * prepare-itc <work_dir> <topic> <input_file> <force>
9
+ * prepare-solo <work_dir> <topic> <input_file> <voice_profile> <force>
10
+ * prepare-storm <work_dir> <topic> <input_file> <max_results> <fixture>
11
+ * validate [<override>] --in <voicescript.yaml>
12
+ * wrap [<override>] --in <voicescript.yaml> --out <docs.json> [--profile <name>]
13
+ * duration <work_dir> <target_duration_min>
14
+ * render-storm [<override>] --in <content.json> --out <content.md>
15
+ *
16
+ * `validate` / `wrap` / `render-storm` resolve the shipped sibling sidecars and delegate to them;
17
+ * nothing about those sidecars changed. The `prepare-*` verbs and `duration` keep the logic that
18
+ * used to live inline in `plugins/kk/workflows/*.yaml`, message-for-message.
19
+ *
20
+ * Node/Bun builtins only — no @gobing-ai/* and no relative imports (the file is resolved from the
21
+ * installed package or from a checkout).
22
+ *
23
+ * The YAML prelude resolves this file as `$pkg/plugins/kk/scripts/kk-workflow-stages.ts` where `pkg`
24
+ * comes from `realpath "$(command -v kk)"` (the installed package root), and falls back to the
25
+ * cwd-relative `plugins/kk/scripts/kk-workflow-stages.ts` when that copy is absent — the checkout
26
+ * tier kk-storm-research's render has always relied on, preserved on purpose.
27
+ *
28
+ * SYNC: plugins/kk/workflows/{kk-solo-podcast,kk-itc,kk-storm-research,kk-daily-ai-voice}.yaml shell
29
+ * this script. Keep message substrings stable — apps/cli/tests/kk-workflow-stages.test.ts and the
30
+ * four workflow static tests match them.
31
+ */
32
+ import { spawnSync } from 'node:child_process';
33
+ import { createHash } from 'node:crypto';
34
+ import {
35
+ appendFileSync,
36
+ copyFileSync,
37
+ existsSync,
38
+ mkdirSync,
39
+ readFileSync,
40
+ realpathSync,
41
+ renameSync,
42
+ statSync,
43
+ unlinkSync,
44
+ writeFileSync,
45
+ } from 'node:fs';
46
+ import { basename, dirname, join, resolve } from 'node:path';
47
+
48
+ const SCRIPTS_DIR = import.meta.dir;
49
+ const VALIDATE_SIDECAR = 'validate-voicescript.ts';
50
+ const WRAP_SIDECAR = 'wrap-voicescript-doc.ts';
51
+ const RENDER_SIDECAR = 'render-md.ts';
52
+ const REVISE_COUNT = '.revise-count';
53
+
54
+ function fail(message: string): never {
55
+ console.error(message);
56
+ process.exit(1);
57
+ }
58
+
59
+ /** coreutils-style reason for the errno codes the staged file operations can hit. */
60
+ const ERRNO_REASONS: Record<string, string> = {
61
+ EACCES: 'Permission denied',
62
+ EROFS: 'Read-only file system',
63
+ ENOSPC: 'No space left on device',
64
+ ENOENT: 'No such file or directory',
65
+ ENOTDIR: 'Not a directory',
66
+ EISDIR: 'Is a directory',
67
+ EEXIST: 'File exists',
68
+ EPERM: 'Operation not permitted',
69
+ EFAULT: 'Bad address',
70
+ };
71
+
72
+ /**
73
+ * Run a filesystem operation, turning a failure into the named diagnostic the shell reported
74
+ * (`mkdir: <path>: Permission denied`) instead of an uncaught Bun stack trace.
75
+ */
76
+ function sted(op: string, path: string, run: () => void): void {
77
+ try {
78
+ run();
79
+ } catch (error) {
80
+ const failure = error as { code?: string; message?: string };
81
+ fail(`${op}: ${path}: ${(failure.code !== undefined && ERRNO_REASONS[failure.code]) || failure.message}`);
82
+ }
83
+ }
84
+
85
+ /** `[ -f ]` semantics: exists AND is a regular file; an unreadable parent answers false, never throws. */
86
+ function isFile(path: string): boolean {
87
+ try {
88
+ return statSync(path, { throwIfNoEntry: false })?.isFile() === true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Runtime override chain, in the order the workflows resolved it inline: explicit var →
96
+ * `KK_WORKFLOWS_DIR` → the shipped package copy → `~/.config/kk/workflows`. The package copy sits
97
+ * before the config-dir copy on purpose (F8 R1) — the shipped bytes win unless the operator elected
98
+ * that directory as an override.
99
+ */
100
+ function resolveSidecar(override: string, sidecar: string): string {
101
+ if (override && isFile(override)) return override;
102
+ const kkWorkflowsDir = process.env.KK_WORKFLOWS_DIR;
103
+ if (kkWorkflowsDir) {
104
+ const fromEnv = join(kkWorkflowsDir, sidecar);
105
+ if (isFile(fromEnv)) return fromEnv;
106
+ }
107
+ const shipped = join(SCRIPTS_DIR, sidecar);
108
+ if (isFile(shipped)) return shipped;
109
+ return join(process.env.HOME ?? '', '.config/kk/workflows', sidecar);
110
+ }
111
+
112
+ /**
113
+ * Fail loud with the remediation path when no copy of a sidecar exists. `label` is the workflow var
114
+ * that carries the override (`validate_script` / `wrap_script` / `render_script`) so the diagnostic
115
+ * names the same thing the operator sees in the YAML.
116
+ */
117
+ function requireSidecar(override: string, sidecar: string, label: string): string {
118
+ const resolved = resolveSidecar(override, sidecar);
119
+ if (!isFile(resolved)) {
120
+ fail(
121
+ `${label} not found — no ${sidecar} under: the explicit var, $KK_WORKFLOWS_DIR, the package scripts dir (also ./plugins/kk/scripts when the prelude's pkg degrades to a dot), $HOME/.config/kk/workflows — install or refresh the kk package (bun link in a checkout)`,
122
+ );
123
+ }
124
+ return resolved;
125
+ }
126
+
127
+ /** Run a shipped sibling under the same bun runtime, passing stdout/stderr and the exit code through. */
128
+ function delegate(sidecar: string, args: string[]): never {
129
+ const runner = spawnSync(process.execPath, [sidecar, ...args], { stdio: 'inherit' });
130
+ process.exit(runner.status ?? 1);
131
+ }
132
+
133
+ /**
134
+ * The directory a relative `input_file` resolves against. bash used its logical `$PWD`; the runner
135
+ * has only `process.cwd()` (physical) plus the inherited `$PWD`, so trust `$PWD` exactly when it
136
+ * realpaths to the same directory and fall back to the physical cwd otherwise.
137
+ */
138
+ function logicalCwd(): string {
139
+ const pwd = process.env.PWD;
140
+ if (!pwd) return process.cwd();
141
+ try {
142
+ return realpathSync(pwd) === realpathSync(process.cwd()) ? pwd : process.cwd();
143
+ } catch {
144
+ return process.cwd();
145
+ }
146
+ }
147
+
148
+ /** jq's `-n '<literal>' > file`: pretty 2-space JSON with a trailing newline, keys in literal order. */
149
+ function writeJson(path: string, value: Record<string, unknown>): void {
150
+ sted('write', path, () => writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`));
151
+ }
152
+
153
+ /** `.revise-count` lives at the work_dir root in every workflow that uses it. */
154
+
155
+ function bumpReviseCount(workDir: string): void {
156
+ const path = join(workDir, '.revise-count');
157
+ let previous = Number.NaN;
158
+ if (isFile(path))
159
+ sted('read', path, () => {
160
+ previous = Number.parseInt(readFileSync(path, 'utf-8'), 10);
161
+ });
162
+ sted('write', path, () => writeFileSync(path, `${(Number.isNaN(previous) ? 0 : previous) + 1}\n`));
163
+ }
164
+
165
+ interface PrepareOptions {
166
+ readonly workDirArg: string;
167
+ readonly topic: string;
168
+ readonly inputFile: string;
169
+ readonly force: string;
170
+ /** Stage directories created under work_dir, in the workflow's declared order. */
171
+ readonly dirs: readonly string[];
172
+ /** solo-podcast requires a profile (argv or $VOICEBOX_DEFAULT_PROFILE); itc does not. */
173
+ readonly requireVoiceProfile: boolean;
174
+ readonly voiceProfile: string;
175
+ /** itc zeroes .revise-count before the XOR checks; solo-podcast zeroes it after brief.md. */
176
+ readonly reviseCountEarly: boolean;
177
+ }
178
+
179
+ /** XOR + brief.md workspace staging shared by the itc and solo-podcast prepares. */
180
+ function prepareWorkspace(options: PrepareOptions): void {
181
+ const { workDirArg, topic, inputFile, force, dirs, voiceProfile, requireVoiceProfile, reviseCountEarly } = options;
182
+ if (!workDirArg) fail('missing work_dir');
183
+ const workDir = resolve(workDirArg);
184
+ for (const dir of dirs) sted('mkdir', join(workDir, dir), () => mkdirSync(join(workDir, dir), { recursive: true }));
185
+ if (reviseCountEarly)
186
+ sted('write', join(workDir, REVISE_COUNT), () => writeFileSync(join(workDir, REVISE_COUNT), '0\n'));
187
+ if (topic && inputFile) fail('XOR violation: both topic and input_file set');
188
+ if (!topic && !inputFile) fail('XOR violation: neither topic nor input_file set');
189
+ if (requireVoiceProfile && !voiceProfile && !process.env.VOICEBOX_DEFAULT_PROFILE) {
190
+ fail('missing voice_profile (set vars.voice_profile or VOICEBOX_DEFAULT_PROFILE)');
191
+ }
192
+ const briefPath = join(workDir, 'brief.md');
193
+ if (isFile(briefPath) && force !== 'true') {
194
+ fail(`initialization target already exists at ${workDirArg}/brief.md; pass force=true to replace`);
195
+ }
196
+ if (topic) {
197
+ sted('write', briefPath, () => writeFileSync(briefPath, `${topic}\n`));
198
+ } else {
199
+ if (!isFile(inputFile)) fail(`input file not found: ${inputFile}`);
200
+ sted('cp', inputFile, () => copyFileSync(inputFile, briefPath));
201
+ }
202
+ if (!reviseCountEarly)
203
+ sted('write', join(workDir, REVISE_COUNT), () => writeFileSync(join(workDir, REVISE_COUNT), '0\n'));
204
+ }
205
+
206
+ function prepareItc(args: string[]): void {
207
+ const [workDirArg = '', topic = '', inputFile = '', force = 'false'] = args;
208
+ prepareWorkspace({
209
+ workDirArg,
210
+ topic,
211
+ inputFile,
212
+ force,
213
+ dirs: ['2-outline'],
214
+ voiceProfile: '',
215
+ requireVoiceProfile: false,
216
+ reviseCountEarly: true,
217
+ });
218
+ }
219
+
220
+ function prepareSolo(args: string[]): void {
221
+ const [workDirArg = '', topic = '', inputFile = '', voiceProfile = '', force = 'false'] = args;
222
+ prepareWorkspace({
223
+ workDirArg,
224
+ topic,
225
+ inputFile,
226
+ force,
227
+ dirs: ['2-outline', '4-audio'],
228
+ voiceProfile,
229
+ requireVoiceProfile: true,
230
+ reviseCountEarly: false,
231
+ });
232
+ }
233
+
234
+ /**
235
+ * STORM prepare: sentence mode writes topic.json; file mode copies input.md and derives the
236
+ * local-doc entry + topic.json query from the file's H1 (0056 works layout).
237
+ */
238
+ function prepareStorm(args: string[]): void {
239
+ const [workDirArg = '', topic = '', inputFile = '', maxResultsRaw = '8', fixture = 'false'] = args;
240
+ if (!workDirArg) fail('missing work_dir');
241
+ const workDir = resolve(workDirArg);
242
+ sted('mkdir', workDir, () => mkdirSync(workDir, { recursive: true }));
243
+ if (topic && inputFile) fail('XOR violation: both topic and input_file set');
244
+ if (!topic && !inputFile) fail('XOR violation: neither topic nor input_file set');
245
+ // `jq`'s `tonumber` grammar (`+8`, `8.`, `.5`, `1e3` yes; `0x10`, surrounding whitespace, empty no),
246
+ // with three deliberate divergences: jq echoed the lexeme it parsed (`8.0`, `1E+3`) where this
247
+ // writes the equivalent JS number; jq accepted the C-float specials `inf`/`Infinity`/`nan` (writing
248
+ // ±1.797e308 and `null`) where this rejects them; and jq accepted `1e400` (writing `1E+400`) where
249
+ // this fails loud rather than emitting a non-finite value.
250
+ if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(maxResultsRaw)) {
251
+ fail(`invalid maxResults: ${maxResultsRaw}`);
252
+ }
253
+ const maxResults = Number(maxResultsRaw);
254
+ if (!Number.isFinite(maxResults)) fail(`invalid maxResults: ${maxResultsRaw}`);
255
+ const topicFile = (query: string): Record<string, unknown> => ({
256
+ topic: query,
257
+ maxResults,
258
+ fixture: fixture === 'true',
259
+ });
260
+
261
+ if (topic) {
262
+ writeJson(join(workDir, 'topic.json'), topicFile(topic));
263
+ return;
264
+ }
265
+ if (!isFile(inputFile)) fail(`input_file not readable: ${inputFile}`);
266
+ sted('cp', inputFile, () => copyFileSync(inputFile, join(workDir, 'input.md')));
267
+ const body = readFileSync(inputFile, 'utf-8');
268
+ const heading = body.split('\n').find((line) => line.startsWith('# '));
269
+ let query = heading === undefined ? '' : heading.replace(/^# */, '');
270
+ if (!query) query = basename(inputFile).replace(/\.[^.]*$/, '');
271
+ if (!query) fail('no query derivable (no H1 and empty basename)');
272
+ // A relative input_file must hash the same file:// URI the shell's logical `$PWD` produced.
273
+ const absolute = join(resolve(logicalCwd(), dirname(inputFile)), basename(inputFile));
274
+ const sourceUri = `file://${absolute}`;
275
+ writeJson(join(workDir, 'local-doc.json'), {
276
+ id: createHash('sha256').update(sourceUri).digest('hex').slice(0, 16),
277
+ // `$(cat file)` drops every trailing newline before jq receives the body.
278
+ body: body.replace(/\n+$/, ''),
279
+ title: query,
280
+ sourceUri,
281
+ mediaType: 'text/markdown',
282
+ metadata: { query, rank: -1, position: 0 },
283
+ });
284
+ writeJson(join(workDir, 'topic.json'), topicFile(query));
285
+ }
286
+
287
+ function duration(args: string[]): void {
288
+ const [workDirArg = '', targetDurationMin = ''] = args;
289
+ if (!workDirArg) fail('missing work_dir');
290
+ const targetMinutes = Number(targetDurationMin);
291
+ if (!Number.isFinite(targetMinutes) || targetMinutes <= 0) {
292
+ fail(`invalid target_duration_min: ${targetDurationMin || '(empty)'}`);
293
+ }
294
+ const workDir = resolve(workDirArg);
295
+ const okPath = join(workDir, '4-audio', '.duration-ok');
296
+ const contentPath = join(workDir, '4-audio', 'content.json');
297
+ // `rm -f <path>`: silent when the file is already gone, loud with the errno otherwise.
298
+ try {
299
+ unlinkSync(okPath);
300
+ } catch (error) {
301
+ if ((error as { code?: string }).code !== 'ENOENT') sted('rm', okPath, () => unlinkSync(okPath));
302
+ }
303
+ if (!isFile(contentPath)) return;
304
+
305
+ let raw: unknown;
306
+ try {
307
+ const parsed = JSON.parse(readFileSync(contentPath, 'utf-8')) as { metadata?: { duration?: unknown } };
308
+ raw = parsed.metadata?.duration;
309
+ } catch (error) {
310
+ // A parse failure is "not a number"; an unreadable file is a real error — re-read inside the
311
+ // wrapper so the errno (EACCES/EIO) is reported instead of being swallowed as missing data.
312
+ if ((error as { code?: string }).code !== undefined) {
313
+ sted('read', contentPath, () => readFileSync(contentPath, 'utf-8'));
314
+ }
315
+ raw = undefined;
316
+ }
317
+ // A numeric string is a number for this gate (`jq` + `awk` read it that way); anything else that
318
+ // is not a finite number takes the "missing or not a number" branch its own message promises.
319
+ const numeric = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN;
320
+ if (!Number.isFinite(numeric)) {
321
+ sted('write', join(workDir, '.duration-feedback'), () =>
322
+ writeFileSync(
323
+ join(workDir, '.duration-feedback'),
324
+ 'Regenerate: audio metadata.duration missing or not a number.\n',
325
+ ),
326
+ );
327
+ bumpReviseCount(workDir);
328
+ return;
329
+ }
330
+ const targetSeconds = targetMinutes * 60;
331
+ if (numeric >= targetSeconds * 0.5 && numeric <= targetSeconds * 2) {
332
+ sted('touch', okPath, () => writeFileSync(okPath, ''));
333
+ return;
334
+ }
335
+ // `printf '%s' "$dur"` echoed the raw token; keep it (a numeric string like "1200.0" stays so).
336
+ const shown = typeof raw === 'string' ? raw : String(numeric);
337
+ sted('write', join(workDir, '.duration-feedback'), () =>
338
+ writeFileSync(
339
+ join(workDir, '.duration-feedback'),
340
+ `Regenerate: last audio duration ${shown}s outside 0.5x–2x of ${targetSeconds}s; adjust segment lengths toward the target.\n`,
341
+ ),
342
+ );
343
+ bumpReviseCount(workDir);
344
+ }
345
+
346
+ /**
347
+ * Positional override (arg 0) + `--flag value` options, for the delegating stages. Scanned by hand
348
+ * rather than with `node:util`'s `parseArgs`: a workflow var may legitimately hold a path that starts
349
+ * with `-` (e.g. `-x`), which `parseArgs` would treat as an option and throw on — the pre-0132 shell
350
+ * chain treated it as a path, fell through the `[ -f ]` test, and kept working.
351
+ */
352
+ function parseStage(
353
+ rest: string[],
354
+ options: Record<string, { type: 'string' }>,
355
+ ): { override: string; values: Record<string, string | undefined> } {
356
+ const values: Record<string, string | undefined> = {};
357
+ let override = '';
358
+ for (let i = 0; i < rest.length; i++) {
359
+ const token = rest[i] ?? '';
360
+ const flag = token.startsWith('--') ? token.slice(2) : '';
361
+ if (flag !== '') {
362
+ // Split on the FIRST `=`: a value may contain one (`--in=/tmp/a=b.yaml`).
363
+ const eq = flag.indexOf('=');
364
+ const name = eq < 0 ? flag : flag.slice(0, eq);
365
+ if (!Object.hasOwn(options, name)) fail(`unknown option: ${token}`);
366
+ values[name] = eq < 0 ? (rest[++i] ?? '') : flag.slice(eq + 1);
367
+ continue;
368
+ }
369
+ if (override === '') override = token;
370
+ }
371
+ return { override, values };
372
+ }
373
+
374
+ // ─── kk-daily-ai-voice stages (0133) ─────────────────────────────────────────────────────────────
375
+ // Ported one-for-one from plugins/kk/workflows/kk-daily-ai-voice.yaml. Every ported stage keeps its
376
+ // step-timing append (success AND failure), its cache predicate and message, the plugins-path argv,
377
+ // and the plugin argv/env it used. `kk` is resolved from PATH, exactly as the shell did. Stages whose
378
+ // inline body already fits the ADR-115 budget (`collect:onEnter:0`'s fan-in at 7 commands, the HITL/prompt states)
379
+ // deliberately stay in the YAML.
380
+
381
+ const DAILY_DIRS = [
382
+ '1-ingest',
383
+ '2-facts',
384
+ '2-plan',
385
+ '2-article',
386
+ '2-cover',
387
+ '2-script',
388
+ '3-audio',
389
+ '3-publish',
390
+ ] as const;
391
+
392
+ /** `date -u +%FT%TZ` — the timestamp grammar the step-timing lines have always used. */
393
+ function utcStamp(): string {
394
+ return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
395
+ }
396
+
397
+ /**
398
+ * The pre-0133 shell wrapped every daily step in `trap … EXIT`, so the timing line lands on failure
399
+ * too. The body signals failure by throwing `failWith`, never by calling `process.exit` directly.
400
+ */
401
+ class StageFailure extends Error {
402
+ constructor(
403
+ readonly status: number,
404
+ readonly named: boolean,
405
+ message: string,
406
+ ) {
407
+ super(message);
408
+ }
409
+ }
410
+
411
+ function failWith(status: number, message?: string): never {
412
+ throw new StageFailure(status, message !== undefined, message ?? `stage failed (status ${status})`);
413
+ }
414
+
415
+ function appendTiming(workDir: string, step: string, startedAt: string): void {
416
+ if (!workDir) return;
417
+ try {
418
+ appendFileSync(
419
+ join(workDir, 'step-timing.json'),
420
+ `${JSON.stringify({ step, startedAt, endedAt: utcStamp() })}\n`,
421
+ );
422
+ } catch {
423
+ // The shell trap was best-effort too: a missing/unwritable work_dir never failed the step.
424
+ }
425
+ }
426
+
427
+ function timed(workDir: string, step: string, body: () => void): void {
428
+ const startedAt = utcStamp();
429
+ let status = 0;
430
+ try {
431
+ body();
432
+ } catch (error) {
433
+ if (error instanceof StageFailure) {
434
+ if (error.named) console.error(error.message);
435
+ status = error.status;
436
+ } else {
437
+ console.error((error as Error).message);
438
+ status = 1;
439
+ }
440
+ } finally {
441
+ appendTiming(workDir, step, startedAt);
442
+ }
443
+ if (status !== 0) process.exit(status);
444
+ }
445
+
446
+ function pluginsArgs(pluginsPath: string): string[] {
447
+ return pluginsPath === '' ? [] : ['--plugins-path', pluginsPath];
448
+ }
449
+
450
+ /** `kk <args>` with the caller's env overrides; `soft` mirrors the shell's `|| true`. */
451
+ function kk(
452
+ args: string[],
453
+ options: { env?: Record<string, string>; unset?: readonly string[]; soft?: boolean } = {},
454
+ ): number {
455
+ const env = { ...process.env, ...(options.env ?? {}) };
456
+ for (const name of options.unset ?? []) delete env[name];
457
+ const runner = spawnSync('kk', args, { stdio: 'inherit', env });
458
+ if (runner.error !== undefined) {
459
+ // A missing/un-launchable `kk` is not a plugin failure: the pre-0133 shell reported
460
+ // `kk: command not found` and exited 127. `soft` callers tolerate a *plugin* failure, not
461
+ // an unusable environment — the operator must still see why nothing ran.
462
+ console.error(`kk: ${runner.error.message}`);
463
+ failWith(127);
464
+ }
465
+ const status = runner.status ?? 1;
466
+ if (status !== 0 && options.soft !== true) failWith(status);
467
+ return status;
468
+ }
469
+
470
+ function writeJsonText(path: string, value: unknown): void {
471
+ // `Bun.write(path, JSON.stringify(value, null, 2))` — no trailing newline.
472
+ writeFileSync(path, JSON.stringify(value, null, 2));
473
+ }
474
+
475
+ function writeJqText(path: string, value: unknown): void {
476
+ // `jq … > path` — pretty output WITH the trailing newline jq always prints.
477
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
478
+ }
479
+
480
+ function writeRaw(path: string, value: string): void {
481
+ // `printf '%s' … > path` / `bun -e 'Bun.write'` — no trailing newline.
482
+ writeFileSync(path, value);
483
+ }
484
+
485
+ function readJson(path: string): Record<string, unknown> | null {
486
+ if (!isFile(path)) return null;
487
+ try {
488
+ return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
489
+ } catch {
490
+ return null;
491
+ }
492
+ }
493
+
494
+ function metadataOf(value: Record<string, unknown> | null): Record<string, unknown> {
495
+ const metadata = value?.metadata;
496
+ return metadata !== null && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : {};
497
+ }
498
+
499
+ /** `jq -e '.targets[]? | select(.target == t and .ok == true and .status == "published")'`. */
500
+ function targetPublished(path: string, target: string): boolean {
501
+ const parsed = readJson(path);
502
+ const targets = parsed?.targets;
503
+ if (!Array.isArray(targets)) return false;
504
+ return targets.some(
505
+ (entry) =>
506
+ entry !== null &&
507
+ typeof entry === 'object' &&
508
+ (entry as Record<string, unknown>).target === target &&
509
+ (entry as Record<string, unknown>).ok === true &&
510
+ (entry as Record<string, unknown>).status === 'published',
511
+ );
512
+ }
513
+
514
+ function dailyPrepare(args: string[]): void {
515
+ const [
516
+ workDirArg = '',
517
+ limit = '',
518
+ cursor = '',
519
+ stateFile = '',
520
+ last30daysEnabled = '',
521
+ topic = '',
522
+ horizonHours = '',
523
+ ] = args;
524
+ const workDir = resolve(workDirArg);
525
+ timed(workDirArg, 'prepare', () => {
526
+ if (!workDirArg) failWith(1, 'missing required variable: work_dir');
527
+ for (const dir of DAILY_DIRS)
528
+ sted('mkdir', join(workDir, dir), () => mkdirSync(join(workDir, dir), { recursive: true }));
529
+ sted('write', join(workDir, 'run-date.txt'), () =>
530
+ writeFileSync(join(workDir, 'run-date.txt'), `${formatRunDate()}\n`),
531
+ );
532
+ sted('write', join(workDir, '1-ingest/source.json'), () =>
533
+ writeJsonText(join(workDir, '1-ingest/source.json'), {
534
+ limit: Number(limit),
535
+ cursor: cursor === '' ? undefined : cursor,
536
+ stateFile: stateFile === '' ? undefined : stateFile,
537
+ }),
538
+ );
539
+ if (last30daysEnabled !== 'true') {
540
+ sted('write', join(workDir, '1-ingest/last30days-docs.json'), () =>
541
+ writeFileSync(join(workDir, '1-ingest/last30days-docs.json'), '[]'),
542
+ );
543
+ }
544
+ sted('write', join(workDir, '1-ingest/last30days.json'), () =>
545
+ writeJsonText(join(workDir, '1-ingest/last30days.json'), { topic }),
546
+ );
547
+ sted('write', join(workDir, '1-ingest/horizon.json'), () =>
548
+ writeJsonText(join(workDir, '1-ingest/horizon.json'), { hours: Number(horizonHours) }),
549
+ );
550
+ });
551
+ }
552
+
553
+ /** `date +%Y%m%d` — the run-date stamp the artifact names carry. */
554
+ function formatRunDate(): string {
555
+ const now = new Date();
556
+ const month = `${now.getMonth() + 1}`.padStart(2, '0');
557
+ const day = `${now.getDate()}`.padStart(2, '0');
558
+ return `${now.getFullYear()}${month}${day}`;
559
+ }
560
+
561
+ function dailyCollectFacts(args: string[]): void {
562
+ const [workDir = '', runDate = '', pluginsPath = ''] = args;
563
+ const content = join(workDir, '2-facts', `${runDate}_02_collect_content.json`);
564
+ const facts = join(workDir, '2-facts', `${runDate}_02_collect_core-facts.md`);
565
+ const blended = join(workDir, '1-ingest', `${runDate}_02_collect_blended.json`);
566
+ timed(workDir, 'collect', () => {
567
+ if (isFile(content) && isFile(facts)) {
568
+ console.log(`core facts cached: ${facts}`);
569
+ return;
570
+ }
571
+ kk(['executor', 'run', 'core-facts-gen', '--in', blended, '--out', content, ...pluginsArgs(pluginsPath)]);
572
+ const body = readJson(content)?.body;
573
+ if (typeof body !== 'string') failWith(1, `collect: ${content} has no string body`);
574
+ sted('write', facts, () => writeRaw(facts, body));
575
+ });
576
+ }
577
+
578
+ function dailyPlan(args: string[]): void {
579
+ const [workDir = '', runDate = '', pluginsPath = '', planMaxItems = ''] = args;
580
+ const planContent = join(workDir, '2-plan', `${runDate}_03_plan_content.json`);
581
+ const planDocs = join(workDir, '2-plan', `${runDate}_03_plan_plan.json`);
582
+ const blended = join(workDir, '1-ingest', `${runDate}_02_collect_blended.json`);
583
+ timed(workDir, 'plan', () => {
584
+ if (isFile(planDocs)) {
585
+ console.log(`plan cached: ${planDocs}`);
586
+ return;
587
+ }
588
+ kk(
589
+ ['executor', 'run', 'episode-plan-gen', '--in', blended, '--out', planContent, ...pluginsArgs(pluginsPath)],
590
+ {
591
+ env: { EPISODE_PLAN_MAX_ITEMS: planMaxItems, EPISODE_PLAN_DATE: runDate },
592
+ },
593
+ );
594
+ const docs = metadataOf(readJson(planContent)).docs;
595
+ if (!Array.isArray(docs) || docs.length === 0) failWith(1, 'plan: content.metadata.docs missing or empty');
596
+ sted('write', planDocs, () => writeJsonText(planDocs, docs));
597
+ if (!isFile(planDocs)) failWith(1, `plan: ${planDocs} not written after episode-plan-gen`);
598
+ });
599
+ }
600
+
601
+ function dailyQcContent(args: string[]): void {
602
+ const [
603
+ workDir = '',
604
+ runDate = '',
605
+ pluginsPath = '',
606
+ qcCategories = '',
607
+ minQuality = '',
608
+ minImportance = '',
609
+ minUrgency = '',
610
+ minImpact = '',
611
+ ] = args;
612
+ const candidates = join(workDir, '2-plan', `${runDate}_04_quality-control-content_candidates.json`);
613
+ const annotated = join(workDir, '2-plan', `${runDate}_04_quality-control-content_annotated.json`);
614
+ const planContent = join(workDir, '2-plan', `${runDate}_03_plan_content.json`);
615
+ timed(workDir, 'quality-control-content', () => {
616
+ if (isFile(candidates)) {
617
+ console.log(`candidates cached: ${candidates}`);
618
+ return;
619
+ }
620
+ sted('write', annotated, () => writeJqText(annotated, metadataOf(readJson(planContent)).docs ?? null));
621
+ kk(
622
+ [
623
+ 'executor',
624
+ 'run',
625
+ 'episode-plan-gen',
626
+ '--in',
627
+ annotated,
628
+ '--out',
629
+ candidates,
630
+ ...pluginsArgs(pluginsPath),
631
+ ],
632
+ {
633
+ env: {
634
+ EPISODE_PLAN_MODE: 'filter',
635
+ QC_CATEGORIES: qcCategories,
636
+ QC_MIN_QUALITY: minQuality,
637
+ QC_MIN_IMPORTANCE: minImportance,
638
+ QC_MIN_URGENCY: minUrgency,
639
+ QC_MIN_IMPACT: minImpact,
640
+ },
641
+ },
642
+ );
643
+ });
644
+ }
645
+
646
+ function dailyArticle(args: string[]): void {
647
+ const [workDir = '', runDate = '', pluginsPath = ''] = args;
648
+ const articleMd = join(workDir, '2-article', `${runDate}_06_article_article.md`);
649
+ const articleContent = join(workDir, '2-article', `${runDate}_06_article_content.json`);
650
+ const planIn = join(workDir, '2-plan', `${runDate}_03_plan_plan.json`);
651
+ const candidates = join(workDir, '2-plan', `${runDate}_04_quality-control-content_candidates.json`);
652
+ timed(workDir, 'article', () => {
653
+ if (isFile(articleMd)) {
654
+ console.log(`article cached: ${articleMd}`);
655
+ return;
656
+ }
657
+ let planUsable = true;
658
+ const raw = isFile(planIn) ? readFileSync(planIn, 'utf-8') : '';
659
+ try {
660
+ JSON.parse(raw);
661
+ } catch (error) {
662
+ console.error(
663
+ `article: plan.json invalid JSON — replacing with untranslated plan (downstream states read this file): ${(error as Error).message}`,
664
+ );
665
+ const docs = metadataOf(readJson(candidates)).docs;
666
+ if (!Array.isArray(docs) || docs.length === 0) {
667
+ console.error('article: candidates.json metadata.docs missing');
668
+ planUsable = false;
669
+ } else {
670
+ sted('write', planIn, () => writeJsonText(planIn, docs));
671
+ }
672
+ }
673
+ if (!planUsable) failWith(1, 'article: no usable plan input');
674
+ kk(
675
+ [
676
+ 'executor',
677
+ 'run',
678
+ 'daily-article-gen',
679
+ '--in',
680
+ planIn,
681
+ '--out',
682
+ articleContent,
683
+ ...pluginsArgs(pluginsPath),
684
+ ],
685
+ {
686
+ env: { ARTICLE_DATE: runDate },
687
+ },
688
+ );
689
+ const body = readJson(articleContent)?.body;
690
+ if (typeof body !== 'string') failWith(1, `article: ${articleContent} has no string body`);
691
+ sted('write', articleMd, () => writeRaw(articleMd, body));
692
+ });
693
+ }
694
+
695
+ function dailyCoverNormalize(args: string[]): void {
696
+ const [workDir = '', runDate = ''] = args;
697
+ // The pre-0133 shell had no timing trap, so this stage is not `timed`; it still fails through
698
+ // `failWith`, which needs the named-failure handling `timed` gives the other stages.
699
+ try {
700
+ coverNormalizeBody(workDir, runDate);
701
+ } catch (error) {
702
+ if (error instanceof StageFailure) {
703
+ if (error.named) console.error(error.message);
704
+ process.exit(error.status);
705
+ }
706
+ console.error((error as Error).message);
707
+ process.exit(1);
708
+ }
709
+ }
710
+
711
+ function coverNormalizeBody(workDir: string, runDate: string): void {
712
+ const png = join(workDir, '2-cover', 'content-cover-01.png');
713
+ const jpg = join(workDir, '2-cover', 'content-cover-01.jpg');
714
+ const out = join(workDir, '2-cover', `cover-${runDate}.png`);
715
+ const source = isFile(png) ? png : isFile(jpg) ? jpg : '';
716
+ if (source === '') failWith(1, 'cover normalize: no content-cover-01.{png,jpg} artifact');
717
+ if (source === jpg) {
718
+ const sips = spawnSync('sips', ['-s', 'format', 'png', source, '--out', out], { stdio: 'ignore' });
719
+ // Bun reports a missing binary as `status: undefined` (older versions: `null`) plus an
720
+ // `error` object — the pre-0133 shell's `command -v sips` failed the same way.
721
+ if (sips.error !== undefined || typeof sips.status !== 'number') {
722
+ failWith(1, 'cover normalize: sips not found; cannot transcode jpg -> png');
723
+ }
724
+ if (sips.status !== 0) {
725
+ failWith(1, `cover normalize: sips failed (status ${sips.status}); jpg -> png did not run`);
726
+ }
727
+ } else {
728
+ sted('mv', source, () => renameSync(source, out));
729
+ }
730
+ for (const stale of [jpg, png]) {
731
+ try {
732
+ unlinkSync(stale);
733
+ } catch {
734
+ // `rm -f` is silent when the file is already gone.
735
+ }
736
+ }
737
+ console.log(`cover normalized: cover-${runDate}.png`);
738
+ }
739
+
740
+ function dailyScript(args: string[]): void {
741
+ const [workDir = '', runDate = '', pluginsPath = '', language = '', title = '', voiceProfile = ''] = args;
742
+ const yamlOut = join(workDir, '2-script', `${runDate}_08_script_voicescript.yaml`);
743
+ const contentOut = join(workDir, '2-script', `${runDate}_08_script_content.json`);
744
+ const planIn = join(workDir, '2-plan', `${runDate}_03_plan_plan.json`);
745
+ timed(workDir, 'script', () => {
746
+ if (isFile(yamlOut)) {
747
+ console.log(`voicescript capped by plan: ${yamlOut}`);
748
+ return;
749
+ }
750
+ kk(['executor', 'run', 'dailynews-gen', '--in', planIn, '--out', contentOut, ...pluginsArgs(pluginsPath)], {
751
+ env: { DAILYNEWS_LANGUAGE: language, DAILYNEWS_TITLE: title, VOICEBOX_DEFAULT_PROFILE: voiceProfile },
752
+ });
753
+ const body = readJson(contentOut)?.body;
754
+ if (typeof body !== 'string') failWith(1, `script: ${contentOut} has no string body`);
755
+ sted('write', yamlOut, () => writeRaw(yamlOut, body));
756
+ });
757
+ }
758
+
759
+ function dailyWrapDocs(args: string[]): void {
760
+ const [workDir = '', runDate = '', voiceProfile = ''] = args;
761
+ const scriptPath = join(workDir, '2-script', `${runDate}_08_script_voicescript.yaml`);
762
+ const docsPath = join(workDir, '3-audio', `${runDate}_10_wrap-docs_docs.json`);
763
+ timed(workDir, 'wrap-docs', () => {
764
+ const url = new URL(scriptPath, `file://${process.cwd()}/`);
765
+ const body = readFileSync(decodeURIComponent(url.pathname), 'utf-8');
766
+ const profile = voiceProfile || process.env.VOICEBOX_DEFAULT_PROFILE || 'robin-news';
767
+ const doc = {
768
+ id: createHash('sha256').update(url.href).digest('hex').slice(0, 16),
769
+ title: 'Daily AI News VoiceScript',
770
+ sourceUri: url.href,
771
+ body,
772
+ mediaType: 'application/yaml',
773
+ metadata: { voiceProfile: profile },
774
+ };
775
+ sted('write', docsPath, () => writeJsonText(docsPath, [doc]));
776
+ });
777
+ }
778
+
779
+ function dailyGenerate(args: string[]): void {
780
+ const [workDir = '', runDate = '', pluginsPath = '', transcodeMp3 = '', voiceGenerator = ''] = args;
781
+ const docs = join(workDir, '3-audio', `${runDate}_10_wrap-docs_docs.json`);
782
+ const content = join(workDir, '3-audio', `${runDate}_11_generate_content.json`);
783
+ timed(workDir, 'generate', () => {
784
+ if (isFile(content) && metadataOf(readJson(content)).qc !== undefined) {
785
+ const qc = metadataOf(readJson(content)).qc as Record<string, unknown> | undefined;
786
+ if (qc?.passed === true) {
787
+ console.log(`audio cached: ${content} (QC passed)`);
788
+ return;
789
+ }
790
+ }
791
+ kk(['executor', 'run', voiceGenerator, '--in', docs, '--out', content, ...pluginsArgs(pluginsPath)], {
792
+ env: { VOICE_GEN_MP3: transcodeMp3 },
793
+ });
794
+ });
795
+ }
796
+
797
+ function dailyQualityReport(args: string[]): void {
798
+ const [workDir = '', runDate = ''] = args;
799
+ const content = join(workDir, '3-audio', `${runDate}_11_generate_content.json`);
800
+ timed(workDir, 'quality-control', () => {
801
+ const qc = metadataOf(readJson(content)).qc as Record<string, unknown> | undefined;
802
+ console.log('=== Voice Quality Control Report ===');
803
+ console.log(`Overall Score: ${qc ? qc.overallScore : 100}/100 | Passed: ${qc ? qc.passed : true}`);
804
+ const issues = qc?.criticalIssues;
805
+ if (Array.isArray(issues) && issues.length > 0) {
806
+ console.warn(`QC Issues:\n${issues.map((issue) => ` - ${issue}`).join('\n')}`);
807
+ } else {
808
+ console.log('All audio segments passed quality control checks cleanly.');
809
+ }
810
+ });
811
+ }
812
+
813
+ function dailyPublishPrep(args: string[]): void {
814
+ const [workDir = '', runDate = ''] = args;
815
+ const articlePath = join(workDir, '2-article', `${runDate}_06_article_content.json`);
816
+ const audioPath = join(workDir, '3-audio', `${runDate}_11_generate_content.json`);
817
+ const prepPath = join(workDir, '3-publish', `${runDate}_13_publish-prep_content.json`);
818
+ const coverPath = join(workDir, '2-cover', `${runDate}_07_cover_cover.png`);
819
+ timed(workDir, 'publish-prep', () => {
820
+ const article = readJson(articlePath) ?? {};
821
+ const audioMeta = metadataOf(readJson(audioPath));
822
+ const rawFile = audioMeta.mp3Path ?? audioMeta.audioPath;
823
+ if (typeof rawFile !== 'string' || rawFile === '') {
824
+ failWith(1, 'publish-prep: voice content carries no mp3Path/audioPath');
825
+ }
826
+ const audioFile = resolve(rawFile);
827
+ const resolvedCover = resolve(coverPath);
828
+ const merged = {
829
+ ...article,
830
+ metadata: { ...metadataOf(article), audioFile, runDate },
831
+ options: {
832
+ audioFile,
833
+ durationSec: audioMeta.duration,
834
+ coverPath: existsSync(resolvedCover) ? resolvedCover : undefined,
835
+ },
836
+ };
837
+ sted('write', prepPath, () => writeJsonText(prepPath, merged));
838
+ });
839
+ }
840
+
841
+ function dailyPublishSurfdash(args: string[]): void {
842
+ const [workDir = '', runDate = '', pluginsPath = ''] = args;
843
+ const prep = join(workDir, '3-publish', `${runDate}_13_publish-prep_content.json`);
844
+ const result = join(workDir, '3-publish', `${runDate}_14_publish-surfdash_result.json`);
845
+ timed(workDir, 'publish-surfdash', () => {
846
+ if (targetPublished(result, 'surfdash-pub')) {
847
+ console.log(`publish-surfdash cached: surfdash target ok in ${result}`);
848
+ return;
849
+ }
850
+ kk(
851
+ [
852
+ 'executor',
853
+ 'fan-out',
854
+ '--content',
855
+ prep,
856
+ '--target',
857
+ 'surfdash-pub',
858
+ '--out',
859
+ result,
860
+ ...pluginsArgs(pluginsPath),
861
+ ],
862
+ {
863
+ unset: ['SPUR_WORKFLOW_RUN_ACTIVE'],
864
+ soft: true,
865
+ },
866
+ );
867
+ console.log('publish-surfdash: surfdash target dispatched; classification happens after podcast publish');
868
+ });
869
+ }
870
+
871
+ function dailyShowNotes(args: string[]): void {
872
+ const [workDir = '', runDate = '', pluginsPath = '', title = ''] = args;
873
+ const article = join(workDir, '2-article', `${runDate}_06_article_content.json`);
874
+ // The preceding `publish-surfdash` state writes this artifact; `_17_publish_result.json` does
875
+ // not exist yet at `show-notes` time (the later `publish` state merges it), so reading it left
876
+ // PODCAST_PUB_ARTICLE_POST_PATH permanently empty (task 0137).
877
+ const result = join(workDir, '3-publish', `${runDate}_14_publish-surfdash_result.json`);
878
+ const out = join(workDir, '3-publish', `${runDate}_15_show-notes_show-notes.md`);
879
+ const plan = join(workDir, '2-plan', `${runDate}_03_plan_plan.json`);
880
+ timed(workDir, 'show-notes', () => {
881
+ const effective = readJson(article)?.title;
882
+ const titleArg = title !== '' ? title : typeof effective === 'string' ? effective : '';
883
+ const postPath = firstPostPath(readJson(result), 'surfdash-pub');
884
+ kk(['executor', 'run', 'podcast-pub', '--in', plan, '--out', out, ...pluginsArgs(pluginsPath)], {
885
+ env: {
886
+ PODCAST_PUB_TITLE: titleArg,
887
+ PODCAST_PUB_RUN_DATE: runDate,
888
+ PODCAST_PUB_BRIEF_MODE: 'true',
889
+ PODCAST_PUB_ARTICLE_POST_PATH: postPath,
890
+ },
891
+ });
892
+ });
893
+ }
894
+
895
+ /** `jq -r '[…select(.target == t and .ok == true) | .metadata.postPath // empty] | first // ""'`. */
896
+ function firstPostPath(parsed: Record<string, unknown> | null, target: string): string {
897
+ const targets = parsed?.targets;
898
+ if (!Array.isArray(targets)) return '';
899
+ for (const entry of targets) {
900
+ if (entry === null || typeof entry !== 'object') continue;
901
+ const record = entry as Record<string, unknown>;
902
+ if (record.target !== target || record.ok !== true) continue;
903
+ const postPath = metadataOf(record).postPath;
904
+ if (typeof postPath === 'string' && postPath !== '') return postPath;
905
+ }
906
+ return '';
907
+ }
908
+
909
+ function dailyPublishPodcast(args: string[]): void {
910
+ const [workDir = '', runDate = '', pluginsPath = ''] = args;
911
+ const prep = join(workDir, '3-publish', `${runDate}_13_publish-prep_content.json`);
912
+ const notes = join(workDir, '3-publish', `${runDate}_15_show-notes_show-notes.md`);
913
+ const result = join(workDir, '3-publish', `${runDate}_16_publish-podcast_result.json`);
914
+ timed(workDir, 'publish-podcast', () => {
915
+ if (targetPublished(result, 'podcast-pub')) {
916
+ console.log(`publish-podcast cached: podcast target ok in ${result}`);
917
+ return;
918
+ }
919
+ try {
920
+ const content = readJson(prep) ?? {};
921
+ const showNotes = readFileSync(notes, 'utf-8');
922
+ const merged = { ...content, metadata: { ...metadataOf(content), showNotes } };
923
+ sted('write', prep, () => writeJsonText(prep, merged));
924
+ kk(
925
+ [
926
+ 'executor',
927
+ 'fan-out',
928
+ '--content',
929
+ prep,
930
+ '--target',
931
+ 'podcast-pub',
932
+ '--out',
933
+ result,
934
+ ...pluginsArgs(pluginsPath),
935
+ ],
936
+ {
937
+ unset: ['SPUR_WORKFLOW_RUN_ACTIVE'],
938
+ soft: true,
939
+ },
940
+ );
941
+ } catch (error) {
942
+ // The shell's `… && … || true` swallowed both steps' failures — but an unusable `kk`
943
+ // (exit 127) is an environment failure, not a tolerated plugin failure.
944
+ if (error instanceof StageFailure && error.status === 127) throw error;
945
+ console.error((error as Error).message);
946
+ }
947
+ console.log('publish-podcast: podcast target dispatched');
948
+ });
949
+ }
950
+
951
+ function dailyPublishClassify(args: string[]): void {
952
+ const [workDir = '', runDate = ''] = args;
953
+ const result = join(workDir, '3-publish', `${runDate}_17_publish_result.json`);
954
+ const statusFile = join(workDir, '3-publish', `${runDate}_17_publish_publish-status.txt`);
955
+ const surfdash = join(workDir, '3-publish', `${runDate}_14_publish-surfdash_result.json`);
956
+ const podcast = join(workDir, '3-publish', `${runDate}_16_publish-podcast_result.json`);
957
+ timed(workDir, 'publish', () => {
958
+ // Both per-target files must exist: the pre-0133 `jq -s … || echo '[]'` chain could not read
959
+ // a missing one (jq exits 2 after printing the slurped partial), which drove status to `none`.
960
+ // Classifying over whichever file happens to exist would report `full` for a publish that
961
+ // never dispatched — the failure masking this rule exists to prevent.
962
+ const bothWritten = isFile(surfdash) && isFile(podcast);
963
+ const targets = bothWritten ? [...targetsOf(surfdash), ...targetsOf(podcast)] : [];
964
+ const okCount = targets.filter((entry) => entry.ok === true && entry.status === 'published').length;
965
+ const total = targets.length;
966
+ const status = okCount > 0 && okCount === total ? 'full' : okCount > 0 ? 'partial' : 'none';
967
+ sted('write', result, () => writeJqText(result, { ok: okCount === total && total > 0, targets }));
968
+ sted('write', statusFile, () => writeRaw(statusFile, status));
969
+ console.log(`publish: status=${status} (${okCount}/${total} targets published); routing is transition-owned`);
970
+ });
971
+ }
972
+
973
+ function targetsOf(path: string): Record<string, unknown>[] {
974
+ const targets = readJson(path)?.targets;
975
+ return Array.isArray(targets)
976
+ ? (targets.filter((entry) => entry !== null && typeof entry === 'object') as Record<string, unknown>[])
977
+ : [];
978
+ }
979
+
980
+ function dailyPublishPartial(args: string[]): void {
981
+ const [workDir = '', runDate = ''] = args;
982
+ const result = join(workDir, '3-publish', `${runDate}_17_publish_result.json`);
983
+ timed(workDir, 'publish-partial', () => {
984
+ const failed = targetsOf(result)
985
+ .filter((entry) => entry.ok !== true || entry.status !== 'published')
986
+ .map((entry) => `${entry.target ?? ''}`)
987
+ .join(',');
988
+ const list = failed === '' ? '(unparsed)' : failed;
989
+ console.error(`WARNING: partial publish — failed targets: ${list}`);
990
+ sted('mkdir', '.spur/run', () => mkdirSync('.spur/run', { recursive: true }));
991
+ sted('write', `.spur/run/publish-partial-${runDate}.txt`, () =>
992
+ writeFileSync(
993
+ `.spur/run/publish-partial-${runDate}.txt`,
994
+ `${runDate} partial publish: failed targets: ${list} (result.json preserved for kk executor fan-out --retry-from)\n`,
995
+ ),
996
+ );
997
+ });
998
+ }
999
+
1000
+ function dailyRunReport(args: string[]): void {
1001
+ const [
1002
+ workDir = '',
1003
+ runDate = '',
1004
+ pluginsPath = '',
1005
+ minQuality = '',
1006
+ minImportance = '',
1007
+ minUrgency = '',
1008
+ minImpact = '',
1009
+ ] = args;
1010
+ const reportMd = join(workDir, `${runDate}_19_run-report_report.md`);
1011
+ const reportJson = join(workDir, `${runDate}_19_run-report_report.json`);
1012
+ const cands = join(workDir, '2-plan', `${runDate}_19_run-report_episode-plan.json`);
1013
+ const candidates = join(workDir, '2-plan', `${runDate}_04_quality-control-content_candidates.json`);
1014
+ timed(workDir, 'run-report', () => {
1015
+ if (isFile(reportMd)) {
1016
+ console.log(`run report cached: ${reportMd}`);
1017
+ return;
1018
+ }
1019
+ if (!isFile(candidates)) {
1020
+ console.log('run-report: no candidates.json (run ended below plan step) — skip');
1021
+ return;
1022
+ }
1023
+ sted('write', cands, () => writeJqText(cands, metadataOf(readJson(candidates)).docs ?? []));
1024
+ kk(['executor', 'run', 'news-report-gen', '--in', cands, '--out', reportJson, ...pluginsArgs(pluginsPath)], {
1025
+ env: {
1026
+ QC_MIN_QUALITY: minQuality,
1027
+ QC_MIN_IMPORTANCE: minImportance,
1028
+ QC_MIN_URGENCY: minUrgency,
1029
+ QC_MIN_IMPACT: minImpact,
1030
+ NEWS_REPORT_REJECTED_FILE: join(
1031
+ workDir,
1032
+ '2-plan',
1033
+ `${runDate}_04_quality-control-content_candidates.rejected.json`,
1034
+ ),
1035
+ NEWS_REPORT_TIMING_FILE: join(workDir, 'step-timing.json'),
1036
+ NEWS_REPORT_DATE: runDate,
1037
+ },
1038
+ });
1039
+ const body = readJson(reportJson)?.body;
1040
+ if (typeof body !== 'string') failWith(1, `run-report: ${reportJson} has no string body`);
1041
+ sted('write', reportMd, () => writeRaw(reportMd, body));
1042
+ console.log(`run report written: ${reportMd}`);
1043
+ });
1044
+ }
1045
+
1046
+ function dailyTranslateEn(args: string[]): void {
1047
+ const [workDir = '', runDate = '', pluginsPath = ''] = args;
1048
+ const result = join(workDir, '3-publish', `${runDate}_17_publish_result.json`);
1049
+ const zhFile = join(workDir, '3-publish', `${runDate}_20_translate-en_zh-post-path.txt`);
1050
+ const enFile = join(workDir, '3-publish', `${runDate}_20_translate-en_en-post-path.txt`);
1051
+ const scaffold = join(workDir, '3-publish', `${runDate}_20_translate-en_scaffold-en.json`);
1052
+ const scaffoldResult = join(workDir, '3-publish', `${runDate}_20_translate-en_scaffold-en-result.json`);
1053
+ timed(workDir, 'translate-en', () => {
1054
+ const postPath = firstPostPath(readJson(result), 'surfdash-pub');
1055
+ const zh = postPath.endsWith('index.md') ? postPath : '';
1056
+ sted('write', zhFile, () => writeRaw(zhFile, zh));
1057
+ const en = zh.replace('/zh/', '/en/');
1058
+ sted('write', enFile, () => writeRaw(enFile, en));
1059
+ if (zh === '') {
1060
+ console.log('no zh post path — skip translate-en');
1061
+ return;
1062
+ }
1063
+ if (isFile(en)) {
1064
+ console.log(`en draft cached: ${en}`);
1065
+ return;
1066
+ }
1067
+ sted('write', scaffold, () =>
1068
+ writeJqText(scaffold, { body: '', options: { operation: 'scaffold-locale', postPath: zh, target: 'en' } }),
1069
+ );
1070
+ kk(['executor', 'run', 'surfdash-pub', '--in', scaffold, '--out', scaffoldResult, ...pluginsArgs(pluginsPath)]);
1071
+ const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
1072
+ sted('write', enFile, () => writeRaw(enFile, typeof draftPath === 'string' ? draftPath : ''));
1073
+ });
1074
+ }
1075
+
1076
+ function dailyTranslateJa(args: string[]): void {
1077
+ const [workDir = '', runDate = '', pluginsPath = ''] = args;
1078
+ const zhFile = join(workDir, '3-publish', `${runDate}_20_translate-en_zh-post-path.txt`);
1079
+ const jaFile = join(workDir, '3-publish', `${runDate}_22_translate-ja_ja-post-path.txt`);
1080
+ const scaffold = join(workDir, '3-publish', `${runDate}_22_translate-ja_scaffold-ja.json`);
1081
+ const scaffoldResult = join(workDir, '3-publish', `${runDate}_22_translate-ja_scaffold-ja-result.json`);
1082
+ timed(workDir, 'translate-ja', () => {
1083
+ const zh = isFile(zhFile) ? readFileSync(zhFile, 'utf-8') : '';
1084
+ const ja = zh.replace('/zh/', '/ja/');
1085
+ sted('write', jaFile, () => writeRaw(jaFile, ja));
1086
+ if (zh === '') {
1087
+ console.log('no zh post path — skip translate-ja');
1088
+ return;
1089
+ }
1090
+ if (isFile(ja)) {
1091
+ console.log(`ja draft cached: ${ja}`);
1092
+ return;
1093
+ }
1094
+ sted('write', scaffold, () =>
1095
+ writeJqText(scaffold, { body: '', options: { operation: 'scaffold-locale', postPath: zh, target: 'ja' } }),
1096
+ );
1097
+ kk(['executor', 'run', 'surfdash-pub', '--in', scaffold, '--out', scaffoldResult, ...pluginsArgs(pluginsPath)]);
1098
+ const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
1099
+ sted('write', jaFile, () => writeRaw(jaFile, typeof draftPath === 'string' ? draftPath : ''));
1100
+ });
1101
+ }
1102
+
1103
+ function dailyTranslateSync(args: string[]): void {
1104
+ const [workDir = '', runDate = '', pluginsPath = '', zhPostPath = ''] = args;
1105
+ const sync = join(workDir, '3-publish', `${runDate}_24_translate-sync_sync.json`);
1106
+ const syncResult = join(workDir, '3-publish', `${runDate}_24_translate-sync_sync-result.json`);
1107
+ timed(workDir, 'translate-sync', () => {
1108
+ if (zhPostPath === '') {
1109
+ console.log('no zh post path — skip translate-sync');
1110
+ return;
1111
+ }
1112
+ sted('write', sync, () =>
1113
+ writeJqText(sync, { body: '', options: { operation: 'sync', postPath: zhPostPath } }),
1114
+ );
1115
+ kk(['executor', 'run', 'surfdash-pub', '--in', sync, '--out', syncResult, ...pluginsArgs(pluginsPath)]);
1116
+ });
1117
+ }
1118
+
1119
+ const [stage, ...rest] = process.argv.slice(2);
1120
+
1121
+ switch (stage) {
1122
+ case 'prepare-itc':
1123
+ prepareItc(rest);
1124
+ break;
1125
+ case 'prepare-solo':
1126
+ prepareSolo(rest);
1127
+ break;
1128
+ case 'prepare-storm':
1129
+ prepareStorm(rest);
1130
+ break;
1131
+ case 'duration':
1132
+ duration(rest);
1133
+ break;
1134
+ case 'validate': {
1135
+ const { override, values } = parseStage(rest, { in: { type: 'string' } });
1136
+ if (!values.in) fail('usage: kk-workflow-stages.ts validate [<override>] --in <voicescript.yaml>');
1137
+ delegate(requireSidecar(override, VALIDATE_SIDECAR, 'validate_script'), ['--in', values.in]);
1138
+ break;
1139
+ }
1140
+ case 'wrap': {
1141
+ const { override, values } = parseStage(rest, {
1142
+ in: { type: 'string' },
1143
+ out: { type: 'string' },
1144
+ profile: { type: 'string' },
1145
+ });
1146
+ if (!values.in || !values.out) {
1147
+ fail(
1148
+ 'usage: kk-workflow-stages.ts wrap [<override>] --in <voicescript.yaml> --out <docs.json> [--profile <name>]',
1149
+ );
1150
+ }
1151
+ delegate(requireSidecar(override, WRAP_SIDECAR, 'wrap_script'), [values.in, values.out, values.profile ?? '']);
1152
+ break;
1153
+ }
1154
+ case 'render-storm': {
1155
+ const { override, values } = parseStage(rest, { in: { type: 'string' }, out: { type: 'string' } });
1156
+ if (!values.in || !values.out) {
1157
+ fail('usage: kk-workflow-stages.ts render-storm [<override>] --in <content.json> --out <content.md>');
1158
+ }
1159
+ delegate(requireSidecar(override, RENDER_SIDECAR, 'render_script'), ['--in', values.in, '--out', values.out]);
1160
+ break;
1161
+ }
1162
+ case 'daily-prepare':
1163
+ dailyPrepare(rest);
1164
+ break;
1165
+ case 'daily-collect-facts':
1166
+ dailyCollectFacts(rest);
1167
+ break;
1168
+ case 'daily-plan':
1169
+ dailyPlan(rest);
1170
+ break;
1171
+ case 'daily-qc-content':
1172
+ dailyQcContent(rest);
1173
+ break;
1174
+ case 'daily-article':
1175
+ dailyArticle(rest);
1176
+ break;
1177
+ case 'daily-cover-normalize':
1178
+ dailyCoverNormalize(rest);
1179
+ break;
1180
+ case 'daily-script':
1181
+ dailyScript(rest);
1182
+ break;
1183
+ case 'daily-wrap-docs':
1184
+ dailyWrapDocs(rest);
1185
+ break;
1186
+ case 'daily-generate':
1187
+ dailyGenerate(rest);
1188
+ break;
1189
+ case 'daily-quality-report':
1190
+ dailyQualityReport(rest);
1191
+ break;
1192
+ case 'daily-publish-prep':
1193
+ dailyPublishPrep(rest);
1194
+ break;
1195
+ case 'daily-publish-surfdash':
1196
+ dailyPublishSurfdash(rest);
1197
+ break;
1198
+ case 'daily-show-notes':
1199
+ dailyShowNotes(rest);
1200
+ break;
1201
+ case 'daily-publish-podcast':
1202
+ dailyPublishPodcast(rest);
1203
+ break;
1204
+ case 'daily-publish-classify':
1205
+ dailyPublishClassify(rest);
1206
+ break;
1207
+ case 'daily-publish-partial':
1208
+ dailyPublishPartial(rest);
1209
+ break;
1210
+ case 'daily-run-report':
1211
+ dailyRunReport(rest);
1212
+ break;
1213
+ case 'daily-translate-en':
1214
+ dailyTranslateEn(rest);
1215
+ break;
1216
+ case 'daily-translate-ja':
1217
+ dailyTranslateJa(rest);
1218
+ break;
1219
+ case 'daily-translate-sync':
1220
+ dailyTranslateSync(rest);
1221
+ break;
1222
+ default:
1223
+ fail(
1224
+ `unknown stage: ${stage ?? '(none)'} (expected prepare-itc|prepare-solo|prepare-storm|validate|wrap|duration|render-storm|daily-*)`,
1225
+ );
1226
+ }