@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.
Files changed (97) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +114 -38
  3. package/package.json +1 -1
  4. package/plugins/generations/daily-article-gen/dist/index.js +45 -5
  5. package/plugins/generations/daily-article-gen/src/index.ts +59 -4
  6. package/plugins/generations/dailynews-gen/dist/index.js +46 -9
  7. package/plugins/generations/dailynews-gen/src/index.ts +36 -1
  8. package/plugins/generations/dailynews-gen/src/script-builder.ts +15 -12
  9. package/plugins/generations/dailynews-gen/src/script-plan.ts +21 -3
  10. package/plugins/generations/episode-plan-gen/dist/index.js +55 -4
  11. package/plugins/generations/episode-plan-gen/src/index.ts +80 -2
  12. package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Bold.otf +0 -0
  13. package/plugins/generations/image-gen/assets/fonts/NotoSansSC-Regular.otf +0 -0
  14. package/plugins/generations/image-gen/assets/fonts/OFL.txt +92 -0
  15. package/plugins/generations/image-gen/assets/fonts/SOURCE.md +15 -0
  16. package/plugins/generations/image-gen/dist/index.js +700 -22
  17. package/plugins/generations/image-gen/package.json +1 -0
  18. package/plugins/generations/image-gen/presets/formats/daily-news-cover.yaml +2 -2
  19. package/plugins/generations/image-gen/presets/formats/xhs-card.yaml +19 -11
  20. package/plugins/generations/image-gen/presets/styles/xhs-knowledge.yaml +2 -1
  21. package/plugins/generations/image-gen/scripts/compose-e2e-artifact.ts +135 -0
  22. package/plugins/generations/image-gen/src/bytes.ts +116 -0
  23. package/plugins/generations/image-gen/src/compose/card.ts +294 -0
  24. package/plugins/generations/image-gen/src/compose/chromium.ts +147 -0
  25. package/plugins/generations/image-gen/src/compose/cmap.ts +177 -0
  26. package/plugins/generations/image-gen/src/index.ts +211 -13
  27. package/plugins/generations/image-gen/src/job.ts +12 -0
  28. package/plugins/generations/image-gen/src/presets.ts +53 -13
  29. package/plugins/generations/image-gen/src/providers/minimax.ts +12 -4
  30. package/plugins/generations/image-gen/src/providers/types.ts +12 -1
  31. package/plugins/kk/plugin.json +1 -1
  32. package/plugins/kk/scripts/daily-voice-style.zh.md +27 -14
  33. package/plugins/kk/scripts/itc-stages.ts +21 -3
  34. package/plugins/kk/scripts/kk-workflow-stages.ts +461 -10
  35. package/plugins/kk/scripts/topic-boundary.json +96 -0
  36. package/plugins/kk/scripts/validate-voicescript.ts +15 -0
  37. package/plugins/kk/skills/content-judge/SKILL.md +30 -0
  38. package/plugins/kk/skills/content-judge/references/workflow-integration.md +9 -2
  39. package/plugins/kk/skills/content-safety/SKILL.md +163 -0
  40. package/plugins/kk/skills/content-safety/references/platforms/apple-podcasts.md +36 -0
  41. package/plugins/kk/skills/content-safety/references/platforms/netease-music.md +31 -0
  42. package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +32 -0
  43. package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +35 -0
  44. package/plugins/kk/skills/publish/SKILL.md +1 -1
  45. package/plugins/kk/skills/xhs-images/SKILL.md +11 -7
  46. package/plugins/kk/skills/xhs-images/references/card-layouts.md +4 -4
  47. package/plugins/kk/workflows/judge-gated-publish-example.yaml +18 -10
  48. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +139 -24
  49. package/plugins/kk/workflows/kk-itc.yaml +21 -8
  50. package/plugins/publishings/podcast-pub/dist/index.js +4 -0
  51. package/plugins/publishings/podcast-pub/src/index.ts +2 -0
  52. package/plugins/publishings/podcast-pub/src/show-notes.ts +6 -0
  53. package/plugins/publishings/xhs-pub/dist/index.js +1 -1
  54. package/plugins/publishings/xhs-pub/src/automation-guard.ts +2 -2
  55. package/plugins/publishings/xhs-pub/src/index.ts +1 -1
  56. package/plugins/publishings/xhs-pub/src/profile-check.ts +2 -2
  57. package/plugins/sp/lib/env.ts +0 -46
  58. package/plugins/sp/lib/inline-run.generated.mjs +0 -1489
  59. package/plugins/sp/scripts/batch-preflight.mjs +0 -346
  60. package/plugins/sp/scripts/batch-preflight.ts +0 -459
  61. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +0 -615
  62. package/plugins/sp/scripts/daily-summary/daily-summary.ts +0 -846
  63. package/plugins/sp/scripts/daily-summary/logger.ts +0 -28
  64. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +0 -223
  65. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +0 -367
  66. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +0 -132
  67. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +0 -169
  68. package/plugins/sp/scripts/feature-dev-precheck.mjs +0 -171
  69. package/plugins/sp/scripts/feature-dev-precheck.ts +0 -238
  70. package/plugins/sp/scripts/feature-sync-bounded.mjs +0 -285
  71. package/plugins/sp/scripts/feature-sync-bounded.ts +0 -478
  72. package/plugins/sp/scripts/feature-verification-steps.ts +0 -275
  73. package/plugins/sp/scripts/history-anatomy-cache.mjs +0 -902
  74. package/plugins/sp/scripts/history-anatomy-cache.ts +0 -1028
  75. package/plugins/sp/scripts/idea-handoff.mjs +0 -22
  76. package/plugins/sp/scripts/idea-handoff.ts +0 -44
  77. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +0 -185
  78. package/plugins/sp/scripts/inline-run-setup.ts +0 -722
  79. package/plugins/sp/scripts/pr-reviewing.mjs +0 -769
  80. package/plugins/sp/scripts/pr-reviewing.ts +0 -925
  81. package/plugins/sp/scripts/quality-gate.mjs +0 -179
  82. package/plugins/sp/scripts/quality-gate.ts +0 -217
  83. package/plugins/sp/scripts/script-contract-check.ts +0 -319
  84. package/plugins/sp/scripts/stage-registry-adapter.ts +0 -1533
  85. package/plugins/sp/scripts/surface-drift-inventory.ts +0 -929
  86. package/plugins/sp/scripts/task-evidence-precheck.ts +0 -181
  87. package/plugins/sp/scripts/task-size-precheck.ts +0 -175
  88. package/plugins/sp/scripts/transition-shim-check.ts +0 -238
  89. package/plugins/sp/scripts/validate-commands.ts +0 -689
  90. package/plugins/sp/scripts/validate-flag-contracts.ts +0 -878
  91. package/plugins/sp/scripts/verify-answer-lint.ts +0 -530
  92. package/plugins/sp/scripts/workflow-step-profile.mjs +0 -316
  93. package/plugins/sp/scripts/workflow-step-profile.ts +0 -456
  94. package/plugins/sp/scripts/wrapup-drift-probe.mjs +0 -190
  95. package/plugins/sp/scripts/wrapup-drift-probe.ts +0 -258
  96. package/plugins/sp/scripts/wrapup-steps.mjs +0 -444
  97. 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. Resume-safe:
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, and the first failure stops
1140
- // the spend — later cards stay unrendered for a resume run to pick up.
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 || '每日AI资讯',
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
- kk(['executor', 'run', 'podcast-pub', '--in', plan, '--out', out, ...pluginsArgs(pluginsPath)], {
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
- zh: `🎧 本期已同步制作为播客节目,[点击收听《每日 AI 资讯 · ${dateIso}》](${showNotesUrl})。`,
1847
- en: `🎧 This episode is also available as a podcast: [listen to the Daily AI Briefing · ${dateIso}](${showNotesUrl}).`,
1848
- ja: `🎧 本エピソードはポッドキャストでも配信中:[Daily AI Briefing · ${dateIso} を聴く](${showNotesUrl})。`,
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 only, never a stage run;
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;