@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/index.js +43047 -24724
- package/package.json +4 -4
- package/plugins/generations/content-gen/dist/index.js +10 -9
- package/plugins/generations/core-facts-gen/dist/index.js +13 -12
- package/plugins/generations/daily-article-gen/dist/index.js +16 -21
- package/plugins/generations/daily-article-gen/src/index.ts +8 -12
- package/plugins/generations/dailynews-gen/dist/index.js +23 -21
- package/plugins/generations/dailynews-gen/src/script-builder.ts +25 -26
- package/plugins/generations/episode-plan-gen/dist/index.js +58 -15
- package/plugins/generations/episode-plan-gen/src/index.ts +76 -6
- package/plugins/generations/gemini-tts/README.md +11 -0
- package/plugins/generations/gemini-tts/dist/index.js +10 -9
- package/plugins/generations/image-gen/dist/index.js +171 -153
- package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +16 -14
- package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +2 -2
- package/plugins/generations/image-gen/src/compose/card.ts +17 -2
- package/plugins/generations/image-gen/src/compose/chromium.ts +60 -42
- package/plugins/generations/news-report-gen/dist/index.js +13 -12
- package/plugins/generations/voice-gen/dist/index.js +10 -9
- package/plugins/ingestions/aihot-ingest/dist/index.js +13 -12
- package/plugins/ingestions/horizon-ingest/dist/index.js +13 -12
- package/plugins/ingestions/karakeep-local/dist/index.js +13 -13
- package/plugins/ingestions/last30days-ingest/dist/index.js +13 -12
- package/plugins/ingestions/web-search/dist/index.js +11 -10
- package/plugins/kk/README.md +38 -0
- package/plugins/kk/decisions/extract/ai-news.yaml +27 -0
- package/plugins/kk/decisions/kk-items.yaml +179 -0
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/schedules/cron.tmpl +15 -0
- package/plugins/kk/schedules/launchd.plist.tmpl +77 -0
- package/plugins/kk/scripts/daily-stages.ts +2023 -0
- package/plugins/kk/scripts/daily-voice-style.zh.md +15 -10
- package/plugins/kk/scripts/kk-workflow-stages.ts +253 -1368
- package/plugins/kk/skills/content-safety/SKILL.md +39 -8
- package/plugins/kk/skills/content-safety/references/platforms/apple-podcasts.md +2 -2
- package/plugins/kk/skills/web-automation/SKILL.md +100 -0
- package/plugins/kk/skills/web-automation/references/browser-launch.md +86 -0
- package/plugins/kk/workflows/kk-collect.yaml +256 -0
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +9 -3
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +774 -0
- package/plugins/kk/workflows/kk-daily-safety.yaml +167 -0
- package/plugins/publishings/emdash-pub/dist/index.js +10 -9
- package/plugins/publishings/infoq-pub/dist/index.js +152 -59
- package/plugins/publishings/infoq-pub/plugin.json +1 -0
- package/plugins/publishings/infoq-pub/src/index.ts +167 -55
- package/plugins/publishings/infoq-pub/src/profile-check.ts +4 -2
- package/plugins/publishings/juejin-pub/dist/index.js +111 -30
- package/plugins/publishings/juejin-pub/plugin.json +1 -0
- package/plugins/publishings/juejin-pub/src/index.ts +49 -9
- package/plugins/publishings/medium-pub/dist/index.js +10 -9
- package/plugins/publishings/podcast-pub/dist/index.js +10 -9
- package/plugins/publishings/qiita-pub/dist/index.js +10 -9
- package/plugins/publishings/substack-pub/dist/index.js +105 -28
- package/plugins/publishings/substack-pub/plugin.json +1 -0
- package/plugins/publishings/substack-pub/src/index.ts +39 -8
- package/plugins/publishings/substack-pub/src/profile-check.ts +2 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +10 -9
- package/plugins/publishings/wechatmp-pub/dist/index.js +115 -31
- package/plugins/publishings/wechatmp-pub/plugin.json +1 -0
- package/plugins/publishings/wechatmp-pub/src/index.ts +42 -9
- package/plugins/publishings/wechatmp-pub/src/profile-check.ts +84 -40
- package/plugins/publishings/x-pub/dist/index.js +114 -35
- package/plugins/publishings/x-pub/plugin.json +1 -0
- package/plugins/publishings/x-pub/src/index.ts +68 -14
- package/plugins/publishings/xhs-pub/dist/index.js +154 -59
- package/plugins/publishings/xhs-pub/plugin.json +1 -0
- package/plugins/publishings/xhs-pub/src/flow.ts +9 -3
- package/plugins/publishings/xhs-pub/src/index.ts +119 -9
- package/plugins/publishings/xhs-pub/src/page.ts +4 -17
- package/plugins/publishings/xhs-pub/src/profile-check.ts +28 -15
- package/plugins/publishings/zenn-pub/dist/index.js +10 -9
|
@@ -57,46 +57,46 @@ import {
|
|
|
57
57
|
import { homedir } from 'node:os';
|
|
58
58
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
59
59
|
|
|
60
|
-
const SCRIPTS_DIR = import.meta.dir;
|
|
61
|
-
const VALIDATE_SIDECAR = 'validate-voicescript.ts';
|
|
60
|
+
export const SCRIPTS_DIR = import.meta.dir;
|
|
61
|
+
export const VALIDATE_SIDECAR = 'validate-voicescript.ts';
|
|
62
62
|
const WRAP_SIDECAR = 'wrap-voicescript-doc.ts';
|
|
63
63
|
const RENDER_SIDECAR = 'render-md.ts';
|
|
64
64
|
// F613: the zh style contract the script-write prompt reads (snapshot stage) and dailynews-gen
|
|
65
65
|
// consumes (DAILYNEWS_STYLE_FILE); no workflow var carries an override, so the chain is
|
|
66
66
|
// $KK_WORKFLOWS_DIR -> package copy -> $HOME.
|
|
67
|
-
const STYLE_SIDECAR = 'daily-voice-style.zh.md';
|
|
67
|
+
export const STYLE_SIDECAR = 'daily-voice-style.zh.md';
|
|
68
68
|
const REVISE_COUNT = '.revise-count';
|
|
69
69
|
/** Bounded draft-remediation counter for the daily safety loop (20261005). */
|
|
70
|
-
const SAFETY_REVISE_COUNT = '.safety-revise-count';
|
|
70
|
+
export const SAFETY_REVISE_COUNT = '.safety-revise-count';
|
|
71
71
|
/**
|
|
72
72
|
* Verdict criteria that describe *platform admission* (the daily's CN 资质口径 / 播报形态 rules) rather
|
|
73
73
|
* than a defect in the draft itself. Only these can be answered by dropping a publish target; every
|
|
74
74
|
* other `fail` is draft integrity, which would ship to the remaining targets too (20261005).
|
|
75
75
|
*/
|
|
76
|
-
const ADMISSION_CRITERIA = new Set(['jurisdictional-rules']);
|
|
76
|
+
export const ADMISSION_CRITERIA = new Set(['jurisdictional-rules']);
|
|
77
77
|
/** content-safety category 3 (可识别个人信息): a draft property, so no target choice answers it. */
|
|
78
|
-
const PII_FINDING_CATEGORY = 3;
|
|
79
|
-
const SAFETY_PLATFORM_DECISIONS = new Set(['OK', 'RISK', 'BLOCK']);
|
|
78
|
+
export const PII_FINDING_CATEGORY = 3;
|
|
79
|
+
export const SAFETY_PLATFORM_DECISIONS = new Set(['OK', 'RISK', 'BLOCK']);
|
|
80
80
|
// Post-translation title-dedup threshold (task 0138 R3). ponytail: the 2026-09-16 Gemini 3.8 Live
|
|
81
81
|
// pair measures 0.417 zh title-only Jaccard, the nearest distinct pair 0.1 (4x margin). Character
|
|
82
82
|
// bigrams need same-language input — pre-translate dedup over the mixed-language blend cannot work.
|
|
83
|
-
const DEDUP_TITLE_THRESHOLD = 0.4;
|
|
83
|
+
export const DEDUP_TITLE_THRESHOLD = 0.4;
|
|
84
84
|
// F614: the daily cover renders through the shipped daily-news presets; the brief's subject is
|
|
85
85
|
// the only per-run var. The format/style pins and the subject cap are part of the style
|
|
86
86
|
// contract — changing the look means editing the preset files, not this stage.
|
|
87
|
-
const COVER_FORMAT = 'daily-news-cover';
|
|
88
|
-
const COVER_STYLE = 'daily-news';
|
|
89
|
-
const COVER_SUBJECT_CAP = 240;
|
|
87
|
+
export const COVER_FORMAT = 'daily-news-cover';
|
|
88
|
+
export const COVER_STYLE = 'daily-news';
|
|
89
|
+
export const COVER_SUBJECT_CAP = 240;
|
|
90
90
|
// E8: the XHS card series. Format and style are pinned exactly like the cover's; only the copy is
|
|
91
91
|
// model-authored. `XHS_MAX_CARDS` mirrors the format preset's own series guidance.
|
|
92
|
-
const XHS_FORMAT = 'xhs-card';
|
|
93
|
-
const XHS_MAX_CARDS = 10;
|
|
94
|
-
const XHS_TITLE_CAP = 20;
|
|
95
|
-
const XHS_BODY_CAP = 1000;
|
|
96
|
-
const XHS_ROLES: readonly string[] = ['cover', 'content', 'closing'];
|
|
92
|
+
export const XHS_FORMAT = 'xhs-card';
|
|
93
|
+
export const XHS_MAX_CARDS = 10;
|
|
94
|
+
export const XHS_TITLE_CAP = 20;
|
|
95
|
+
export const XHS_BODY_CAP = 1000;
|
|
96
|
+
export const XHS_ROLES: readonly string[] = ['cover', 'content', 'closing'];
|
|
97
97
|
|
|
98
98
|
/** One planned card, normalized: `index` zero-padded for the preset's artifact name. */
|
|
99
|
-
interface XhsCard {
|
|
99
|
+
export interface XhsCard {
|
|
100
100
|
index: string;
|
|
101
101
|
role: string;
|
|
102
102
|
copy: string;
|
|
@@ -104,9 +104,11 @@ interface XhsCard {
|
|
|
104
104
|
layout: string;
|
|
105
105
|
subject: string;
|
|
106
106
|
sourcePoint: string;
|
|
107
|
+
/** 0235: the plan item a voice2 content card covers (absent on the hook/closing cards and in voice1). */
|
|
108
|
+
itemId?: string;
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
function fail(message: string): never {
|
|
111
|
+
export function fail(message: string): never {
|
|
110
112
|
console.error(message);
|
|
111
113
|
process.exit(1);
|
|
112
114
|
}
|
|
@@ -127,7 +129,22 @@ async function loadItcStages(): Promise<typeof import('./itc-stages.ts')> {
|
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
/** coreutils-style reason for the errno codes the staged file operations can hit. */
|
|
130
|
-
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Load the daily-* helper sibling lazily (task 0211 R7): this runner is resolved — and copied
|
|
135
|
+
* alone — from installs where the sibling may be absent, so the daily-* stages fail loud with a
|
|
136
|
+
* remediation path instead of breaking every stage at load.
|
|
137
|
+
*/
|
|
138
|
+
async function loadDailyStages(): Promise<typeof import('./daily-stages.ts')> {
|
|
139
|
+
try {
|
|
140
|
+
return await import('./daily-stages.ts');
|
|
141
|
+
} catch {
|
|
142
|
+
return fail(
|
|
143
|
+
'daily-stages.ts not found next to kk-workflow-stages.ts — install or refresh the kk package (bun link in a checkout)',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export const ERRNO_REASONS: Record<string, string> = {
|
|
131
148
|
EACCES: 'Permission denied',
|
|
132
149
|
EROFS: 'Read-only file system',
|
|
133
150
|
ENOSPC: 'No space left on device',
|
|
@@ -143,7 +160,7 @@ const ERRNO_REASONS: Record<string, string> = {
|
|
|
143
160
|
* Run a filesystem operation, turning a failure into the named diagnostic the shell reported
|
|
144
161
|
* (`mkdir: <path>: Permission denied`) instead of an uncaught Bun stack trace.
|
|
145
162
|
*/
|
|
146
|
-
function sted(op: string, path: string, run: () => void): void {
|
|
163
|
+
export function sted(op: string, path: string, run: () => void): void {
|
|
147
164
|
try {
|
|
148
165
|
run();
|
|
149
166
|
} catch (error) {
|
|
@@ -153,7 +170,7 @@ function sted(op: string, path: string, run: () => void): void {
|
|
|
153
170
|
}
|
|
154
171
|
|
|
155
172
|
/** `[ -f ]` semantics: exists AND is a regular file; an unreadable parent answers false, never throws. */
|
|
156
|
-
function isFile(path: string): boolean {
|
|
173
|
+
export function isFile(path: string): boolean {
|
|
157
174
|
try {
|
|
158
175
|
return statSync(path, { throwIfNoEntry: false })?.isFile() === true;
|
|
159
176
|
} catch {
|
|
@@ -167,7 +184,7 @@ function isFile(path: string): boolean {
|
|
|
167
184
|
* before the config-dir copy on purpose (F8 R1) — the shipped bytes win unless the operator elected
|
|
168
185
|
* that directory as an override.
|
|
169
186
|
*/
|
|
170
|
-
function resolveSidecar(override: string, sidecar: string): string {
|
|
187
|
+
export function resolveSidecar(override: string, sidecar: string): string {
|
|
171
188
|
if (override && isFile(override)) return override;
|
|
172
189
|
const kkWorkflowsDir = process.env.KK_WORKFLOWS_DIR;
|
|
173
190
|
if (kkWorkflowsDir) {
|
|
@@ -184,7 +201,7 @@ function resolveSidecar(override: string, sidecar: string): string {
|
|
|
184
201
|
* that carries the override (`validate_script` / `wrap_script` / `render_script`) so the diagnostic
|
|
185
202
|
* names the same thing the operator sees in the YAML.
|
|
186
203
|
*/
|
|
187
|
-
function requireSidecar(override: string, sidecar: string, label: string): string {
|
|
204
|
+
export function requireSidecar(override: string, sidecar: string, label: string): string {
|
|
188
205
|
const resolved = resolveSidecar(override, sidecar);
|
|
189
206
|
if (!isFile(resolved)) {
|
|
190
207
|
fail(
|
|
@@ -222,7 +239,7 @@ function writeJson(path: string, value: Record<string, unknown>): void {
|
|
|
222
239
|
|
|
223
240
|
/** `.revise-count` lives at the work_dir root in every workflow that uses it. */
|
|
224
241
|
|
|
225
|
-
function bumpReviseCount(workDir: string): void {
|
|
242
|
+
export function bumpReviseCount(workDir: string): void {
|
|
226
243
|
const path = join(workDir, '.revise-count');
|
|
227
244
|
let previous = Number.NaN;
|
|
228
245
|
if (isFile(path))
|
|
@@ -233,7 +250,7 @@ function bumpReviseCount(workDir: string): void {
|
|
|
233
250
|
}
|
|
234
251
|
|
|
235
252
|
/** `0` when `.safety-revise-count` is absent or unparsable: a fresh run revises, it never blocks on state. */
|
|
236
|
-
function readSafetyReviseCount(workDir: string): number {
|
|
253
|
+
export function readSafetyReviseCount(workDir: string): number {
|
|
237
254
|
const path = join(workDir, SAFETY_REVISE_COUNT);
|
|
238
255
|
if (!isFile(path)) return 0;
|
|
239
256
|
const value = Number.parseInt(readFileSync(path, 'utf-8'), 10);
|
|
@@ -362,7 +379,7 @@ function prepareStorm(args: string[]): void {
|
|
|
362
379
|
writeJson(join(workDir, 'topic.json'), topicFile(query));
|
|
363
380
|
}
|
|
364
381
|
|
|
365
|
-
function duration(args: string[]): void {
|
|
382
|
+
export function duration(args: string[]): void {
|
|
366
383
|
const [workDirArg = '', targetDurationMin = ''] = args;
|
|
367
384
|
if (!workDirArg) fail('missing work_dir');
|
|
368
385
|
const targetMinutes = Number(targetDurationMin);
|
|
@@ -469,7 +486,7 @@ const DAILY_DIRS = [
|
|
|
469
486
|
] as const;
|
|
470
487
|
|
|
471
488
|
/** `date -u +%FT%TZ` — the timestamp grammar the step-timing lines have always used. */
|
|
472
|
-
function utcStamp(): string {
|
|
489
|
+
export function utcStamp(): string {
|
|
473
490
|
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
474
491
|
}
|
|
475
492
|
|
|
@@ -477,7 +494,7 @@ function utcStamp(): string {
|
|
|
477
494
|
* The pre-0133 shell wrapped every daily step in `trap … EXIT`, so the timing line lands on failure
|
|
478
495
|
* too. The body signals failure by throwing `failWith`, never by calling `process.exit` directly.
|
|
479
496
|
*/
|
|
480
|
-
class StageFailure extends Error {
|
|
497
|
+
export class StageFailure extends Error {
|
|
481
498
|
constructor(
|
|
482
499
|
readonly status: number,
|
|
483
500
|
readonly named: boolean,
|
|
@@ -487,11 +504,11 @@ class StageFailure extends Error {
|
|
|
487
504
|
}
|
|
488
505
|
}
|
|
489
506
|
|
|
490
|
-
function failWith(status: number, message?: string): never {
|
|
507
|
+
export function failWith(status: number, message?: string): never {
|
|
491
508
|
throw new StageFailure(status, message !== undefined, message ?? `stage failed (status ${status})`);
|
|
492
509
|
}
|
|
493
510
|
|
|
494
|
-
function appendTiming(workDir: string, step: string, startedAt: string): void {
|
|
511
|
+
export function appendTiming(workDir: string, step: string, startedAt: string): void {
|
|
495
512
|
if (!workDir) return;
|
|
496
513
|
try {
|
|
497
514
|
appendFileSync(
|
|
@@ -503,7 +520,7 @@ function appendTiming(workDir: string, step: string, startedAt: string): void {
|
|
|
503
520
|
}
|
|
504
521
|
}
|
|
505
522
|
|
|
506
|
-
function timed(workDir: string, step: string, body: () => void): void {
|
|
523
|
+
export function timed(workDir: string, step: string, body: () => void): void {
|
|
507
524
|
const startedAt = utcStamp();
|
|
508
525
|
let status = 0;
|
|
509
526
|
try {
|
|
@@ -522,7 +539,7 @@ function timed(workDir: string, step: string, body: () => void): void {
|
|
|
522
539
|
if (status !== 0) process.exit(status);
|
|
523
540
|
}
|
|
524
541
|
|
|
525
|
-
function pluginsArgs(pluginsPath: string): string[] {
|
|
542
|
+
export function pluginsArgs(pluginsPath: string): string[] {
|
|
526
543
|
return pluginsPath === '' ? [] : ['--plugins-path', pluginsPath];
|
|
527
544
|
}
|
|
528
545
|
|
|
@@ -531,9 +548,9 @@ function pluginsArgs(pluginsPath: string): string[] {
|
|
|
531
548
|
// every descendant env; a nested `spur workflow run` — postsurfing's publishing workflow under
|
|
532
549
|
// surfdash-pub — resolves a DIFFERENT digest and refuses to start. Strip both launcher vars at
|
|
533
550
|
// the nested-spur boundary (SPUR_WORKFLOW_RUN_ACTIVE is the older sibling guard, task 0133-era).
|
|
534
|
-
const NESTED_SPUR_UNSET = ['SPUR_WORKFLOW_RUN_ACTIVE', 'SPUR_EXPECTED_DEFINITION_DIGEST'] as const;
|
|
551
|
+
export const NESTED_SPUR_UNSET = ['SPUR_WORKFLOW_RUN_ACTIVE', 'SPUR_EXPECTED_DEFINITION_DIGEST'] as const;
|
|
535
552
|
|
|
536
|
-
function kk(
|
|
553
|
+
export function kk(
|
|
537
554
|
args: string[],
|
|
538
555
|
options: { env?: Record<string, string>; unset?: readonly string[]; soft?: boolean } = {},
|
|
539
556
|
): number {
|
|
@@ -552,22 +569,22 @@ function kk(
|
|
|
552
569
|
return status;
|
|
553
570
|
}
|
|
554
571
|
|
|
555
|
-
function writeJsonText(path: string, value: unknown): void {
|
|
572
|
+
export function writeJsonText(path: string, value: unknown): void {
|
|
556
573
|
// `Bun.write(path, JSON.stringify(value, null, 2))` — no trailing newline.
|
|
557
574
|
writeFileSync(path, JSON.stringify(value, null, 2));
|
|
558
575
|
}
|
|
559
576
|
|
|
560
|
-
function writeJqText(path: string, value: unknown): void {
|
|
577
|
+
export function writeJqText(path: string, value: unknown): void {
|
|
561
578
|
// `jq … > path` — pretty output WITH the trailing newline jq always prints.
|
|
562
579
|
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
563
580
|
}
|
|
564
581
|
|
|
565
|
-
function writeRaw(path: string, value: string): void {
|
|
582
|
+
export function writeRaw(path: string, value: string): void {
|
|
566
583
|
// `printf '%s' … > path` / `bun -e 'Bun.write'` — no trailing newline.
|
|
567
584
|
writeFileSync(path, value);
|
|
568
585
|
}
|
|
569
586
|
|
|
570
|
-
function readJson(path: string): Record<string, unknown> | null {
|
|
587
|
+
export function readJson(path: string): Record<string, unknown> | null {
|
|
571
588
|
if (!isFile(path)) return null;
|
|
572
589
|
try {
|
|
573
590
|
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
|
|
@@ -576,7 +593,7 @@ function readJson(path: string): Record<string, unknown> | null {
|
|
|
576
593
|
}
|
|
577
594
|
}
|
|
578
595
|
|
|
579
|
-
function metadataOf(value: Record<string, unknown> | null): Record<string, unknown> {
|
|
596
|
+
export function metadataOf(value: Record<string, unknown> | null): Record<string, unknown> {
|
|
580
597
|
const metadata = value?.metadata;
|
|
581
598
|
return metadata !== null && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : {};
|
|
582
599
|
}
|
|
@@ -585,14 +602,14 @@ function metadataOf(value: Record<string, unknown> | null): Record<string, unkno
|
|
|
585
602
|
* Required stage input: a missing or unparsable file fails the stage naming the path, so a
|
|
586
603
|
* run-date/path mismatch surfaces as its real cause instead of a downstream "field missing" error.
|
|
587
604
|
*/
|
|
588
|
-
function requireJson(path: string, label: string): Record<string, unknown> {
|
|
605
|
+
export function requireJson(path: string, label: string): Record<string, unknown> {
|
|
589
606
|
const parsed = readJson(path);
|
|
590
607
|
if (parsed === null) failWith(1, `${label}: required input not found or unparsable: ${path}`);
|
|
591
608
|
return parsed;
|
|
592
609
|
}
|
|
593
610
|
|
|
594
611
|
/** `jq -e '.targets[]? | select(.target == t and .ok == true and .status == "published")'`. */
|
|
595
|
-
function targetPublished(path: string, target: string): boolean {
|
|
612
|
+
export function targetPublished(path: string, target: string): boolean {
|
|
596
613
|
const parsed = readJson(path);
|
|
597
614
|
const targets = parsed?.targets;
|
|
598
615
|
if (!Array.isArray(targets)) return false;
|
|
@@ -636,6 +653,14 @@ function dailyPrepare(args: string[]): void {
|
|
|
636
653
|
sted('write', join(workDir, SAFETY_REVISE_COUNT), () =>
|
|
637
654
|
writeFileSync(join(workDir, SAFETY_REVISE_COUNT), '0\n'),
|
|
638
655
|
);
|
|
656
|
+
// Version-stamp the run: every later stage resolves `kk` from PATH, so name the binary
|
|
657
|
+
// this run will actually use (20261006 outage: a stale global kk pre-dated the revise
|
|
658
|
+
// loop and the skew was only visible after the fact).
|
|
659
|
+
const versionProbe = spawnSync('kk', ['--version'], { encoding: 'utf8', timeout: 10_000 });
|
|
660
|
+
const kkVersion = versionProbe.status === 0 ? versionProbe.stdout.trim() : 'unknown';
|
|
661
|
+
sted('write', join(workDir, 'kk-version.txt'), () =>
|
|
662
|
+
writeFileSync(join(workDir, 'kk-version.txt'), `${kkVersion}\n`),
|
|
663
|
+
);
|
|
639
664
|
// Anchor for the workflow: shell actions cannot set vars, so the YAML reads the
|
|
640
665
|
// effective work_dir back into vars.work_dir from this cwd-relative file.
|
|
641
666
|
const anchorDir = join('.spur', 'runs', 'kk-daily-ai-voice');
|
|
@@ -701,6 +726,35 @@ function resolveWorksDir(): string {
|
|
|
701
726
|
return join(homedir(), '.config/kk/works');
|
|
702
727
|
}
|
|
703
728
|
|
|
729
|
+
/**
|
|
730
|
+
* `collect-prepare <stamp> <limit> <topic> <horizonHours>` — kk-collect's workspace: the pinned stamp
|
|
731
|
+
* (else the UTC minute, `YYYYMMDDTHHMMZ`), the cwd-relative `.works/kk-collect/<stamp>` work dir, the
|
|
732
|
+
* anchor files the workflow reads back into vars (shell actions cannot set vars), and the three
|
|
733
|
+
* source configs `kk executor fan-in --source <plugin>:<file>` requires. Configs go through
|
|
734
|
+
* `writeJsonText` so a topic carrying quotes or backslashes stays valid JSON.
|
|
735
|
+
*/
|
|
736
|
+
function collectPrepare(args: string[]): void {
|
|
737
|
+
const [stampArg = '', limit = '', topic = '', horizonHours = ''] = args;
|
|
738
|
+
const stamp = stampArg || new Date().toISOString().replace(/[-:]/g, '').slice(0, 13).concat('Z');
|
|
739
|
+
const workDir = join('.works', 'kk-collect', stamp);
|
|
740
|
+
const anchorDir = join('.spur', 'runs', 'kk-collect');
|
|
741
|
+
sted('mkdir', join(workDir, '10-ingest'), () => mkdirSync(join(workDir, '10-ingest'), { recursive: true }));
|
|
742
|
+
sted('mkdir', anchorDir, () => mkdirSync(anchorDir, { recursive: true }));
|
|
743
|
+
sted('write', join(anchorDir, 'work-dir.txt'), () =>
|
|
744
|
+
writeFileSync(join(anchorDir, 'work-dir.txt'), `${workDir}\n`),
|
|
745
|
+
);
|
|
746
|
+
sted('write', join(anchorDir, 'stamp.txt'), () => writeFileSync(join(anchorDir, 'stamp.txt'), `${stamp}\n`));
|
|
747
|
+
sted('write', join(workDir, '10-ingest/source.json'), () =>
|
|
748
|
+
writeJsonText(join(workDir, '10-ingest/source.json'), { limit: Number(limit) }),
|
|
749
|
+
);
|
|
750
|
+
sted('write', join(workDir, '10-ingest/last30days.json'), () =>
|
|
751
|
+
writeJsonText(join(workDir, '10-ingest/last30days.json'), { topic }),
|
|
752
|
+
);
|
|
753
|
+
sted('write', join(workDir, '10-ingest/horizon.json'), () =>
|
|
754
|
+
writeJsonText(join(workDir, '10-ingest/horizon.json'), { hours: Number(horizonHours) }),
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
704
758
|
function dailyCollectFacts(args: string[]): void {
|
|
705
759
|
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
706
760
|
const content = join(workDir, '20-facts', `${runDate}_02_collect_content.json`);
|
|
@@ -708,7 +762,7 @@ function dailyCollectFacts(args: string[]): void {
|
|
|
708
762
|
const blended = join(workDir, '10-ingest', `${runDate}_02_collect_blended.json`);
|
|
709
763
|
timed(workDir, 'collect', () => {
|
|
710
764
|
if (isFile(content) && isFile(facts)) {
|
|
711
|
-
console.
|
|
765
|
+
console.error(`core facts cached: ${facts}`);
|
|
712
766
|
return;
|
|
713
767
|
}
|
|
714
768
|
kk(['executor', 'run', 'core-facts-gen', '--in', blended, '--out', content, ...pluginsArgs(pluginsPath)]);
|
|
@@ -718,46 +772,20 @@ function dailyCollectFacts(args: string[]): void {
|
|
|
718
772
|
});
|
|
719
773
|
}
|
|
720
774
|
|
|
721
|
-
|
|
722
|
-
const [workDir = '', runDate = '', pluginsPath = '', planMaxItems = ''] = args;
|
|
723
|
-
const planContent = join(workDir, '21-plan', `${runDate}_03_plan_content.json`);
|
|
724
|
-
const planDocs = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
|
|
725
|
-
const blended = join(workDir, '10-ingest', `${runDate}_02_collect_blended.json`);
|
|
726
|
-
timed(workDir, 'plan', () => {
|
|
727
|
-
if (isFile(planDocs)) {
|
|
728
|
-
console.log(`plan cached: ${planDocs}`);
|
|
729
|
-
return;
|
|
730
|
-
}
|
|
731
|
-
kk(
|
|
732
|
-
['executor', 'run', 'episode-plan-gen', '--in', blended, '--out', planContent, ...pluginsArgs(pluginsPath)],
|
|
733
|
-
{
|
|
734
|
-
env: { EPISODE_PLAN_MAX_ITEMS: planMaxItems, EPISODE_PLAN_DATE: runDate },
|
|
735
|
-
},
|
|
736
|
-
);
|
|
737
|
-
const docs = metadataOf(readJson(planContent)).docs;
|
|
738
|
-
if (!Array.isArray(docs) || docs.length === 0) failWith(1, 'plan: content.metadata.docs missing or empty');
|
|
739
|
-
sted('write', planDocs, () => writeJsonText(planDocs, docs));
|
|
740
|
-
if (!isFile(planDocs)) failWith(1, `plan: ${planDocs} not written after episode-plan-gen`);
|
|
741
|
-
});
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
/**
|
|
745
|
-
* Topic-boundary sidecar (task 0189 R2). Sibling of this file; node-builtins-only read.
|
|
746
|
-
*/
|
|
747
|
-
interface TopicBoundaryRule {
|
|
775
|
+
export interface TopicBoundaryRule {
|
|
748
776
|
id: string;
|
|
749
777
|
reason: string;
|
|
750
778
|
keywords: string[];
|
|
751
779
|
}
|
|
752
780
|
|
|
753
|
-
interface TopicBoundarySpec {
|
|
781
|
+
export interface TopicBoundarySpec {
|
|
754
782
|
version: number;
|
|
755
783
|
scanChars: number;
|
|
756
784
|
rules: TopicBoundaryRule[];
|
|
757
785
|
dateConflict: { reason: string };
|
|
758
786
|
}
|
|
759
787
|
|
|
760
|
-
interface TopicBoundaryExclusion {
|
|
788
|
+
export interface TopicBoundaryExclusion {
|
|
761
789
|
id: string;
|
|
762
790
|
title: string;
|
|
763
791
|
rule: string;
|
|
@@ -765,7 +793,7 @@ interface TopicBoundaryExclusion {
|
|
|
765
793
|
matched: string;
|
|
766
794
|
}
|
|
767
795
|
|
|
768
|
-
function readTopicBoundary(): TopicBoundarySpec {
|
|
796
|
+
export function readTopicBoundary(): TopicBoundarySpec {
|
|
769
797
|
const path = join(SCRIPTS_DIR, 'topic-boundary.json');
|
|
770
798
|
let parsed: TopicBoundarySpec;
|
|
771
799
|
try {
|
|
@@ -854,86 +882,7 @@ export function applyTopicBoundary(
|
|
|
854
882
|
return { kept, excluded };
|
|
855
883
|
}
|
|
856
884
|
|
|
857
|
-
function
|
|
858
|
-
const [
|
|
859
|
-
workDir = '',
|
|
860
|
-
runDate = '',
|
|
861
|
-
pluginsPath = '',
|
|
862
|
-
qcCategories = '',
|
|
863
|
-
minQuality = '',
|
|
864
|
-
minImportance = '',
|
|
865
|
-
minUrgency = '',
|
|
866
|
-
minImpact = '',
|
|
867
|
-
minEpisodeItems = '',
|
|
868
|
-
] = args;
|
|
869
|
-
const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
|
|
870
|
-
const annotated = join(workDir, '21-plan', `${runDate}_04_quality-control-content_annotated.json`);
|
|
871
|
-
const exclusions = join(workDir, '21-plan', `${runDate}_04_quality-control-content_exclusions.json`);
|
|
872
|
-
const planContent = join(workDir, '21-plan', `${runDate}_03_plan_content.json`);
|
|
873
|
-
timed(workDir, 'quality-control-content', () => {
|
|
874
|
-
if (isFile(candidates)) {
|
|
875
|
-
console.log(`candidates cached: ${candidates}`);
|
|
876
|
-
return;
|
|
877
|
-
}
|
|
878
|
-
sted('write', annotated, () => writeJqText(annotated, metadataOf(readJson(planContent)).docs ?? null));
|
|
879
|
-
kk(
|
|
880
|
-
[
|
|
881
|
-
'executor',
|
|
882
|
-
'run',
|
|
883
|
-
'episode-plan-gen',
|
|
884
|
-
'--in',
|
|
885
|
-
annotated,
|
|
886
|
-
'--out',
|
|
887
|
-
candidates,
|
|
888
|
-
...pluginsArgs(pluginsPath),
|
|
889
|
-
],
|
|
890
|
-
{
|
|
891
|
-
env: {
|
|
892
|
-
EPISODE_PLAN_MODE: 'filter',
|
|
893
|
-
QC_CATEGORIES: qcCategories,
|
|
894
|
-
QC_MIN_QUALITY: minQuality,
|
|
895
|
-
QC_MIN_IMPORTANCE: minImportance,
|
|
896
|
-
QC_MIN_URGENCY: minUrgency,
|
|
897
|
-
QC_MIN_IMPACT: minImpact,
|
|
898
|
-
QC_MIN_EPISODE_ITEMS: minEpisodeItems,
|
|
899
|
-
},
|
|
900
|
-
},
|
|
901
|
-
);
|
|
902
|
-
});
|
|
903
|
-
// Topic boundary (task 0189 R2) runs on every invocation — re-applying to already-filtered
|
|
904
|
-
// candidates is a no-op, so cached reruns stay idempotent while a crash between the candidates
|
|
905
|
-
// write and the exclusions write still converges on the next run. Zero kept docs leaves the
|
|
906
|
-
// candidates file empty for the workflow's existing done-no-content edge.
|
|
907
|
-
const boundaryDocs = metadataOf(readJson(candidates)).docs;
|
|
908
|
-
if (!Array.isArray(boundaryDocs)) return;
|
|
909
|
-
const { kept, excluded } = applyTopicBoundary(boundaryDocs, runDate, readTopicBoundary());
|
|
910
|
-
if (excluded.length === 0) return;
|
|
911
|
-
sted('write', exclusions, () =>
|
|
912
|
-
writeJsonText(exclusions, {
|
|
913
|
-
version: 1,
|
|
914
|
-
runDate,
|
|
915
|
-
excluded,
|
|
916
|
-
kept: kept.map((doc) => (doc as { id: string }).id),
|
|
917
|
-
}),
|
|
918
|
-
);
|
|
919
|
-
const content = requireJson(candidates, 'topic-boundary');
|
|
920
|
-
const meta = content.metadata;
|
|
921
|
-
if (meta === null || typeof meta !== 'object') {
|
|
922
|
-
failWith(1, 'topic-boundary: candidates content carries no metadata object');
|
|
923
|
-
}
|
|
924
|
-
(meta as Record<string, unknown>).docs = kept;
|
|
925
|
-
sted('write', candidates, () => writeJsonText(candidates, content));
|
|
926
|
-
console.log(
|
|
927
|
-
`topic-boundary: excluded ${excluded.length} item(s): ${excluded.map((entry) => `${entry.id}(${entry.rule})`).join(', ')}`,
|
|
928
|
-
);
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
/**
|
|
932
|
-
* Character-bigram Jaccard over whitespace-normalized, lowercased titles — the twin of
|
|
933
|
-
* episode-plan-gen's `titleSimilarity`. Duplicated on purpose: this file is node-builtins-only
|
|
934
|
-
* (no workspace imports), so it cannot reach that module.
|
|
935
|
-
*/
|
|
936
|
-
function titleSimilarity(a: string, b: string): number {
|
|
885
|
+
export function titleSimilarity(a: string, b: string): number {
|
|
937
886
|
const bigrams = (text: string): Set<string> => {
|
|
938
887
|
const normalized = text.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
939
888
|
const out = new Set<string>();
|
|
@@ -983,7 +932,7 @@ export function dedupPlanByTitle<T extends { id?: unknown; title?: unknown }>(
|
|
|
983
932
|
* reads. The sidecar's own stderr carries `content check failed: segment <idx> …`; this adds the
|
|
984
933
|
* stage-level naming so the operator sees which artifact was rejected.
|
|
985
934
|
*/
|
|
986
|
-
function assertScriptContent(scriptPath: string): void {
|
|
935
|
+
export function assertScriptContent(scriptPath: string): void {
|
|
987
936
|
// `spawnSync` + `failWith`, deliberately NOT `delegate`: delegate is `never`-returning (it calls
|
|
988
937
|
// process.exit), so using it mid-function would abort the calling stage on SUCCESS — dailyWrapDocs
|
|
989
938
|
// would exit before packaging its docs. A clean exit here falls through to the rest of the stage.
|
|
@@ -994,84 +943,7 @@ function assertScriptContent(scriptPath: string): void {
|
|
|
994
943
|
}
|
|
995
944
|
}
|
|
996
945
|
|
|
997
|
-
function
|
|
998
|
-
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
999
|
-
const articleMd = join(workDir, '22-article', `${runDate}_06_article_article.md`);
|
|
1000
|
-
const articleContent = join(workDir, '22-article', `${runDate}_06_article_content.json`);
|
|
1001
|
-
const planIn = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
|
|
1002
|
-
const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
|
|
1003
|
-
timed(workDir, 'article', () => {
|
|
1004
|
-
let planUsable = true;
|
|
1005
|
-
const raw = isFile(planIn) ? readFileSync(planIn, 'utf-8') : '';
|
|
1006
|
-
try {
|
|
1007
|
-
JSON.parse(raw);
|
|
1008
|
-
} catch (error) {
|
|
1009
|
-
console.error(
|
|
1010
|
-
`article: plan.json invalid JSON — replacing with untranslated plan (downstream states read this file): ${(error as Error).message}`,
|
|
1011
|
-
);
|
|
1012
|
-
const docs = metadataOf(readJson(candidates)).docs;
|
|
1013
|
-
if (!Array.isArray(docs) || docs.length === 0) {
|
|
1014
|
-
console.error('article: candidates.json metadata.docs missing');
|
|
1015
|
-
planUsable = false;
|
|
1016
|
-
} else {
|
|
1017
|
-
sted('write', planIn, () => writeJsonText(planIn, docs));
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
if (!planUsable) failWith(1, 'article: no usable plan input');
|
|
1021
|
-
// R3 (task 0138): dedup runs BEFORE the cache early-return — the script stage reads this same
|
|
1022
|
-
// plan.json and must see one survivor even when the article is already cached. Idempotent:
|
|
1023
|
-
// a no-op once the plan is deduped.
|
|
1024
|
-
const planParsed: unknown = readJson(planIn);
|
|
1025
|
-
if (Array.isArray(planParsed)) {
|
|
1026
|
-
const { kept, dropped } = dedupPlanByTitle(planParsed as Record<string, unknown>[]);
|
|
1027
|
-
if (dropped.length > 0) {
|
|
1028
|
-
sted('write', planIn, () => writeJsonText(planIn, kept));
|
|
1029
|
-
console.error(`article: dropped ${dropped.length} near-duplicate plan item(s): ${dropped.join(', ')}`);
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
// The cache pair must be COMPLETE: markdown without its content.json means the generator
|
|
1033
|
-
// half-ran (or the json was pruned), and returning here starves every downstream stage
|
|
1034
|
-
// that reads content.json — publish-prep refuses to merge a draft it cannot read. Requiring
|
|
1035
|
-
// both artifacts makes the pair self-healing instead of permanently stuck.
|
|
1036
|
-
if (isFile(articleMd) && isFile(articleContent)) {
|
|
1037
|
-
console.log(`article cached: ${articleMd}`);
|
|
1038
|
-
return;
|
|
1039
|
-
}
|
|
1040
|
-
// Agent-written prose body (workflow `article-write` state) replaces the mechanical
|
|
1041
|
-
// per-item render when present — the 3-format split: surfdash gets an article,
|
|
1042
|
-
// not a news list (2026-09-16 feedback).
|
|
1043
|
-
const proseBody = join(workDir, '22-article', `${runDate}_06_article-write_body.md`);
|
|
1044
|
-
kk(
|
|
1045
|
-
[
|
|
1046
|
-
'executor',
|
|
1047
|
-
'run',
|
|
1048
|
-
'daily-article-gen',
|
|
1049
|
-
'--in',
|
|
1050
|
-
planIn,
|
|
1051
|
-
'--out',
|
|
1052
|
-
articleContent,
|
|
1053
|
-
...pluginsArgs(pluginsPath),
|
|
1054
|
-
],
|
|
1055
|
-
{
|
|
1056
|
-
env: {
|
|
1057
|
-
ARTICLE_DATE: runDate,
|
|
1058
|
-
// Plugin entries run with cwd = plugin dir (invoke.ts), so relative
|
|
1059
|
-
// paths in env must be resolved or the plugin sees ENOENT.
|
|
1060
|
-
...(isFile(proseBody) ? { ARTICLE_BODY_FILE: resolve(proseBody) } : {}),
|
|
1061
|
-
},
|
|
1062
|
-
},
|
|
1063
|
-
);
|
|
1064
|
-
const body = readJson(articleContent)?.body;
|
|
1065
|
-
if (typeof body !== 'string') failWith(1, `article: ${articleContent} has no string body`);
|
|
1066
|
-
sted('write', articleMd, () => writeRaw(articleMd, body));
|
|
1067
|
-
});
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
/**
|
|
1071
|
-
* F614 R2/R4: validate the agent-written cover brief in the order R4 names — the first failing
|
|
1072
|
-
* check is the fallback reason; `reason: null` carries the trimmed subject.
|
|
1073
|
-
*/
|
|
1074
|
-
function coverBrief(briefPath: string): { reason: string | null; subject: string } {
|
|
946
|
+
export function coverBrief(briefPath: string): { reason: string | null; subject: string } {
|
|
1075
947
|
if (!isFile(briefPath)) return { reason: 'missing-brief', subject: '' };
|
|
1076
948
|
let parsed: unknown;
|
|
1077
949
|
try {
|
|
@@ -1089,71 +961,7 @@ function coverBrief(briefPath: string): { reason: string | null; subject: string
|
|
|
1089
961
|
return { reason: null, subject: trimmed };
|
|
1090
962
|
}
|
|
1091
963
|
|
|
1092
|
-
|
|
1093
|
-
* F614 R3: build the image-gen envelope from the day's cover brief and render it. The subject is
|
|
1094
|
-
* the only model-authored input (the workflow's brief agent.run wrote it); format, style,
|
|
1095
|
-
* provider and model are pinned by this stage so no caller can drift the look. A missing or
|
|
1096
|
-
* invalid brief degrades to the format's default subject (R4) — a plugin failure still fails
|
|
1097
|
-
* the stage, matching the pre-F614 cover behavior.
|
|
1098
|
-
*/
|
|
1099
|
-
function dailyCover(args: string[]): void {
|
|
1100
|
-
const [workDir = '', runDate = '', pluginsPath = '', provider = '', model = '', fallback = ''] = args;
|
|
1101
|
-
const briefPath = join(workDir, '23-cover', `${runDate}_07_cover-brief.json`);
|
|
1102
|
-
const envPath = join(workDir, '23-cover', `${runDate}_07_cover_image-gen-in.json`);
|
|
1103
|
-
const contentOut = join(workDir, '23-cover', 'content.json');
|
|
1104
|
-
timed(workDir, 'cover', () => {
|
|
1105
|
-
const brief = coverBrief(briefPath);
|
|
1106
|
-
const vars: Record<string, string> = {};
|
|
1107
|
-
if (brief.reason !== null) {
|
|
1108
|
-
console.error(`cover brief: fallback (${brief.reason})`);
|
|
1109
|
-
} else {
|
|
1110
|
-
vars.subject = brief.subject;
|
|
1111
|
-
}
|
|
1112
|
-
const imageGen: Record<string, unknown> = {
|
|
1113
|
-
job: 'cover',
|
|
1114
|
-
format: COVER_FORMAT,
|
|
1115
|
-
style: COVER_STYLE,
|
|
1116
|
-
vars,
|
|
1117
|
-
maxImages: 1,
|
|
1118
|
-
};
|
|
1119
|
-
if (provider !== '') imageGen.provider = provider;
|
|
1120
|
-
if (model !== '') imageGen.model = model;
|
|
1121
|
-
// D6 0166 R7: runtime-fallback opt-in. '' (the shipped var default) keeps strict fail-fast —
|
|
1122
|
-
// F614's pinned look stays the default; 'true' = retry down AUTO_ORDER; anything else is a
|
|
1123
|
-
// comma-split, trimmed candidate list. Unknown names are rejected by image-gen, not here.
|
|
1124
|
-
if (fallback !== '') {
|
|
1125
|
-
imageGen.fallback =
|
|
1126
|
-
fallback === 'true'
|
|
1127
|
-
? true
|
|
1128
|
-
: fallback
|
|
1129
|
-
.split(',')
|
|
1130
|
-
.map((s) => s.trim())
|
|
1131
|
-
.filter(Boolean);
|
|
1132
|
-
}
|
|
1133
|
-
sted('write', envPath, () =>
|
|
1134
|
-
writeJsonText(envPath, [{ id: 'daily-cover', title: runDate, body: '', metadata: { imageGen } }]),
|
|
1135
|
-
);
|
|
1136
|
-
kk(['executor', 'run', 'image-gen', '--in', envPath, '--out', contentOut, ...pluginsArgs(pluginsPath)]);
|
|
1137
|
-
});
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
function dailyCoverNormalize(args: string[]): void {
|
|
1141
|
-
const [workDir = '', runDate = ''] = args;
|
|
1142
|
-
// The pre-0133 shell had no timing trap, so this stage is not `timed`; it still fails through
|
|
1143
|
-
// `failWith`, which needs the named-failure handling `timed` gives the other stages.
|
|
1144
|
-
try {
|
|
1145
|
-
coverNormalizeBody(workDir, runDate);
|
|
1146
|
-
} catch (error) {
|
|
1147
|
-
if (error instanceof StageFailure) {
|
|
1148
|
-
if (error.named) console.error(error.message);
|
|
1149
|
-
process.exit(error.status);
|
|
1150
|
-
}
|
|
1151
|
-
console.error((error as Error).message);
|
|
1152
|
-
process.exit(1);
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
function coverNormalizeBody(workDir: string, runDate: string): void {
|
|
964
|
+
export function coverNormalizeBody(workDir: string, runDate: string): void {
|
|
1157
965
|
// F614 R5: the artifact name follows the format preset (`<stem>-<format>-NN.<ext>`,
|
|
1158
966
|
// paths.ts), so normalize reads the path the plugin itself recorded in the manifest instead
|
|
1159
967
|
// of a hard-coded name. The legacy content-cover-01.{png,jpg} paths stay as stale cleanup.
|
|
@@ -1193,11 +1001,11 @@ function coverNormalizeBody(workDir: string, runDate: string): void {
|
|
|
1193
1001
|
// `rm -f` is silent when the file is already gone (a renamed source lands here too).
|
|
1194
1002
|
}
|
|
1195
1003
|
}
|
|
1196
|
-
console.
|
|
1004
|
+
console.error(`cover normalized: ${runDate}_07_cover_cover.png`);
|
|
1197
1005
|
}
|
|
1198
1006
|
|
|
1199
1007
|
/** `metadata.images[0]` — the artifact the image-gen plugin recorded for one render. */
|
|
1200
|
-
function firstArtifact(content: Record<string, unknown> | null): Record<string, unknown> | null {
|
|
1008
|
+
export function firstArtifact(content: Record<string, unknown> | null): Record<string, unknown> | null {
|
|
1201
1009
|
const images = metadataOf(content).images;
|
|
1202
1010
|
const first = Array.isArray(images) ? images[0] : undefined;
|
|
1203
1011
|
return first !== null && typeof first === 'object' ? (first as Record<string, unknown>) : null;
|
|
@@ -1209,7 +1017,7 @@ function firstArtifact(content: Record<string, unknown> | null): Record<string,
|
|
|
1209
1017
|
* check fails loud naming the field, so an unusable plan skips the XHS target visibly instead of
|
|
1210
1018
|
* publishing a partial card series (the owning state carries `onError: continue`).
|
|
1211
1019
|
*/
|
|
1212
|
-
function xhsPlan(planPath: string, count: number): { title: string; body: string; cards: XhsCard[] } {
|
|
1020
|
+
export function xhsPlan(planPath: string, count: number): { title: string; body: string; cards: XhsCard[] } {
|
|
1213
1021
|
const parsed = requireJson(planPath, 'xhs plan');
|
|
1214
1022
|
const title = typeof parsed.title === 'string' ? parsed.title.trim() : '';
|
|
1215
1023
|
if (title === '' || title.length > XHS_TITLE_CAP) {
|
|
@@ -1252,306 +1060,20 @@ function xhsPlan(planPath: string, count: number): { title: string; body: string
|
|
|
1252
1060
|
layout: text('layout'),
|
|
1253
1061
|
subject: text('subject'),
|
|
1254
1062
|
sourcePoint: text('sourcePoint'),
|
|
1063
|
+
...(typeof card.itemId === 'string' && card.itemId.trim() !== '' ? { itemId: card.itemId.trim() } : {}),
|
|
1255
1064
|
};
|
|
1256
1065
|
});
|
|
1257
1066
|
return { title, body, cards };
|
|
1258
1067
|
}
|
|
1259
1068
|
|
|
1260
|
-
|
|
1261
|
-
* E8: build one `image-gen` envelope per planned card, render them sequentially, and package the
|
|
1262
|
-
* ordered card manifest + `Content` that `xhs-pub` consumes. Format, style, provider and model
|
|
1263
|
-
* are pinned here (never model-authored), so the series cannot drift from its preset. Card copy
|
|
1264
|
-
* is typeset by image-gen's composer; the provider prompt stays text-free (0181). Resume-safe:
|
|
1265
|
-
* a card whose recorded artifact is still on disk is not re-rendered — and not re-billed.
|
|
1266
|
-
*/
|
|
1267
|
-
function dailyXhsRender(args: string[]): void {
|
|
1268
|
-
const [workDir = '', runDate = '', pluginsPath = '', countArg = '', style = '', provider = '', model = ''] = args;
|
|
1269
|
-
const count = Number.parseInt(countArg, 10);
|
|
1270
|
-
const planPath = join(workDir, '25-xhs', `${runDate}_18_xhs_plan.json`);
|
|
1271
|
-
const cardsDir = join(workDir, '25-xhs', 'cards');
|
|
1272
|
-
const manifestPath = join(workDir, '25-xhs', 'cards.json');
|
|
1273
|
-
const contentPath = join(workDir, '25-xhs', 'content.json');
|
|
1274
|
-
timed(workDir, 'xhs-render', () => {
|
|
1275
|
-
if (!Number.isInteger(count) || count < 1 || count > XHS_MAX_CARDS) {
|
|
1276
|
-
failWith(1, `xhs render: xhs_cards must be 1..${XHS_MAX_CARDS}, got '${countArg}'`);
|
|
1277
|
-
}
|
|
1278
|
-
const plan = xhsPlan(planPath, count);
|
|
1279
|
-
sted('mkdir', cardsDir, () => mkdirSync(cardsDir, { recursive: true }));
|
|
1280
|
-
const images: Record<string, unknown>[] = [];
|
|
1281
|
-
const cards: Record<string, unknown>[] = [];
|
|
1282
|
-
for (const card of plan.cards) {
|
|
1283
|
-
const out = join(cardsDir, `card-${card.index}.json`);
|
|
1284
|
-
const promptPath = join(cardsDir, `card-${card.index}.prompt.txt`);
|
|
1285
|
-
let artifact = firstArtifact(readJson(out));
|
|
1286
|
-
if (artifact === null || !isFile(typeof artifact.path === 'string' ? artifact.path : '')) {
|
|
1287
|
-
const envPath = join(cardsDir, `card-${card.index}.in.json`);
|
|
1288
|
-
const imageGen: Record<string, unknown> = {
|
|
1289
|
-
job: 'free',
|
|
1290
|
-
format: XHS_FORMAT,
|
|
1291
|
-
style,
|
|
1292
|
-
vars: {
|
|
1293
|
-
role: card.role,
|
|
1294
|
-
layout: card.layout,
|
|
1295
|
-
headline: card.copy,
|
|
1296
|
-
points: card.points.join('\n'),
|
|
1297
|
-
subject: card.subject,
|
|
1298
|
-
index: card.index,
|
|
1299
|
-
},
|
|
1300
|
-
maxImages: 1,
|
|
1301
|
-
};
|
|
1302
|
-
if (provider !== '') imageGen.provider = provider;
|
|
1303
|
-
if (model !== '') imageGen.model = model;
|
|
1304
|
-
sted('write', envPath, () =>
|
|
1305
|
-
writeJsonText(envPath, [
|
|
1306
|
-
{ id: `xhs-card-${card.index}`, title: runDate, body: '', metadata: { imageGen } },
|
|
1307
|
-
]),
|
|
1308
|
-
);
|
|
1309
|
-
// Sequential on purpose: one provider call per card (illustration only —
|
|
1310
|
-
// card copy is typeset by image-gen's composer, never prompted), and the
|
|
1311
|
-
// first failure stops the spend — later cards stay unrendered for a resume
|
|
1312
|
-
// run to pick up.
|
|
1313
|
-
kk(['executor', 'run', 'image-gen', '--in', envPath, '--out', out, ...pluginsArgs(pluginsPath)]);
|
|
1314
|
-
artifact = firstArtifact(requireJson(out, 'xhs render'));
|
|
1315
|
-
}
|
|
1316
|
-
if (artifact === null) failWith(1, `xhs render: card ${card.index} recorded no image artifact (${out})`);
|
|
1317
|
-
const recorded = artifact.path;
|
|
1318
|
-
if (typeof recorded !== 'string' || !isFile(recorded)) {
|
|
1319
|
-
failWith(1, `xhs render: card ${card.index} image artifact is not on disk: '${String(recorded)}'`);
|
|
1320
|
-
}
|
|
1321
|
-
// `image-gen` writes the artifact beside `--out`; the publisher requires absolute paths.
|
|
1322
|
-
const imagePath = resolve(recorded);
|
|
1323
|
-
// 0181: a composed card may also carry the text-free illustration layer; keep
|
|
1324
|
-
// its recorded path so the manifest shows both layers.
|
|
1325
|
-
const rawIllustration: unknown = artifact.illustration;
|
|
1326
|
-
const illustrationPath =
|
|
1327
|
-
typeof rawIllustration === 'object' &&
|
|
1328
|
-
rawIllustration !== null &&
|
|
1329
|
-
typeof (rawIllustration as Record<string, unknown>).path === 'string'
|
|
1330
|
-
? resolve((rawIllustration as Record<string, unknown>).path as string)
|
|
1331
|
-
: undefined;
|
|
1332
|
-
sted('write', promptPath, () =>
|
|
1333
|
-
writeRaw(promptPath, typeof artifact.prompt === 'string' ? artifact.prompt : ''),
|
|
1334
|
-
);
|
|
1335
|
-
images.push({ ...artifact, path: imagePath });
|
|
1336
|
-
cards.push({
|
|
1337
|
-
index: card.index,
|
|
1338
|
-
role: card.role,
|
|
1339
|
-
sourcePoint: card.sourcePoint,
|
|
1340
|
-
copy: card.copy,
|
|
1341
|
-
layout: card.layout,
|
|
1342
|
-
promptPath,
|
|
1343
|
-
imagePath,
|
|
1344
|
-
illustrationPath,
|
|
1345
|
-
status: 'rendered',
|
|
1346
|
-
});
|
|
1347
|
-
}
|
|
1348
|
-
sted('write', manifestPath, () => writeJsonText(manifestPath, { source: planPath, cards }));
|
|
1349
|
-
sted('write', contentPath, () =>
|
|
1350
|
-
writeJsonText(contentPath, {
|
|
1351
|
-
title: plan.title,
|
|
1352
|
-
body: plan.body,
|
|
1353
|
-
format: 'text',
|
|
1354
|
-
metadata: { images, publishStatus: 'draft' },
|
|
1355
|
-
}),
|
|
1356
|
-
);
|
|
1357
|
-
console.log(`xhs render: ${cards.length} cards packaged in card order (${contentPath})`);
|
|
1358
|
-
});
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
/** A single-target publish Result, not the fan-out aggregate: `ok` plus `metadata.publishStatus`. */
|
|
1362
|
-
function resultPublishStatus(path: string): string {
|
|
1069
|
+
export function resultPublishStatus(path: string): string {
|
|
1363
1070
|
const parsed = readJson(path);
|
|
1364
1071
|
if (parsed?.ok !== true) return '';
|
|
1365
1072
|
const status = metadataOf(parsed).publishStatus;
|
|
1366
1073
|
return status === 'public' || status === 'draft' ? status : '';
|
|
1367
1074
|
}
|
|
1368
1075
|
|
|
1369
|
-
|
|
1370
|
-
* E8: publish the packaged card series through `xhs-pub`'s script driver. Draft by default; live
|
|
1371
|
-
* only on an explicit launch-time `xhs_live=true` (ADR-022 — an unattended run never posts live on
|
|
1372
|
-
* its own). Non-blocking by operator decision: a missing/expired XHS session or a changed console
|
|
1373
|
-
* logs a loud failure and leaves the run alive, and the outcome never enters `publish_status`
|
|
1374
|
-
* (surfdash + podcast remain the episode's publication contract).
|
|
1375
|
-
*/
|
|
1376
|
-
function dailyPublishXhs(args: string[]): void {
|
|
1377
|
-
const [workDir = '', runDate = '', pluginsPath = '', live = 'false'] = args;
|
|
1378
|
-
const content = join(workDir, '25-xhs', 'content.json');
|
|
1379
|
-
const input = join(workDir, '31-publish', `${runDate}_19_publish-xhs_content.json`);
|
|
1380
|
-
const result = join(workDir, '31-publish', `${runDate}_19_publish-xhs_result.json`);
|
|
1381
|
-
const want = live === 'true' ? 'public' : 'draft';
|
|
1382
|
-
timed(workDir, 'publish-xhs', () => {
|
|
1383
|
-
// Cache on the *requested* status: a cached draft must not silently satisfy a later
|
|
1384
|
-
// xhs_live=true re-run (that would report a live post that never happened).
|
|
1385
|
-
if (resultPublishStatus(result) === want) {
|
|
1386
|
-
console.log(`publish-xhs cached: ${want} already recorded in ${result}`);
|
|
1387
|
-
return;
|
|
1388
|
-
}
|
|
1389
|
-
if (!isFile(content)) {
|
|
1390
|
-
console.log(`publish-xhs: no card series at ${content} (render packaged none) — skip`);
|
|
1391
|
-
return;
|
|
1392
|
-
}
|
|
1393
|
-
const packaged = requireJson(content, 'publish-xhs');
|
|
1394
|
-
sted('write', input, () =>
|
|
1395
|
-
writeJsonText(input, { ...packaged, metadata: { ...metadataOf(packaged), publishStatus: want } }),
|
|
1396
|
-
);
|
|
1397
|
-
// `soft` keeps the XHS target non-blocking: the plugin still writes its own failed Result
|
|
1398
|
-
// and prints the failing step (login gate, upload, body, submit control), which is what the
|
|
1399
|
-
// operator needs to re-run — never a silent skip.
|
|
1400
|
-
const status = kk(['executor', 'run', 'xhs-pub', '--in', input, '--out', result, ...pluginsArgs(pluginsPath)], {
|
|
1401
|
-
soft: true,
|
|
1402
|
-
});
|
|
1403
|
-
console.log(
|
|
1404
|
-
status === 0
|
|
1405
|
-
? `publish-xhs: ${want} dispatched; outcome recorded in ${result}`
|
|
1406
|
-
: `WARNING: publish-xhs — xhs-pub exited ${status}; XHS target skipped (run continues, see ${result})`,
|
|
1407
|
-
);
|
|
1408
|
-
});
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
/**
|
|
1412
|
-
* WeChat MP: drive the console via `wechatmp-pub`'s browser transport and save the article to the
|
|
1413
|
-
* draft box (草稿箱). Draft by default; the 发表 flow runs only on an explicit launch-time
|
|
1414
|
-
* `wechat_live=true` (ADR-022 rail, same as xhs). Independent and non-blocking like XHS: the
|
|
1415
|
-
* outcome never enters `publish_status` (surfdash + podcast remain the publication contract).
|
|
1416
|
-
*/
|
|
1417
|
-
function dailyPublishWechat(args: string[]): void {
|
|
1418
|
-
const [workDirRaw = '', runDate = '', pluginsPath = '', live = 'false', collectionArg = ''] = args;
|
|
1419
|
-
// Metadata carries file paths consumed by the plugin child process (cwd = plugin dir) - never emit relatives.
|
|
1420
|
-
const workDir = resolve(workDirRaw);
|
|
1421
|
-
const article = join(workDir, '22-article', `${runDate}_06_article_content.json`);
|
|
1422
|
-
const input = join(workDir, '31-publish', `${runDate}_20_publish-wechat_content.json`);
|
|
1423
|
-
const result = join(workDir, '31-publish', `${runDate}_20_publish-wechat_result.json`);
|
|
1424
|
-
const want = live === 'true' ? 'public' : 'draft';
|
|
1425
|
-
timed(workDir, 'publish-wechat', () => {
|
|
1426
|
-
// Cache on the *requested* status: a cached draft must not silently satisfy a later
|
|
1427
|
-
// wechat_live=true re-run (that would report a live post that never happened).
|
|
1428
|
-
if (resultPublishStatus(result) === want) {
|
|
1429
|
-
console.log(`publish-wechat cached: ${want} already recorded in ${result}`);
|
|
1430
|
-
return;
|
|
1431
|
-
}
|
|
1432
|
-
// 20261005: a platform the content-safety gate excluded never gets a draft. Skipping here
|
|
1433
|
-
// keeps the run alive for the targets that did pass, which is the point of the gate.
|
|
1434
|
-
const safety = readJson(safetyPath(workDir, runDate, 'safety-judge_stamp.json'));
|
|
1435
|
-
const excluded = Array.isArray(safety?.excludedPlatforms) ? (safety.excludedPlatforms as string[]) : [];
|
|
1436
|
-
if (excluded.includes('wechatmp')) {
|
|
1437
|
-
console.log(`publish-wechat: SKIPPED — content-safety excluded wechatmp for this run`);
|
|
1438
|
-
return;
|
|
1439
|
-
}
|
|
1440
|
-
if (!isFile(article)) {
|
|
1441
|
-
console.log(`publish-wechat: no article at ${article} — skip`);
|
|
1442
|
-
return;
|
|
1443
|
-
}
|
|
1444
|
-
const packaged = requireJson(article, 'publish-wechat');
|
|
1445
|
-
// MP draft enrichment (operator preference 2026-09-29): 原创+作者, 赞赏, 合集, 原文链接
|
|
1446
|
-
// (the surfdash post), AI 生成声明, cover. The brand strings are this daily show's constants.
|
|
1447
|
-
const cover = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
|
|
1448
|
-
const audio = join(workDir, '30-audio', `${runDate}_11_generate_content.mp3`);
|
|
1449
|
-
const wechatmp: Record<string, unknown> = {
|
|
1450
|
-
original: true,
|
|
1451
|
-
aiGenerated: true,
|
|
1452
|
-
author: '冰原奔狼',
|
|
1453
|
-
collection: collectionArg || 'AI 乱炖',
|
|
1454
|
-
...(isFile(cover) ? { coverPath: cover } : {}),
|
|
1455
|
-
// Audio block at the article top (operator request 2026-09-29): the daily MP3 goes to
|
|
1456
|
-
// the 素材库 under the article title; the plugin reuses an existing same-title material.
|
|
1457
|
-
...(isFile(audio)
|
|
1458
|
-
? {
|
|
1459
|
-
audioPath: audio,
|
|
1460
|
-
audioTitle: typeof packaged.title === 'string' ? packaged.title : '',
|
|
1461
|
-
audioCoverPath: isFile(cover) ? cover : '',
|
|
1462
|
-
}
|
|
1463
|
-
: {}),
|
|
1464
|
-
};
|
|
1465
|
-
// WeChat-only closing line (operator request 2026-09-29): point readers/listeners back to
|
|
1466
|
-
// the show's platforms. Appended to the wechat input body only, never the master article.
|
|
1467
|
-
const outro = '\n\n欲听往期节目,可至小宇宙、Apple Podcast、YouTube 等平台搜索【咸话咸说】。';
|
|
1468
|
-
sted('write', input, () =>
|
|
1469
|
-
writeJsonText(input, {
|
|
1470
|
-
...packaged,
|
|
1471
|
-
body: `${typeof packaged.body === 'string' ? packaged.body : ''}${outro}`,
|
|
1472
|
-
metadata: { ...metadataOf(packaged), publishStatus: want, wechatmp },
|
|
1473
|
-
}),
|
|
1474
|
-
);
|
|
1475
|
-
// `soft` keeps the WeChat target non-blocking: the plugin still writes its own failed Result
|
|
1476
|
-
// and prints the failing step (session preflight, editor paste, save control), which is what
|
|
1477
|
-
// the operator needs to re-run — never a silent skip.
|
|
1478
|
-
const status = kk(
|
|
1479
|
-
['executor', 'run', 'wechatmp-pub', '--in', input, '--out', result, ...pluginsArgs(pluginsPath)],
|
|
1480
|
-
{
|
|
1481
|
-
soft: true,
|
|
1482
|
-
},
|
|
1483
|
-
);
|
|
1484
|
-
console.log(
|
|
1485
|
-
status === 0
|
|
1486
|
-
? `publish-wechat: ${want} dispatched; outcome recorded in ${result}`
|
|
1487
|
-
: `WARNING: publish-wechat — wechatmp-pub exited ${status}; WeChat target skipped (run continues, see ${result})`,
|
|
1488
|
-
);
|
|
1489
|
-
});
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
/**
|
|
1493
|
-
* F613 R1: snapshot the shipped style contract into the run's work dir. Spur workflows have no
|
|
1494
|
-
* workflow-dir var, so the script-write prompt reads this copy — and the snapshot freezes the
|
|
1495
|
-
* exact contract the run's plan was written against. Idempotent (an existing snapshot is kept);
|
|
1496
|
-
* a missing sidecar is a broken install, so `requireSidecar` fails loud.
|
|
1497
|
-
*/
|
|
1498
|
-
function dailyScriptStyle(args: string[]): void {
|
|
1499
|
-
const [workDir = '', runDate = ''] = args;
|
|
1500
|
-
const snapshot = join(workDir, '24-script', `${runDate}_08_script-write_style.md`);
|
|
1501
|
-
if (isFile(snapshot)) {
|
|
1502
|
-
console.log(`script style cached: ${snapshot}`);
|
|
1503
|
-
return;
|
|
1504
|
-
}
|
|
1505
|
-
const sidecar = requireSidecar('', STYLE_SIDECAR, 'daily-voice-style');
|
|
1506
|
-
const scriptDir = join(workDir, '24-script');
|
|
1507
|
-
sted('mkdir', scriptDir, () => mkdirSync(scriptDir, { recursive: true }));
|
|
1508
|
-
sted('cp', snapshot, () => copyFileSync(sidecar, snapshot));
|
|
1509
|
-
console.log(`script style snapshot: ${snapshot}`);
|
|
1510
|
-
}
|
|
1511
|
-
|
|
1512
|
-
function dailyScript(args: string[]): void {
|
|
1513
|
-
const [workDir = '', runDate = '', pluginsPath = '', language = '', title = '', voiceProfile = ''] = args;
|
|
1514
|
-
const yamlOut = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
|
|
1515
|
-
const contentOut = join(workDir, '24-script', `${runDate}_08_script_content.json`);
|
|
1516
|
-
const planIn = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
|
|
1517
|
-
timed(workDir, 'script', () => {
|
|
1518
|
-
if (isFile(yamlOut)) {
|
|
1519
|
-
console.log(`voicescript capped by plan: ${yamlOut}`);
|
|
1520
|
-
return;
|
|
1521
|
-
}
|
|
1522
|
-
kk(['executor', 'run', 'dailynews-gen', '--in', planIn, '--out', contentOut, ...pluginsArgs(pluginsPath)], {
|
|
1523
|
-
env: {
|
|
1524
|
-
DAILYNEWS_LANGUAGE: language,
|
|
1525
|
-
DAILYNEWS_TITLE: title,
|
|
1526
|
-
// Task 0189: the broadcast greeting carries the episode's run date, not the render
|
|
1527
|
-
// date — a backfilled/retried run must not greet "today".
|
|
1528
|
-
DAILYNEWS_DATE: runDate,
|
|
1529
|
-
VOICEBOX_DEFAULT_PROFILE: voiceProfile,
|
|
1530
|
-
// F613 R4: always passed as absolute paths — spawnPluginEntry runs plugins with
|
|
1531
|
-
// cwd = the plugin package dir, so a relative workDir would silently miss the
|
|
1532
|
-
// plan; a plan file absent at build time reports missing-plan (template fallback).
|
|
1533
|
-
DAILYNEWS_SCRIPT_PLAN: resolve(join(workDir, '24-script', `${runDate}_08_script-write_plan.json`)),
|
|
1534
|
-
DAILYNEWS_STYLE_FILE: resolve(resolveSidecar('', STYLE_SIDECAR)),
|
|
1535
|
-
},
|
|
1536
|
-
});
|
|
1537
|
-
const body = readJson(contentOut)?.body;
|
|
1538
|
-
if (typeof body !== 'string') failWith(1, `script: ${contentOut} has no string body`);
|
|
1539
|
-
sted('write', yamlOut, () => writeRaw(yamlOut, body));
|
|
1540
|
-
// R3 (task 0139): fresh writes are validated here; the script cache path is covered by
|
|
1541
|
-
// wrap-docs, which runs on every path that reaches audio.
|
|
1542
|
-
try {
|
|
1543
|
-
assertScriptContent(yamlOut);
|
|
1544
|
-
} catch (err) {
|
|
1545
|
-
// A rejected fresh write would otherwise poison the isFile(yamlOut) cache: every
|
|
1546
|
-
// resume skips regeneration and re-fails at wrap-docs. Drop it so retry regenerates.
|
|
1547
|
-
unlinkSync(yamlOut);
|
|
1548
|
-
throw err;
|
|
1549
|
-
}
|
|
1550
|
-
});
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
/** Task 0189 R10 artifacts of the content-safety verdict gate (24-script, step 09). */
|
|
1554
|
-
function safetyPath(workDir: string, runDate: string, name: string): string {
|
|
1076
|
+
export function safetyPath(workDir: string, runDate: string, name: string): string {
|
|
1555
1077
|
return join(workDir, '24-script', `${runDate}_09_${name}`);
|
|
1556
1078
|
}
|
|
1557
1079
|
|
|
@@ -1560,7 +1082,7 @@ function safetyPath(workDir: string, runDate: string, name: string): string {
|
|
|
1560
1082
|
* for the bytes it was issued on: the gate and publish-prep both recompute this, so an edit after
|
|
1561
1083
|
* PASS — including a HITL edit at `review` — invalidates the stamp instead of riding on it.
|
|
1562
1084
|
*/
|
|
1563
|
-
function safetyDigest(workDir: string, runDate: string): string {
|
|
1085
|
+
export function safetyDigest(workDir: string, runDate: string): string {
|
|
1564
1086
|
const hash = createHash('sha256');
|
|
1565
1087
|
for (const path of safetyDraftPaths(workDir, runDate)) {
|
|
1566
1088
|
if (!isFile(path)) failWith(1, `safety-review: required draft not found: ${path}`);
|
|
@@ -1570,441 +1092,14 @@ function safetyDigest(workDir: string, runDate: string): string {
|
|
|
1570
1092
|
}
|
|
1571
1093
|
|
|
1572
1094
|
/** The drafts under review — the digest's bytes, and the quarantine set on a quality block. */
|
|
1573
|
-
function safetyDraftPaths(workDir: string, runDate: string): [string, string] {
|
|
1095
|
+
export function safetyDraftPaths(workDir: string, runDate: string): [string, string] {
|
|
1574
1096
|
return [
|
|
1575
1097
|
join(workDir, '22-article', `${runDate}_06_article_content.json`),
|
|
1576
1098
|
join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`),
|
|
1577
1099
|
];
|
|
1578
1100
|
}
|
|
1579
1101
|
|
|
1580
|
-
|
|
1581
|
-
* Open a content-safety review of the current drafts: drop every artifact of an earlier review
|
|
1582
|
-
* (a retry must be re-reviewed on its final version) and write the request the two agent.run
|
|
1583
|
-
* steps read. The program passes context only — platform policy lives in the kk:content-safety
|
|
1584
|
-
* skill's reference files, never here.
|
|
1585
|
-
*/
|
|
1586
|
-
function dailySafetyPrep(args: string[]): void {
|
|
1587
|
-
const [workDir = '', runDate = '', platforms = ''] = args;
|
|
1588
|
-
timed(workDir, 'safety-review', () => {
|
|
1589
|
-
const digest = safetyDigest(workDir, runDate);
|
|
1590
|
-
for (const name of [
|
|
1591
|
-
'safety-review_report.json',
|
|
1592
|
-
'safety-judge_verdict.json',
|
|
1593
|
-
'safety-judge_status.txt',
|
|
1594
|
-
'safety-judge_stamp.json',
|
|
1595
|
-
]) {
|
|
1596
|
-
const stale = safetyPath(workDir, runDate, name);
|
|
1597
|
-
if (isFile(stale)) unlinkSync(stale);
|
|
1598
|
-
}
|
|
1599
|
-
const request = safetyPath(workDir, runDate, 'safety-review_request.json');
|
|
1600
|
-
const targets = platforms
|
|
1601
|
-
.split(',')
|
|
1602
|
-
.map((platform) => platform.trim())
|
|
1603
|
-
.filter(Boolean);
|
|
1604
|
-
sted('write', request, () => writeJsonText(request, { digest, platforms: targets, mode: 'detect-only' }));
|
|
1605
|
-
});
|
|
1606
|
-
}
|
|
1607
|
-
|
|
1608
|
-
/**
|
|
1609
|
-
* Consume the content-safety report and the content-judge verdict into a publish decision (exit 0 in
|
|
1610
|
-
* every case — routing is transition-owned). The gate's job is to *adjust*, not to kill the run
|
|
1611
|
-
* (20261005 operator decision): a non-PASS verdict first routes back through one bounded
|
|
1612
|
-
* draft-remediation loop (`revise`), and what survives the loop is classified by what it is:
|
|
1613
|
-
*
|
|
1614
|
-
* - `revise` — non-PASS and the remediation budget is not spent: re-review after the drafts change.
|
|
1615
|
-
* - `exclude` — admission-only residue (the CN 资质口径 / 播报形态 findings): the platforms that
|
|
1616
|
-
* BLOCKed are dropped from this run and every other target publishes normally.
|
|
1617
|
-
* - `escalate` — a RISK decision or `escalate: true` with an otherwise clean pass: human review.
|
|
1618
|
-
* - `pass` — explicit PASS verdict, unchanged drafts, no BLOCK/RISK.
|
|
1619
|
-
* - `block` — draft integrity: PII reported on a blocked platform, or a judge must-pass `fail`
|
|
1620
|
-
* outside `jurisdictional-rules` (prohibited claims / disclosure / data-handling);
|
|
1621
|
-
* also an unreadable or out-of-contract report/verdict, a non-PASS that documents
|
|
1622
|
-
* nothing, or drafts edited after the review. Never published.
|
|
1623
|
-
*
|
|
1624
|
-
* Fail-closed where it matters: a missing report, verdict or confidence still blocks.
|
|
1625
|
-
*/
|
|
1626
|
-
function dailySafetyGate(args: string[]): void {
|
|
1627
|
-
const [workDir = '', runDate = '', maxReviseArg = '0'] = args;
|
|
1628
|
-
const maxRevise = Number.parseInt(maxReviseArg, 10);
|
|
1629
|
-
const statusFile = safetyPath(workDir, runDate, 'safety-judge_status.txt');
|
|
1630
|
-
const stampFile = safetyPath(workDir, runDate, 'safety-judge_stamp.json');
|
|
1631
|
-
timed(workDir, 'safety-judge', () => {
|
|
1632
|
-
const request = readJson(safetyPath(workDir, runDate, 'safety-review_request.json'));
|
|
1633
|
-
const report = readJson(safetyPath(workDir, runDate, 'safety-review_report.json'));
|
|
1634
|
-
const verdict = readJson(safetyPath(workDir, runDate, 'safety-judge_verdict.json'));
|
|
1635
|
-
const platforms = Array.isArray(report?.platforms) ? (report.platforms as Record<string, unknown>[]) : [];
|
|
1636
|
-
const decisions = platforms.map((platform) => platform?.decision);
|
|
1637
|
-
const digest = safetyDigest(workDir, runDate);
|
|
1638
|
-
const revisions = readSafetyReviseCount(workDir);
|
|
1639
|
-
// A RISK decision or an explicit escalation is a human decision even on a clean verdict.
|
|
1640
|
-
const risky = decisions.includes('RISK') || report?.escalate === true;
|
|
1641
|
-
const verdictFindings = Array.isArray(verdict?.findings) ? (verdict.findings as Record<string, unknown>[]) : [];
|
|
1642
|
-
const verdictFails = verdictFindings.filter((finding) => finding?.status === 'fail');
|
|
1643
|
-
// What a BLOCK *is* decides who can answer it. Dropping a blocked platform answers the
|
|
1644
|
-
// platform's own admission rules (资质口径/播报形态 …) — but the drafts' bytes ship to every
|
|
1645
|
-
// other target too, so a *draft* defect reported on that platform survives the drop. The
|
|
1646
|
-
// one finding class that is unambiguously a draft property is category 3 (可识别个人信息):
|
|
1647
|
-
// PII cannot be answered by choosing targets. The rest of the draft-integrity class is owned
|
|
1648
|
-
// by the judge's must-pass criteria below (no-prohibited-claims / disclosure-presence /
|
|
1649
|
-
// data-handling), which is where an independent reviewer can grade it.
|
|
1650
|
-
const platformFindings = (platform: Record<string, unknown>): Record<string, unknown>[] =>
|
|
1651
|
-
Array.isArray(platform?.findings) ? (platform.findings as Record<string, unknown>[]) : [];
|
|
1652
|
-
const blockedPlatforms = platforms.filter((platform) => platform?.decision === 'BLOCK');
|
|
1653
|
-
const excludedPlatforms = blockedPlatforms.map((platform) => String(platform?.platform ?? '')).filter(Boolean);
|
|
1654
|
-
const piiBlock = blockedPlatforms.some((platform) =>
|
|
1655
|
-
platformFindings(platform).some((finding) => Number(finding?.category) === PII_FINDING_CATEGORY),
|
|
1656
|
-
);
|
|
1657
|
-
// A BLOCK that names no finding is a contract violation, not a signal: there is nothing to
|
|
1658
|
-
// remediate and nothing to exclude, so it blocks (fail-closed).
|
|
1659
|
-
const unnamedBlock =
|
|
1660
|
-
blockedPlatforms.length > 0 && blockedPlatforms.every((p) => platformFindings(p).length === 0);
|
|
1661
|
-
const malformed =
|
|
1662
|
-
decisions.length === 0 || decisions.some((decision) => !SAFETY_PLATFORM_DECISIONS.has(String(decision)));
|
|
1663
|
-
const confidence = verdict?.confidence;
|
|
1664
|
-
const confidenceOk = confidence === 'HIGH' || confidence === 'MEDIUM' || confidence === 'LOW';
|
|
1665
|
-
const verdictValue = String(verdict?.verdict ?? '');
|
|
1666
|
-
const outOfContract =
|
|
1667
|
-
(verdictValue !== 'PASS' && verdictValue !== 'FAIL' && verdictValue !== 'NEEDS_REVISION') || !confidenceOk;
|
|
1668
|
-
const nonPass = verdictValue !== 'PASS' || !confidenceOk;
|
|
1669
|
-
// Integrity = the finding survives any target choice: PII reported on a blocked platform, an
|
|
1670
|
-
// out-of-contract verdict, a non-PASS that documents nothing, or a `fail` outside the
|
|
1671
|
-
// admission criteria.
|
|
1672
|
-
const integrity =
|
|
1673
|
-
piiBlock ||
|
|
1674
|
-
unnamedBlock ||
|
|
1675
|
-
(nonPass && verdictFindings.length === 0) ||
|
|
1676
|
-
verdictFails.some((finding) => !ADMISSION_CRITERIA.has(String(finding?.criterion ?? '')));
|
|
1677
|
-
let status = 'block';
|
|
1678
|
-
let reason = 'verdict PASS on unchanged drafts, no BLOCK/RISK';
|
|
1679
|
-
// A digest-mismatch block means the drafts were deliberately edited (HITL): keep them so
|
|
1680
|
-
// a re-run of safety-prep re-reviews the edited version. Any other block means the
|
|
1681
|
-
// content itself is rejected — quarantine the drafts below.
|
|
1682
|
-
let edited = false;
|
|
1683
|
-
if (request?.digest !== digest) {
|
|
1684
|
-
reason = 'drafts changed after the review was requested';
|
|
1685
|
-
edited = true;
|
|
1686
|
-
} else if (malformed) {
|
|
1687
|
-
reason = 'content-safety report missing or malformed';
|
|
1688
|
-
} else if (outOfContract) {
|
|
1689
|
-
// 20261003 hardening, unchanged: a verdict outside PASS|FAIL|NEEDS_REVISION, or any
|
|
1690
|
-
// verdict without a HIGH/MEDIUM/LOW confidence, is not a verdict — block, never revise
|
|
1691
|
-
// against it (there is no contract to read findings from).
|
|
1692
|
-
reason = `content-judge verdict out of contract (${verdictValue || 'missing'}/${confidenceOk ? 'ok' : 'no confidence'})`;
|
|
1693
|
-
} else if (nonPass || blockedPlatforms.length > 0) {
|
|
1694
|
-
const trigger =
|
|
1695
|
-
blockedPlatforms.length > 0
|
|
1696
|
-
? `content-safety BLOCK on ${blockedPlatforms.map((p) => String(p?.platform ?? '')).join(', ')}`
|
|
1697
|
-
: `content-judge verdict ${verdictValue}`;
|
|
1698
|
-
if (revisions < maxRevise) {
|
|
1699
|
-
status = 'revise';
|
|
1700
|
-
reason = `${trigger} — remediation pass ${revisions + 1}/${maxRevise} (drafts re-reviewed after the edit)`;
|
|
1701
|
-
} else if (integrity) {
|
|
1702
|
-
status = 'block';
|
|
1703
|
-
const named = blockedPlatforms
|
|
1704
|
-
.flatMap((platform) => platformFindings(platform))
|
|
1705
|
-
.filter((finding) => Number(finding?.category) === PII_FINDING_CATEGORY)
|
|
1706
|
-
.map((finding) => `${String(finding?.item ?? '?')}(category ${String(finding?.category)})`)
|
|
1707
|
-
.join(', ');
|
|
1708
|
-
const criteria = verdictFails.map((finding) => String(finding?.criterion)).join(', ');
|
|
1709
|
-
reason = `${trigger} — draft-integrity findings survive ${revisions} remediation passes (${named || criteria || 'the verdict names no criterion'}); exclusion cannot fix these`;
|
|
1710
|
-
} else {
|
|
1711
|
-
status = 'exclude';
|
|
1712
|
-
reason =
|
|
1713
|
-
excludedPlatforms.length > 0
|
|
1714
|
-
? `${trigger} — admission-only, ${revisions} remediation passes spent: ${excludedPlatforms.join(', ')} dropped from this run, other targets publish${risky ? ' (RISK/escalation recorded)' : ''}`
|
|
1715
|
-
: `${trigger} — admission-only, no platform BLOCK: submitting to the remaining targets at recorded risk`;
|
|
1716
|
-
}
|
|
1717
|
-
} else if (risky) {
|
|
1718
|
-
status = 'escalate';
|
|
1719
|
-
reason = 'RISK decision or escalation — human review required';
|
|
1720
|
-
} else status = 'pass';
|
|
1721
|
-
if (status === 'pass' || status === 'escalate' || status === 'exclude') {
|
|
1722
|
-
sted('write', stampFile, () => writeJsonText(stampFile, { digest, status, excludedPlatforms }));
|
|
1723
|
-
}
|
|
1724
|
-
if (status === 'block' && !edited) {
|
|
1725
|
-
// A quality block poisons the isFile() draft cache: every same-day retry would
|
|
1726
|
-
// re-fail on unchanged bytes (20261002 dogfood — two judge-fail runs replayed until
|
|
1727
|
-
// the drafts were moved by hand). Quarantine (mv, never rm) so the next run
|
|
1728
|
-
// regenerates from scratch; the 09_* verdict artifacts stay in 24-script for audit.
|
|
1729
|
-
const q = join(workDir, '24-script', `.quarantine-${runDate}-judge-block`);
|
|
1730
|
-
mkdirSync(q, { recursive: true });
|
|
1731
|
-
for (const path of safetyDraftPaths(workDir, runDate)) {
|
|
1732
|
-
if (!isFile(path)) continue;
|
|
1733
|
-
let dest = join(q, basename(path));
|
|
1734
|
-
for (let n = 2; isFile(dest); n += 1) dest = join(q, `${n}-${basename(path)}`);
|
|
1735
|
-
renameSync(path, dest);
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
sted('write', statusFile, () => writeRaw(statusFile, status));
|
|
1739
|
-
console.log(`safety: status=${status} (${reason}); routing is transition-owned`);
|
|
1740
|
-
});
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
/**
|
|
1744
|
-
* Post-remediation bookkeeping for the `safety-revise` state: the agent edited the drafts, this
|
|
1745
|
-
* stage checks they are still the artifacts the rest of the pipeline consumes and spends one unit
|
|
1746
|
-
* of the remediation budget (`daily-safety-gate` reads it to choose revise-vs-exclude). Validating
|
|
1747
|
-
* here fails inside the loop instead of at `wrap-docs`, after the run has moved on. A broken
|
|
1748
|
-
* revision spends the budget too: an unparsable draft must not be retried forever.
|
|
1749
|
-
*/
|
|
1750
|
-
function dailySafetyRevise(args: string[]): void {
|
|
1751
|
-
const [workDir = '', runDate = ''] = args;
|
|
1752
|
-
timed(workDir, 'safety-revise', () => {
|
|
1753
|
-
const [articlePath, scriptPath] = safetyDraftPaths(workDir, runDate);
|
|
1754
|
-
const article = requireJson(articlePath, 'safety-revise');
|
|
1755
|
-
for (const field of ['title', 'body'] as const) {
|
|
1756
|
-
const value = article[field];
|
|
1757
|
-
if (typeof value !== 'string' || value.trim() === '') {
|
|
1758
|
-
failWith(1, `safety-revise: article ${field} must be a non-empty string in ${articlePath}`);
|
|
1759
|
-
}
|
|
1760
|
-
}
|
|
1761
|
-
assertScriptContent(scriptPath);
|
|
1762
|
-
const path = join(workDir, SAFETY_REVISE_COUNT);
|
|
1763
|
-
const next = readSafetyReviseCount(workDir) + 1;
|
|
1764
|
-
sted('write', path, () => writeFileSync(path, `${next}\n`));
|
|
1765
|
-
console.log(`safety-revise: remediation pass ${next} applied; drafts re-validated`);
|
|
1766
|
-
});
|
|
1767
|
-
}
|
|
1768
|
-
|
|
1769
|
-
function dailyWrapDocs(args: string[]): void {
|
|
1770
|
-
const [workDir = '', runDate = '', voiceProfile = ''] = args;
|
|
1771
|
-
const scriptPath = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
|
|
1772
|
-
const docsPath = join(workDir, '30-audio', `${runDate}_10_wrap-docs_docs.json`);
|
|
1773
|
-
timed(workDir, 'wrap-docs', () => {
|
|
1774
|
-
// R3 (task 0139): the second validator placement — covers a HITL-edited YAML and the
|
|
1775
|
-
// script-stage cache path, neither of which re-enters dailyScript's fresh-write branch.
|
|
1776
|
-
assertScriptContent(scriptPath);
|
|
1777
|
-
const url = new URL(scriptPath, `file://${process.cwd()}/`);
|
|
1778
|
-
const body = readFileSync(decodeURIComponent(url.pathname), 'utf-8');
|
|
1779
|
-
const profile = voiceProfile || process.env.VOICEBOX_DEFAULT_PROFILE || 'robin-news';
|
|
1780
|
-
const doc = {
|
|
1781
|
-
id: createHash('sha256').update(url.href).digest('hex').slice(0, 16),
|
|
1782
|
-
title: 'Daily AI News VoiceScript',
|
|
1783
|
-
sourceUri: url.href,
|
|
1784
|
-
body,
|
|
1785
|
-
mediaType: 'application/yaml',
|
|
1786
|
-
metadata: { voiceProfile: profile },
|
|
1787
|
-
};
|
|
1788
|
-
sted('write', docsPath, () => writeJsonText(docsPath, [doc]));
|
|
1789
|
-
});
|
|
1790
|
-
}
|
|
1791
|
-
|
|
1792
|
-
function dailyGenerate(args: string[]): void {
|
|
1793
|
-
const [workDir = '', runDate = '', pluginsPath = '', transcodeMp3 = '', vdriver = ''] = args;
|
|
1794
|
-
// 20260917 review: the workflow picks a voice driver by short name; both drivers' envs are
|
|
1795
|
-
// passed unconditionally so switching `vdriver` needs no shell changes. Unknown -> fail loud.
|
|
1796
|
-
const VOICE_DRIVERS: Record<string, string> = {
|
|
1797
|
-
ominivoice: 'omni-voice-gen',
|
|
1798
|
-
voicebox: 'voice-gen',
|
|
1799
|
-
gemini: 'gemini-tts',
|
|
1800
|
-
};
|
|
1801
|
-
const voiceGenerator = VOICE_DRIVERS[vdriver];
|
|
1802
|
-
if (!voiceGenerator) {
|
|
1803
|
-
failWith(
|
|
1804
|
-
1,
|
|
1805
|
-
`generate: unknown vdriver '${vdriver}' (expected one of: ${Object.keys(VOICE_DRIVERS).join(', ')})`,
|
|
1806
|
-
);
|
|
1807
|
-
}
|
|
1808
|
-
const docs = join(workDir, '30-audio', `${runDate}_10_wrap-docs_docs.json`);
|
|
1809
|
-
const content = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
|
|
1810
|
-
timed(workDir, 'generate', () => {
|
|
1811
|
-
if (isFile(content) && metadataOf(readJson(content)).qc !== undefined) {
|
|
1812
|
-
const qc = metadataOf(readJson(content)).qc as Record<string, unknown> | undefined;
|
|
1813
|
-
if (qc?.passed === true) {
|
|
1814
|
-
console.log(`audio cached: ${content} (QC passed)`);
|
|
1815
|
-
return;
|
|
1816
|
-
}
|
|
1817
|
-
}
|
|
1818
|
-
kk(['executor', 'run', voiceGenerator, '--in', docs, '--out', content, ...pluginsArgs(pluginsPath)], {
|
|
1819
|
-
env: { VOICE_GEN_MP3: transcodeMp3 },
|
|
1820
|
-
});
|
|
1821
|
-
});
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
/**
|
|
1825
|
-
* 20260917 review #7: prepend the show's 8s intro sting to the generated audio.
|
|
1826
|
-
* `intro_music` empty -> skip. Writes `<stem>.intro.wav` (original render kept), points
|
|
1827
|
-
* `audioPath` at the merged file, extends `duration`, marks `introMerged` (idempotent).
|
|
1828
|
-
*/
|
|
1829
|
-
/**
|
|
1830
|
-
* Episode duration gate (standing requirement, 20261002): a daily episode must run 15–20 minutes
|
|
1831
|
-
* (duration is authoritative; the 8–10-item figure is only an estimate). Reads the final audio
|
|
1832
|
-
* content's `metadata.duration` — after the intro sting merged, this is the true episode length —
|
|
1833
|
-
* and fails loud outside `[min, max]` seconds-worth of minutes. A missing duration is a failure,
|
|
1834
|
-
* not a pass: the gate must never silently let an under-length episode through again.
|
|
1835
|
-
*/
|
|
1836
|
-
function dailyEpisodeDuration(args: string[]): void {
|
|
1837
|
-
const [workDir = '', runDate = '', minArg = '', maxArg = ''] = args;
|
|
1838
|
-
if (!workDir) fail('missing work_dir');
|
|
1839
|
-
if (!runDate) fail('missing run_date');
|
|
1840
|
-
const minMinutes = Number(minArg);
|
|
1841
|
-
const maxMinutes = Number(maxArg);
|
|
1842
|
-
if (!Number.isFinite(minMinutes) || minMinutes <= 0) fail(`invalid min_minutes: ${minArg || '(empty)'}`);
|
|
1843
|
-
if (!Number.isFinite(maxMinutes) || maxMinutes < minMinutes) fail(`invalid max_minutes: ${maxArg || '(empty)'}`);
|
|
1844
|
-
const contentPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
|
|
1845
|
-
if (!isFile(contentPath)) failWith(1, `episode-duration: audio content not found: ${contentPath}`);
|
|
1846
|
-
let raw: unknown;
|
|
1847
|
-
try {
|
|
1848
|
-
const parsed = JSON.parse(readFileSync(contentPath, 'utf-8')) as { metadata?: { duration?: unknown } };
|
|
1849
|
-
raw = parsed.metadata?.duration;
|
|
1850
|
-
} catch (error) {
|
|
1851
|
-
if ((error as { code?: string }).code !== undefined) {
|
|
1852
|
-
sted('read', contentPath, () => readFileSync(contentPath, 'utf-8'));
|
|
1853
|
-
}
|
|
1854
|
-
raw = undefined;
|
|
1855
|
-
}
|
|
1856
|
-
const seconds = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN;
|
|
1857
|
-
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
1858
|
-
failWith(1, `episode-duration: audio metadata.duration missing or not a number in ${contentPath}`);
|
|
1859
|
-
}
|
|
1860
|
-
const minutes = seconds / 60;
|
|
1861
|
-
const shown = minutes.toFixed(1);
|
|
1862
|
-
if (minutes < minMinutes) {
|
|
1863
|
-
failWith(
|
|
1864
|
-
1,
|
|
1865
|
-
`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.`,
|
|
1866
|
-
);
|
|
1867
|
-
}
|
|
1868
|
-
if (minutes > maxMinutes) {
|
|
1869
|
-
failWith(
|
|
1870
|
-
1,
|
|
1871
|
-
`episode-duration: episode runs ${shown} min, above the ${minMinutes}–${maxMinutes} min requirement. Trim items or per-item treatment.`,
|
|
1872
|
-
);
|
|
1873
|
-
}
|
|
1874
|
-
console.log(`episode-duration ok: ${shown} min within ${minMinutes}–${maxMinutes} min`);
|
|
1875
|
-
}
|
|
1876
|
-
|
|
1877
|
-
function dailyIntro(args: string[]): void {
|
|
1878
|
-
const [workDir = '', runDate = '', introSrc = ''] = args;
|
|
1879
|
-
if (!introSrc || introSrc.trim() === '') {
|
|
1880
|
-
console.log('intro-music: intro_music var empty — skip');
|
|
1881
|
-
return;
|
|
1882
|
-
}
|
|
1883
|
-
const contentPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
|
|
1884
|
-
timed(workDir, 'intro-music', () => {
|
|
1885
|
-
const content = requireJson(contentPath, 'intro-music');
|
|
1886
|
-
const meta = metadataOf(content);
|
|
1887
|
-
if (meta.introMerged === true) {
|
|
1888
|
-
console.log(`intro-music cached: ${contentPath}`);
|
|
1889
|
-
return;
|
|
1890
|
-
}
|
|
1891
|
-
// Merge from the raw lossless render; mp3Path is the derived publish artifact.
|
|
1892
|
-
// Preferring mp3Path here made `<stem>.mp3` the concat input while the output
|
|
1893
|
-
// suffix rewrite (`.wav` -> `.intro.wav`) no longer matched, so ffmpeg saw one
|
|
1894
|
-
// path as both input #1 and output and aborted with "same as Input #1".
|
|
1895
|
-
const candidates = [meta.audioPath, meta.mp3Path].filter(
|
|
1896
|
-
(candidate): candidate is string => typeof candidate === 'string' && candidate !== '',
|
|
1897
|
-
);
|
|
1898
|
-
const rawFile = candidates.find((candidate) => existsSync(resolve(candidate))) ?? candidates[0];
|
|
1899
|
-
if (rawFile === undefined) {
|
|
1900
|
-
failWith(1, 'intro-music: voice content carries no mp3Path/audioPath');
|
|
1901
|
-
}
|
|
1902
|
-
const introPath = resolve(introSrc);
|
|
1903
|
-
if (!existsSync(introPath)) failWith(1, `intro-music: intro file not found: ${introPath}`);
|
|
1904
|
-
const mainPath = resolve(rawFile);
|
|
1905
|
-
if (!existsSync(mainPath)) failWith(1, `intro-music: generated audio not found: ${mainPath}`);
|
|
1906
|
-
const mergedPath = `${mainPath.replace(/\.(wav|mp3)$/i, '')}.intro.wav`;
|
|
1907
|
-
const probe = (file: string, entries: string): string =>
|
|
1908
|
-
spawnSync('ffprobe', [
|
|
1909
|
-
'-v',
|
|
1910
|
-
'error',
|
|
1911
|
-
'-select_streams',
|
|
1912
|
-
'a:0',
|
|
1913
|
-
'-show_entries',
|
|
1914
|
-
entries,
|
|
1915
|
-
'-of',
|
|
1916
|
-
'csv=p=0',
|
|
1917
|
-
file,
|
|
1918
|
-
])
|
|
1919
|
-
.stdout?.toString()
|
|
1920
|
-
.trim() ?? '';
|
|
1921
|
-
const mainDur = Number.parseFloat(probe(mainPath, 'format=duration'));
|
|
1922
|
-
const introDur = Number.parseFloat(probe(introPath, 'format=duration'));
|
|
1923
|
-
if (!Number.isFinite(mainDur) || !Number.isFinite(introDur)) {
|
|
1924
|
-
failWith(1, `intro-music: ffprobe could not read durations (main=${mainDur} intro=${introDur})`);
|
|
1925
|
-
}
|
|
1926
|
-
const rate = probe(mainPath, 'stream=sample_rate') || '24000'; // match the generated audio's rate; mono s16 both sides
|
|
1927
|
-
const norm = (label: string): string =>
|
|
1928
|
-
`[${label}:a]aresample=${rate},aformat=sample_fmts=s16:channel_layouts=mono[${label}]`;
|
|
1929
|
-
const fc = `${norm('0')};${norm('1')};[0][1]concat=n=2:v=0:a=1[out]`;
|
|
1930
|
-
const conv = spawnSync('ffmpeg', [
|
|
1931
|
-
'-y',
|
|
1932
|
-
'-hide_banner',
|
|
1933
|
-
'-loglevel',
|
|
1934
|
-
'error',
|
|
1935
|
-
'-i',
|
|
1936
|
-
introPath,
|
|
1937
|
-
'-i',
|
|
1938
|
-
mainPath,
|
|
1939
|
-
'-filter_complex',
|
|
1940
|
-
fc,
|
|
1941
|
-
'-map',
|
|
1942
|
-
'[out]',
|
|
1943
|
-
'-c:a',
|
|
1944
|
-
'pcm_s16le',
|
|
1945
|
-
mergedPath,
|
|
1946
|
-
]);
|
|
1947
|
-
if (conv.status !== 0 || !existsSync(mergedPath)) {
|
|
1948
|
-
failWith(1, `intro-music: ffmpeg concat failed: ${conv.stderr?.toString().trim()}`);
|
|
1949
|
-
}
|
|
1950
|
-
if (meta.mp3Path && typeof meta.mp3Path === 'string') {
|
|
1951
|
-
// transcode_mp3=true: keep the mp3 path honest by re-transcoding the merged wav.
|
|
1952
|
-
const mp3 = spawnSync('ffmpeg', [
|
|
1953
|
-
'-y',
|
|
1954
|
-
'-hide_banner',
|
|
1955
|
-
'-loglevel',
|
|
1956
|
-
'error',
|
|
1957
|
-
'-i',
|
|
1958
|
-
mergedPath,
|
|
1959
|
-
'-codec:a',
|
|
1960
|
-
'libmp3lame',
|
|
1961
|
-
'-qscale:a',
|
|
1962
|
-
'2',
|
|
1963
|
-
resolve(meta.mp3Path),
|
|
1964
|
-
]);
|
|
1965
|
-
if (mp3.status !== 0) failWith(1, `intro-music: mp3 re-transcode failed: ${mp3.stderr?.toString().trim()}`);
|
|
1966
|
-
}
|
|
1967
|
-
const mergedDur = Number.parseFloat(probe(mergedPath, 'format=duration')) || mainDur + introDur;
|
|
1968
|
-
const updated = {
|
|
1969
|
-
...content,
|
|
1970
|
-
metadata: {
|
|
1971
|
-
...meta,
|
|
1972
|
-
audioPath: mergedPath,
|
|
1973
|
-
duration: mergedDur,
|
|
1974
|
-
introMerged: true,
|
|
1975
|
-
},
|
|
1976
|
-
};
|
|
1977
|
-
sted('write', contentPath, () => writeJsonText(contentPath, updated));
|
|
1978
|
-
console.log(
|
|
1979
|
-
`intro-music: merged ${introDur.toFixed(1)}s intro -> ${mergedPath} (${mergedDur.toFixed(1)}s total)`,
|
|
1980
|
-
);
|
|
1981
|
-
});
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
|
-
function dailyQualityReport(args: string[]): void {
|
|
1985
|
-
const [workDir = '', runDate = ''] = args;
|
|
1986
|
-
const content = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
|
|
1987
|
-
timed(workDir, 'quality-control', () => {
|
|
1988
|
-
const qc = metadataOf(readJson(content)).qc as Record<string, unknown> | undefined;
|
|
1989
|
-
console.log('=== Voice Quality Control Report ===');
|
|
1990
|
-
console.log(`Overall Score: ${qc ? qc.overallScore : 100}/100 | Passed: ${qc ? qc.passed : true}`);
|
|
1991
|
-
const issues = qc?.criticalIssues;
|
|
1992
|
-
if (Array.isArray(issues) && issues.length > 0) {
|
|
1993
|
-
console.warn(`QC Issues:\n${issues.map((issue) => ` - ${issue}`).join('\n')}`);
|
|
1994
|
-
} else {
|
|
1995
|
-
console.log('All audio segments passed quality control checks cleanly.');
|
|
1996
|
-
}
|
|
1997
|
-
});
|
|
1998
|
-
}
|
|
1999
|
-
|
|
2000
|
-
/**
|
|
2001
|
-
* 0171 R3: Apple Podcasts reads the enclosure's ID3 tags, so the published MP3 carries
|
|
2002
|
-
* title/artist/album/date. `-c copy` moves the audio frames without a re-encode, and the temp
|
|
2003
|
-
* file + rename keeps a failed ffmpeg from truncating the episode in place. An empty value
|
|
2004
|
-
* omits that tag rather than failing. The caller runs this after intro-music, whose mp3
|
|
2005
|
-
* re-transcode would otherwise strip tags stamped earlier.
|
|
2006
|
-
*/
|
|
2007
|
-
function stampId3Tags(audioFile: string, title: string, runDate: string, artist: string, album: string): void {
|
|
1102
|
+
export function stampId3Tags(audioFile: string, title: string, runDate: string, artist: string, album: string): void {
|
|
2008
1103
|
const tmpFile = `${audioFile}.id3.tmp.mp3`;
|
|
2009
1104
|
const metadata: string[] = [];
|
|
2010
1105
|
const addTag = (name: string, value: string): void => {
|
|
@@ -2039,184 +1134,7 @@ function stampId3Tags(audioFile: string, title: string, runDate: string, artist:
|
|
|
2039
1134
|
sted('rename', audioFile, () => renameSync(tmpFile, audioFile));
|
|
2040
1135
|
}
|
|
2041
1136
|
|
|
2042
|
-
function
|
|
2043
|
-
const [workDir = '', runDate = '', artist = '', album = ''] = args;
|
|
2044
|
-
const articlePath = join(workDir, '22-article', `${runDate}_06_article_content.json`);
|
|
2045
|
-
const audioPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
|
|
2046
|
-
const prepPath = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
|
|
2047
|
-
const coverPath = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
|
|
2048
|
-
timed(workDir, 'publish-prep', () => {
|
|
2049
|
-
const article = requireJson(articlePath, 'publish-prep');
|
|
2050
|
-
const audioMeta = metadataOf(readJson(audioPath));
|
|
2051
|
-
const rawFile = audioMeta.mp3Path ?? audioMeta.audioPath;
|
|
2052
|
-
if (typeof rawFile !== 'string' || rawFile === '') {
|
|
2053
|
-
failWith(1, 'publish-prep: voice content carries no mp3Path/audioPath');
|
|
2054
|
-
}
|
|
2055
|
-
const audioFile = resolve(rawFile);
|
|
2056
|
-
// 0171 R2: publish-prep publishes metadata.mp3Path ?? audioPath, so a WAV here means
|
|
2057
|
-
// transcode_mp3 was turned off — the run would ship an enclosure Apple Podcasts rejects.
|
|
2058
|
-
if (!audioFile.endsWith('.mp3')) {
|
|
2059
|
-
failWith(1, `publish-prep: resolved audio is not an MP3 (transcode_mp3 must be "true"): ${audioFile}`);
|
|
2060
|
-
}
|
|
2061
|
-
stampId3Tags(audioFile, typeof article.title === 'string' ? article.title : '', runDate, artist, album);
|
|
2062
|
-
const resolvedCover = resolve(coverPath);
|
|
2063
|
-
const hasCover = existsSync(resolvedCover);
|
|
2064
|
-
// Daily-show itunes convention: season = year, episode = day-of-year. run_date arrives
|
|
2065
|
-
// dashed (`$(date +%F)`) or digits-only (formatRunDate); map.ts forwards numbers only,
|
|
2066
|
-
// so a malformed run_date omits both tags instead of failing the publish.
|
|
2067
|
-
const digits = runDate.replaceAll(/\D/g, '');
|
|
2068
|
-
const season = Number(digits.slice(0, 4));
|
|
2069
|
-
const month = Number(digits.slice(4, 6));
|
|
2070
|
-
const day = Number(digits.slice(6, 8));
|
|
2071
|
-
const episode =
|
|
2072
|
-
digits.length === 8 && month >= 1 && month <= 12 && day >= 1 && day <= 31
|
|
2073
|
-
? Math.round((Date.UTC(season, month - 1, day) - Date.UTC(season, 0, 1)) / 86_400_000) + 1
|
|
2074
|
-
: 0;
|
|
2075
|
-
// podcast-pub's documented contract reads metadata.coverImage (map.ts); options.coverPath
|
|
2076
|
-
// alone was silently dropped by the mapper — episodes published without a cover.
|
|
2077
|
-
const merged = {
|
|
2078
|
-
...article,
|
|
2079
|
-
metadata: {
|
|
2080
|
-
...metadataOf(article),
|
|
2081
|
-
audioFile,
|
|
2082
|
-
runDate,
|
|
2083
|
-
...(hasCover ? { coverImage: resolvedCover } : {}),
|
|
2084
|
-
...(episode > 0 ? { itunes: { season, episode } } : {}),
|
|
2085
|
-
},
|
|
2086
|
-
options: {
|
|
2087
|
-
audioFile,
|
|
2088
|
-
durationSec: audioMeta.duration,
|
|
2089
|
-
coverPath: hasCover ? resolvedCover : undefined,
|
|
2090
|
-
},
|
|
2091
|
-
};
|
|
2092
|
-
// Task 0189 R10: the last program seam before anything is dispatched. No stamp, or a
|
|
2093
|
-
// stamp issued on different bytes (edited after PASS, or an older draft), never publishes.
|
|
2094
|
-
const stamp = readJson(safetyPath(workDir, runDate, 'safety-judge_stamp.json'));
|
|
2095
|
-
if (stamp === null) failWith(1, 'publish-prep: no content-safety verdict stamp for this run — not publishing');
|
|
2096
|
-
// 20261005: pass/escalate/exclude all stamp the drafts as publishable; only `block` (draft
|
|
2097
|
-
// integrity) has no stamp. An `exclude` stamp carries the admission-blocked platforms so
|
|
2098
|
-
// the per-target stages can skip those targets instead of failing the run.
|
|
2099
|
-
if (stamp.status !== 'pass' && stamp.status !== 'escalate' && stamp.status !== 'exclude') {
|
|
2100
|
-
failWith(1, `publish-prep: content-safety status ${String(stamp.status)} does not publish`);
|
|
2101
|
-
}
|
|
2102
|
-
if (stamp.digest !== safetyDigest(workDir, runDate)) {
|
|
2103
|
-
failWith(1, 'publish-prep: drafts changed after the content-safety verdict — re-run the review');
|
|
2104
|
-
}
|
|
2105
|
-
const excludedPlatforms = Array.isArray(stamp.excludedPlatforms) ? (stamp.excludedPlatforms as string[]) : [];
|
|
2106
|
-
if (excludedPlatforms.length > 0) {
|
|
2107
|
-
console.log(`publish-prep: content-safety excluded platforms: ${excludedPlatforms.join(', ')}`);
|
|
2108
|
-
}
|
|
2109
|
-
sted('write', prepPath, () =>
|
|
2110
|
-
writeJsonText(prepPath, {
|
|
2111
|
-
...merged,
|
|
2112
|
-
metadata: { ...metadataOf(merged), safetyExcludedPlatforms: excludedPlatforms },
|
|
2113
|
-
}),
|
|
2114
|
-
);
|
|
2115
|
-
});
|
|
2116
|
-
}
|
|
2117
|
-
|
|
2118
|
-
function dailyPublishSurfdash(args: string[]): void {
|
|
2119
|
-
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
2120
|
-
const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
|
|
2121
|
-
const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
|
|
2122
|
-
timed(workDir, 'publish-surfdash', () => {
|
|
2123
|
-
if (targetPublished(result, 'surfdash-pub')) {
|
|
2124
|
-
console.log(`publish-surfdash cached: surfdash target ok in ${result}`);
|
|
2125
|
-
return;
|
|
2126
|
-
}
|
|
2127
|
-
kk(
|
|
2128
|
-
[
|
|
2129
|
-
'executor',
|
|
2130
|
-
'fan-out',
|
|
2131
|
-
'--content',
|
|
2132
|
-
prep,
|
|
2133
|
-
'--target',
|
|
2134
|
-
'surfdash-pub',
|
|
2135
|
-
'--out',
|
|
2136
|
-
result,
|
|
2137
|
-
...pluginsArgs(pluginsPath),
|
|
2138
|
-
],
|
|
2139
|
-
{
|
|
2140
|
-
unset: NESTED_SPUR_UNSET,
|
|
2141
|
-
soft: true,
|
|
2142
|
-
},
|
|
2143
|
-
);
|
|
2144
|
-
console.log('publish-surfdash: surfdash target dispatched; classification happens after podcast publish');
|
|
2145
|
-
});
|
|
2146
|
-
}
|
|
2147
|
-
|
|
2148
|
-
function dailyShowNotes(args: string[]): void {
|
|
2149
|
-
const [workDir = '', runDate = '', pluginsPath = '', title = ''] = args;
|
|
2150
|
-
const article = join(workDir, '22-article', `${runDate}_06_article_content.json`);
|
|
2151
|
-
// The preceding `publish-surfdash` state writes this artifact; `_17_publish_result.json` does
|
|
2152
|
-
// not exist yet at `show-notes` time (the later `publish` state merges it), so reading it left
|
|
2153
|
-
// PODCAST_PUB_ARTICLE_POST_PATH permanently empty (task 0137).
|
|
2154
|
-
const result = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
|
|
2155
|
-
const out = join(workDir, '31-publish', `${runDate}_15_show-notes_show-notes.md`);
|
|
2156
|
-
const plan = join(workDir, '21-plan', `${runDate}_03_plan_plan.json`);
|
|
2157
|
-
// Generated-episode content carries `metadata.segments[]` — the source of real
|
|
2158
|
-
// audio offsets for the show-notes timeline (jump-to-item timestamps).
|
|
2159
|
-
const audioContent = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
|
|
2160
|
-
timed(workDir, 'show-notes', () => {
|
|
2161
|
-
const effective = readJson(article)?.title;
|
|
2162
|
-
const titleArg = title !== '' ? title : typeof effective === 'string' ? effective : '';
|
|
2163
|
-
// Boundary-excluded docs must not render into show notes (judge finding 2026-10-02): the
|
|
2164
|
-
// article body's 未写入 note names the dropped ids; filter the plan copy podcast-pub sees.
|
|
2165
|
-
// Inlined parse — this file must not import @gobing-ai/* (header contract); the same note
|
|
2166
|
-
// grammar lives in daily-article-gen's excludedIdsFromNote.
|
|
2167
|
-
const postPath = firstPostPath(readJson(result), 'surfdash-pub');
|
|
2168
|
-
const articleBody = readJson(article)?.body;
|
|
2169
|
-
const excluded = new Set<string>();
|
|
2170
|
-
if (typeof articleBody === 'string') {
|
|
2171
|
-
for (const note of articleBody.matchAll(/未写入[::](.*)/g)) {
|
|
2172
|
-
for (const id of note[1].matchAll(/(?:[a-zA-Z0-9_-]+:)?[0-9a-f]{16}/g)) {
|
|
2173
|
-
excluded.add(id[0]);
|
|
2174
|
-
const colon = id[0].indexOf(':');
|
|
2175
|
-
if (colon !== -1) excluded.add(id[0].slice(colon + 1));
|
|
2176
|
-
}
|
|
2177
|
-
}
|
|
2178
|
-
}
|
|
2179
|
-
let planIn = plan;
|
|
2180
|
-
if (excluded.size > 0) {
|
|
2181
|
-
const parsed: unknown = readJson(plan);
|
|
2182
|
-
if (Array.isArray(parsed)) {
|
|
2183
|
-
const kept = parsed.filter(
|
|
2184
|
-
(d) =>
|
|
2185
|
-
!(
|
|
2186
|
-
d !== null &&
|
|
2187
|
-
typeof d === 'object' &&
|
|
2188
|
-
(excluded.has(String((d as Record<string, unknown>).id)) ||
|
|
2189
|
-
(String((d as Record<string, unknown>).id).includes(':') &&
|
|
2190
|
-
excluded.has(
|
|
2191
|
-
String((d as Record<string, unknown>).id).slice(
|
|
2192
|
-
String((d as Record<string, unknown>).id).indexOf(':') + 1,
|
|
2193
|
-
),
|
|
2194
|
-
)))
|
|
2195
|
-
),
|
|
2196
|
-
);
|
|
2197
|
-
if (kept.length !== parsed.length) {
|
|
2198
|
-
planIn = plan.replace(/\.json$/, '-shownotes.json');
|
|
2199
|
-
writeJsonText(planIn, kept);
|
|
2200
|
-
}
|
|
2201
|
-
}
|
|
2202
|
-
}
|
|
2203
|
-
kk(['executor', 'run', 'podcast-pub', '--in', planIn, '--out', out, ...pluginsArgs(pluginsPath)], {
|
|
2204
|
-
env: {
|
|
2205
|
-
PODCAST_PUB_TITLE: titleArg,
|
|
2206
|
-
PODCAST_PUB_RUN_DATE: runDate,
|
|
2207
|
-
PODCAST_PUB_BRIEF_MODE: 'true',
|
|
2208
|
-
PODCAST_PUB_ARTICLE_POST_PATH: postPath,
|
|
2209
|
-
// Task 0189 R1 / Apple §1.11: AI-voice disclosure in the episode metadata, not only
|
|
2210
|
-
// in the audio sign-off and the article footer.
|
|
2211
|
-
PODCAST_PUB_FOOTER_NOTE: '本期文字由 AI 辅助整理,音频为 AI 合成配音。',
|
|
2212
|
-
...(isFile(audioContent) ? { PODCAST_PUB_AUDIO_CONTENT: audioContent } : {}),
|
|
2213
|
-
},
|
|
2214
|
-
});
|
|
2215
|
-
});
|
|
2216
|
-
}
|
|
2217
|
-
|
|
2218
|
-
/** `jq -r '[…select(.target == t and .ok == true) | .metadata.postPath // empty] | first // ""'`. */
|
|
2219
|
-
function firstPostPath(parsed: Record<string, unknown> | null, target: string): string {
|
|
1137
|
+
export function firstPostPath(parsed: Record<string, unknown> | null, target: string): string {
|
|
2220
1138
|
const targets = parsed?.targets;
|
|
2221
1139
|
if (!Array.isArray(targets)) return '';
|
|
2222
1140
|
for (const entry of targets) {
|
|
@@ -2229,48 +1147,6 @@ function firstPostPath(parsed: Record<string, unknown> | null, target: string):
|
|
|
2229
1147
|
return '';
|
|
2230
1148
|
}
|
|
2231
1149
|
|
|
2232
|
-
function dailyPublishPodcast(args: string[]): void {
|
|
2233
|
-
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
2234
|
-
const prep = join(workDir, '31-publish', `${runDate}_13_publish-prep_content.json`);
|
|
2235
|
-
const notes = join(workDir, '31-publish', `${runDate}_15_show-notes_show-notes.md`);
|
|
2236
|
-
const result = join(workDir, '31-publish', `${runDate}_16_publish-podcast_result.json`);
|
|
2237
|
-
timed(workDir, 'publish-podcast', () => {
|
|
2238
|
-
if (targetPublished(result, 'podcast-pub')) {
|
|
2239
|
-
console.log(`publish-podcast cached: podcast target ok in ${result}`);
|
|
2240
|
-
return;
|
|
2241
|
-
}
|
|
2242
|
-
try {
|
|
2243
|
-
const content = requireJson(prep, 'publish-podcast');
|
|
2244
|
-
const showNotes = readFileSync(notes, 'utf-8');
|
|
2245
|
-
const merged = { ...content, metadata: { ...metadataOf(content), showNotes } };
|
|
2246
|
-
sted('write', prep, () => writeJsonText(prep, merged));
|
|
2247
|
-
kk(
|
|
2248
|
-
[
|
|
2249
|
-
'executor',
|
|
2250
|
-
'fan-out',
|
|
2251
|
-
'--content',
|
|
2252
|
-
prep,
|
|
2253
|
-
'--target',
|
|
2254
|
-
'podcast-pub',
|
|
2255
|
-
'--out',
|
|
2256
|
-
result,
|
|
2257
|
-
...pluginsArgs(pluginsPath),
|
|
2258
|
-
],
|
|
2259
|
-
{
|
|
2260
|
-
unset: ['SPUR_WORKFLOW_RUN_ACTIVE'],
|
|
2261
|
-
soft: true,
|
|
2262
|
-
},
|
|
2263
|
-
);
|
|
2264
|
-
} catch (error) {
|
|
2265
|
-
// The shell's `… && … || true` swallowed both steps' failures — but an unusable `kk`
|
|
2266
|
-
// (exit 127) is an environment failure, not a tolerated plugin failure.
|
|
2267
|
-
if (error instanceof StageFailure && error.status === 127) throw error;
|
|
2268
|
-
console.error((error as Error).message);
|
|
2269
|
-
}
|
|
2270
|
-
console.log('publish-podcast: podcast target dispatched');
|
|
2271
|
-
});
|
|
2272
|
-
}
|
|
2273
|
-
|
|
2274
1150
|
function dailyPublishClassify(args: string[]): void {
|
|
2275
1151
|
const [workDir = '', runDate = ''] = args;
|
|
2276
1152
|
const result = join(workDir, '31-publish', `${runDate}_17_publish_result.json`);
|
|
@@ -2289,11 +1165,27 @@ function dailyPublishClassify(args: string[]): void {
|
|
|
2289
1165
|
const status = okCount > 0 && okCount === total ? 'full' : okCount > 0 ? 'partial' : 'none';
|
|
2290
1166
|
sted('write', result, () => writeJqText(result, { ok: okCount === total && total > 0, targets }));
|
|
2291
1167
|
sted('write', statusFile, () => writeRaw(statusFile, status));
|
|
1168
|
+
// The ledger's Result cell for the legacy `publish` node (task 0212 R1): same target-set
|
|
1169
|
+
// shape the voice2 classify writes to publish-classify.summary.txt, so cutover-check can
|
|
1170
|
+
// compare baseline and candidate ledgers during the cutover window.
|
|
1171
|
+
const summaryFile = join(workDir, 'publish.summary.txt');
|
|
1172
|
+
sted('write', summaryFile, () =>
|
|
1173
|
+
writeRaw(summaryFile, `${JSON.stringify({ status, published: okCount, targets: total })}\n`),
|
|
1174
|
+
);
|
|
1175
|
+
// 0220: completeness moved to terminal daily-publish-completeness stage
|
|
1176
|
+
console.error(`publish: status=${status} (${okCount}/${total} targets published); routing is transition-owned`);
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function dailyPublishCompleteness(args: string[]): void {
|
|
1181
|
+
const [workDir = '', runDate = ''] = args;
|
|
1182
|
+
timed(workDir, 'publish-completeness', () => {
|
|
2292
1183
|
const completeness = publishCompleteness(workDir, runDate);
|
|
2293
1184
|
const completenessFile = join(workDir, '31-publish', `${runDate}_17_publish_completeness.json`);
|
|
2294
1185
|
sted('write', completenessFile, () => writeJqText(completenessFile, { runDate, ...completeness }));
|
|
2295
|
-
console.
|
|
2296
|
-
|
|
1186
|
+
console.error(
|
|
1187
|
+
`publish-completeness: ${completeness.rows.map((row) => `${row.channel}=${row.state}`).join(' ')}`,
|
|
1188
|
+
);
|
|
2297
1189
|
if (completeness.missing.length > 0) {
|
|
2298
1190
|
const details = completeness.rows
|
|
2299
1191
|
.filter((row) => row.state !== 'done')
|
|
@@ -2329,14 +1221,38 @@ function publishCompleteness(
|
|
|
2329
1221
|
const at = (name: string): string => join(workDir, '31-publish', `${runDate}_${name}`);
|
|
2330
1222
|
const markerFilled = (path: string): boolean => isFile(path) && readFileSync(path, 'utf-8').trim() !== '';
|
|
2331
1223
|
const rows: { channel: string; state: string; detail: string }[] = [];
|
|
2332
|
-
const
|
|
1224
|
+
const localesScaffolded = ['en', 'ja'].every((locale) =>
|
|
2333
1225
|
markerFilled(at(`20_translate-localize_${locale}-post-path.txt`)),
|
|
2334
1226
|
);
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
1227
|
+
if (!localesScaffolded) {
|
|
1228
|
+
rows.push({
|
|
1229
|
+
channel: 'locales',
|
|
1230
|
+
state: 'not-run',
|
|
1231
|
+
detail: 'translate markers absent (en/ja never scaffolded)',
|
|
1232
|
+
});
|
|
1233
|
+
} else {
|
|
1234
|
+
const syncResultFile = at('24_translate-sync_sync-result.json');
|
|
1235
|
+
const syncResult = isFile(syncResultFile) ? readJson(syncResultFile) : null;
|
|
1236
|
+
if (syncResult === null) {
|
|
1237
|
+
rows.push({
|
|
1238
|
+
channel: 'locales',
|
|
1239
|
+
state: 'not-run',
|
|
1240
|
+
detail: 'en/ja scaffolded, never synced',
|
|
1241
|
+
});
|
|
1242
|
+
} else if (syncResult.ok === true) {
|
|
1243
|
+
rows.push({
|
|
1244
|
+
channel: 'locales',
|
|
1245
|
+
state: 'done',
|
|
1246
|
+
detail: 'en/ja drafts scaffolded',
|
|
1247
|
+
});
|
|
1248
|
+
} else {
|
|
1249
|
+
rows.push({
|
|
1250
|
+
channel: 'locales',
|
|
1251
|
+
state: 'failed',
|
|
1252
|
+
detail: String(syncResult.error ?? 'ok=false'),
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
2340
1256
|
const resultChannels: [string, string, string][] = [
|
|
2341
1257
|
['xhs', '19_publish-xhs_result.json', ''],
|
|
2342
1258
|
['wechat', '20_publish-wechat_result.json', ''],
|
|
@@ -2397,7 +1313,7 @@ function dailyPublishPartial(args: string[]): void {
|
|
|
2397
1313
|
function dailyPatchSurfdash(args: string[]): void {
|
|
2398
1314
|
const [workDir = '', runDate = '', surfdashRoot = '', pluginsPath = ''] = args;
|
|
2399
1315
|
if (!surfdashRoot || !existsSync(surfdashRoot)) {
|
|
2400
|
-
console.
|
|
1316
|
+
console.error(`patch-surfdash: surfdash root '${surfdashRoot}' not found — skip`);
|
|
2401
1317
|
return;
|
|
2402
1318
|
}
|
|
2403
1319
|
timed(workDir, 'patch-surfdash', () => {
|
|
@@ -2434,9 +1350,9 @@ function dailyPatchSurfdash(args: string[]): void {
|
|
|
2434
1350
|
const crossLink: Record<string, string> = {
|
|
2435
1351
|
// Task 0189 R4: cross-links carry the column's episode-title pattern, not the retired
|
|
2436
1352
|
// news-bulletin name (the VoiceScript episode title is episodeTitle(lead, date)).
|
|
2437
|
-
zh: `🎧 本期已同步制作为播客节目,[
|
|
2438
|
-
en: `🎧 This episode is also available as a podcast: [listen to AI
|
|
2439
|
-
ja: `🎧 本エピソードはポッドキャストでも配信中:[AI
|
|
1353
|
+
zh: `🎧 本期已同步制作为播客节目,[点击收听《每日AI乱炖 · ${dateIso}》](${showNotesUrl})。`,
|
|
1354
|
+
en: `🎧 This episode is also available as a podcast: [listen to 每日AI乱炖 · ${dateIso}](${showNotesUrl}).`,
|
|
1355
|
+
ja: `🎧 本エピソードはポッドキャストでも配信中:[每日AI乱炖 · ${dateIso} を聴く](${showNotesUrl})。`,
|
|
2440
1356
|
};
|
|
2441
1357
|
let changed = 0;
|
|
2442
1358
|
for (const [locale, dir] of [
|
|
@@ -2449,7 +1365,7 @@ function dailyPatchSurfdash(args: string[]): void {
|
|
|
2449
1365
|
] as const) {
|
|
2450
1366
|
const indexMd = join(dir, 'index.md');
|
|
2451
1367
|
if (!existsSync(indexMd)) {
|
|
2452
|
-
console.
|
|
1368
|
+
console.error(`patch-surfdash: ${locale} post not published (${indexMd}) — skip`);
|
|
2453
1369
|
continue;
|
|
2454
1370
|
}
|
|
2455
1371
|
let md = readFileSync(indexMd, 'utf-8');
|
|
@@ -2512,7 +1428,7 @@ function dailyPatchSurfdash(args: string[]): void {
|
|
|
2512
1428
|
// cover this way). Leave a marker for the classify step's completeness report.
|
|
2513
1429
|
const skipMarker = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_emdash-resync.txt`);
|
|
2514
1430
|
sted('write', skipMarker, () => writeRaw(skipMarker, 'skipped: no plugins-path\n'));
|
|
2515
|
-
console.
|
|
1431
|
+
console.error('patch-surfdash: no plugins-path — skip emdash re-sync');
|
|
2516
1432
|
} else {
|
|
2517
1433
|
const syncIn = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync.json`);
|
|
2518
1434
|
const syncResult = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync-result.json`);
|
|
@@ -2529,15 +1445,15 @@ function dailyPatchSurfdash(args: string[]): void {
|
|
|
2529
1445
|
result !== null &&
|
|
2530
1446
|
(result.ok === true || (result.targets as Array<{ ok?: boolean }> | undefined)?.[0]?.ok === true);
|
|
2531
1447
|
if (!ok) {
|
|
2532
|
-
console.
|
|
1448
|
+
console.error(
|
|
2533
1449
|
`patch-surfdash: WARNING: emdash re-sync failed (exit ${status}) — CMS cards keep the stale cover; fix: kk executor run surfdash-pub --in ${syncIn} --out ${syncResult} --plugins-path ${pluginsPath}`,
|
|
2534
1450
|
);
|
|
2535
1451
|
} else {
|
|
2536
|
-
console.
|
|
1452
|
+
console.error('patch-surfdash: emdash rows re-synced from patched files');
|
|
2537
1453
|
}
|
|
2538
1454
|
}
|
|
2539
1455
|
if (changed === 0) {
|
|
2540
|
-
console.
|
|
1456
|
+
console.error('patch-surfdash: nothing to change');
|
|
2541
1457
|
return;
|
|
2542
1458
|
}
|
|
2543
1459
|
const git = (...a: string[]): void => {
|
|
@@ -2553,7 +1469,7 @@ function dailyPatchSurfdash(args: string[]): void {
|
|
|
2553
1469
|
...written,
|
|
2554
1470
|
);
|
|
2555
1471
|
if (process.env.SURFDASH_AUTO_PUSH !== '0') git('push', 'origin', 'main');
|
|
2556
|
-
console.
|
|
1472
|
+
console.error(
|
|
2557
1473
|
`patch-surfdash: patched ${changed} post(s), committed${process.env.SURFDASH_AUTO_PUSH !== '0' ? ' + pushed' : ' (push skipped: SURFDASH_AUTO_PUSH=0)'}`,
|
|
2558
1474
|
);
|
|
2559
1475
|
});
|
|
@@ -2575,11 +1491,11 @@ function dailyRunReport(args: string[]): void {
|
|
|
2575
1491
|
const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
|
|
2576
1492
|
timed(workDir, 'run-report', () => {
|
|
2577
1493
|
if (isFile(reportMd)) {
|
|
2578
|
-
console.
|
|
1494
|
+
console.error(`run report cached: ${reportMd}`);
|
|
2579
1495
|
return;
|
|
2580
1496
|
}
|
|
2581
1497
|
if (!isFile(candidates)) {
|
|
2582
|
-
console.
|
|
1498
|
+
console.error('run-report: no candidates.json (run ended below plan step) — skip');
|
|
2583
1499
|
return;
|
|
2584
1500
|
}
|
|
2585
1501
|
sted('write', cands, () => writeJqText(cands, metadataOf(readJson(candidates)).docs ?? []));
|
|
@@ -2614,92 +1530,7 @@ function dailyRunReport(args: string[]): void {
|
|
|
2614
1530
|
const body = readJson(reportJson)?.body;
|
|
2615
1531
|
if (typeof body !== 'string') failWith(1, `run-report: ${reportJson} has no string body`);
|
|
2616
1532
|
sted('write', reportMd, () => writeRaw(reportMd, body));
|
|
2617
|
-
console.
|
|
2618
|
-
});
|
|
2619
|
-
}
|
|
2620
|
-
|
|
2621
|
-
/**
|
|
2622
|
-
* Collapsed translate scaffolding (2026-09-16 feedback): surfdash-pub owns the
|
|
2623
|
-
* deterministic mechanics — `scaffold-locale` for both target locales — while the
|
|
2624
|
-
* workflow `translate` state's agent step owns the LLM translation of the drafts.
|
|
2625
|
-
* Writes one marker file per locale (zh post path + en/ja draft paths); empty
|
|
2626
|
-
* marker = locale absent (callers skip). Reuses an existing on-disk draft as the
|
|
2627
|
-
* cache across run retries — re-scaffolding would erase the agent's translation.
|
|
2628
|
-
*/
|
|
2629
|
-
function dailyTranslateLocalize(args: string[]): void {
|
|
2630
|
-
const [workDir = '', runDate = '', pluginsPath = ''] = args;
|
|
2631
|
-
const result = join(workDir, '31-publish', `${runDate}_17_publish_result.json`);
|
|
2632
|
-
const zhFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_zh-post-path.txt`);
|
|
2633
|
-
timed(workDir, 'translate-localize', () => {
|
|
2634
|
-
const postPath = firstPostPath(readJson(result), 'surfdash-pub');
|
|
2635
|
-
const zh = postPath.endsWith('index.md') ? postPath : '';
|
|
2636
|
-
sted('write', zhFile, () => writeRaw(zhFile, zh));
|
|
2637
|
-
if (zh === '') {
|
|
2638
|
-
for (const target of ['en', 'ja'] as const) {
|
|
2639
|
-
sted(
|
|
2640
|
-
'write',
|
|
2641
|
-
join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`),
|
|
2642
|
-
() =>
|
|
2643
|
-
writeRaw(
|
|
2644
|
-
join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`),
|
|
2645
|
-
'',
|
|
2646
|
-
),
|
|
2647
|
-
);
|
|
2648
|
-
}
|
|
2649
|
-
console.log('no zh post path — skip localize');
|
|
2650
|
-
return;
|
|
2651
|
-
}
|
|
2652
|
-
for (const target of ['en', 'ja'] as const) {
|
|
2653
|
-
const markerFile = join(workDir, '31-publish', `${runDate}_20_translate-localize_${target}-post-path.txt`);
|
|
2654
|
-
const existing = zh.replace('/zh/', `/${target}/`);
|
|
2655
|
-
if (isFile(existing)) {
|
|
2656
|
-
sted('write', markerFile, () => writeRaw(markerFile, existing));
|
|
2657
|
-
console.log(`${target} draft cached: ${existing}`);
|
|
2658
|
-
continue;
|
|
2659
|
-
}
|
|
2660
|
-
const scaffold = join(workDir, '31-publish', `${runDate}_20_translate-localize_scaffold-${target}.json`);
|
|
2661
|
-
const scaffoldResult = join(
|
|
2662
|
-
workDir,
|
|
2663
|
-
'31-publish',
|
|
2664
|
-
`${runDate}_20_translate-localize_scaffold-${target}-result.json`,
|
|
2665
|
-
);
|
|
2666
|
-
sted('write', scaffold, () =>
|
|
2667
|
-
writeJqText(scaffold, { body: '', options: { operation: 'scaffold-locale', postPath: zh, target } }),
|
|
2668
|
-
);
|
|
2669
|
-
kk([
|
|
2670
|
-
'executor',
|
|
2671
|
-
'run',
|
|
2672
|
-
'surfdash-pub',
|
|
2673
|
-
'--in',
|
|
2674
|
-
scaffold,
|
|
2675
|
-
'--out',
|
|
2676
|
-
scaffoldResult,
|
|
2677
|
-
...pluginsArgs(pluginsPath),
|
|
2678
|
-
]);
|
|
2679
|
-
const draftPath = metadataOf(readJson(scaffoldResult)).draftPath;
|
|
2680
|
-
sted('write', markerFile, () => writeRaw(markerFile, typeof draftPath === 'string' ? draftPath : ''));
|
|
2681
|
-
}
|
|
2682
|
-
});
|
|
2683
|
-
}
|
|
2684
|
-
|
|
2685
|
-
function dailyTranslateSync(args: string[]): void {
|
|
2686
|
-
const [workDir = '', runDate = '', pluginsPath = '', zhPathFile = ''] = args;
|
|
2687
|
-
const sync = join(workDir, '31-publish', `${runDate}_24_translate-sync_sync.json`);
|
|
2688
|
-
const syncResult = join(workDir, '31-publish', `${runDate}_24_translate-sync_sync-result.json`);
|
|
2689
|
-
timed(workDir, 'translate-sync', () => {
|
|
2690
|
-
// Reads the marker file itself — the collapsed `translate` state has no
|
|
2691
|
-
// cross-state var to hand the path through.
|
|
2692
|
-
const zhPostPath = isFile(zhPathFile) ? readFileSync(zhPathFile, 'utf-8') : '';
|
|
2693
|
-
if (zhPostPath === '') {
|
|
2694
|
-
console.log('no zh post path — skip translate-sync');
|
|
2695
|
-
return;
|
|
2696
|
-
}
|
|
2697
|
-
sted('write', sync, () =>
|
|
2698
|
-
writeJqText(sync, { body: '', options: { operation: 'sync', postPath: zhPostPath } }),
|
|
2699
|
-
);
|
|
2700
|
-
kk(['executor', 'run', 'surfdash-pub', '--in', sync, '--out', syncResult, ...pluginsArgs(pluginsPath)], {
|
|
2701
|
-
unset: NESTED_SPUR_UNSET,
|
|
2702
|
-
});
|
|
1533
|
+
console.error(`run report written: ${reportMd}`);
|
|
2703
1534
|
});
|
|
2704
1535
|
}
|
|
2705
1536
|
|
|
@@ -2765,75 +1596,125 @@ if (import.meta.main)
|
|
|
2765
1596
|
case 'daily-collect-facts':
|
|
2766
1597
|
dailyCollectFacts(rest);
|
|
2767
1598
|
break;
|
|
2768
|
-
case '
|
|
1599
|
+
case 'collect-prepare':
|
|
1600
|
+
collectPrepare(rest);
|
|
1601
|
+
break;
|
|
1602
|
+
case 'daily-plan': {
|
|
1603
|
+
const { dailyPlan } = await loadDailyStages();
|
|
2769
1604
|
dailyPlan(rest);
|
|
2770
1605
|
break;
|
|
2771
|
-
|
|
1606
|
+
}
|
|
1607
|
+
case 'daily-qc-content': {
|
|
1608
|
+
const { dailyQcContent } = await loadDailyStages();
|
|
2772
1609
|
dailyQcContent(rest);
|
|
2773
1610
|
break;
|
|
2774
|
-
|
|
1611
|
+
}
|
|
1612
|
+
case 'daily-article': {
|
|
1613
|
+
const { dailyArticle } = await loadDailyStages();
|
|
2775
1614
|
dailyArticle(rest);
|
|
2776
1615
|
break;
|
|
2777
|
-
|
|
1616
|
+
}
|
|
1617
|
+
case 'daily-cover': {
|
|
1618
|
+
const { dailyCover } = await loadDailyStages();
|
|
2778
1619
|
dailyCover(rest);
|
|
2779
1620
|
break;
|
|
2780
|
-
|
|
1621
|
+
}
|
|
1622
|
+
case 'daily-cover-normalize': {
|
|
1623
|
+
const { dailyCoverNormalize } = await loadDailyStages();
|
|
2781
1624
|
dailyCoverNormalize(rest);
|
|
2782
1625
|
break;
|
|
2783
|
-
|
|
1626
|
+
}
|
|
1627
|
+
case 'daily-xhs-render': {
|
|
1628
|
+
const { dailyXhsRender } = await loadDailyStages();
|
|
2784
1629
|
dailyXhsRender(rest);
|
|
2785
1630
|
break;
|
|
2786
|
-
|
|
1631
|
+
}
|
|
1632
|
+
case 'daily-script-style': {
|
|
1633
|
+
const { dailyScriptStyle } = await loadDailyStages();
|
|
2787
1634
|
dailyScriptStyle(rest);
|
|
2788
1635
|
break;
|
|
2789
|
-
|
|
1636
|
+
}
|
|
1637
|
+
case 'daily-script': {
|
|
1638
|
+
const { dailyScript } = await loadDailyStages();
|
|
2790
1639
|
dailyScript(rest);
|
|
2791
1640
|
break;
|
|
2792
|
-
|
|
1641
|
+
}
|
|
1642
|
+
case 'daily-safety-prep': {
|
|
1643
|
+
const { dailySafetyPrep } = await loadDailyStages();
|
|
2793
1644
|
dailySafetyPrep(rest);
|
|
2794
1645
|
break;
|
|
2795
|
-
|
|
1646
|
+
}
|
|
1647
|
+
case 'daily-safety-gate': {
|
|
1648
|
+
const { dailySafetyGate } = await loadDailyStages();
|
|
2796
1649
|
dailySafetyGate(rest);
|
|
2797
1650
|
break;
|
|
2798
|
-
|
|
1651
|
+
}
|
|
1652
|
+
case 'daily-safety-revise': {
|
|
1653
|
+
const { dailySafetyRevise } = await loadDailyStages();
|
|
2799
1654
|
dailySafetyRevise(rest);
|
|
2800
1655
|
break;
|
|
2801
|
-
|
|
1656
|
+
}
|
|
1657
|
+
case 'daily-wrap-docs': {
|
|
1658
|
+
const { dailyWrapDocs } = await loadDailyStages();
|
|
2802
1659
|
dailyWrapDocs(rest);
|
|
2803
1660
|
break;
|
|
2804
|
-
|
|
1661
|
+
}
|
|
1662
|
+
case 'daily-generate': {
|
|
1663
|
+
const { dailyGenerate } = await loadDailyStages();
|
|
2805
1664
|
dailyGenerate(rest);
|
|
2806
1665
|
break;
|
|
2807
|
-
|
|
1666
|
+
}
|
|
1667
|
+
case 'daily-intro': {
|
|
1668
|
+
const { dailyIntro } = await loadDailyStages();
|
|
2808
1669
|
dailyIntro(rest);
|
|
2809
1670
|
break;
|
|
2810
|
-
|
|
1671
|
+
}
|
|
1672
|
+
case 'daily-episode-duration': {
|
|
1673
|
+
const { dailyEpisodeDuration } = await loadDailyStages();
|
|
2811
1674
|
dailyEpisodeDuration(rest);
|
|
2812
1675
|
break;
|
|
2813
|
-
|
|
1676
|
+
}
|
|
1677
|
+
case 'daily-quality-report': {
|
|
1678
|
+
const { dailyQualityReport } = await loadDailyStages();
|
|
2814
1679
|
dailyQualityReport(rest);
|
|
2815
1680
|
break;
|
|
2816
|
-
|
|
1681
|
+
}
|
|
1682
|
+
case 'daily-publish-prep': {
|
|
1683
|
+
const { dailyPublishPrep } = await loadDailyStages();
|
|
2817
1684
|
dailyPublishPrep(rest);
|
|
2818
1685
|
break;
|
|
2819
|
-
|
|
1686
|
+
}
|
|
1687
|
+
case 'daily-publish-surfdash': {
|
|
1688
|
+
const { dailyPublishSurfdash } = await loadDailyStages();
|
|
2820
1689
|
dailyPublishSurfdash(rest);
|
|
2821
1690
|
break;
|
|
2822
|
-
|
|
1691
|
+
}
|
|
1692
|
+
case 'daily-show-notes': {
|
|
1693
|
+
const { dailyShowNotes } = await loadDailyStages();
|
|
2823
1694
|
dailyShowNotes(rest);
|
|
2824
1695
|
break;
|
|
2825
|
-
|
|
1696
|
+
}
|
|
1697
|
+
case 'daily-publish-podcast': {
|
|
1698
|
+
const { dailyPublishPodcast } = await loadDailyStages();
|
|
2826
1699
|
dailyPublishPodcast(rest);
|
|
2827
1700
|
break;
|
|
2828
|
-
|
|
1701
|
+
}
|
|
1702
|
+
case 'daily-publish-xhs': {
|
|
1703
|
+
const { dailyPublishXhs } = await loadDailyStages();
|
|
2829
1704
|
dailyPublishXhs(rest);
|
|
2830
1705
|
break;
|
|
2831
|
-
|
|
1706
|
+
}
|
|
1707
|
+
case 'daily-publish-wechat': {
|
|
1708
|
+
const { dailyPublishWechat } = await loadDailyStages();
|
|
2832
1709
|
dailyPublishWechat(rest);
|
|
2833
1710
|
break;
|
|
1711
|
+
}
|
|
2834
1712
|
case 'daily-publish-classify':
|
|
2835
1713
|
dailyPublishClassify(rest);
|
|
2836
1714
|
break;
|
|
1715
|
+
case 'daily-publish-completeness':
|
|
1716
|
+
dailyPublishCompleteness(rest);
|
|
1717
|
+
break;
|
|
2837
1718
|
case 'daily-patch-surfdash':
|
|
2838
1719
|
dailyPatchSurfdash(rest);
|
|
2839
1720
|
break;
|
|
@@ -2843,12 +1724,16 @@ if (import.meta.main)
|
|
|
2843
1724
|
case 'daily-run-report':
|
|
2844
1725
|
dailyRunReport(rest);
|
|
2845
1726
|
break;
|
|
2846
|
-
case 'daily-translate-localize':
|
|
1727
|
+
case 'daily-translate-localize': {
|
|
1728
|
+
const { dailyTranslateLocalize } = await loadDailyStages();
|
|
2847
1729
|
dailyTranslateLocalize(rest);
|
|
2848
1730
|
break;
|
|
2849
|
-
|
|
1731
|
+
}
|
|
1732
|
+
case 'daily-translate-sync': {
|
|
1733
|
+
const { dailyTranslateSync } = await loadDailyStages();
|
|
2850
1734
|
dailyTranslateSync(rest);
|
|
2851
1735
|
break;
|
|
1736
|
+
}
|
|
2852
1737
|
case 'itc-outline-pick': {
|
|
2853
1738
|
const { itcOutlinePick } = await loadItcStages();
|
|
2854
1739
|
itcOutlinePick(rest);
|