@gobing-ai/knowledge-kit 0.0.28 → 0.0.30
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 +114 -38
- package/package.json +1 -1
- package/plugins/generations/daily-article-gen/dist/index.js +45 -5
- package/plugins/generations/daily-article-gen/src/index.ts +59 -4
- package/plugins/generations/dailynews-gen/dist/index.js +46 -9
- package/plugins/generations/dailynews-gen/src/index.ts +36 -1
- package/plugins/generations/dailynews-gen/src/script-builder.ts +15 -12
- package/plugins/generations/dailynews-gen/src/script-plan.ts +21 -3
- package/plugins/generations/episode-plan-gen/dist/index.js +55 -4
- package/plugins/generations/episode-plan-gen/src/index.ts +80 -2
- package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Bold.otf +0 -0
- package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Regular.otf +0 -0
- package/plugins/generations/image-gen/assets/fonts/OFL.txt +92 -0
- package/plugins/generations/image-gen/assets/fonts/SOURCE.md +15 -0
- package/plugins/generations/image-gen/dist/index.js +700 -22
- package/plugins/generations/image-gen/package.json +1 -0
- package/plugins/generations/image-gen/presets/formats/daily-news-cover.yaml +2 -2
- package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +19 -11
- package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +2 -1
- package/plugins/generations/image-gen/scripts/compose-e2e-artifact.ts +135 -0
- package/plugins/generations/image-gen/src/bytes.ts +116 -0
- package/plugins/generations/image-gen/src/compose/card.ts +294 -0
- package/plugins/generations/image-gen/src/compose/chromium.ts +147 -0
- package/plugins/generations/image-gen/src/compose/cmap.ts +177 -0
- package/plugins/generations/image-gen/src/index.ts +211 -13
- package/plugins/generations/image-gen/src/job.ts +12 -0
- package/plugins/generations/image-gen/src/presets.ts +53 -13
- package/plugins/generations/image-gen/src/providers/minimax.ts +12 -4
- package/plugins/generations/image-gen/src/providers/types.ts +12 -1
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/scripts/daily-voice-style.zh.md +27 -14
- package/plugins/kk/scripts/itc-stages.ts +21 -3
- package/plugins/kk/scripts/kk-workflow-stages.ts +461 -10
- package/plugins/kk/scripts/topic-boundary.json +96 -0
- package/plugins/kk/scripts/validate-voicescript.ts +15 -0
- package/plugins/kk/skills/content-judge/SKILL.md +30 -0
- package/plugins/kk/skills/content-judge/references/workflow-integration.md +9 -2
- package/plugins/kk/skills/content-safety/SKILL.md +163 -0
- package/plugins/kk/skills/content-safety/references/platforms/apple-podcasts.md +36 -0
- package/plugins/kk/skills/content-safety/references/platforms/netease-music.md +31 -0
- package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +32 -0
- package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +35 -0
- package/plugins/kk/skills/publish/SKILL.md +1 -1
- package/plugins/kk/skills/xhs-images/SKILL.md +11 -7
- package/plugins/kk/skills/xhs-images/references/card-layouts.md +4 -4
- package/plugins/kk/workflows/judge-gated-publish-example.yaml +18 -10
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +139 -24
- package/plugins/kk/workflows/kk-itc.yaml +21 -8
- package/plugins/publishings/podcast-pub/dist/index.js +4 -0
- package/plugins/publishings/podcast-pub/src/index.ts +2 -0
- package/plugins/publishings/podcast-pub/src/show-notes.ts +6 -0
- package/plugins/publishings/xhs-pub/dist/index.js +1 -1
- package/plugins/publishings/xhs-pub/src/automation-guard.ts +2 -2
- package/plugins/publishings/xhs-pub/src/index.ts +1 -1
- package/plugins/publishings/xhs-pub/src/profile-check.ts +2 -2
- package/plugins/sp/lib/env.ts +0 -46
- package/plugins/sp/lib/inline-run.generated.mjs +0 -1489
- package/plugins/sp/scripts/batch-preflight.mjs +0 -346
- package/plugins/sp/scripts/batch-preflight.ts +0 -459
- package/plugins/sp/scripts/daily-summary/daily-summary.mjs +0 -615
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +0 -846
- package/plugins/sp/scripts/daily-summary/logger.ts +0 -28
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +0 -223
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +0 -367
- package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +0 -132
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +0 -169
- package/plugins/sp/scripts/feature-dev-precheck.mjs +0 -171
- package/plugins/sp/scripts/feature-dev-precheck.ts +0 -238
- package/plugins/sp/scripts/feature-sync-bounded.mjs +0 -285
- package/plugins/sp/scripts/feature-sync-bounded.ts +0 -478
- package/plugins/sp/scripts/feature-verification-steps.ts +0 -275
- package/plugins/sp/scripts/history-anatomy-cache.mjs +0 -902
- package/plugins/sp/scripts/history-anatomy-cache.ts +0 -1028
- package/plugins/sp/scripts/idea-handoff.mjs +0 -22
- package/plugins/sp/scripts/idea-handoff.ts +0 -44
- package/plugins/sp/scripts/inline-pipeline-parity-check.ts +0 -185
- package/plugins/sp/scripts/inline-run-setup.ts +0 -722
- package/plugins/sp/scripts/pr-reviewing.mjs +0 -769
- package/plugins/sp/scripts/pr-reviewing.ts +0 -925
- package/plugins/sp/scripts/quality-gate.mjs +0 -179
- package/plugins/sp/scripts/quality-gate.ts +0 -217
- package/plugins/sp/scripts/script-contract-check.ts +0 -319
- package/plugins/sp/scripts/stage-registry-adapter.ts +0 -1533
- package/plugins/sp/scripts/surface-drift-inventory.ts +0 -929
- package/plugins/sp/scripts/task-evidence-precheck.ts +0 -181
- package/plugins/sp/scripts/task-size-precheck.ts +0 -175
- package/plugins/sp/scripts/transition-shim-check.ts +0 -238
- package/plugins/sp/scripts/validate-commands.ts +0 -689
- package/plugins/sp/scripts/validate-flag-contracts.ts +0 -878
- package/plugins/sp/scripts/verify-answer-lint.ts +0 -530
- package/plugins/sp/scripts/workflow-step-profile.mjs +0 -316
- package/plugins/sp/scripts/workflow-step-profile.ts +0 -456
- package/plugins/sp/scripts/wrapup-drift-probe.mjs +0 -190
- package/plugins/sp/scripts/wrapup-drift-probe.ts +0 -258
- package/plugins/sp/scripts/wrapup-steps.mjs +0 -444
- package/plugins/sp/scripts/wrapup-steps.ts +0 -552
|
@@ -717,6 +717,119 @@ function dailyPlan(args: string[]): void {
|
|
|
717
717
|
});
|
|
718
718
|
}
|
|
719
719
|
|
|
720
|
+
/**
|
|
721
|
+
* Topic-boundary sidecar (task 0189 R2). Sibling of this file; node-builtins-only read.
|
|
722
|
+
*/
|
|
723
|
+
interface TopicBoundaryRule {
|
|
724
|
+
id: string;
|
|
725
|
+
reason: string;
|
|
726
|
+
keywords: string[];
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
interface TopicBoundarySpec {
|
|
730
|
+
version: number;
|
|
731
|
+
scanChars: number;
|
|
732
|
+
rules: TopicBoundaryRule[];
|
|
733
|
+
dateConflict: { reason: string };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
interface TopicBoundaryExclusion {
|
|
737
|
+
id: string;
|
|
738
|
+
title: string;
|
|
739
|
+
rule: string;
|
|
740
|
+
reason: string;
|
|
741
|
+
matched: string;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function readTopicBoundary(): TopicBoundarySpec {
|
|
745
|
+
const path = join(SCRIPTS_DIR, 'topic-boundary.json');
|
|
746
|
+
let parsed: TopicBoundarySpec;
|
|
747
|
+
try {
|
|
748
|
+
parsed = JSON.parse(readFileSync(path, 'utf-8')) as TopicBoundarySpec;
|
|
749
|
+
} catch (err: unknown) {
|
|
750
|
+
failWith(1, `topic-boundary: spec unreadable at ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
751
|
+
}
|
|
752
|
+
if (!Array.isArray(parsed.rules) || parsed.rules.length === 0) {
|
|
753
|
+
failWith(1, `topic-boundary: spec at ${path} carries no rules`);
|
|
754
|
+
}
|
|
755
|
+
return parsed;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Deterministic topic admission for the AI 乱炖 column (task 0189 R2): an item whose mainline is
|
|
760
|
+
* political/diplomatic-trade, military, public-policy dispute, or a social public event is
|
|
761
|
+
* excluded with its ID, rule and matched keyword recorded — sources stay untouched. A date that
|
|
762
|
+
* contradicts the run (publishedAt after the run date, e.g. an aggregated future-dated draft) is
|
|
763
|
+
* inadmissible as evidence (R3). Matches scan the title plus the first `scanChars` chars of the
|
|
764
|
+
* body — precision first: a body-mention far below the fold does not exclude. Matching is
|
|
765
|
+
* case-insensitive (task 0189 F2: both sides lowercased; zh unaffected) so headline-capitalized
|
|
766
|
+
* "President"/"Pentagon" still hit the lowercase English keywords in the spec.
|
|
767
|
+
*/
|
|
768
|
+
export function applyTopicBoundary(
|
|
769
|
+
docs: unknown[],
|
|
770
|
+
runDate: string,
|
|
771
|
+
spec: TopicBoundarySpec,
|
|
772
|
+
): {
|
|
773
|
+
kept: unknown[];
|
|
774
|
+
excluded: TopicBoundaryExclusion[];
|
|
775
|
+
} {
|
|
776
|
+
const excluded: TopicBoundaryExclusion[] = [];
|
|
777
|
+
const kept: unknown[] = [];
|
|
778
|
+
for (const doc of docs) {
|
|
779
|
+
const record = (doc ?? {}) as Record<string, unknown>;
|
|
780
|
+
const title = typeof record.title === 'string' ? record.title : '';
|
|
781
|
+
const body = typeof record.body === 'string' ? record.body : '';
|
|
782
|
+
const haystack = `${title}\n${body.slice(0, spec.scanChars)}`;
|
|
783
|
+
// Case-insensitive comparison (task 0189 F2): spec keywords are lowercase English; the
|
|
784
|
+
// recorded `matched` stays the spec's keyword verbatim. zh is unaffected by lowercasing.
|
|
785
|
+
const needle = haystack.toLowerCase();
|
|
786
|
+
let exclusion: TopicBoundaryExclusion | null = null;
|
|
787
|
+
for (const rule of spec.rules) {
|
|
788
|
+
// ASCII keywords must start at a word boundary ("selection" is not "election", "patriot"
|
|
789
|
+
// is not "riot"); the tail stays open so plurals/inflections still hit. zh keywords have
|
|
790
|
+
// no word boundaries and keep plain substring matching.
|
|
791
|
+
const matched = rule.keywords.find((keyword) => {
|
|
792
|
+
const lower = keyword.toLowerCase();
|
|
793
|
+
if (lower === '') return false;
|
|
794
|
+
return /^[a-z]/.test(lower)
|
|
795
|
+
? new RegExp(`\\b${lower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`).test(needle)
|
|
796
|
+
: needle.includes(lower);
|
|
797
|
+
});
|
|
798
|
+
if (matched !== undefined) {
|
|
799
|
+
exclusion = { id: String(record.id ?? ''), title, rule: rule.id, reason: rule.reason, matched };
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if (exclusion === null) {
|
|
804
|
+
const publishedAt = metadataOf(record).publishedAt;
|
|
805
|
+
const stamp = typeof publishedAt === 'string' ? publishedAt : '';
|
|
806
|
+
const runIso = /^\d{8}$/.test(runDate)
|
|
807
|
+
? `${runDate.slice(0, 4)}-${runDate.slice(4, 6)}-${runDate.slice(6, 8)}`
|
|
808
|
+
: '';
|
|
809
|
+
if (runIso !== '' && stamp !== '') {
|
|
810
|
+
const published = new Date(stamp);
|
|
811
|
+
if (
|
|
812
|
+
!Number.isNaN(published.getTime()) &&
|
|
813
|
+
// No zone suffix: run_date is a LOCAL day, so its end is local too — a UTC
|
|
814
|
+
// cutoff flags same-evening items west of Greenwich as future-dated.
|
|
815
|
+
published.getTime() > new Date(`${runIso}T23:59:59`).getTime()
|
|
816
|
+
) {
|
|
817
|
+
exclusion = {
|
|
818
|
+
id: String(record.id ?? ''),
|
|
819
|
+
title,
|
|
820
|
+
rule: 'date-conflict',
|
|
821
|
+
reason: spec.dateConflict.reason,
|
|
822
|
+
matched: stamp,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (exclusion !== null) excluded.push(exclusion);
|
|
828
|
+
else kept.push(doc);
|
|
829
|
+
}
|
|
830
|
+
return { kept, excluded };
|
|
831
|
+
}
|
|
832
|
+
|
|
720
833
|
function dailyQcContent(args: string[]): void {
|
|
721
834
|
const [
|
|
722
835
|
workDir = '',
|
|
@@ -727,9 +840,11 @@ function dailyQcContent(args: string[]): void {
|
|
|
727
840
|
minImportance = '',
|
|
728
841
|
minUrgency = '',
|
|
729
842
|
minImpact = '',
|
|
843
|
+
minEpisodeItems = '',
|
|
730
844
|
] = args;
|
|
731
845
|
const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
|
|
732
846
|
const annotated = join(workDir, '21-plan', `${runDate}_04_quality-control-content_annotated.json`);
|
|
847
|
+
const exclusions = join(workDir, '21-plan', `${runDate}_04_quality-control-content_exclusions.json`);
|
|
733
848
|
const planContent = join(workDir, '21-plan', `${runDate}_03_plan_content.json`);
|
|
734
849
|
timed(workDir, 'quality-control-content', () => {
|
|
735
850
|
if (isFile(candidates)) {
|
|
@@ -756,10 +871,37 @@ function dailyQcContent(args: string[]): void {
|
|
|
756
871
|
QC_MIN_IMPORTANCE: minImportance,
|
|
757
872
|
QC_MIN_URGENCY: minUrgency,
|
|
758
873
|
QC_MIN_IMPACT: minImpact,
|
|
874
|
+
QC_MIN_EPISODE_ITEMS: minEpisodeItems,
|
|
759
875
|
},
|
|
760
876
|
},
|
|
761
877
|
);
|
|
762
878
|
});
|
|
879
|
+
// Topic boundary (task 0189 R2) runs on every invocation — re-applying to already-filtered
|
|
880
|
+
// candidates is a no-op, so cached reruns stay idempotent while a crash between the candidates
|
|
881
|
+
// write and the exclusions write still converges on the next run. Zero kept docs leaves the
|
|
882
|
+
// candidates file empty for the workflow's existing done-no-content edge.
|
|
883
|
+
const boundaryDocs = metadataOf(readJson(candidates)).docs;
|
|
884
|
+
if (!Array.isArray(boundaryDocs)) return;
|
|
885
|
+
const { kept, excluded } = applyTopicBoundary(boundaryDocs, runDate, readTopicBoundary());
|
|
886
|
+
if (excluded.length === 0) return;
|
|
887
|
+
sted('write', exclusions, () =>
|
|
888
|
+
writeJsonText(exclusions, {
|
|
889
|
+
version: 1,
|
|
890
|
+
runDate,
|
|
891
|
+
excluded,
|
|
892
|
+
kept: kept.map((doc) => (doc as { id: string }).id),
|
|
893
|
+
}),
|
|
894
|
+
);
|
|
895
|
+
const content = requireJson(candidates, 'topic-boundary');
|
|
896
|
+
const meta = content.metadata;
|
|
897
|
+
if (meta === null || typeof meta !== 'object') {
|
|
898
|
+
failWith(1, 'topic-boundary: candidates content carries no metadata object');
|
|
899
|
+
}
|
|
900
|
+
(meta as Record<string, unknown>).docs = kept;
|
|
901
|
+
sted('write', candidates, () => writeJsonText(candidates, content));
|
|
902
|
+
console.log(
|
|
903
|
+
`topic-boundary: excluded ${excluded.length} item(s): ${excluded.map((entry) => `${entry.id}(${entry.rule})`).join(', ')}`,
|
|
904
|
+
);
|
|
763
905
|
}
|
|
764
906
|
|
|
765
907
|
/**
|
|
@@ -1090,7 +1232,8 @@ function xhsPlan(planPath: string, count: number): { title: string; body: string
|
|
|
1090
1232
|
/**
|
|
1091
1233
|
* E8: build one `image-gen` envelope per planned card, render them sequentially, and package the
|
|
1092
1234
|
* ordered card manifest + `Content` that `xhs-pub` consumes. Format, style, provider and model
|
|
1093
|
-
* are pinned here (never model-authored), so the series cannot drift from its preset.
|
|
1235
|
+
* are pinned here (never model-authored), so the series cannot drift from its preset. Card copy
|
|
1236
|
+
* is typeset by image-gen's composer; the provider prompt stays text-free (0181). Resume-safe:
|
|
1094
1237
|
* a card whose recorded artifact is still on disk is not re-rendered — and not re-billed.
|
|
1095
1238
|
*/
|
|
1096
1239
|
function dailyXhsRender(args: string[]): void {
|
|
@@ -1124,7 +1267,6 @@ function dailyXhsRender(args: string[]): void {
|
|
|
1124
1267
|
headline: card.copy,
|
|
1125
1268
|
points: card.points.join('\n'),
|
|
1126
1269
|
subject: card.subject,
|
|
1127
|
-
language: '简体中文',
|
|
1128
1270
|
index: card.index,
|
|
1129
1271
|
},
|
|
1130
1272
|
maxImages: 1,
|
|
@@ -1136,8 +1278,10 @@ function dailyXhsRender(args: string[]): void {
|
|
|
1136
1278
|
{ id: `xhs-card-${card.index}`, title: runDate, body: '', metadata: { imageGen } },
|
|
1137
1279
|
]),
|
|
1138
1280
|
);
|
|
1139
|
-
// Sequential on purpose: one provider call per card
|
|
1140
|
-
//
|
|
1281
|
+
// Sequential on purpose: one provider call per card (illustration only —
|
|
1282
|
+
// card copy is typeset by image-gen's composer, never prompted), and the
|
|
1283
|
+
// first failure stops the spend — later cards stay unrendered for a resume
|
|
1284
|
+
// run to pick up.
|
|
1141
1285
|
kk(['executor', 'run', 'image-gen', '--in', envPath, '--out', out, ...pluginsArgs(pluginsPath)]);
|
|
1142
1286
|
artifact = firstArtifact(requireJson(out, 'xhs render'));
|
|
1143
1287
|
}
|
|
@@ -1148,6 +1292,15 @@ function dailyXhsRender(args: string[]): void {
|
|
|
1148
1292
|
}
|
|
1149
1293
|
// `image-gen` writes the artifact beside `--out`; the publisher requires absolute paths.
|
|
1150
1294
|
const imagePath = resolve(recorded);
|
|
1295
|
+
// 0181: a composed card may also carry the text-free illustration layer; keep
|
|
1296
|
+
// its recorded path so the manifest shows both layers.
|
|
1297
|
+
const rawIllustration: unknown = artifact.illustration;
|
|
1298
|
+
const illustrationPath =
|
|
1299
|
+
typeof rawIllustration === 'object' &&
|
|
1300
|
+
rawIllustration !== null &&
|
|
1301
|
+
typeof (rawIllustration as Record<string, unknown>).path === 'string'
|
|
1302
|
+
? resolve((rawIllustration as Record<string, unknown>).path as string)
|
|
1303
|
+
: undefined;
|
|
1151
1304
|
sted('write', promptPath, () =>
|
|
1152
1305
|
writeRaw(promptPath, typeof artifact.prompt === 'string' ? artifact.prompt : ''),
|
|
1153
1306
|
);
|
|
@@ -1160,6 +1313,7 @@ function dailyXhsRender(args: string[]): void {
|
|
|
1160
1313
|
layout: card.layout,
|
|
1161
1314
|
promptPath,
|
|
1162
1315
|
imagePath,
|
|
1316
|
+
illustrationPath,
|
|
1163
1317
|
status: 'rendered',
|
|
1164
1318
|
});
|
|
1165
1319
|
}
|
|
@@ -1260,7 +1414,7 @@ function dailyPublishWechat(args: string[]): void {
|
|
|
1260
1414
|
original: true,
|
|
1261
1415
|
aiGenerated: true,
|
|
1262
1416
|
author: '冰原奔狼',
|
|
1263
|
-
collection: collectionArg || '
|
|
1417
|
+
collection: collectionArg || 'AI 乱炖',
|
|
1264
1418
|
...(isFile(cover) ? { coverPath: cover } : {}),
|
|
1265
1419
|
// Audio block at the article top (operator request 2026-09-29): the daily MP3 goes to
|
|
1266
1420
|
// the 素材库 under the article title; the plugin reuses an existing same-title material.
|
|
@@ -1333,6 +1487,9 @@ function dailyScript(args: string[]): void {
|
|
|
1333
1487
|
env: {
|
|
1334
1488
|
DAILYNEWS_LANGUAGE: language,
|
|
1335
1489
|
DAILYNEWS_TITLE: title,
|
|
1490
|
+
// Task 0189: the broadcast greeting carries the episode's run date, not the render
|
|
1491
|
+
// date — a backfilled/retried run must not greet "today".
|
|
1492
|
+
DAILYNEWS_DATE: runDate,
|
|
1336
1493
|
VOICEBOX_DEFAULT_PROFILE: voiceProfile,
|
|
1337
1494
|
// F613 R4: always passed as absolute paths — spawnPluginEntry runs plugins with
|
|
1338
1495
|
// cwd = the plugin package dir, so a relative workDir would silently miss the
|
|
@@ -1357,6 +1514,118 @@ function dailyScript(args: string[]): void {
|
|
|
1357
1514
|
});
|
|
1358
1515
|
}
|
|
1359
1516
|
|
|
1517
|
+
/** Task 0189 R10 artifacts of the content-safety verdict gate (24-script, step 09). */
|
|
1518
|
+
function safetyPath(workDir: string, runDate: string, name: string): string {
|
|
1519
|
+
return join(workDir, '24-script', `${runDate}_09_${name}`);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* Digest of the exact drafts under review (article Content + VoiceScript). A verdict is only valid
|
|
1524
|
+
* for the bytes it was issued on: the gate and publish-prep both recompute this, so an edit after
|
|
1525
|
+
* PASS — including a HITL edit at `review` — invalidates the stamp instead of riding on it.
|
|
1526
|
+
*/
|
|
1527
|
+
function safetyDigest(workDir: string, runDate: string): string {
|
|
1528
|
+
const hash = createHash('sha256');
|
|
1529
|
+
for (const path of safetyDraftPaths(workDir, runDate)) {
|
|
1530
|
+
if (!isFile(path)) failWith(1, `safety-review: required draft not found: ${path}`);
|
|
1531
|
+
hash.update(readFileSync(path)).update('\0');
|
|
1532
|
+
}
|
|
1533
|
+
return hash.digest('hex');
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/** The drafts under review — the digest's bytes, and the quarantine set on a quality block. */
|
|
1537
|
+
function safetyDraftPaths(workDir: string, runDate: string): [string, string] {
|
|
1538
|
+
return [
|
|
1539
|
+
join(workDir, '22-article', `${runDate}_06_article_content.json`),
|
|
1540
|
+
join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`),
|
|
1541
|
+
];
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* Open a content-safety review of the current drafts: drop every artifact of an earlier review
|
|
1546
|
+
* (a retry must be re-reviewed on its final version) and write the request the two agent.run
|
|
1547
|
+
* steps read. The program passes context only — platform policy lives in the kk:content-safety
|
|
1548
|
+
* skill's reference files, never here.
|
|
1549
|
+
*/
|
|
1550
|
+
function dailySafetyPrep(args: string[]): void {
|
|
1551
|
+
const [workDir = '', runDate = '', platforms = ''] = args;
|
|
1552
|
+
timed(workDir, 'safety-review', () => {
|
|
1553
|
+
const digest = safetyDigest(workDir, runDate);
|
|
1554
|
+
for (const name of [
|
|
1555
|
+
'safety-review_report.json',
|
|
1556
|
+
'safety-judge_verdict.json',
|
|
1557
|
+
'safety-judge_status.txt',
|
|
1558
|
+
'safety-judge_stamp.json',
|
|
1559
|
+
]) {
|
|
1560
|
+
const stale = safetyPath(workDir, runDate, name);
|
|
1561
|
+
if (isFile(stale)) unlinkSync(stale);
|
|
1562
|
+
}
|
|
1563
|
+
const request = safetyPath(workDir, runDate, 'safety-review_request.json');
|
|
1564
|
+
const targets = platforms
|
|
1565
|
+
.split(',')
|
|
1566
|
+
.map((platform) => platform.trim())
|
|
1567
|
+
.filter(Boolean);
|
|
1568
|
+
sted('write', request, () => writeJsonText(request, { digest, platforms: targets, mode: 'detect-only' }));
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* Consume the content-safety report and the content-judge verdict into pass|escalate|block (exit 0
|
|
1574
|
+
* in all three — routing is transition-owned). Fail-closed: only an explicit PASS verdict on the
|
|
1575
|
+
* unchanged drafts, with a well-formed report carrying no BLOCK, unblocks; a RISK decision or
|
|
1576
|
+
* `escalate: true` forces the human review regardless of the verdict score.
|
|
1577
|
+
*/
|
|
1578
|
+
function dailySafetyGate(args: string[]): void {
|
|
1579
|
+
const [workDir = '', runDate = ''] = args;
|
|
1580
|
+
const statusFile = safetyPath(workDir, runDate, 'safety-judge_status.txt');
|
|
1581
|
+
const stampFile = safetyPath(workDir, runDate, 'safety-judge_stamp.json');
|
|
1582
|
+
timed(workDir, 'safety-judge', () => {
|
|
1583
|
+
const request = readJson(safetyPath(workDir, runDate, 'safety-review_request.json'));
|
|
1584
|
+
const report = readJson(safetyPath(workDir, runDate, 'safety-review_report.json'));
|
|
1585
|
+
const verdict = readJson(safetyPath(workDir, runDate, 'safety-judge_verdict.json'));
|
|
1586
|
+
const platforms = Array.isArray(report?.platforms) ? (report.platforms as Record<string, unknown>[]) : [];
|
|
1587
|
+
const decisions = platforms.map((platform) => platform?.decision);
|
|
1588
|
+
const digest = safetyDigest(workDir, runDate);
|
|
1589
|
+
let status = 'block';
|
|
1590
|
+
let reason = 'verdict PASS on unchanged drafts, no BLOCK/RISK';
|
|
1591
|
+
// A digest-mismatch block means the drafts were deliberately edited (HITL): keep them so
|
|
1592
|
+
// a re-run of safety-prep re-reviews the edited version. Any other block means the
|
|
1593
|
+
// content itself is rejected — quarantine the drafts below.
|
|
1594
|
+
let edited = false;
|
|
1595
|
+
if (request?.digest !== digest) {
|
|
1596
|
+
reason = 'drafts changed after the review was requested';
|
|
1597
|
+
edited = true;
|
|
1598
|
+
} else if (decisions.length === 0 || decisions.some((d) => d !== 'OK' && d !== 'RISK' && d !== 'BLOCK'))
|
|
1599
|
+
reason = 'content-safety report missing or malformed';
|
|
1600
|
+
else if (decisions.includes('BLOCK')) reason = 'content-safety report carries a BLOCK decision';
|
|
1601
|
+
else if (verdict?.verdict !== 'PASS')
|
|
1602
|
+
reason = `content-judge verdict is ${typeof verdict?.verdict === 'string' ? verdict.verdict : 'missing or unreadable'}`;
|
|
1603
|
+
else if (verdict?.confidence !== 'HIGH' && verdict?.confidence !== 'MEDIUM' && verdict?.confidence !== 'LOW')
|
|
1604
|
+
reason = 'content-judge verdict is missing a HIGH/MEDIUM/LOW confidence level';
|
|
1605
|
+
else if (decisions.includes('RISK') || report?.escalate === true) {
|
|
1606
|
+
status = 'escalate';
|
|
1607
|
+
reason = 'RISK decision or escalation — human review required';
|
|
1608
|
+
} else status = 'pass';
|
|
1609
|
+
if (status !== 'block') sted('write', stampFile, () => writeJsonText(stampFile, { digest, status }));
|
|
1610
|
+
if (status === 'block' && !edited) {
|
|
1611
|
+
// A quality block poisons the isFile() draft cache: every same-day retry would
|
|
1612
|
+
// re-fail on unchanged bytes (20261002 dogfood — two judge-fail runs replayed until
|
|
1613
|
+
// the drafts were moved by hand). Quarantine (mv, never rm) so the next run
|
|
1614
|
+
// regenerates from scratch; the 09_* verdict artifacts stay in 24-script for audit.
|
|
1615
|
+
const q = join(workDir, '24-script', `.quarantine-${runDate}-judge-block`);
|
|
1616
|
+
mkdirSync(q, { recursive: true });
|
|
1617
|
+
for (const path of safetyDraftPaths(workDir, runDate)) {
|
|
1618
|
+
if (!isFile(path)) continue;
|
|
1619
|
+
let dest = join(q, basename(path));
|
|
1620
|
+
for (let n = 2; isFile(dest); n += 1) dest = join(q, `${n}-${basename(path)}`);
|
|
1621
|
+
renameSync(path, dest);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
sted('write', statusFile, () => writeRaw(statusFile, status));
|
|
1625
|
+
console.log(`safety: status=${status} (${reason}); routing is transition-owned`);
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1360
1629
|
function dailyWrapDocs(args: string[]): void {
|
|
1361
1630
|
const [workDir = '', runDate = '', voiceProfile = ''] = args;
|
|
1362
1631
|
const scriptPath = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
|
|
@@ -1417,6 +1686,54 @@ function dailyGenerate(args: string[]): void {
|
|
|
1417
1686
|
* `intro_music` empty -> skip. Writes `<stem>.intro.wav` (original render kept), points
|
|
1418
1687
|
* `audioPath` at the merged file, extends `duration`, marks `introMerged` (idempotent).
|
|
1419
1688
|
*/
|
|
1689
|
+
/**
|
|
1690
|
+
* Episode duration gate (standing requirement, 20261002): a daily episode must run 15–20 minutes
|
|
1691
|
+
* (duration is authoritative; the 8–10-item figure is only an estimate). Reads the final audio
|
|
1692
|
+
* content's `metadata.duration` — after the intro sting merged, this is the true episode length —
|
|
1693
|
+
* and fails loud outside `[min, max]` seconds-worth of minutes. A missing duration is a failure,
|
|
1694
|
+
* not a pass: the gate must never silently let an under-length episode through again.
|
|
1695
|
+
*/
|
|
1696
|
+
function dailyEpisodeDuration(args: string[]): void {
|
|
1697
|
+
const [workDir = '', runDate = '', minArg = '', maxArg = ''] = args;
|
|
1698
|
+
if (!workDir) fail('missing work_dir');
|
|
1699
|
+
if (!runDate) fail('missing run_date');
|
|
1700
|
+
const minMinutes = Number(minArg);
|
|
1701
|
+
const maxMinutes = Number(maxArg);
|
|
1702
|
+
if (!Number.isFinite(minMinutes) || minMinutes <= 0) fail(`invalid min_minutes: ${minArg || '(empty)'}`);
|
|
1703
|
+
if (!Number.isFinite(maxMinutes) || maxMinutes < minMinutes) fail(`invalid max_minutes: ${maxArg || '(empty)'}`);
|
|
1704
|
+
const contentPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
|
|
1705
|
+
if (!isFile(contentPath)) failWith(1, `episode-duration: audio content not found: ${contentPath}`);
|
|
1706
|
+
let raw: unknown;
|
|
1707
|
+
try {
|
|
1708
|
+
const parsed = JSON.parse(readFileSync(contentPath, 'utf-8')) as { metadata?: { duration?: unknown } };
|
|
1709
|
+
raw = parsed.metadata?.duration;
|
|
1710
|
+
} catch (error) {
|
|
1711
|
+
if ((error as { code?: string }).code !== undefined) {
|
|
1712
|
+
sted('read', contentPath, () => readFileSync(contentPath, 'utf-8'));
|
|
1713
|
+
}
|
|
1714
|
+
raw = undefined;
|
|
1715
|
+
}
|
|
1716
|
+
const seconds = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN;
|
|
1717
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
1718
|
+
failWith(1, `episode-duration: audio metadata.duration missing or not a number in ${contentPath}`);
|
|
1719
|
+
}
|
|
1720
|
+
const minutes = seconds / 60;
|
|
1721
|
+
const shown = minutes.toFixed(1);
|
|
1722
|
+
if (minutes < minMinutes) {
|
|
1723
|
+
failWith(
|
|
1724
|
+
1,
|
|
1725
|
+
`episode-duration: episode runs ${shown} min, below the ${minMinutes}–${maxMinutes} min requirement (duration is authoritative). The candidate pool is too thin — deepen per-item treatment or add items; do not ship a short episode silently.`,
|
|
1726
|
+
);
|
|
1727
|
+
}
|
|
1728
|
+
if (minutes > maxMinutes) {
|
|
1729
|
+
failWith(
|
|
1730
|
+
1,
|
|
1731
|
+
`episode-duration: episode runs ${shown} min, above the ${minMinutes}–${maxMinutes} min requirement. Trim items or per-item treatment.`,
|
|
1732
|
+
);
|
|
1733
|
+
}
|
|
1734
|
+
console.log(`episode-duration ok: ${shown} min within ${minMinutes}–${maxMinutes} min`);
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1420
1737
|
function dailyIntro(args: string[]): void {
|
|
1421
1738
|
const [workDir = '', runDate = '', introSrc = ''] = args;
|
|
1422
1739
|
if (!introSrc || introSrc.trim() === '') {
|
|
@@ -1632,6 +1949,13 @@ function dailyPublishPrep(args: string[]): void {
|
|
|
1632
1949
|
coverPath: hasCover ? resolvedCover : undefined,
|
|
1633
1950
|
},
|
|
1634
1951
|
};
|
|
1952
|
+
// Task 0189 R10: the last program seam before anything is dispatched. No stamp, or a
|
|
1953
|
+
// stamp issued on different bytes (edited after PASS, or an older draft), never publishes.
|
|
1954
|
+
const stamp = readJson(safetyPath(workDir, runDate, 'safety-judge_stamp.json'));
|
|
1955
|
+
if (stamp === null) failWith(1, 'publish-prep: no content-safety verdict stamp for this run — not publishing');
|
|
1956
|
+
if (stamp.digest !== safetyDigest(workDir, runDate)) {
|
|
1957
|
+
failWith(1, 'publish-prep: drafts changed after the content-safety verdict — re-run the review');
|
|
1958
|
+
}
|
|
1635
1959
|
sted('write', prepPath, () => writeJsonText(prepPath, merged));
|
|
1636
1960
|
});
|
|
1637
1961
|
}
|
|
@@ -1681,13 +2005,55 @@ function dailyShowNotes(args: string[]): void {
|
|
|
1681
2005
|
timed(workDir, 'show-notes', () => {
|
|
1682
2006
|
const effective = readJson(article)?.title;
|
|
1683
2007
|
const titleArg = title !== '' ? title : typeof effective === 'string' ? effective : '';
|
|
2008
|
+
// Boundary-excluded docs must not render into show notes (judge finding 2026-10-02): the
|
|
2009
|
+
// article body's 未写入 note names the dropped ids; filter the plan copy podcast-pub sees.
|
|
2010
|
+
// Inlined parse — this file must not import @gobing-ai/* (header contract); the same note
|
|
2011
|
+
// grammar lives in daily-article-gen's excludedIdsFromNote.
|
|
1684
2012
|
const postPath = firstPostPath(readJson(result), 'surfdash-pub');
|
|
1685
|
-
|
|
2013
|
+
const articleBody = readJson(article)?.body;
|
|
2014
|
+
const excluded = new Set<string>();
|
|
2015
|
+
if (typeof articleBody === 'string') {
|
|
2016
|
+
for (const note of articleBody.matchAll(/未写入[::](.*)/g)) {
|
|
2017
|
+
for (const id of note[1].matchAll(/(?:[a-zA-Z0-9_-]+:)?[0-9a-f]{16}/g)) {
|
|
2018
|
+
excluded.add(id[0]);
|
|
2019
|
+
const colon = id[0].indexOf(':');
|
|
2020
|
+
if (colon !== -1) excluded.add(id[0].slice(colon + 1));
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
let planIn = plan;
|
|
2025
|
+
if (excluded.size > 0) {
|
|
2026
|
+
const parsed: unknown = readJson(plan);
|
|
2027
|
+
if (Array.isArray(parsed)) {
|
|
2028
|
+
const kept = parsed.filter(
|
|
2029
|
+
(d) =>
|
|
2030
|
+
!(
|
|
2031
|
+
d !== null &&
|
|
2032
|
+
typeof d === 'object' &&
|
|
2033
|
+
(excluded.has(String((d as Record<string, unknown>).id)) ||
|
|
2034
|
+
(String((d as Record<string, unknown>).id).includes(':') &&
|
|
2035
|
+
excluded.has(
|
|
2036
|
+
String((d as Record<string, unknown>).id).slice(
|
|
2037
|
+
String((d as Record<string, unknown>).id).indexOf(':') + 1,
|
|
2038
|
+
),
|
|
2039
|
+
)))
|
|
2040
|
+
),
|
|
2041
|
+
);
|
|
2042
|
+
if (kept.length !== parsed.length) {
|
|
2043
|
+
planIn = plan.replace(/\.json$/, '-shownotes.json');
|
|
2044
|
+
writeJsonText(planIn, kept);
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
kk(['executor', 'run', 'podcast-pub', '--in', planIn, '--out', out, ...pluginsArgs(pluginsPath)], {
|
|
1686
2049
|
env: {
|
|
1687
2050
|
PODCAST_PUB_TITLE: titleArg,
|
|
1688
2051
|
PODCAST_PUB_RUN_DATE: runDate,
|
|
1689
2052
|
PODCAST_PUB_BRIEF_MODE: 'true',
|
|
1690
2053
|
PODCAST_PUB_ARTICLE_POST_PATH: postPath,
|
|
2054
|
+
// Task 0189 R1 / Apple §1.11: AI-voice disclosure in the episode metadata, not only
|
|
2055
|
+
// in the audio sign-off and the article footer.
|
|
2056
|
+
PODCAST_PUB_FOOTER_NOTE: '本期文字由 AI 辅助整理,音频为 AI 合成配音。',
|
|
1691
2057
|
...(isFile(audioContent) ? { PODCAST_PUB_AUDIO_CONTENT: audioContent } : {}),
|
|
1692
2058
|
},
|
|
1693
2059
|
});
|
|
@@ -1768,7 +2134,22 @@ function dailyPublishClassify(args: string[]): void {
|
|
|
1768
2134
|
const status = okCount > 0 && okCount === total ? 'full' : okCount > 0 ? 'partial' : 'none';
|
|
1769
2135
|
sted('write', result, () => writeJqText(result, { ok: okCount === total && total > 0, targets }));
|
|
1770
2136
|
sted('write', statusFile, () => writeRaw(statusFile, status));
|
|
2137
|
+
const completeness = publishCompleteness(workDir, runDate);
|
|
2138
|
+
const completenessFile = join(workDir, '31-publish', `${runDate}_17_publish_completeness.json`);
|
|
2139
|
+
sted('write', completenessFile, () => writeJqText(completenessFile, { runDate, ...completeness }));
|
|
1771
2140
|
console.log(`publish: status=${status} (${okCount}/${total} targets published); routing is transition-owned`);
|
|
2141
|
+
console.log(`publish-completeness: ${completeness.rows.map((row) => `${row.channel}=${row.state}`).join(' ')}`);
|
|
2142
|
+
if (completeness.missing.length > 0) {
|
|
2143
|
+
const details = completeness.rows
|
|
2144
|
+
.filter((row) => row.state !== 'done')
|
|
2145
|
+
.map((row) => `${row.channel}=${row.state}${row.detail === '' ? '' : ` (${row.detail})`}`)
|
|
2146
|
+
.join('; ');
|
|
2147
|
+
console.error(
|
|
2148
|
+
`WARNING: publish completeness — ${completeness.missing.join(', ')} did not complete: ${details}. ` +
|
|
2149
|
+
'Independent channels never enter publish_status; re-run their stages for this work dir (see ' +
|
|
2150
|
+
`${completenessFile})`,
|
|
2151
|
+
);
|
|
2152
|
+
}
|
|
1772
2153
|
});
|
|
1773
2154
|
}
|
|
1774
2155
|
|
|
@@ -1779,6 +2160,59 @@ function targetsOf(path: string): Record<string, unknown>[] {
|
|
|
1779
2160
|
: [];
|
|
1780
2161
|
}
|
|
1781
2162
|
|
|
2163
|
+
/**
|
|
2164
|
+
* The publish tail's independent channels (locales, XHS, WeChat) and the surfdash CMS re-sync never
|
|
2165
|
+
* enter `publish_status` — they are non-blocking by design (ADR-022/025/026). That is also why a
|
|
2166
|
+
* tail driven by hand can skip every one of them and still leave a `full` status file behind:
|
|
2167
|
+
* 20261002 shipped zh-only surfdash with no XHS and no WeChat draft, and nothing in the run's own
|
|
2168
|
+
* artifacts said so. Record each channel's state beside the status file so the gap is visible.
|
|
2169
|
+
*/
|
|
2170
|
+
function publishCompleteness(
|
|
2171
|
+
workDir: string,
|
|
2172
|
+
runDate: string,
|
|
2173
|
+
): { rows: { channel: string; state: string; detail: string }[]; missing: string[] } {
|
|
2174
|
+
const at = (name: string): string => join(workDir, '31-publish', `${runDate}_${name}`);
|
|
2175
|
+
const markerFilled = (path: string): boolean => isFile(path) && readFileSync(path, 'utf-8').trim() !== '';
|
|
2176
|
+
const rows: { channel: string; state: string; detail: string }[] = [];
|
|
2177
|
+
const localesDone = ['en', 'ja'].every((locale) =>
|
|
2178
|
+
markerFilled(at(`20_translate-localize_${locale}-post-path.txt`)),
|
|
2179
|
+
);
|
|
2180
|
+
rows.push({
|
|
2181
|
+
channel: 'locales',
|
|
2182
|
+
state: localesDone ? 'done' : 'not-run',
|
|
2183
|
+
detail: localesDone ? 'en/ja drafts scaffolded' : 'translate markers absent (en/ja never scaffolded)',
|
|
2184
|
+
});
|
|
2185
|
+
const resultChannels: [string, string, string][] = [
|
|
2186
|
+
['xhs', '19_publish-xhs_result.json', ''],
|
|
2187
|
+
['wechat', '20_publish-wechat_result.json', ''],
|
|
2188
|
+
['cms-resync', '23_patch-surfdash_sync-result.json', '23_patch-surfdash_emdash-resync.txt'],
|
|
2189
|
+
];
|
|
2190
|
+
for (const [channel, resultName, skipMarkerName] of resultChannels) {
|
|
2191
|
+
const result = isFile(at(resultName)) ? readJson(at(resultName)) : null;
|
|
2192
|
+
if (result !== null) {
|
|
2193
|
+
const metadata = (result.metadata ?? {}) as Record<string, unknown>;
|
|
2194
|
+
rows.push({
|
|
2195
|
+
channel,
|
|
2196
|
+
state: result.ok === true ? 'done' : 'failed',
|
|
2197
|
+
detail:
|
|
2198
|
+
result.ok === true
|
|
2199
|
+
? typeof metadata.publishStatus === 'string'
|
|
2200
|
+
? metadata.publishStatus
|
|
2201
|
+
: 'ok'
|
|
2202
|
+
: String(result.error ?? 'ok=false'),
|
|
2203
|
+
});
|
|
2204
|
+
continue;
|
|
2205
|
+
}
|
|
2206
|
+
const skipMarker = skipMarkerName === '' ? '' : at(skipMarkerName);
|
|
2207
|
+
rows.push(
|
|
2208
|
+
skipMarker !== '' && isFile(skipMarker)
|
|
2209
|
+
? { channel, state: 'skipped', detail: readFileSync(skipMarker, 'utf-8').trim() }
|
|
2210
|
+
: { channel, state: 'not-run', detail: 'no result artifact' },
|
|
2211
|
+
);
|
|
2212
|
+
}
|
|
2213
|
+
return { rows, missing: rows.filter((row) => row.state !== 'done').map((row) => row.channel) };
|
|
2214
|
+
}
|
|
2215
|
+
|
|
1782
2216
|
function dailyPublishPartial(args: string[]): void {
|
|
1783
2217
|
const [workDir = '', runDate = ''] = args;
|
|
1784
2218
|
const result = join(workDir, '31-publish', `${runDate}_17_publish_result.json`);
|
|
@@ -1843,9 +2277,11 @@ function dailyPatchSurfdash(args: string[]): void {
|
|
|
1843
2277
|
const localCover = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
|
|
1844
2278
|
const dateIso = `${runDate.slice(0, 4)}-${runDate.slice(4, 6)}-${runDate.slice(6, 8)}`;
|
|
1845
2279
|
const crossLink: Record<string, string> = {
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
2280
|
+
// Task 0189 R4: cross-links carry the column's episode-title pattern, not the retired
|
|
2281
|
+
// news-bulletin name (the VoiceScript episode title is episodeTitle(lead, date)).
|
|
2282
|
+
zh: `🎧 本期已同步制作为播客节目,[点击收听《AI 乱炖 · ${dateIso}》](${showNotesUrl})。`,
|
|
2283
|
+
en: `🎧 This episode is also available as a podcast: [listen to AI 乱炖 · ${dateIso}](${showNotesUrl}).`,
|
|
2284
|
+
ja: `🎧 本エピソードはポッドキャストでも配信中:[AI 乱炖 · ${dateIso} を聴く](${showNotesUrl})。`,
|
|
1849
2285
|
};
|
|
1850
2286
|
let changed = 0;
|
|
1851
2287
|
for (const [locale, dir] of [
|
|
@@ -1916,6 +2352,11 @@ function dailyPatchSurfdash(args: string[]): void {
|
|
|
1916
2352
|
// changed===0 early-return so re-runs heal rows an earlier commit left stale
|
|
1917
2353
|
// (20260925/20260929). Fixture tests pass no plugins-path and must not shell out.
|
|
1918
2354
|
if (pluginsPath === '') {
|
|
2355
|
+
// A missing plugins-path silently drops the CMS re-sync, and the CMS cards then keep the
|
|
2356
|
+
// pre-patch `./assets/cover.png` the deployed worker cannot serve (20261002 lost its
|
|
2357
|
+
// cover this way). Leave a marker for the classify step's completeness report.
|
|
2358
|
+
const skipMarker = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_emdash-resync.txt`);
|
|
2359
|
+
sted('write', skipMarker, () => writeRaw(skipMarker, 'skipped: no plugins-path\n'));
|
|
1919
2360
|
console.log('patch-surfdash: no plugins-path — skip emdash re-sync');
|
|
1920
2361
|
} else {
|
|
1921
2362
|
const syncIn = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync.json`);
|
|
@@ -2109,7 +2550,8 @@ function dailyTranslateSync(args: string[]): void {
|
|
|
2109
2550
|
|
|
2110
2551
|
const [stage, ...rest] = process.argv.slice(2);
|
|
2111
2552
|
|
|
2112
|
-
// Imported as a module (unit tests reach `dedupPlanByTitle`) → exports
|
|
2553
|
+
// Imported as a module (unit tests reach `dedupPlanByTitle` / `applyTopicBoundary`) → exports
|
|
2554
|
+
// only, never a stage run;
|
|
2113
2555
|
// executed as the entrypoint → dispatch.
|
|
2114
2556
|
if (import.meta.main)
|
|
2115
2557
|
switch (stage) {
|
|
@@ -2192,6 +2634,12 @@ if (import.meta.main)
|
|
|
2192
2634
|
case 'daily-script':
|
|
2193
2635
|
dailyScript(rest);
|
|
2194
2636
|
break;
|
|
2637
|
+
case 'daily-safety-prep':
|
|
2638
|
+
dailySafetyPrep(rest);
|
|
2639
|
+
break;
|
|
2640
|
+
case 'daily-safety-gate':
|
|
2641
|
+
dailySafetyGate(rest);
|
|
2642
|
+
break;
|
|
2195
2643
|
case 'daily-wrap-docs':
|
|
2196
2644
|
dailyWrapDocs(rest);
|
|
2197
2645
|
break;
|
|
@@ -2201,6 +2649,9 @@ if (import.meta.main)
|
|
|
2201
2649
|
case 'daily-intro':
|
|
2202
2650
|
dailyIntro(rest);
|
|
2203
2651
|
break;
|
|
2652
|
+
case 'daily-episode-duration':
|
|
2653
|
+
dailyEpisodeDuration(rest);
|
|
2654
|
+
break;
|
|
2204
2655
|
case 'daily-quality-report':
|
|
2205
2656
|
dailyQualityReport(rest);
|
|
2206
2657
|
break;
|