@gobing-ai/knowledge-kit 0.1.7 → 0.1.9

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 (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/board/collect/index.js +744 -1107
  3. package/dist/board/daily-voice/index.js +741 -107
  4. package/dist/board/itc/index.js +32 -6
  5. package/dist/index.js +1793 -103
  6. package/package.json +1 -1
  7. package/plugins/ingestions/horizon-ingest/dist/index.js +24 -1
  8. package/plugins/ingestions/horizon-ingest/src/index.ts +34 -0
  9. package/plugins/kk/config.example.yaml +8 -0
  10. package/plugins/kk/decisions/extract/ai-news.yaml +7 -1
  11. package/plugins/kk/plugin.json +1 -1
  12. package/plugins/kk/publish-presets/daily-ai-voice.json +123 -0
  13. package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +20 -0
  14. package/plugins/kk/schedules/kk-daily-publish3-overseas.cron.tmpl +17 -0
  15. package/plugins/kk/scripts/daily-stages.ts +664 -83
  16. package/plugins/kk/scripts/kk-workflow-stages.ts +11 -1
  17. package/plugins/kk/scripts/topic-boundary.json +18 -83
  18. package/plugins/kk/workflows/kk-collect.yaml +2 -0
  19. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +82 -8
  20. package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +842 -0
  21. package/plugins/kk/workflows/kk-daily-publish3.yaml +282 -0
  22. package/plugins/publishings/podcast-pub/README.md +65 -0
  23. package/plugins/publishings/podcast-pub/dist/index.js +1709 -274
  24. package/plugins/publishings/podcast-pub/plugin.json +1 -1
  25. package/plugins/publishings/podcast-pub/src/backfill.ts +465 -0
  26. package/plugins/publishings/podcast-pub/src/index.ts +211 -34
  27. package/plugins/publishings/podcast-pub/src/microfeed-client.ts +32 -3
  28. package/plugins/publishings/podcast-pub/src/seo.ts +1081 -0
  29. package/plugins/publishings/podcast-pub/src/show-notes.ts +71 -6
  30. package/plugins/publishings/surfdash-pub/dist/index.js +66 -5
  31. package/plugins/publishings/surfdash-pub/src/index.ts +94 -10
  32. package/plugins/publishings/wechatmp-pub/dist/index.js +54 -30
  33. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +8 -3
  34. package/plugins/publishings/wechatmp-pub/src/enrich.ts +58 -38
  35. package/plugins/publishings/wechatmp-pub/src/flow.ts +18 -1
  36. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +7 -1
package/dist/index.js CHANGED
@@ -42732,6 +42732,9 @@ async function runInvoke(opts) {
42732
42732
  echoError(`Plugin entry '${record.name}' failed (exit code ${proc.exitCode}): ${errMsg}`);
42733
42733
  return proc.exitCode;
42734
42734
  }
42735
+ if (proc.stderr !== "") {
42736
+ echoError(`[plugin:${record.name}] ${proc.stderr}`);
42737
+ }
42735
42738
  if (!existsSync3(absOutPath)) {
42736
42739
  echoError(`Plugin entry succeeded but did not write output file: ${absOutPath}`);
42737
42740
  return 1;
@@ -42977,6 +42980,90 @@ var DAILY_VOICE_ARTIFACTS = [
42977
42980
  kind: "text",
42978
42981
  pattern: "{stamp}_19_run-report_report.md",
42979
42982
  producerNode: "run-report"
42983
+ },
42984
+ {
42985
+ id: "episode-pool",
42986
+ label: "Episode pool",
42987
+ kind: "json",
42988
+ pattern: "10-collect/pool.json",
42989
+ producerNode: "extract"
42990
+ },
42991
+ {
42992
+ id: "episode-annotated",
42993
+ label: "QC annotation input",
42994
+ kind: "json",
42995
+ pattern: "21-plan/{stamp}_04_quality-control-content_annotated.json",
42996
+ producerNode: "qc-content"
42997
+ },
42998
+ {
42999
+ id: "episode-qc-rejections",
43000
+ label: "QC rejections",
43001
+ kind: "json",
43002
+ pattern: "21-plan/{stamp}_04_quality-control-content_candidates.rejected.json",
43003
+ producerNode: "qc-content"
43004
+ },
43005
+ {
43006
+ id: "episode-exclusions",
43007
+ label: "Topic exclusions",
43008
+ kind: "json",
43009
+ pattern: "21-plan/{stamp}_04_quality-control-content_exclusions.json",
43010
+ producerNode: "qc-content"
43011
+ },
43012
+ {
43013
+ id: "episode-candidates",
43014
+ label: "Post-QC candidates",
43015
+ kind: "json",
43016
+ pattern: "21-plan/{stamp}_04_quality-control-content_candidates.json",
43017
+ producerNode: "qc-content"
43018
+ },
43019
+ {
43020
+ id: "episode-plan",
43021
+ label: "Episode plan",
43022
+ kind: "json",
43023
+ pattern: "21-plan/{stamp}_03_plan_plan.json",
43024
+ producerNode: "plan"
43025
+ },
43026
+ {
43027
+ id: "episode-floor-refusal",
43028
+ label: "QC floor refusal",
43029
+ kind: "json",
43030
+ pattern: "21-plan/{stamp}_04_quality-control-content_floor-refusal.json",
43031
+ producerNode: "qc-content"
43032
+ },
43033
+ {
43034
+ id: "episode-duration",
43035
+ label: "Duration verdict",
43036
+ kind: "json",
43037
+ pattern: "30-audio/{stamp}_11_generate_content_duration.json",
43038
+ producerNode: "duration"
43039
+ },
43040
+ {
43041
+ id: "episode-duration-refusal",
43042
+ label: "Duration refusal",
43043
+ kind: "json",
43044
+ pattern: "30-audio/{stamp}_11_generate_content_duration-refusal.json",
43045
+ producerNode: "duration"
43046
+ },
43047
+ {
43048
+ id: "episode-script-refusal",
43049
+ label: "Script refusal",
43050
+ kind: "json",
43051
+ pattern: "30-audio/{stamp}_11_generate_content_duration-refusal.json",
43052
+ producerNode: "generate"
43053
+ },
43054
+ {
43055
+ id: "episode-script-refusal-fallback",
43056
+ label: "Script refusal (fallback)",
43057
+ kind: "json",
43058
+ pattern: "30-audio/{stamp}_11_generate_content_duration-refusal.json",
43059
+ producerNode: "generate-fallback"
43060
+ },
43061
+ {
43062
+ id: "episode-script-refusal-fallback2",
43063
+ label: "Script refusal (second fallback)",
43064
+ kind: "json",
43065
+ pattern: "30-audio/{stamp}_11_generate_content_duration-refusal.json",
43066
+ producerNode: "generate-fallback2"
42980
43067
  }
42981
43068
  ];
42982
43069
  var ITC_ARTIFACTS = [
@@ -44621,6 +44708,23 @@ function statusOf(rank) {
44621
44708
  return "dropped";
44622
44709
  return "none";
44623
44710
  }
44711
+ function parseSourceCounts(raw) {
44712
+ let parsed;
44713
+ try {
44714
+ parsed = JSON.parse(raw);
44715
+ } catch {
44716
+ return null;
44717
+ }
44718
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
44719
+ return null;
44720
+ const counts = {};
44721
+ for (const [key, value] of Object.entries(parsed)) {
44722
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0)
44723
+ return null;
44724
+ counts[key] = value;
44725
+ }
44726
+ return counts;
44727
+ }
44624
44728
 
44625
44729
  class CollectViewDao extends BaseDao {
44626
44730
  constructor(adapter) {
@@ -44721,6 +44825,66 @@ class CollectViewDao extends BaseDao {
44721
44825
  const rows = await this.adapter.queryAll("SELECT DISTINCT topic AS topic FROM selections WHERE topic <> '' ORDER BY topic");
44722
44826
  return rows.map((row) => row.topic);
44723
44827
  }
44828
+ async channelYield(sinceMs, untilMs) {
44829
+ const rows = untilMs === undefined ? await this.adapter.queryAll(`SELECT date(first_seen_at / 1000, 'unixepoch', 'localtime') AS day,
44830
+ source AS source,
44831
+ COUNT(*) AS items
44832
+ FROM items WHERE first_seen_at >= ?
44833
+ GROUP BY day, source
44834
+ ORDER BY day DESC, items DESC, source ASC`, sinceMs) : await this.adapter.queryAll(`SELECT date(first_seen_at / 1000, 'unixepoch', 'localtime') AS day,
44835
+ source AS source,
44836
+ COUNT(*) AS items
44837
+ FROM items WHERE first_seen_at >= ? AND first_seen_at < ?
44838
+ GROUP BY day, source
44839
+ ORDER BY day DESC, items DESC, source ASC`, sinceMs, untilMs);
44840
+ return rows.map((row) => ({
44841
+ day: row.day,
44842
+ source: row.source === "" ? "(none)" : row.source,
44843
+ items: row.items
44844
+ }));
44845
+ }
44846
+ async channelRunYield(fromMs, untilMs) {
44847
+ const rows = await this.adapter.queryAll(`SELECT date(started_at / 1000, 'unixepoch', 'localtime') AS day,
44848
+ started_at AS startedAt,
44849
+ source_counts_json AS sourceCountsJson
44850
+ FROM collect_runs WHERE started_at >= ? AND started_at < ?
44851
+ ORDER BY started_at ASC`, fromMs, untilMs);
44852
+ const byDay = new Map;
44853
+ for (const row of rows) {
44854
+ const entry = byDay.get(row.day) ?? { runs: 0, plugins: {} };
44855
+ entry.runs += 1;
44856
+ const counts = parseSourceCounts(row.sourceCountsJson);
44857
+ if (counts === null) {
44858
+ entry.plugins = null;
44859
+ } else if (entry.plugins !== null) {
44860
+ for (const [plugin, count] of Object.entries(counts)) {
44861
+ entry.plugins[plugin] = (entry.plugins[plugin] ?? 0) + count;
44862
+ }
44863
+ }
44864
+ byDay.set(row.day, entry);
44865
+ }
44866
+ const history = await this.adapter.queryAll(`SELECT started_at AS startedAt, source_counts_json AS sourceCountsJson
44867
+ FROM collect_runs WHERE started_at < ?
44868
+ ORDER BY started_at ASC`, untilMs);
44869
+ const lastPositiveAt = {};
44870
+ for (const row of history) {
44871
+ const counts = parseSourceCounts(row.sourceCountsJson);
44872
+ if (counts === null)
44873
+ continue;
44874
+ for (const [plugin, count] of Object.entries(counts)) {
44875
+ if (count > 0)
44876
+ lastPositiveAt[plugin] = row.startedAt;
44877
+ }
44878
+ }
44879
+ return {
44880
+ days: [...byDay.entries()].map(([day, entry]) => ({
44881
+ day,
44882
+ runs: entry.runs,
44883
+ plugins: entry.plugins
44884
+ })),
44885
+ lastPositiveAt
44886
+ };
44887
+ }
44724
44888
  async itemsPage(runId, filter, page, policy) {
44725
44889
  const params = [];
44726
44890
  const conditions = [];
@@ -45737,6 +45901,12 @@ var SOURCE_COUNTS = {
45737
45901
  code: "board.store.row-invalid",
45738
45902
  message: "A stored run row carries source counts that are not valid JSON."
45739
45903
  };
45904
+ var NAMED_PLUGINS = ["aihot-ingest", "horizon-ingest", "last30days-ingest"];
45905
+ var CHANNELS_PAGE_SIZE = 30;
45906
+ var CHANNEL_INVALID = {
45907
+ code: "board.store.channel-invalid",
45908
+ message: "A stored run row carries plugin counts that are not nonnegative integers."
45909
+ };
45740
45910
  async function sessionFor(deps) {
45741
45911
  const fs = deps.fs ?? createNodeFileSystem();
45742
45912
  const dbPath = join5(deps.projectPath, DEFAULT_STORE_DB_PATH);
@@ -46043,6 +46213,103 @@ function emptyItems(runId, query, sources) {
46043
46213
  diagnostics: []
46044
46214
  };
46045
46215
  }
46216
+ function hostTimeZone() {
46217
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
46218
+ }
46219
+ function emptyChannels(query, diagnostics) {
46220
+ return {
46221
+ schemaVersion: 1,
46222
+ availability: "unavailable",
46223
+ fromMs: query.fromMs,
46224
+ untilMs: query.untilMs,
46225
+ timeZone: hostTimeZone(),
46226
+ page: query.page,
46227
+ pageSize: CHANNELS_PAGE_SIZE,
46228
+ totalDays: 0,
46229
+ sources: [],
46230
+ plugins: [],
46231
+ byDay: [],
46232
+ bySource: {},
46233
+ runsByDay: [],
46234
+ lastPositiveAt: {},
46235
+ diagnostics
46236
+ };
46237
+ }
46238
+ async function storeChannels(deps, query) {
46239
+ const session = await sessionFor(deps);
46240
+ if (!session.ok)
46241
+ return emptyChannels(query, session.diagnostics);
46242
+ const { store, diagnostics } = session.session;
46243
+ try {
46244
+ const [itemDays, runYield] = await Promise.all([
46245
+ store.collectView.channelYield(query.fromMs, query.untilMs),
46246
+ store.collectView.channelRunYield(query.fromMs, query.untilMs)
46247
+ ]);
46248
+ const sources = [...new Set(itemDays.map((row) => row.source))].sort();
46249
+ const bySource = {};
46250
+ const itemByDay = new Map;
46251
+ for (const row of itemDays) {
46252
+ const entry = itemByDay.get(row.day) ?? { total: 0, sources: {} };
46253
+ entry.total += row.items;
46254
+ entry.sources[row.source] = (entry.sources[row.source] ?? 0) + row.items;
46255
+ itemByDay.set(row.day, entry);
46256
+ bySource[row.source] = (bySource[row.source] ?? 0) + row.items;
46257
+ }
46258
+ const runByDay = new Map(runYield.days.map((day) => [day.day, day]));
46259
+ const observedPlugins = new Set;
46260
+ for (const day of runYield.days) {
46261
+ if (day.plugins !== null)
46262
+ for (const plugin of Object.keys(day.plugins))
46263
+ observedPlugins.add(plugin);
46264
+ }
46265
+ for (const plugin of Object.keys(runYield.lastPositiveAt))
46266
+ observedPlugins.add(plugin);
46267
+ const plugins = [...new Set([...NAMED_PLUGINS, ...observedPlugins])].sort();
46268
+ const days = [...new Set([...itemByDay.keys(), ...runByDay.keys()])].sort().reverse();
46269
+ const totalDays = days.length;
46270
+ const offset = (Math.max(1, query.page) - 1) * CHANNELS_PAGE_SIZE;
46271
+ const pageDays = days.slice(offset, offset + CHANNELS_PAGE_SIZE);
46272
+ const byDay = pageDays.map((day) => {
46273
+ const item = itemByDay.get(day);
46274
+ const sourcesOfDay = {};
46275
+ for (const source of sources)
46276
+ sourcesOfDay[source] = item?.sources[source] ?? 0;
46277
+ return { day, total: item?.total ?? 0, sources: sourcesOfDay };
46278
+ });
46279
+ const runsByDay = pageDays.map((day) => {
46280
+ const run = runByDay.get(day);
46281
+ const pluginsOfDay = run?.plugins === null ? null : Object.fromEntries(plugins.map((plugin) => [plugin, run?.plugins?.[plugin] ?? 0]));
46282
+ return { day, runs: run?.runs ?? 0, plugins: pluginsOfDay };
46283
+ });
46284
+ const lastPositiveAt = {};
46285
+ for (const plugin of plugins)
46286
+ lastPositiveAt[plugin] = runYield.lastPositiveAt[plugin] ?? null;
46287
+ const channelDiagnostics = [...diagnostics];
46288
+ if (runYield.days.some((day) => day.plugins === null))
46289
+ channelDiagnostics.push(CHANNEL_INVALID);
46290
+ return {
46291
+ schemaVersion: 1,
46292
+ availability: "available",
46293
+ fromMs: query.fromMs,
46294
+ untilMs: query.untilMs,
46295
+ timeZone: hostTimeZone(),
46296
+ page: query.page,
46297
+ pageSize: CHANNELS_PAGE_SIZE,
46298
+ totalDays,
46299
+ sources,
46300
+ plugins,
46301
+ byDay,
46302
+ bySource,
46303
+ runsByDay,
46304
+ lastPositiveAt,
46305
+ diagnostics: channelDiagnostics
46306
+ };
46307
+ } catch {
46308
+ return emptyChannels(query, [...diagnostics, UNREADABLE]);
46309
+ } finally {
46310
+ await store.close();
46311
+ }
46312
+ }
46046
46313
 
46047
46314
  // src/board/daily-voice.ts
46048
46315
  var isTrue = (settings, key) => settings[key] === "true";
@@ -46258,6 +46525,280 @@ function channelOutcomes(settings, availability, textOf) {
46258
46525
  function stringArray(value) {
46259
46526
  return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
46260
46527
  }
46528
+ var FUNNEL_EXCLUSION_ROWS = 100;
46529
+ var FUNNEL_TEXT_CHARS = 200;
46530
+ function parseAnyJson(raw) {
46531
+ if (raw === undefined)
46532
+ return null;
46533
+ try {
46534
+ return JSON.parse(raw);
46535
+ } catch {
46536
+ return null;
46537
+ }
46538
+ }
46539
+ function nonNegativeNumber(value) {
46540
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
46541
+ }
46542
+ function arrayCount(value) {
46543
+ return Array.isArray(value) ? value.length : null;
46544
+ }
46545
+ function minutesFromSeconds(seconds) {
46546
+ return Math.round(seconds / 60 * 10) / 10;
46547
+ }
46548
+ function exclusionRow(entry) {
46549
+ const record = asRecord3(entry);
46550
+ if (record === null || typeof record.rule !== "string")
46551
+ return null;
46552
+ return {
46553
+ rule: record.rule.slice(0, FUNNEL_TEXT_CHARS),
46554
+ matched: typeof record.matched === "string" ? record.matched.slice(0, FUNNEL_TEXT_CHARS) : ""
46555
+ };
46556
+ }
46557
+ function measuredRefusalOf(record) {
46558
+ if (record === null || record.stage === "script")
46559
+ return null;
46560
+ const measuredMinutes = nonNegativeNumber(record.measuredMinutes);
46561
+ const minMinutes = nonNegativeNumber(record.minMinutes);
46562
+ if (measuredMinutes === null || minMinutes === null)
46563
+ return null;
46564
+ return {
46565
+ measuredMinutes,
46566
+ estimatedMinutes: nonNegativeNumber(record.estimatedMinutes),
46567
+ minMinutes,
46568
+ keptCount: nonNegativeNumber(record.keptCount),
46569
+ id: "episode-duration-refusal"
46570
+ };
46571
+ }
46572
+ function scriptRefusalOf(availability, jsonOf) {
46573
+ for (const id of [
46574
+ "episode-script-refusal",
46575
+ "episode-script-refusal-fallback",
46576
+ "episode-script-refusal-fallback2"
46577
+ ]) {
46578
+ if (availability.get(id) !== "available")
46579
+ continue;
46580
+ const record = jsonOf(id);
46581
+ if (record === null || record.stage !== "script")
46582
+ continue;
46583
+ return {
46584
+ estimatedMinutes: nonNegativeNumber(record.estimatedMinutes),
46585
+ minMinutes: nonNegativeNumber(record.minMinutes),
46586
+ keptCount: nonNegativeNumber(record.keptCount),
46587
+ id
46588
+ };
46589
+ }
46590
+ return null;
46591
+ }
46592
+ function floorRefusalOf(record) {
46593
+ if (record === null)
46594
+ return null;
46595
+ return {
46596
+ estimatedMinutes: nonNegativeNumber(record.estimatedMinutes),
46597
+ minMinutes: nonNegativeNumber(record.minMinutes),
46598
+ id: "episode-floor-refusal"
46599
+ };
46600
+ }
46601
+ function projectFunnel(settings, availability, textOf, outcome) {
46602
+ const diagnostics = [];
46603
+ const evidence = {};
46604
+ const rawOf = (id) => parseAnyJson(textOf(id));
46605
+ const poolRecord = rawOf("episode-pool");
46606
+ const poolCount = arrayCount(poolRecord);
46607
+ if (poolCount !== null)
46608
+ evidence.poolCount = "episode-pool";
46609
+ const annotatedCount = arrayCount(rawOf("episode-annotated"));
46610
+ if (annotatedCount !== null)
46611
+ evidence.annotatedCount = "episode-annotated";
46612
+ const qcRejectedCount = arrayCount(asRecord3(rawOf("episode-qc-rejections"))?.rejected);
46613
+ if (qcRejectedCount !== null)
46614
+ evidence.qcRejectedCount = "episode-qc-rejections";
46615
+ const exclusionsRecord = asRecord3(rawOf("episode-exclusions"));
46616
+ const topicKeptCount = arrayCount(exclusionsRecord?.kept);
46617
+ if (topicKeptCount !== null)
46618
+ evidence.topicKeptCount = "episode-exclusions";
46619
+ const plannedCount = arrayCount(rawOf("episode-plan"));
46620
+ if (plannedCount !== null)
46621
+ evidence.plannedCount = "episode-plan";
46622
+ const exclusions = [];
46623
+ const excludedRaw = Array.isArray(exclusionsRecord?.excluded) ? exclusionsRecord.excluded : [];
46624
+ const truncated = excludedRaw.length > FUNNEL_EXCLUSION_ROWS || excludedRaw.some((entry) => typeof entry?.rule === "string" && entry.rule.length > FUNNEL_TEXT_CHARS);
46625
+ for (const entry of excludedRaw.slice(0, FUNNEL_EXCLUSION_ROWS)) {
46626
+ const row = exclusionRow(entry);
46627
+ if (row !== null)
46628
+ exclusions.push(row);
46629
+ }
46630
+ if (truncated) {
46631
+ diagnostics.push({
46632
+ code: "board.daily.funnel.truncated",
46633
+ message: `Exclusion evidence was bounded to ${FUNNEL_EXCLUSION_ROWS} rows of ${FUNNEL_TEXT_CHARS} characters.`
46634
+ });
46635
+ }
46636
+ const measured = measuredRefusalOf(asRecord3(rawOf("episode-duration-refusal")));
46637
+ const script = scriptRefusalOf(availability, (id) => asRecord3(rawOf(id)) ?? null);
46638
+ const floor = floorRefusalOf(asRecord3(rawOf("episode-floor-refusal")));
46639
+ const refusalPresent = measured !== null || script !== null || floor !== null;
46640
+ const candidatesRecord = asRecord3(rawOf("episode-candidates"));
46641
+ const candidateDocs = arrayCount(metadataOf(candidatesRecord ?? {})?.docs);
46642
+ const dedupKeptCount = refusalPresent ? null : candidateDocs;
46643
+ if (dedupKeptCount !== null)
46644
+ evidence.dedupKeptCount = "episode-candidates";
46645
+ const verdict = asRecord3(rawOf("episode-duration"));
46646
+ const verdictMeasured = nonNegativeNumber(verdict?.measuredMinutes);
46647
+ const verdictMin = nonNegativeNumber(verdict?.minMinutes);
46648
+ const verdictMax = nonNegativeNumber(verdict?.maxMinutes);
46649
+ const audioSeconds = nonNegativeNumber(asRecord3(asRecord3(rawOf("audio-record"))?.metadata)?.duration);
46650
+ let measuredMinutes = null;
46651
+ if (measured !== null) {
46652
+ measuredMinutes = measured.measuredMinutes;
46653
+ evidence.measuredMinutes = measured.id;
46654
+ } else if (verdictMeasured !== null) {
46655
+ measuredMinutes = verdictMeasured;
46656
+ evidence.measuredMinutes = "episode-duration";
46657
+ } else if (audioSeconds !== null) {
46658
+ measuredMinutes = minutesFromSeconds(audioSeconds);
46659
+ evidence.measuredMinutes = "audio-record";
46660
+ }
46661
+ let estimatedMinutes = null;
46662
+ if (measured?.estimatedMinutes != null) {
46663
+ estimatedMinutes = measured.estimatedMinutes;
46664
+ evidence.estimatedMinutes = measured.id;
46665
+ } else if (script?.estimatedMinutes != null) {
46666
+ estimatedMinutes = script.estimatedMinutes;
46667
+ evidence.estimatedMinutes = script.id;
46668
+ } else if (floor?.estimatedMinutes != null) {
46669
+ estimatedMinutes = floor.estimatedMinutes;
46670
+ evidence.estimatedMinutes = floor.id;
46671
+ }
46672
+ const settingsNumber = (key) => {
46673
+ const value = Number(settings[key]);
46674
+ return settings[key] !== undefined && Number.isFinite(value) && value >= 0 ? value : null;
46675
+ };
46676
+ let minMinutes = measured?.minMinutes ?? script?.minMinutes ?? floor?.minMinutes ?? verdictMin;
46677
+ if (minMinutes !== null)
46678
+ evidence.minMinutes = measured?.id ?? script?.id ?? floor?.id ?? "episode-duration";
46679
+ else if (settingsNumber("episode_min_minutes") !== null) {
46680
+ minMinutes = settingsNumber("episode_min_minutes");
46681
+ evidence.minMinutes = "captured-settings";
46682
+ }
46683
+ let maxMinutes = verdictMax;
46684
+ if (maxMinutes !== null)
46685
+ evidence.maxMinutes = "episode-duration";
46686
+ else if (settingsNumber("episode_max_minutes") !== null) {
46687
+ maxMinutes = settingsNumber("episode_max_minutes");
46688
+ evidence.maxMinutes = "captured-settings";
46689
+ }
46690
+ if (poolCount !== null && annotatedCount !== null && poolCount !== annotatedCount) {
46691
+ diagnostics.push({
46692
+ code: "board.daily.funnel.mismatch",
46693
+ message: `Stage evidence disagrees: the pool records ${poolCount} item(s) while the annotation input carries ${annotatedCount}; both are shown as recorded.`
46694
+ });
46695
+ }
46696
+ let gate = null;
46697
+ if (outcome === "full" || outcome === "partial") {
46698
+ if (refusalPresent) {
46699
+ diagnostics.push({
46700
+ code: "board.daily.funnel.contradictory",
46701
+ message: "A refusal artifact is present beside a recorded content outcome; the outcome stands and the gate stays unset."
46702
+ });
46703
+ }
46704
+ } else {
46705
+ if (measured !== null)
46706
+ gate = "episode-duration";
46707
+ else if (script !== null)
46708
+ gate = "script";
46709
+ else if (floor !== null)
46710
+ gate = "qc-floor";
46711
+ else if (poolCount === 0)
46712
+ gate = "empty-pool";
46713
+ else if (poolCount !== null && poolCount > 0 && candidateDocs === 0)
46714
+ gate = "qc-empty";
46715
+ else
46716
+ gate = "unknown";
46717
+ if (gate === "empty-pool")
46718
+ evidence.gate = "episode-pool";
46719
+ else if (gate === "qc-empty")
46720
+ evidence.gate = "episode-candidates";
46721
+ else if (gate !== "unknown")
46722
+ evidence.gate = measured?.id ?? script?.id ?? floor?.id ?? "";
46723
+ }
46724
+ return {
46725
+ poolCount,
46726
+ annotatedCount,
46727
+ qcRejectedCount,
46728
+ topicKeptCount,
46729
+ dedupKeptCount,
46730
+ plannedCount,
46731
+ estimatedMinutes,
46732
+ measuredMinutes,
46733
+ minMinutes,
46734
+ maxMinutes,
46735
+ gate,
46736
+ exclusions,
46737
+ evidence,
46738
+ diagnostics
46739
+ };
46740
+ }
46741
+ function boundedText(value, limit = FUNNEL_TEXT_CHARS) {
46742
+ return typeof value === "string" && value !== "" ? value.slice(0, limit) : null;
46743
+ }
46744
+ function countProjection(value, key = "count") {
46745
+ return Array.isArray(value) ? { [key]: value.length } : null;
46746
+ }
46747
+ function sanitizeFunnelArtifact(workflow, artifactId, raw) {
46748
+ if (workflow !== "kk-daily-ai-voice2")
46749
+ return null;
46750
+ const parsed = parseAnyJson(raw);
46751
+ if (parsed === null && raw !== undefined)
46752
+ return { note: "malformed" };
46753
+ const record = asRecord3(parsed);
46754
+ switch (artifactId) {
46755
+ case "episode-pool":
46756
+ case "episode-annotated":
46757
+ case "episode-plan":
46758
+ return countProjection(parsed);
46759
+ case "episode-qc-rejections":
46760
+ return countProjection(record?.rejected, "rejectedCount");
46761
+ case "episode-candidates":
46762
+ return countProjection(metadataOf(record ?? {})?.docs, "docCount");
46763
+ case "episode-exclusions": {
46764
+ if (record === null)
46765
+ return null;
46766
+ const excluded = Array.isArray(record.excluded) ? record.excluded : [];
46767
+ return {
46768
+ keptCount: arrayCount(record.kept),
46769
+ excluded: excluded.slice(0, FUNNEL_EXCLUSION_ROWS).map((entry) => exclusionRow(entry)).filter((row) => row !== null)
46770
+ };
46771
+ }
46772
+ case "episode-floor-refusal":
46773
+ return record === null ? null : {
46774
+ estimatedMinutes: nonNegativeNumber(record.estimatedMinutes),
46775
+ minMinutes: nonNegativeNumber(record.minMinutes),
46776
+ candidateChars: nonNegativeNumber(record.candidateChars),
46777
+ reason: boundedText(record.reason)
46778
+ };
46779
+ case "episode-duration":
46780
+ return record === null ? null : {
46781
+ measuredMinutes: nonNegativeNumber(record.measuredMinutes),
46782
+ minMinutes: nonNegativeNumber(record.minMinutes),
46783
+ maxMinutes: nonNegativeNumber(record.maxMinutes),
46784
+ ok: typeof record.ok === "boolean" ? record.ok : null
46785
+ };
46786
+ case "episode-duration-refusal":
46787
+ case "episode-script-refusal":
46788
+ case "episode-script-refusal-fallback":
46789
+ case "episode-script-refusal-fallback2":
46790
+ return record === null ? null : {
46791
+ stage: record.stage === "script" ? "script" : null,
46792
+ measuredMinutes: nonNegativeNumber(record.measuredMinutes),
46793
+ estimatedMinutes: nonNegativeNumber(record.estimatedMinutes),
46794
+ minMinutes: nonNegativeNumber(record.minMinutes),
46795
+ keptCount: nonNegativeNumber(record.keptCount),
46796
+ reason: boundedText(record.reason)
46797
+ };
46798
+ default:
46799
+ return null;
46800
+ }
46801
+ }
46261
46802
  function projectDailyResults(input) {
46262
46803
  const { record, detail, marker } = input;
46263
46804
  const terminal = isTerminalStatus(detail.status);
@@ -46300,6 +46841,7 @@ function projectDailyResults(input) {
46300
46841
  channels,
46301
46842
  quality,
46302
46843
  safety,
46844
+ funnel: projectFunnel(record.settings, availability, textOf, outcome),
46303
46845
  progress: projectProgress(input.actions, record.topology),
46304
46846
  counts: {
46305
46847
  artifactsAvailable: availableCount,
@@ -46988,6 +47530,13 @@ function createBoardReader(options) {
46988
47530
  }
46989
47531
  };
46990
47532
  }
47533
+ const sanitized = sanitizeFunnelArtifact(workflow, artifactId, observed.text);
47534
+ if (sanitized !== null) {
47535
+ return {
47536
+ status: 200,
47537
+ body: { schemaVersion: 1, artifact: artifactId, projection: sanitized }
47538
+ };
47539
+ }
46991
47540
  return {
46992
47541
  status: range === null ? 200 : 206,
46993
47542
  body: {
@@ -47018,6 +47567,9 @@ function createBoardReader(options) {
47018
47567
  async function storeSelectionsFor(query) {
47019
47568
  return { status: 200, body: await storeSelections({ projectPath, fs }, query) };
47020
47569
  }
47570
+ async function storeChannelsFor(query) {
47571
+ return { status: 200, body: await storeChannels({ projectPath, fs }, query) };
47572
+ }
47021
47573
  const server = Bun.serve({
47022
47574
  hostname: "127.0.0.1",
47023
47575
  port,
@@ -47049,11 +47601,12 @@ function createBoardReader(options) {
47049
47601
  }
47050
47602
  const overviewMatch = /^\/api\/workflows\/([^/]+)\/store\/overview$/.exec(path);
47051
47603
  const selectionsMatch = /^\/api\/workflows\/([^/]+)\/store\/selections$/.exec(path);
47604
+ const channelsMatch = /^\/api\/workflows\/([^/]+)\/store\/channels$/.exec(path);
47052
47605
  const storeItemsMatch = /^\/api\/workflows\/([^/]+)\/store\/items$/.exec(path);
47053
47606
  const runStoreSelectionsMatch = /^\/api\/workflows\/([^/]+)\/runs\/([^/]+)\/store\/selections$/.exec(path);
47054
47607
  const runStoreMatch = /^\/api\/workflows\/([^/]+)\/runs\/([^/]+)\/store$/.exec(path);
47055
47608
  const runStoreItemsMatch = /^\/api\/workflows\/([^/]+)\/runs\/([^/]+)\/store\/items$/.exec(path);
47056
- const storeMatch = overviewMatch ?? selectionsMatch ?? storeItemsMatch ?? runStoreSelectionsMatch ?? runStoreMatch ?? runStoreItemsMatch;
47609
+ const storeMatch = overviewMatch ?? selectionsMatch ?? channelsMatch ?? storeItemsMatch ?? runStoreSelectionsMatch ?? runStoreMatch ?? runStoreItemsMatch;
47057
47610
  if (storeMatch !== null) {
47058
47611
  const storeRaw = storeMatch[1] ?? "";
47059
47612
  if (decodeURIComponent(storeRaw) !== STORE_WORKFLOW) {
@@ -47073,6 +47626,13 @@ function createBoardReader(options) {
47073
47626
  const response = await storeSelectionsFor(query.query);
47074
47627
  return jsonResponse(response.body, response.status, cors);
47075
47628
  }
47629
+ if (channelsMatch !== null) {
47630
+ const query = parseChannelsQuery(url.searchParams);
47631
+ if ("response" in query)
47632
+ return jsonResponse(query.response.body, query.response.status, cors);
47633
+ const response = await storeChannelsFor(query.query);
47634
+ return jsonResponse(response.body, response.status, cors);
47635
+ }
47076
47636
  if (storeItemsMatch !== null) {
47077
47637
  const query = parseItemsQuery(url.searchParams);
47078
47638
  if ("response" in query)
@@ -47145,6 +47705,7 @@ var LABEL_VALUE_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
47145
47705
  var SELECTION_FILTERS = ["selected", "published", "dropped", "none"];
47146
47706
  var ITEMS_QUERY_KEYS = new Set(["page", "source", "seen", "decision", "value", "selection", "search"]);
47147
47707
  var SELECTIONS_QUERY_KEYS = new Set(["page", "runId", "topic", "status", "search"]);
47708
+ var CHANNELS_QUERY_KEYS = new Set(["from", "until", "page"]);
47148
47709
  function invalidQuery(message) {
47149
47710
  return { status: 400, body: { code: "board.query.invalid", message } };
47150
47711
  }
@@ -47245,6 +47806,40 @@ function parseSelectionsQuery(params) {
47245
47806
  }
47246
47807
  };
47247
47808
  }
47809
+ function parseChannelsQuery(params) {
47810
+ for (const key of params.keys()) {
47811
+ if (!CHANNELS_QUERY_KEYS.has(key))
47812
+ return { response: invalidQuery("Unknown query parameter.") };
47813
+ }
47814
+ for (const key of CHANNELS_QUERY_KEYS) {
47815
+ if (params.getAll(key).length > 1)
47816
+ return { response: invalidQuery("Duplicate query parameter.") };
47817
+ }
47818
+ const rawFrom = params.get("from");
47819
+ const rawUntil = params.get("until");
47820
+ if (rawFrom === null || rawUntil === null) {
47821
+ return { response: invalidQuery("`from` and `until` are required.") };
47822
+ }
47823
+ const fromMs = parseEpochMs(rawFrom);
47824
+ const untilMs = parseEpochMs(rawUntil);
47825
+ if (fromMs === null || untilMs === null) {
47826
+ return { response: invalidQuery("`from` and `until` must be nonnegative integer epoch milliseconds.") };
47827
+ }
47828
+ if (fromMs >= untilMs)
47829
+ return { response: invalidQuery("`from` must be less than `until`.") };
47830
+ const rawPage = params.get("page");
47831
+ const page = rawPage === null ? 1 : Number(rawPage);
47832
+ if (rawPage !== null && (!/^\d+$/.test(rawPage) || !Number.isSafeInteger(page) || page < 1)) {
47833
+ return { response: invalidQuery("`page` must be an integer of at least 1.") };
47834
+ }
47835
+ return { query: { fromMs, untilMs, page } };
47836
+ }
47837
+ function parseEpochMs(raw) {
47838
+ if (!/^\d+$/.test(raw))
47839
+ return null;
47840
+ const value = Number(raw);
47841
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
47842
+ }
47248
47843
  function parseQuery(params) {
47249
47844
  if (params.size === 0)
47250
47845
  return null;
@@ -47685,6 +48280,9 @@ function noContentReason(workDir, runDate, poolCount) {
47685
48280
  if (measured !== null && measuredEstimate !== null && durationFloor !== null && keptCount !== null) {
47686
48281
  return `no-content: the episode ran ${measured} min (pre-spend estimate ${measuredEstimate} min), below the ${durationFloor} min floor \u2014 duration refusal after synthesis (pool ${poolCount ?? 0}, kept ${keptCount})`;
47687
48282
  }
48283
+ if (duration?.stage === "script" && measuredEstimate !== null && durationFloor !== null && keptCount !== null) {
48284
+ return `no-content: the authored script estimates ${measuredEstimate} min of speech, below the ${durationFloor} min floor \u2014 script gate refusal before the TTS spend (pool ${poolCount ?? 0}, kept ${keptCount})`;
48285
+ }
47688
48286
  const floor = readRefusal(join7(workDir, "21-plan", `${runDate}_04_quality-control-content_floor-refusal.json`));
47689
48287
  const estimated = num(floor?.estimatedMinutes);
47690
48288
  const minMinutes = num(floor?.minMinutes);
@@ -48332,6 +48930,9 @@ async function collectSourceDocs(discovery, source, timeoutMs) {
48332
48930
  const why = proc.timedOut === true ? `timed out after ${timeoutMs}ms` : `exited with code ${proc.exitCode}`;
48333
48931
  throw new Error(`Plugin '${source.plugin}' ${why}: ${proc.stderr || proc.stdout || "no output"}`);
48334
48932
  }
48933
+ if (proc.stderr !== "") {
48934
+ echoError(`[source:${source.plugin}] ${proc.stderr.trim()}`);
48935
+ }
48335
48936
  if (!await fs.exists(outTmp)) {
48336
48937
  throw new Error(`Plugin '${source.plugin}' succeeded but did not write an output file`);
48337
48938
  }
@@ -49085,7 +49686,7 @@ function checkOne(record, options) {
49085
49686
  if (!existsSync6(record.check)) {
49086
49687
  return { name: record.name, kind: record.kind, status: "error", detail: "probe entry missing" };
49087
49688
  }
49088
- const timeoutMs = options.timeout !== undefined ? Number(options.timeout) : 30000;
49689
+ const timeoutMs = options.timeout !== undefined ? Number(options.timeout) : 60000;
49089
49690
  const result = spawnPluginProbe({
49090
49691
  entryPath: record.check,
49091
49692
  cwd: record.dir,
@@ -49201,7 +49802,9 @@ var SCHEDULES_REL = "plugins/kk/schedules";
49201
49802
  var CHECKOUT_ROOT2 = resolve17(import.meta.dir, "../../../..");
49202
49803
  var SCHEDULE_TEMPLATES = {
49203
49804
  "kk-collect": { launchd: "launchd.plist.tmpl", cron: "cron.tmpl" },
49204
- "kk-serve": { launchd: "kk-serve.launchd.plist.tmpl" }
49805
+ "kk-serve": { launchd: "kk-serve.launchd.plist.tmpl" },
49806
+ "kk-daily-publish3-cn-peak": { cron: "kk-daily-publish3-cn-peak.cron.tmpl" },
49807
+ "kk-daily-publish3-overseas": { cron: "kk-daily-publish3-overseas.cron.tmpl" }
49205
49808
  };
49206
49809
  var REPO_PLACEHOLDER = "{{repo}}";
49207
49810
  var HOME_PLACEHOLDER = "{{home}}";
@@ -49915,9 +50518,18 @@ var ItcPlanTargetRequestSchema = object2({
49915
50518
  plugin: string2().min(1),
49916
50519
  profile: string2().min(1).optional()
49917
50520
  }).strict();
50521
+ var PublishWindowSchema = _enum2(["after-generate", "cn-peak", "overseas-rss"]);
49918
50522
  var PublishPresetTargetSchema = object2({
49919
50523
  plugin: string2().min(1),
49920
- profile: string2().min(1).optional()
50524
+ profile: string2().min(1).optional(),
50525
+ id: string2().min(1).optional(),
50526
+ operation: string2().min(1).optional(),
50527
+ variant: string2().min(1).optional(),
50528
+ tier: number2().int().min(0).max(3).optional(),
50529
+ window: PublishWindowSchema.optional(),
50530
+ mode: _enum2(["off", "dummy", "draft"]).optional(),
50531
+ required: boolean2().optional(),
50532
+ options: record(string2(), unknown()).optional()
49921
50533
  }).strict();
49922
50534
  var PublishPresetSchema = object2({
49923
50535
  schemaVersion: literal(1).default(1),
@@ -50092,6 +50704,11 @@ async function resolveItcTargets(input, fs = createNodeFileSystem()) {
50092
50704
  origin: "none"
50093
50705
  };
50094
50706
  }
50707
+ async function loadPublishPreset(slug, roots = {}, fs = createNodeFileSystem()) {
50708
+ const projectRoot = roots.projectRoot ? resolve19(roots.projectRoot) : process.cwd();
50709
+ const packageRoot = roots.packageRoot ? resolve19(roots.packageRoot) : await resolveDefaultPackageRoot(fs);
50710
+ return resolvePresetFromRoots(fs, slug.trim(), projectRoot, packageRoot);
50711
+ }
50095
50712
  async function hasCoverImage(fs, workDir) {
50096
50713
  const dir = join17(workDir, "4-illustrations");
50097
50714
  if (!await fs.exists(dir))
@@ -50609,7 +51226,13 @@ var ItcReceiptSchema = object2({
50609
51226
  aggregatePath: string2().optional(),
50610
51227
  publicUrlVerified: boolean2().optional(),
50611
51228
  url: string2().optional(),
50612
- durationMs: number2().optional()
51229
+ durationMs: number2().optional(),
51230
+ target: string2().min(1).optional(),
51231
+ window: string2().min(1).optional(),
51232
+ dummy: boolean2().optional(),
51233
+ error: string2().optional(),
51234
+ startedAt: string2().optional(),
51235
+ finishedAt: string2().optional()
50613
51236
  }).strict();
50614
51237
  async function findCover(fs, workDir) {
50615
51238
  const dir = join19(workDir, "4-illustrations");
@@ -50824,6 +51447,32 @@ async function prepareTargetPayloads(fs, workDir, plan, plugins) {
50824
51447
  await fs.writeFile(join19(publishDir, "publish-plan.json"), `${JSON.stringify(plan, null, 2)}
50825
51448
  `);
50826
51449
  }
51450
+ async function probePlugin(plugin, executor = new NodeProcessExecutor) {
51451
+ if (!plugin.check)
51452
+ return { ok: true };
51453
+ const probe = await executor.run({
51454
+ command: process.execPath,
51455
+ args: [plugin.check, "check"],
51456
+ env: pluginProcessEnv(plugin.kind, plugin.name),
51457
+ timeout: 60000
51458
+ });
51459
+ if (probe.exitCode === 0)
51460
+ return { ok: true };
51461
+ let detail = "";
51462
+ try {
51463
+ const last = probe.stdout.trim().split(`
51464
+ `).filter(Boolean).at(-1) ?? "";
51465
+ const parsed = JSON.parse(last);
51466
+ if (parsed.detail) {
51467
+ detail = `${parsed.status ?? "refused"}: ${parsed.detail}`;
51468
+ for (const value of Object.values(pluginEnvFromConfig(plugin.kind, plugin.name))) {
51469
+ if (value)
51470
+ detail = detail.split(value).join("<redacted>");
51471
+ }
51472
+ }
51473
+ } catch {}
51474
+ return { ok: false, ...detail ? { detail } : {} };
51475
+ }
50827
51476
  async function itcCheckTargets(args, fs = createNodeFileSystem(), executor = new NodeProcessExecutor) {
50828
51477
  const [workDir = ""] = args;
50829
51478
  if (!workDir) {
@@ -50840,69 +51489,33 @@ async function itcCheckTargets(args, fs = createNodeFileSystem(), executor = new
50840
51489
  const byName = new Map(plugins.map((p) => [p.name, p]));
50841
51490
  for (const target of plan.targets) {
50842
51491
  const plugin = byName.get(target.plugin);
50843
- if (plugin?.check) {
50844
- const probe = await executor.run({
50845
- command: process.execPath,
50846
- args: [plugin.check, "check"],
50847
- env: pluginProcessEnv(plugin.kind, plugin.name),
50848
- timeout: 60000
50849
- });
50850
- if (probe.exitCode !== 0) {
50851
- let detail = "";
50852
- try {
50853
- const last = probe.stdout.trim().split(`
50854
- `).filter(Boolean).at(-1) ?? "";
50855
- const parsed = JSON.parse(last);
50856
- if (parsed.detail) {
50857
- detail = `${parsed.status ?? "refused"}: ${parsed.detail}`;
50858
- for (const value of Object.values(pluginEnvFromConfig(plugin.kind, plugin.name))) {
50859
- if (value)
50860
- detail = detail.split(value).join("<redacted>");
50861
- }
50862
- }
50863
- } catch {}
50864
- const botName = plugin.browserSession?.profileName ?? plugin.name;
50865
- echoError(detail ? `channel probe for '${target.plugin}' refused: ${detail}` : `channel probe for '${target.plugin}' refused: run \`kk bot open/login ${botName}\` to authenticate`);
50866
- return 1;
50867
- }
51492
+ if (!plugin?.check)
51493
+ continue;
51494
+ const probe = await probePlugin(plugin, executor);
51495
+ if (!probe.ok) {
51496
+ const botName = plugin.browserSession?.profileName ?? plugin.name;
51497
+ echoError(probe.detail ? `channel probe for '${target.plugin}' refused: ${probe.detail}` : `channel probe for '${target.plugin}' refused: run \`kk bot open/login ${botName}\` to authenticate`);
51498
+ return 1;
50868
51499
  }
50869
51500
  }
50870
51501
  return 0;
50871
51502
  }
50872
- async function itcSendTargets(args, fs = createNodeFileSystem(), dispatch = runFanout) {
50873
- const [workDir = ""] = args;
50874
- const isRetry = args.includes("--retry");
50875
- if (!workDir) {
50876
- echoError("usage: itc-send-targets <work_dir> [--retry]");
50877
- return 1;
50878
- }
50879
- const publishDir = join19(workDir, "4-publish");
50880
- const planPath = join19(publishDir, "publish-plan.json");
50881
- if (!await fs.exists(planPath))
50882
- return 0;
50883
- const plan = JSON.parse(await fs.readFile(planPath));
50884
- if (!plan.targets || plan.targets.length === 0)
50885
- return 0;
50886
- const parentRunId = (await readOptionsSnapshot(fs, workDir))?.parentRunId;
50887
- if (!parentRunId) {
50888
- echoError(`itc-send-targets: ${join19(workDir, "itc-options.json")} has no parentRunId`);
50889
- return 1;
50890
- }
50891
- const receiptsDir = join19(publishDir, "receipts");
50892
- await fs.ensureDir(receiptsDir);
50893
- const planRaw = await fs.readFile(planPath);
50894
- const planDigest = createHash6("sha256").update(planRaw).digest("hex");
50895
- for (const target of plan.targets) {
50896
- const targetReceiptPath = join19(receiptsDir, `${target.plugin}.json`);
51503
+ async function sendRows(options) {
51504
+ const fs = options.fs ?? createNodeFileSystem();
51505
+ const dispatch = options.dispatch ?? runFanout;
51506
+ const { rows, publishDir, receiptsDir, parentRunId, planDigest, retry, label } = options;
51507
+ const result = { sent: [], skipped: [], receipts: [] };
51508
+ for (const row of rows) {
51509
+ const startedAt = new Date().toISOString();
51510
+ const targetReceiptPath = join19(receiptsDir, `${row.key}.json`);
50897
51511
  let reattempt = false;
50898
- if (isRetry) {
51512
+ if (retry) {
50899
51513
  if (!await fs.exists(targetReceiptPath))
50900
51514
  continue;
50901
51515
  try {
50902
51516
  const priorReceipt = JSON.parse(await fs.readFile(targetReceiptPath));
50903
- if (priorReceipt.status === "succeeded" || priorReceipt.effect !== "none") {
51517
+ if (priorReceipt.status === "succeeded" || priorReceipt.effect !== "none")
50904
51518
  continue;
50905
- }
50906
51519
  } catch {
50907
51520
  continue;
50908
51521
  }
@@ -50910,31 +51523,80 @@ async function itcSendTargets(args, fs = createNodeFileSystem(), dispatch = runF
50910
51523
  } else if (await fs.exists(targetReceiptPath)) {
50911
51524
  try {
50912
51525
  const prior = JSON.parse(await fs.readFile(targetReceiptPath));
50913
- if (prior.status === "succeeded")
51526
+ const dummyOnly = prior.dummy === true && row.mode !== "dummy";
51527
+ if (prior.status === "succeeded" && !dummyOnly)
50914
51528
  continue;
50915
- const mayAlreadyBeLive = (prior.status === "uncertain" || prior.effect === "unknown") && (target.mode === "live" || prior.mode === "live");
51529
+ const mayAlreadyBeLive = (prior.status === "uncertain" || prior.effect === "unknown") && (row.mode === "live" || prior.mode === "live");
50916
51530
  if (mayAlreadyBeLive) {
50917
- echoError(`itc-send-targets: ${target.plugin} has an unknown-effect live receipt (may already be published) \u2014 resolve it before resending; skipped`);
51531
+ echoError(`${label}: ${row.key} has an unknown-effect live receipt (may already be published) \u2014 resolve it before resending; skipped`);
51532
+ result.skipped.push(row.key);
50918
51533
  continue;
50919
51534
  }
50920
51535
  } catch {}
50921
51536
  reattempt = true;
50922
51537
  }
50923
- const payloadRel = target.payloadPath ?? `4-publish/content.${target.plugin}.json`;
50924
- const payloadPath = join19(workDir, payloadRel);
51538
+ let payloadPath = row.payloadPath;
51539
+ let preparedError;
51540
+ if (options.preparePayload) {
51541
+ const prepared = await options.preparePayload(row);
51542
+ if ("error" in prepared) {
51543
+ preparedError = prepared.error;
51544
+ } else {
51545
+ payloadPath = prepared.payloadPath;
51546
+ }
51547
+ }
50925
51548
  const payloadRaw = await fs.exists(payloadPath) ? await fs.readFile(payloadPath) : "{}";
50926
51549
  const payloadDigest = createHash6("sha256").update(payloadRaw).digest("hex");
50927
- const outPath = join19(publishDir, `fanout-result.${target.plugin}${isRetry ? ".retry" : ""}.json`);
51550
+ if (preparedError !== undefined) {
51551
+ const receipt = {
51552
+ ...row.receiptExtras ?? {},
51553
+ schemaVersion: 1,
51554
+ parentRunId,
51555
+ planDigest,
51556
+ payloadDigest,
51557
+ plugin: row.plugin,
51558
+ profile: row.profile,
51559
+ mode: row.mode === "live" ? "live" : "draft",
51560
+ attemptId: retry ? "retry-1" : "attempt-1",
51561
+ status: "failed",
51562
+ effect: "none",
51563
+ publicUrlVerified: false,
51564
+ error: preparedError
51565
+ };
51566
+ await fs.writeFile(targetReceiptPath, `${JSON.stringify(receipt, null, 2)}
51567
+ `);
51568
+ result.receipts.push(receipt);
51569
+ if (options.onReceipt)
51570
+ await options.onReceipt(receipt);
51571
+ continue;
51572
+ }
51573
+ if (row.mode === "dummy") {
51574
+ if (options.dummyReceipt === undefined) {
51575
+ echoError(`${label}: ${row.key} is dummy but no dummy receipt writer is configured; skipped`);
51576
+ result.skipped.push(row.key);
51577
+ continue;
51578
+ }
51579
+ const receipt = await options.dummyReceipt(row, { payloadPath, payloadDigest, startedAt });
51580
+ await fs.writeFile(targetReceiptPath, `${JSON.stringify(receipt, null, 2)}
51581
+ `);
51582
+ result.receipts.push(receipt);
51583
+ if (receipt.status === "succeeded")
51584
+ result.sent.push(row.key);
51585
+ if (options.onReceipt)
51586
+ await options.onReceipt(receipt);
51587
+ continue;
51588
+ }
51589
+ const outPath = join19(publishDir, `fanout-result.${row.key}${retry ? ".retry" : ""}.json`);
50928
51590
  if (reattempt && await fs.exists(outPath))
50929
51591
  await fs.deleteFile(outPath);
50930
51592
  const fanoutArgs = [
50931
51593
  "--content",
50932
51594
  payloadPath,
50933
51595
  "--target",
50934
- target.plugin,
51596
+ row.plugin,
50935
51597
  "--out",
50936
51598
  outPath,
50937
- ...target.transport === "browser" ? ["--serial-target", target.plugin] : []
51599
+ ...row.transport === "browser" ? ["--serial-target", row.plugin] : []
50938
51600
  ];
50939
51601
  let fanoutExit = 0;
50940
51602
  if (!await fs.exists(outPath)) {
@@ -50948,7 +51610,7 @@ async function itcSendTargets(args, fs = createNodeFileSystem(), dispatch = runF
50948
51610
  if (await fs.exists(outPath)) {
50949
51611
  try {
50950
51612
  const agg = JSON.parse(await fs.readFile(outPath));
50951
- const found = (agg.targets ?? []).find((t) => t.target === target.plugin);
51613
+ const found = (agg.targets ?? []).find((t) => t.target === row.plugin);
50952
51614
  if (found)
50953
51615
  outcome = found;
50954
51616
  } catch {
@@ -50961,37 +51623,45 @@ async function itcSendTargets(args, fs = createNodeFileSystem(), dispatch = runF
50961
51623
  let publicUrlVerified = false;
50962
51624
  if (isSuccess) {
50963
51625
  effect = "confirmed";
50964
- if (target.mode === "live" && outcome.url) {
51626
+ if (row.mode === "live" && outcome.url) {
50965
51627
  publicUrlVerified = itcDelivery?.publicUrlVerified === true;
50966
51628
  }
50967
51629
  } else if (itcDelivery?.effect === "none") {
50968
51630
  effect = "none";
50969
51631
  }
50970
51632
  const receipt = {
51633
+ ...row.receiptExtras ?? {},
50971
51634
  schemaVersion: 1,
50972
51635
  parentRunId,
50973
51636
  planDigest,
50974
51637
  payloadDigest,
50975
- plugin: target.plugin,
50976
- profile: target.profile,
50977
- mode: target.mode,
50978
- attemptId: isRetry ? "retry-1" : "attempt-1",
51638
+ plugin: row.plugin,
51639
+ profile: row.profile,
51640
+ mode: row.mode === "live" ? "live" : "draft",
51641
+ attemptId: retry ? "retry-1" : "attempt-1",
50979
51642
  status: isSuccess ? "succeeded" : effect === "unknown" ? "uncertain" : "failed",
50980
51643
  effect,
50981
51644
  publicUrlVerified,
50982
51645
  ...outcome.url ? { url: outcome.url } : {},
51646
+ ...outcome.error ? { error: outcome.error } : {},
50983
51647
  ...outcome.durationMs !== undefined ? { durationMs: outcome.durationMs } : {},
50984
51648
  aggregatePath: outPath
50985
51649
  };
50986
51650
  await fs.writeFile(targetReceiptPath, `${JSON.stringify(receipt, null, 2)}
50987
51651
  `);
51652
+ result.receipts.push(receipt);
51653
+ if (receipt.status === "succeeded")
51654
+ result.sent.push(row.key);
51655
+ if (options.onReceipt)
51656
+ await options.onReceipt(receipt);
50988
51657
  }
50989
- return 0;
51658
+ return result;
50990
51659
  }
50991
- async function itcReconcileReceipts(args, fs = createNodeFileSystem()) {
51660
+ async function itcSendTargets(args, fs = createNodeFileSystem(), dispatch = runFanout) {
50992
51661
  const [workDir = ""] = args;
51662
+ const isRetry = args.includes("--retry");
50993
51663
  if (!workDir) {
50994
- echoError("usage: itc-reconcile-receipts <work_dir>");
51664
+ echoError("usage: itc-send-targets <work_dir> [--retry]");
50995
51665
  return 1;
50996
51666
  }
50997
51667
  const publishDir = join19(workDir, "4-publish");
@@ -50999,14 +51669,57 @@ async function itcReconcileReceipts(args, fs = createNodeFileSystem()) {
50999
51669
  if (!await fs.exists(planPath))
51000
51670
  return 0;
51001
51671
  const plan = JSON.parse(await fs.readFile(planPath));
51002
- const chosenPath = join19(workDir, "chosen-source.json");
51003
- const chosen = await fs.exists(chosenPath) ? JSON.parse(await fs.readFile(chosenPath)) : undefined;
51004
- const settle = async (outcome) => {
51005
- if (chosen === undefined)
51006
- return;
51007
- await itcSelectionSettle({
51008
- schemaVersion: 1,
51009
- intakeDir: workDir,
51672
+ if (!plan.targets || plan.targets.length === 0)
51673
+ return 0;
51674
+ const parentRunId = (await readOptionsSnapshot(fs, workDir))?.parentRunId;
51675
+ if (!parentRunId) {
51676
+ echoError(`itc-send-targets: ${join19(workDir, "itc-options.json")} has no parentRunId`);
51677
+ return 1;
51678
+ }
51679
+ const receiptsDir = join19(publishDir, "receipts");
51680
+ await fs.ensureDir(receiptsDir);
51681
+ const planRaw = await fs.readFile(planPath);
51682
+ const planDigest = createHash6("sha256").update(planRaw).digest("hex");
51683
+ const rows = plan.targets.map((target) => ({
51684
+ key: target.plugin,
51685
+ plugin: target.plugin,
51686
+ profile: target.profile,
51687
+ mode: target.mode,
51688
+ payloadPath: join19(workDir, target.payloadPath ?? `4-publish/content.${target.plugin}.json`),
51689
+ transport: target.transport
51690
+ }));
51691
+ await sendRows({
51692
+ rows,
51693
+ publishDir,
51694
+ receiptsDir,
51695
+ parentRunId,
51696
+ planDigest,
51697
+ retry: isRetry,
51698
+ label: "itc-send-targets",
51699
+ fs,
51700
+ dispatch
51701
+ });
51702
+ return 0;
51703
+ }
51704
+ async function itcReconcileReceipts(args, fs = createNodeFileSystem()) {
51705
+ const [workDir = ""] = args;
51706
+ if (!workDir) {
51707
+ echoError("usage: itc-reconcile-receipts <work_dir>");
51708
+ return 1;
51709
+ }
51710
+ const publishDir = join19(workDir, "4-publish");
51711
+ const planPath = join19(publishDir, "publish-plan.json");
51712
+ if (!await fs.exists(planPath))
51713
+ return 0;
51714
+ const plan = JSON.parse(await fs.readFile(planPath));
51715
+ const chosenPath = join19(workDir, "chosen-source.json");
51716
+ const chosen = await fs.exists(chosenPath) ? JSON.parse(await fs.readFile(chosenPath)) : undefined;
51717
+ const settle = async (outcome) => {
51718
+ if (chosen === undefined)
51719
+ return;
51720
+ await itcSelectionSettle({
51721
+ schemaVersion: 1,
51722
+ intakeDir: workDir,
51010
51723
  parentRunId: chosen.parentRunId,
51011
51724
  topicProfile: chosen.topicProfile,
51012
51725
  outcome
@@ -51562,6 +52275,922 @@ async function runItcPrepare(args, fs) {
51562
52275
  return 0;
51563
52276
  }
51564
52277
 
52278
+ // src/publish-matrix-stage.ts
52279
+ init_dist3();
52280
+
52281
+ // src/publish-matrix.ts
52282
+ import { createHash as createHash8 } from "crypto";
52283
+ import { dirname as dirname12, join as join21, resolve as resolve21 } from "path";
52284
+ init_dist3();
52285
+ init_dist();
52286
+ init_zod();
52287
+ var BundleVariantSchema = object2({
52288
+ path: string2().min(1),
52289
+ sha256: string2().min(1)
52290
+ }).strict();
52291
+ var BundleSchema = object2({
52292
+ schemaVersion: literal(1).default(1),
52293
+ runId: string2().min(1),
52294
+ runDate: string2().min(1),
52295
+ parentRunId: string2().min(1).optional(),
52296
+ runs: array(string2()).default([]),
52297
+ episode: object2({ title: string2().min(1), guid: string2().optional() }).strict(),
52298
+ variants: record(string2(), BundleVariantSchema)
52299
+ }).strict();
52300
+ var PublishPlanRowSchema = object2({
52301
+ id: string2().min(1),
52302
+ plugin: string2().min(1),
52303
+ operation: string2().min(1).optional(),
52304
+ variant: string2().min(1),
52305
+ tier: number2().int().min(0).max(3),
52306
+ window: PublishWindowSchema,
52307
+ mode: _enum2(["dummy", "draft", "live"]),
52308
+ required: boolean2(),
52309
+ approval: _enum2(["none", "required"]),
52310
+ status: literal("planned"),
52311
+ profile: string2().min(1).optional(),
52312
+ payloadPath: string2().min(1),
52313
+ payloadDigest: string2().min(1)
52314
+ }).strict();
52315
+ var PublishPlanSchema = object2({
52316
+ schemaVersion: literal(1).default(1),
52317
+ window: PublishWindowSchema,
52318
+ bundleRunId: string2().min(1),
52319
+ parentRunId: string2().min(1),
52320
+ presetName: string2().min(1),
52321
+ presetDigest: string2().min(1),
52322
+ rows: array(PublishPlanRowSchema)
52323
+ }).strict();
52324
+ var PublishOutcomeRowSchema = object2({
52325
+ id: string2().min(1),
52326
+ tier: number2().int().min(0).max(3),
52327
+ required: boolean2(),
52328
+ status: _enum2(["succeeded", "failed", "uncertain", "missing"]),
52329
+ effect: _enum2(["none", "confirmed", "unknown"]).optional(),
52330
+ dummy: boolean2()
52331
+ }).strict();
52332
+ var PublishOutcomeSchema = object2({
52333
+ schemaVersion: literal(1).default(1),
52334
+ window: PublishWindowSchema,
52335
+ runDate: string2().min(1),
52336
+ status: _enum2(["full", "partial", "none", "dummy"]),
52337
+ published: number2().int().min(0),
52338
+ targets: number2().int().min(0),
52339
+ rows: array(PublishOutcomeRowSchema)
52340
+ }).strict();
52341
+ function classifyPublishMatrix(plan, receipts, runDate) {
52342
+ const byTarget = new Map;
52343
+ for (const receipt of receipts) {
52344
+ const key = receipt.target ?? receipt.plugin;
52345
+ byTarget.set(key, receipt);
52346
+ }
52347
+ const rows = plan.rows.map((row) => {
52348
+ const receipt = byTarget.get(row.id);
52349
+ return {
52350
+ id: row.id,
52351
+ tier: row.tier,
52352
+ required: row.required,
52353
+ status: receipt?.status ?? "missing",
52354
+ ...receipt?.effect !== undefined ? { effect: receipt.effect } : {},
52355
+ dummy: receipt?.dummy === true
52356
+ };
52357
+ });
52358
+ const real = rows.filter((row) => !row.dummy);
52359
+ const succeeded = real.filter((row) => row.status === "succeeded");
52360
+ const status = real.length === 0 ? "dummy" : succeeded.length === real.length ? "full" : succeeded.length > 0 ? "partial" : "none";
52361
+ return {
52362
+ schemaVersion: 1,
52363
+ window: plan.window,
52364
+ runDate,
52365
+ status,
52366
+ published: succeeded.length,
52367
+ targets: real.length,
52368
+ rows
52369
+ };
52370
+ }
52371
+ var LEGACY_RESULT_FILES = [
52372
+ { plugin: "surfdash-pub", file: "_14_publish-surfdash_result.json" },
52373
+ { plugin: "podcast-pub", file: "_16_publish-podcast_result.json" }
52374
+ ];
52375
+ function slugify2(value) {
52376
+ return value.trim().replace(/[\s/]+/g, "-");
52377
+ }
52378
+ function targetId(target) {
52379
+ if (target.id)
52380
+ return target.id;
52381
+ if (target.operation)
52382
+ return `${target.plugin}.${slugify2(target.operation)}`;
52383
+ return target.plugin;
52384
+ }
52385
+ function flagValue(args, flag) {
52386
+ const idx = args.indexOf(flag);
52387
+ if (idx === -1)
52388
+ return;
52389
+ const value = args[idx + 1];
52390
+ return value !== undefined && !value.startsWith("--") ? value : undefined;
52391
+ }
52392
+ function listValue(args, flag) {
52393
+ const raw = flagValue(args, flag);
52394
+ if (raw === undefined)
52395
+ return [];
52396
+ return raw.split(",").map((token) => token.trim()).filter(Boolean);
52397
+ }
52398
+ async function appendEvent(fs, publishDir, event) {
52399
+ await fs.ensureDir(publishDir);
52400
+ const path = join21(publishDir, "publish-events.jsonl");
52401
+ const prior = await fs.exists(path) ? await fs.readFile(path) : "";
52402
+ await fs.writeFile(path, `${prior}${JSON.stringify({ at: new Date().toISOString(), ...event })}
52403
+ `);
52404
+ }
52405
+ var LINK_SOURCES = [
52406
+ { placeholder: "{{article_url}}", plugin: "surfdash-pub", receipt: "article" },
52407
+ { placeholder: "{{podcast_url}}", plugin: "podcast-pub", receipt: "podcast" }
52408
+ ];
52409
+ async function readPlan(fs, workDir, window) {
52410
+ const planPath = join21(workDir, "4-publish", `plan.${window}.json`);
52411
+ if (!await fs.exists(planPath)) {
52412
+ throw new Error(`no plan at ${planPath}; run publish-matrix-plan first`);
52413
+ }
52414
+ const parsed = PublishPlanSchema.safeParse(JSON.parse(await fs.readFile(planPath)));
52415
+ if (!parsed.success) {
52416
+ throw new Error(`malformed plan at ${planPath}: ${parsed.error.message}`);
52417
+ }
52418
+ return parsed.data;
52419
+ }
52420
+ async function composePlan(fs, input) {
52421
+ const bundleAbs = resolve21(input.bundlePath);
52422
+ if (!await fs.exists(bundleAbs))
52423
+ throw new Error(`bundle not found: ${bundleAbs}`);
52424
+ const bundleParsed = BundleSchema.safeParse(JSON.parse(await fs.readFile(bundleAbs)));
52425
+ if (!bundleParsed.success) {
52426
+ throw new Error(`malformed bundle at ${bundleAbs}: ${bundleParsed.error.message}`);
52427
+ }
52428
+ const bundle = bundleParsed.data;
52429
+ const bundleDir = dirname12(bundleAbs);
52430
+ const { preset, digest } = await loadPublishPreset(input.preset, { ...input.projectRoot ? { projectRoot: input.projectRoot } : {} }, fs);
52431
+ const inWindow = preset.targets.filter((target) => (target.window ?? "after-generate") === input.window && (target.mode ?? "draft") !== "off");
52432
+ const matches = (target, token) => target.plugin === token || target.id === token;
52433
+ for (const token of [...input.channels, ...input.liveChannels]) {
52434
+ if (!preset.targets.some((target) => matches(target, token))) {
52435
+ throw new Error(`channel '${token}' is not listed in preset '${preset.name}'`);
52436
+ }
52437
+ }
52438
+ for (const token of [...input.channels, ...input.liveChannels]) {
52439
+ if (!inWindow.some((target) => matches(target, token))) {
52440
+ throw new Error(`channel '${token}' has no target in window '${input.window}'`);
52441
+ }
52442
+ }
52443
+ const selected = input.channels.length === 0 ? inWindow : inWindow.filter((target) => input.channels.some((token) => matches(target, token)));
52444
+ const rows = [];
52445
+ const seen = new Set;
52446
+ for (const target of selected) {
52447
+ const id = targetId(target);
52448
+ if (seen.has(id))
52449
+ throw new Error(`duplicate target id '${id}' in preset '${preset.name}'`);
52450
+ seen.add(id);
52451
+ const liveToken = input.liveChannels.find((token) => matches(target, token));
52452
+ if (liveToken !== undefined && target.mode === "dummy") {
52453
+ throw new Error(`channel '${liveToken}' is dummy-only and cannot be promoted to live`);
52454
+ }
52455
+ const mode = input.dummy ? "dummy" : liveToken !== undefined ? "live" : target.mode === "dummy" ? "dummy" : "draft";
52456
+ if (target.variant === undefined) {
52457
+ throw new Error(`target '${id}' has no variant; a publish-matrix target must name one`);
52458
+ }
52459
+ const entry = bundle.variants[`${target.variant}@${target.plugin}`] ?? bundle.variants[target.variant];
52460
+ if (entry === undefined) {
52461
+ throw new Error(`bundle has no variant '${target.variant}' for target '${id}'`);
52462
+ }
52463
+ const variantPath = resolve21(bundleDir, entry.path);
52464
+ if (!await fs.exists(variantPath))
52465
+ throw new Error(`variant file not found: ${variantPath}`);
52466
+ const variantRaw = await fs.readFile(variantPath);
52467
+ const actual = createHash8("sha256").update(variantRaw).digest("hex");
52468
+ if (actual !== entry.sha256) {
52469
+ throw new Error(`variant digest mismatch for '${target.variant}': expected ${entry.sha256}, got ${actual}`);
52470
+ }
52471
+ const parsedContent = ContentSchema.safeParse(JSON.parse(variantRaw));
52472
+ if (!parsedContent.success) {
52473
+ throw new Error(`variant '${target.variant}' is not valid Content: ${parsedContent.error.message}`);
52474
+ }
52475
+ const payload = {
52476
+ ...parsedContent.data,
52477
+ metadata: {
52478
+ ...parsedContent.data.metadata ?? {},
52479
+ ...target.options ?? {},
52480
+ ...target.operation !== undefined ? { operation: target.operation } : {},
52481
+ publishStatus: mode === "live" ? "public" : "draft"
52482
+ }
52483
+ };
52484
+ const payloadRelative = `4-publish/payloads/content.${id}.json`;
52485
+ const payloadText = `${JSON.stringify(payload, null, 2)}
52486
+ `;
52487
+ rows.push({
52488
+ id,
52489
+ plugin: target.plugin,
52490
+ ...target.operation !== undefined ? { operation: target.operation } : {},
52491
+ variant: target.variant,
52492
+ tier: target.tier ?? 3,
52493
+ window: input.window,
52494
+ mode,
52495
+ required: target.required ?? false,
52496
+ approval: mode === "live" ? "required" : "none",
52497
+ status: "planned",
52498
+ ...target.profile !== undefined ? { profile: target.profile } : {},
52499
+ payloadPath: payloadRelative,
52500
+ payloadDigest: createHash8("sha256").update(payloadText).digest("hex")
52501
+ });
52502
+ await fs.ensureDir(join21(input.workDir, "4-publish", "payloads"));
52503
+ await fs.writeFile(join21(input.workDir, payloadRelative), payloadText);
52504
+ }
52505
+ return {
52506
+ schemaVersion: 1,
52507
+ window: input.window,
52508
+ bundleRunId: bundle.runId,
52509
+ parentRunId: bundle.parentRunId ?? bundle.runId,
52510
+ presetName: preset.name,
52511
+ presetDigest: digest,
52512
+ rows
52513
+ };
52514
+ }
52515
+ async function planPublishMatrix(args, deps = {}) {
52516
+ const fs = deps.fs ?? createNodeFileSystem();
52517
+ const [workDir = ""] = args;
52518
+ const bundlePath = flagValue(args, "--bundle");
52519
+ const presetFlag = flagValue(args, "--preset");
52520
+ const windowArg = flagValue(args, "--window");
52521
+ const channels = listValue(args, "--channels");
52522
+ const liveChannels = listValue(args, "--live-channels");
52523
+ const dummy = args.includes("--dummy");
52524
+ if (!workDir || bundlePath === undefined || windowArg === undefined) {
52525
+ echoError("usage: publish-matrix-plan <work_dir> --bundle <bundle.json> [--preset <slug>] --window <after-generate|cn-peak|overseas-rss> [--channels a,b] [--live-channels a,b] [--dummy]");
52526
+ return 1;
52527
+ }
52528
+ const parsedWindow = PublishWindowSchema.safeParse(windowArg);
52529
+ if (!parsedWindow.success) {
52530
+ echoError(`publish-matrix-plan: unknown window '${windowArg}'`);
52531
+ return 1;
52532
+ }
52533
+ if (dummy && liveChannels.length > 0) {
52534
+ echoError("publish-matrix-plan: --dummy is a rehearsal and cannot be combined with --live-channels");
52535
+ return 1;
52536
+ }
52537
+ try {
52538
+ const preset = presetFlag ?? await readPublishPresetSelector(process.env, fs);
52539
+ const plan = await composePlan(fs, {
52540
+ workDir,
52541
+ bundlePath,
52542
+ preset,
52543
+ window: parsedWindow.data,
52544
+ channels,
52545
+ liveChannels,
52546
+ dummy,
52547
+ ...deps.projectRoot ? { projectRoot: deps.projectRoot } : {}
52548
+ });
52549
+ const publishDir = join21(workDir, "4-publish");
52550
+ await fs.ensureDir(publishDir);
52551
+ await fs.writeFile(join21(publishDir, `plan.${plan.window}.json`), `${JSON.stringify(plan, null, 2)}
52552
+ `);
52553
+ for (const row of plan.rows) {
52554
+ await appendEvent(fs, publishDir, {
52555
+ event: "planned",
52556
+ window: plan.window,
52557
+ id: row.id,
52558
+ plugin: row.plugin,
52559
+ mode: row.mode,
52560
+ tier: row.tier
52561
+ });
52562
+ }
52563
+ echo(`${plan.rows.length} target(s) planned for window ${plan.window}`);
52564
+ return 0;
52565
+ } catch (err) {
52566
+ echoError(`publish-matrix-plan: ${err instanceof Error ? err.message : String(err)}`);
52567
+ return 1;
52568
+ }
52569
+ }
52570
+ async function checkPublishMatrix(args, deps = {}) {
52571
+ const fs = deps.fs ?? createNodeFileSystem();
52572
+ const [workDir = ""] = args;
52573
+ const windowArg = flagValue(args, "--window");
52574
+ const presetFlag = flagValue(args, "--preset");
52575
+ const allWindows = args.includes("--all-windows");
52576
+ const presetMode = allWindows;
52577
+ if (!workDir || windowArg === undefined && !presetMode) {
52578
+ echoError("usage: publish-matrix-check <work_dir> --window <after-generate|cn-peak|overseas-rss> | [--preset <slug>] --all-windows");
52579
+ return 1;
52580
+ }
52581
+ const plugins = deps.plugins ?? discoverAllPlugins().plugins;
52582
+ const byName = new Map(plugins.map((plugin) => [plugin.name, plugin]));
52583
+ const executor = deps.executor ?? new NodeProcessExecutor;
52584
+ const probes = [];
52585
+ try {
52586
+ let names;
52587
+ let outFile;
52588
+ let label;
52589
+ if (presetMode) {
52590
+ const { preset } = await loadPublishPreset(presetFlag ?? await readPublishPresetSelector(process.env, fs), { ...deps.projectRoot ? { projectRoot: deps.projectRoot } : {} }, fs);
52591
+ const unknown = preset.targets.filter((target) => !byName.has(target.plugin));
52592
+ if (unknown.length > 0) {
52593
+ echoError(`publish-matrix-check: preset '${preset.name}' names uninstalled plugin(s): ${unknown.map((target) => target.plugin).join(", ")}`);
52594
+ return 1;
52595
+ }
52596
+ names = [
52597
+ ...new Set(preset.targets.filter((target) => (target.mode ?? "draft") !== "off" && target.mode !== "dummy").map((target) => target.plugin))
52598
+ ];
52599
+ label = "preflight";
52600
+ outFile = "check.preflight.json";
52601
+ } else {
52602
+ const parsedWindow = PublishWindowSchema.safeParse(windowArg);
52603
+ if (!parsedWindow.success) {
52604
+ echoError(`publish-matrix-check: unknown window '${windowArg}'`);
52605
+ return 1;
52606
+ }
52607
+ const plan = await readPlan(fs, workDir, parsedWindow.data);
52608
+ names = [...new Set(plan.rows.filter((row) => row.mode !== "dummy").map((row) => row.plugin))];
52609
+ label = plan.window;
52610
+ outFile = `check.${plan.window}.json`;
52611
+ }
52612
+ for (const name of names) {
52613
+ const plugin = byName.get(name);
52614
+ if (plugin === undefined) {
52615
+ probes.push({ plugin: name, ok: false, detail: "plugin not installed" });
52616
+ echoError(`publish-matrix-check: plugin '${name}' is not installed`);
52617
+ continue;
52618
+ }
52619
+ const probe = await probePlugin(plugin, executor);
52620
+ probes.push({ plugin: name, ok: probe.ok, ...probe.detail ? { detail: probe.detail } : {} });
52621
+ if (!probe.ok) {
52622
+ echoError(`publish-matrix-check: channel probe for '${name}' refused: ${probe.detail ?? "unknown"}`);
52623
+ }
52624
+ }
52625
+ const publishDir = join21(workDir, "4-publish");
52626
+ await fs.ensureDir(publishDir);
52627
+ await fs.writeFile(join21(publishDir, outFile), `${JSON.stringify({ schemaVersion: 1, window: label, plugins: probes }, null, 2)}
52628
+ `);
52629
+ echo(names.length === 0 ? "no probes" : `${probes.length} probe(s) recorded for ${label}`);
52630
+ return 0;
52631
+ } catch (err) {
52632
+ echoError(`publish-matrix-check: ${err instanceof Error ? err.message : String(err)}`);
52633
+ return 1;
52634
+ }
52635
+ }
52636
+ async function runPublishMatrixClassify(args, deps = {}) {
52637
+ const fs = deps.fs ?? createNodeFileSystem();
52638
+ const [workDir = ""] = args;
52639
+ const windowArg = flagValue(args, "--window");
52640
+ const runDate = flagValue(args, "--run-date");
52641
+ if (!workDir || windowArg === undefined || runDate === undefined) {
52642
+ echoError("usage: publish-matrix-classify <work_dir> --window <w> --run-date <stamp>");
52643
+ return 1;
52644
+ }
52645
+ const parsedWindow = PublishWindowSchema.safeParse(windowArg);
52646
+ if (!parsedWindow.success) {
52647
+ echoError(`publish-matrix-classify: unknown window '${windowArg}'`);
52648
+ return 1;
52649
+ }
52650
+ const publishDir = join21(workDir, "4-publish");
52651
+ let plan;
52652
+ try {
52653
+ plan = await readPlan(fs, workDir, parsedWindow.data);
52654
+ } catch (err) {
52655
+ echoError(`publish-matrix-classify: ${err instanceof Error ? err.message : String(err)}`);
52656
+ await fs.ensureDir(publishDir);
52657
+ const none = {
52658
+ schemaVersion: 1,
52659
+ window: parsedWindow.data,
52660
+ runDate,
52661
+ status: "none",
52662
+ published: 0,
52663
+ targets: 0,
52664
+ rows: []
52665
+ };
52666
+ await fs.writeFile(join21(publishDir, `outcome.${parsedWindow.data}.json`), `${JSON.stringify(none, null, 2)}
52667
+ `);
52668
+ await fs.writeFile(join21(publishDir, `publish-status.${parsedWindow.data}.txt`), `none
52669
+ `);
52670
+ await fs.writeFile(join21(workDir, "publish-classify.summary.txt"), `${JSON.stringify({ status: "none", published: 0, targets: 0 })}
52671
+ `);
52672
+ return 0;
52673
+ }
52674
+ const receiptsDir = join21(publishDir, "receipts");
52675
+ const receipts = [];
52676
+ for (const row of plan.rows) {
52677
+ const path = join21(receiptsDir, `${row.id}.json`);
52678
+ if (!await fs.exists(path))
52679
+ continue;
52680
+ try {
52681
+ receipts.push(JSON.parse(await fs.readFile(path)));
52682
+ } catch {}
52683
+ }
52684
+ const outcome = classifyPublishMatrix(plan, receipts, runDate);
52685
+ await fs.ensureDir(publishDir);
52686
+ await fs.writeFile(join21(publishDir, `outcome.${outcome.window}.json`), `${JSON.stringify(outcome, null, 2)}
52687
+ `);
52688
+ await fs.writeFile(join21(publishDir, `publish-status.${outcome.window}.txt`), `${outcome.status}
52689
+ `);
52690
+ await fs.writeFile(join21(workDir, "publish-classify.summary.txt"), `${JSON.stringify({ status: outcome.status, published: outcome.published, targets: outcome.targets })}
52691
+ `);
52692
+ if (outcome.window === "after-generate") {
52693
+ const legacyDir = join21(workDir, "31-publish");
52694
+ for (const source of LEGACY_RESULT_FILES) {
52695
+ const row = plan.rows.find((candidate) => candidate.plugin === source.plugin && candidate.tier === 0);
52696
+ if (row === undefined || row.mode === "dummy")
52697
+ continue;
52698
+ const receipt = receipts.find((candidate) => (candidate.target ?? candidate.plugin) === row.id);
52699
+ if (receipt === undefined || receipt.dummy === true || receipt.aggregatePath === undefined)
52700
+ continue;
52701
+ const aggregate = receipt.aggregatePath;
52702
+ if (!await fs.exists(aggregate))
52703
+ continue;
52704
+ await fs.ensureDir(legacyDir);
52705
+ await fs.writeFile(join21(legacyDir, `${runDate}${source.file}`), await fs.readFile(aggregate));
52706
+ }
52707
+ }
52708
+ echo(outcome.status);
52709
+ return 0;
52710
+ }
52711
+ async function writeDummyReceipt(fs, row, context) {
52712
+ const base = {
52713
+ ...row.receiptExtras ?? {},
52714
+ schemaVersion: 1,
52715
+ parentRunId: context.parentRunId,
52716
+ planDigest: context.planDigest,
52717
+ payloadDigest: context.payloadDigest,
52718
+ plugin: row.plugin,
52719
+ profile: row.profile,
52720
+ mode: "draft",
52721
+ attemptId: "attempt-1",
52722
+ status: "succeeded",
52723
+ effect: "none",
52724
+ publicUrlVerified: false,
52725
+ dummy: true,
52726
+ url: `dummy://${row.key}`,
52727
+ startedAt: context.startedAt,
52728
+ finishedAt: new Date().toISOString()
52729
+ };
52730
+ let invalid;
52731
+ try {
52732
+ const raw = await fs.readFile(context.payloadPath);
52733
+ const parsed = ContentSchema.safeParse(JSON.parse(raw));
52734
+ if (!parsed.success)
52735
+ invalid = parsed.error.message;
52736
+ } catch (err) {
52737
+ invalid = err instanceof Error ? err.message : String(err);
52738
+ }
52739
+ if (invalid === undefined)
52740
+ return base;
52741
+ return {
52742
+ ...base,
52743
+ status: "failed",
52744
+ effect: "none",
52745
+ error: "invalid-payload",
52746
+ url: undefined
52747
+ };
52748
+ }
52749
+ async function readSucceeded(fs, receiptsDir, id) {
52750
+ const path = join21(receiptsDir, `${id}.json`);
52751
+ if (!await fs.exists(path))
52752
+ return false;
52753
+ try {
52754
+ return JSON.parse(await fs.readFile(path)).status === "succeeded";
52755
+ } catch {
52756
+ return false;
52757
+ }
52758
+ }
52759
+ async function readLinkMap(fs, receiptsDir, rows) {
52760
+ const map = {};
52761
+ for (const source of LINK_SOURCES) {
52762
+ const row = rows.find((candidate) => candidate.plugin === source.plugin && candidate.tier === 0);
52763
+ if (row === undefined)
52764
+ continue;
52765
+ const path = join21(receiptsDir, `${row.id}.json`);
52766
+ if (!await fs.exists(path))
52767
+ continue;
52768
+ try {
52769
+ const receipt = JSON.parse(await fs.readFile(path));
52770
+ if (receipt.status === "succeeded" && receipt.url)
52771
+ map[source.receipt] = receipt.url;
52772
+ } catch {}
52773
+ }
52774
+ return map;
52775
+ }
52776
+ async function sendPublishMatrix(args, deps = {}) {
52777
+ const fs = deps.fs ?? createNodeFileSystem();
52778
+ const [workDir = ""] = args;
52779
+ const windowArg = flagValue(args, "--window");
52780
+ const tierArg = flagValue(args, "--tier");
52781
+ const retry = args.includes("--retry");
52782
+ if (!workDir || windowArg === undefined) {
52783
+ echoError("usage: publish-matrix-send <work_dir> --window <w> [--retry] [--tier <n>]");
52784
+ return 1;
52785
+ }
52786
+ const parsedWindow = PublishWindowSchema.safeParse(windowArg);
52787
+ if (!parsedWindow.success) {
52788
+ echoError(`publish-matrix-send: unknown window '${windowArg}'`);
52789
+ return 1;
52790
+ }
52791
+ let onlyTier;
52792
+ if (tierArg !== undefined) {
52793
+ onlyTier = Number(tierArg);
52794
+ if (!Number.isInteger(onlyTier) || onlyTier < 0 || onlyTier > 3) {
52795
+ echoError(`publish-matrix-send: --tier must be 0-3, got '${tierArg}'`);
52796
+ return 1;
52797
+ }
52798
+ }
52799
+ try {
52800
+ const plan = await readPlan(fs, workDir, parsedWindow.data);
52801
+ const publishDir = join21(workDir, "4-publish");
52802
+ const receiptsDir = join21(publishDir, "receipts");
52803
+ await fs.ensureDir(receiptsDir);
52804
+ const planDigest = createHash8("sha256").update(await fs.readFile(join21(publishDir, `plan.${plan.window}.json`))).digest("hex");
52805
+ const succeededAlready = (id) => readSucceeded(fs, receiptsDir, id);
52806
+ const tiers = [...new Set(plan.rows.map((row) => row.tier))].sort((a, b) => a - b);
52807
+ const pending = onlyTier === undefined ? plan.rows : plan.rows.filter((row) => row.tier === onlyTier);
52808
+ let tierZeroBroken = false;
52809
+ for (const tier of tiers) {
52810
+ const rows = pending.filter((row) => row.tier === tier);
52811
+ if (rows.length === 0)
52812
+ continue;
52813
+ if (tierZeroBroken) {
52814
+ for (const row of rows) {
52815
+ await appendEvent(fs, publishDir, {
52816
+ event: "skipped",
52817
+ window: plan.window,
52818
+ id: row.id,
52819
+ plugin: row.plugin,
52820
+ reason: "tier-0-required-failed"
52821
+ });
52822
+ }
52823
+ continue;
52824
+ }
52825
+ const links = tier === 0 ? {} : await readLinkMap(fs, receiptsDir, plan.rows);
52826
+ const sendRowsInput = rows.map((row) => ({
52827
+ key: row.id,
52828
+ plugin: row.plugin,
52829
+ profile: row.profile ?? "article",
52830
+ mode: row.mode,
52831
+ payloadPath: join21(workDir, row.payloadPath),
52832
+ transport: "fanout",
52833
+ receiptExtras: { target: row.id, window: plan.window }
52834
+ }));
52835
+ await sendRows({
52836
+ rows: sendRowsInput,
52837
+ publishDir,
52838
+ receiptsDir,
52839
+ parentRunId: plan.parentRunId,
52840
+ planDigest,
52841
+ retry,
52842
+ label: "publish-matrix-send",
52843
+ fs,
52844
+ ...deps.dispatch ? { dispatch: deps.dispatch } : {},
52845
+ dummyReceipt: (row, context) => writeDummyReceipt(fs, row, { ...context, parentRunId: plan.parentRunId, planDigest }),
52846
+ preparePayload: async (row) => {
52847
+ if (tier === 0)
52848
+ return { payloadPath: row.payloadPath };
52849
+ const raw = await fs.exists(row.payloadPath) ? await fs.readFile(row.payloadPath) : "";
52850
+ let resolved = raw;
52851
+ for (const source of LINK_SOURCES) {
52852
+ const url = links[source.receipt];
52853
+ if (url === undefined)
52854
+ continue;
52855
+ resolved = resolved.split(source.placeholder).join(url);
52856
+ }
52857
+ if (LINK_SOURCES.some((source) => resolved.includes(source.placeholder))) {
52858
+ return { error: "unresolved-link" };
52859
+ }
52860
+ const resolvedPath = join21(workDir, "4-publish", "payloads", `content.${row.key}.resolved.json`);
52861
+ await fs.writeFile(resolvedPath, resolved);
52862
+ return { payloadPath: resolvedPath };
52863
+ },
52864
+ onReceipt: async (receipt) => {
52865
+ await appendEvent(fs, publishDir, {
52866
+ event: receipt.status === "succeeded" ? "sent" : "failed",
52867
+ window: plan.window,
52868
+ id: receipt.target ?? receipt.plugin,
52869
+ plugin: receipt.plugin,
52870
+ mode: receipt.mode,
52871
+ effect: receipt.effect,
52872
+ ...receipt.error ? { error: receipt.error } : {}
52873
+ });
52874
+ }
52875
+ });
52876
+ if (tier === 0) {
52877
+ tierZeroBroken = false;
52878
+ for (const row of rows) {
52879
+ if (!row.required)
52880
+ continue;
52881
+ if (await succeededAlready(row.id))
52882
+ continue;
52883
+ tierZeroBroken = true;
52884
+ break;
52885
+ }
52886
+ }
52887
+ }
52888
+ echo(`window ${plan.window}: ${pending.length} target(s) dispatched`);
52889
+ return 0;
52890
+ } catch (err) {
52891
+ echoError(`publish-matrix-send: ${err instanceof Error ? err.message : String(err)}`);
52892
+ return 1;
52893
+ }
52894
+ }
52895
+ var RssGateResultSchema = object2({
52896
+ schemaVersion: literal(1).default(1),
52897
+ date: string2().min(1),
52898
+ feedUrl: string2().min(1),
52899
+ hit: boolean2(),
52900
+ reason: _enum2(["guid", "title", "missing", "fetch-failed"]),
52901
+ detail: string2().optional()
52902
+ }).strict();
52903
+ function feedItems(xml) {
52904
+ const items = [];
52905
+ for (const block of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) {
52906
+ const body = block[1] ?? "";
52907
+ const title = /<title>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/.exec(body)?.[1]?.trim() ?? "";
52908
+ const guid = /<guid[^>]*>([\s\S]*?)<\/guid>/.exec(body)?.[1]?.trim() ?? "";
52909
+ items.push({ title, guid });
52910
+ }
52911
+ return items;
52912
+ }
52913
+ function matchRssGate(items, date, guid) {
52914
+ if (guid !== "" && items.some((item) => item.guid === guid))
52915
+ return "guid";
52916
+ if (items.some((item) => item.title.includes(date)))
52917
+ return "title";
52918
+ return "missing";
52919
+ }
52920
+ async function runPublishMatrixRssGate(args, deps = {}) {
52921
+ const fs = deps.fs ?? createNodeFileSystem();
52922
+ const [workDir = ""] = args;
52923
+ const date = flagValue(args, "--date");
52924
+ const feedUrl = flagValue(args, "--feed") ?? "https://podcast.salty.vip/rss/";
52925
+ if (!workDir || date === undefined) {
52926
+ echoError("usage: publish-matrix-rss-gate <work_dir> --date <YYYY-MM-DD> [--feed <url>]");
52927
+ return 1;
52928
+ }
52929
+ const publishDir = join21(workDir, "4-publish");
52930
+ let guid = "";
52931
+ const receiptsDir = join21(publishDir, "receipts");
52932
+ if (await fs.exists(receiptsDir)) {
52933
+ for (const planWindow of ["after-generate", "cn-peak"]) {
52934
+ const planPath = join21(publishDir, `plan.${planWindow}.json`);
52935
+ if (!await fs.exists(planPath))
52936
+ continue;
52937
+ const plan = JSON.parse(await fs.readFile(planPath));
52938
+ for (const row of plan.rows) {
52939
+ if (row.plugin !== "podcast-pub")
52940
+ continue;
52941
+ const receiptPath = join21(receiptsDir, `${row.id}.json`);
52942
+ if (!await fs.exists(receiptPath))
52943
+ continue;
52944
+ const receipt = JSON.parse(await fs.readFile(receiptPath));
52945
+ if (typeof receipt.guid === "string")
52946
+ guid = receipt.guid;
52947
+ }
52948
+ }
52949
+ }
52950
+ const write = async (result) => {
52951
+ await fs.ensureDir(publishDir);
52952
+ await fs.writeFile(join21(publishDir, "rss-gate.json"), `${JSON.stringify(result, null, 2)}
52953
+ `);
52954
+ await appendEvent(fs, publishDir, {
52955
+ event: result.hit ? "gate-hit" : "gate-miss",
52956
+ window: "overseas-rss",
52957
+ reason: result.reason,
52958
+ date: result.date
52959
+ });
52960
+ if (!result.hit) {
52961
+ const incident = {
52962
+ schemaVersion: 1,
52963
+ date: result.date,
52964
+ class: "rss-lag",
52965
+ reason: `feed ${result.feedUrl} has no episode for ${result.date} (${result.reason})`,
52966
+ evidence: ["4-publish/rss-gate.json", ...result.detail === undefined ? [] : [result.detail]]
52967
+ };
52968
+ await fs.writeFile(join21(publishDir, "incident.json"), `${JSON.stringify(incident, null, 2)}
52969
+ `);
52970
+ await fs.writeFile(join21(publishDir, "incident.md"), [
52971
+ `# Overseas window missed \u2014 ${result.date}`,
52972
+ "",
52973
+ `The podcast feed \`${result.feedUrl}\` does not carry the ${result.date} episode (${result.reason}).`,
52974
+ "Class `rss-lag`: the episode is not in the feed yet.",
52975
+ result.detail === undefined ? "" : `Detail: ${result.detail}`,
52976
+ "",
52977
+ "Next: confirm the generation run published the podcast tier-0 target, then re-run the",
52978
+ "overseas window for this date. An automatic repair only resends `effect: none` tier-0 rows."
52979
+ ].filter((line) => line !== "").join(`
52980
+ `));
52981
+ echoError(`publish-matrix-rss-gate: miss (${result.reason}) for ${result.date}`);
52982
+ }
52983
+ return result.hit ? 0 : 3;
52984
+ };
52985
+ let xml;
52986
+ try {
52987
+ const read = deps.readFeed ?? (async (url) => (await fetch(url)).text());
52988
+ xml = await read(feedUrl);
52989
+ } catch (err) {
52990
+ return write({
52991
+ schemaVersion: 1,
52992
+ date,
52993
+ feedUrl,
52994
+ hit: false,
52995
+ reason: "fetch-failed",
52996
+ detail: err instanceof Error ? err.message : String(err)
52997
+ });
52998
+ }
52999
+ const reason = matchRssGate(feedItems(xml), date, guid);
53000
+ return write({ schemaVersion: 1, date, feedUrl, hit: reason !== "missing", reason });
53001
+ }
53002
+ async function runPublishMatrixWaitUntil(args, deps = {}) {
53003
+ const [target = "", zone = ""] = args;
53004
+ const match = /^(\d{1,2}):(\d{2})$/.exec(target);
53005
+ if (!match || zone === "") {
53006
+ echoError("usage: publish-matrix-wait-until <HH:MM> <Area/City>");
53007
+ return 1;
53008
+ }
53009
+ const hours = Number(match[1]);
53010
+ const minutes = Number(match[2]);
53011
+ if (hours > 23 || minutes > 59) {
53012
+ echoError(`publish-matrix-wait-until: '${target}' is not a valid wall-clock time`);
53013
+ return 1;
53014
+ }
53015
+ const now = deps.now ?? Date.now;
53016
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
53017
+ const offsetAt = (at) => {
53018
+ const parts = new Intl.DateTimeFormat("en-US", {
53019
+ timeZone: zone,
53020
+ hour12: false,
53021
+ year: "numeric",
53022
+ month: "2-digit",
53023
+ day: "2-digit",
53024
+ hour: "2-digit",
53025
+ minute: "2-digit",
53026
+ second: "2-digit"
53027
+ }).formatToParts(new Date(at));
53028
+ const get = (type) => Number(parts.find((part) => part.type === type)?.value ?? "0");
53029
+ const asUtc = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
53030
+ return Math.round((asUtc - at) / 60000);
53031
+ };
53032
+ const wall = new Intl.DateTimeFormat("en-US", {
53033
+ timeZone: zone,
53034
+ hour12: false,
53035
+ hour: "2-digit",
53036
+ minute: "2-digit"
53037
+ }).formatToParts(new Date(now()));
53038
+ const part = (type) => Number(wall.find((entry) => entry.type === type)?.value ?? "0");
53039
+ const localHour = part("hour") % 24;
53040
+ const localMinute = part("minute");
53041
+ const already = localHour * 60 + localMinute;
53042
+ if (already >= hours * 60 + minutes) {
53043
+ echo(`publish-matrix-wait-until: ${zone} is already past ${target}`);
53044
+ return 0;
53045
+ }
53046
+ let wait = (hours * 60 + minutes - already) * 60000;
53047
+ const delta = offsetAt(now() + wait) - offsetAt(now());
53048
+ wait -= delta * 60000;
53049
+ if (wait <= 0) {
53050
+ echo(`publish-matrix-wait-until: ${zone} is already past ${target}`);
53051
+ return 0;
53052
+ }
53053
+ echo(`publish-matrix-wait-until: sleeping ${Math.round(wait / 60000)} min to ${target} ${zone}`);
53054
+ await sleep(wait);
53055
+ return 0;
53056
+ }
53057
+ var DEFAULT_PUBLISH_PRESET = "daily-ai-voice";
53058
+ async function readPublishPresetSelector(env = process.env, fs = createNodeFileSystem()) {
53059
+ const configPath = resolveItcConfigPath(env, env.HOME ?? "");
53060
+ if (!await fs.exists(configPath))
53061
+ return DEFAULT_PUBLISH_PRESET;
53062
+ let parsed;
53063
+ try {
53064
+ parsed = Bun.YAML.parse(await fs.readFile(configPath));
53065
+ } catch {
53066
+ throw new Error(`config file is not valid YAML: ${configPath}`);
53067
+ }
53068
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
53069
+ return DEFAULT_PUBLISH_PRESET;
53070
+ const workflows = parsed.workflows;
53071
+ if (workflows === null || typeof workflows !== "object" || Array.isArray(workflows))
53072
+ return DEFAULT_PUBLISH_PRESET;
53073
+ const block = workflows["kk-daily-ai-voice3"];
53074
+ if (block === undefined)
53075
+ return DEFAULT_PUBLISH_PRESET;
53076
+ if (block === null || typeof block !== "object" || Array.isArray(block)) {
53077
+ throw new Error("config key 'workflows.kk-daily-ai-voice3' must be a mapping");
53078
+ }
53079
+ const settings = block;
53080
+ for (const key of Object.keys(settings)) {
53081
+ if (key !== "publish_preset") {
53082
+ throw new Error(`config key 'workflows.kk-daily-ai-voice3.${key}' is not a declared setting (allowed: publish_preset)`);
53083
+ }
53084
+ }
53085
+ const value = settings.publish_preset;
53086
+ if (value === undefined)
53087
+ return DEFAULT_PUBLISH_PRESET;
53088
+ if (typeof value !== "string" || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
53089
+ throw new Error("config key 'workflows.kk-daily-ai-voice3.publish_preset' must be a lowercase hyphenated slug");
53090
+ }
53091
+ return value;
53092
+ }
53093
+ async function runPublishMatrixBundle(args, deps = {}) {
53094
+ const fs = deps.fs ?? createNodeFileSystem();
53095
+ const [workDir = ""] = args;
53096
+ const runDate = flagValue(args, "--run-date");
53097
+ const parentRunId = flagValue(args, "--run-id");
53098
+ if (!workDir || runDate === undefined || parentRunId === undefined) {
53099
+ echoError("usage: publish-matrix-bundle <work_dir> --run-date <YYYY-MM-DD> --run-id <voice3 run id>");
53100
+ return 1;
53101
+ }
53102
+ const variantsDir = join21(workDir, "30-variants");
53103
+ if (!await fs.exists(variantsDir)) {
53104
+ echoError(`publish-matrix-bundle: no variants at ${variantsDir}; run daily-bundle-variants first`);
53105
+ return 1;
53106
+ }
53107
+ try {
53108
+ const entries = (await fs.readDir(variantsDir)).filter((file) => file.endsWith(".content.json")).sort();
53109
+ if (entries.length === 0)
53110
+ throw new Error(`no *.content.json under ${variantsDir}`);
53111
+ const variants = {};
53112
+ for (const file of entries) {
53113
+ const key = file.replace(/\.content\.json$/, "").replaceAll("_", ":");
53114
+ const raw = await fs.readFile(join21(variantsDir, file));
53115
+ const parsed = ContentSchema.safeParse(JSON.parse(raw));
53116
+ if (!parsed.success) {
53117
+ throw new Error(`variant '${key}' is not valid Content: ${parsed.error.message}`);
53118
+ }
53119
+ variants[key] = {
53120
+ path: `30-variants/${file}`,
53121
+ sha256: createHash8("sha256").update(raw).digest("hex")
53122
+ };
53123
+ }
53124
+ const hasOwnedVariant = Object.keys(variants).some((key) => key === "article" || key === "episode");
53125
+ if (!hasOwnedVariant) {
53126
+ echoError("publish-matrix-bundle: no article/episode variant \u2014 the publication contract needs both");
53127
+ return 1;
53128
+ }
53129
+ const articleTitle = await readVariantTitle(fs, workDir, variants);
53130
+ const bundle = {
53131
+ schemaVersion: 1,
53132
+ runId: `${runDate}-${parentRunId}`,
53133
+ runDate,
53134
+ parentRunId,
53135
+ runs: [],
53136
+ episode: { title: articleTitle === "" ? `\u6BCF\u65E5AI\u4E71\u7096 \xB7 ${runDate}` : articleTitle },
53137
+ variants
53138
+ };
53139
+ const validated = BundleSchema.parse(bundle);
53140
+ const bundlePath = join21(workDir, "bundle.json");
53141
+ if (await fs.exists(bundlePath)) {
53142
+ try {
53143
+ const prior = BundleSchema.parse(JSON.parse(await fs.readFile(bundlePath)));
53144
+ if (prior.runs.length > 0)
53145
+ validated.runs = prior.runs;
53146
+ } catch {}
53147
+ }
53148
+ await fs.writeFile(bundlePath, `${JSON.stringify(validated, null, 2)}
53149
+ `);
53150
+ echo(`bundle frozen: ${Object.keys(variants).length} variant(s) -> ${bundlePath}`);
53151
+ return 0;
53152
+ } catch (err) {
53153
+ echoError(`publish-matrix-bundle: ${err instanceof Error ? err.message : String(err)}`);
53154
+ return 1;
53155
+ }
53156
+ }
53157
+ async function readVariantTitle(fs, workDir, variants) {
53158
+ for (const key of ["episode", "episode@podcast-pub", "article"]) {
53159
+ const entry = variants[key];
53160
+ if (entry === undefined)
53161
+ continue;
53162
+ try {
53163
+ const parsed = ContentSchema.parse(JSON.parse(await fs.readFile(join21(workDir, entry.path))));
53164
+ if (parsed.title !== undefined && parsed.title.trim() !== "")
53165
+ return parsed.title.trim();
53166
+ } catch {}
53167
+ }
53168
+ return "";
53169
+ }
53170
+
53171
+ // src/publish-matrix-stage.ts
53172
+ async function runPublishMatrixStage(args, fs = createNodeFileSystem()) {
53173
+ const [stageName, ...rest] = args;
53174
+ switch (stageName) {
53175
+ case "publish-matrix-plan":
53176
+ return planPublishMatrix(rest, { fs });
53177
+ case "publish-matrix-check":
53178
+ return checkPublishMatrix(rest, { fs });
53179
+ case "publish-matrix-send":
53180
+ return sendPublishMatrix(rest, { fs });
53181
+ case "publish-matrix-classify":
53182
+ return runPublishMatrixClassify(rest, { fs });
53183
+ case "publish-matrix-bundle":
53184
+ return runPublishMatrixBundle(rest, { fs });
53185
+ case "publish-matrix-rss-gate":
53186
+ return runPublishMatrixRssGate(rest, { fs });
53187
+ case "publish-matrix-wait-until":
53188
+ return runPublishMatrixWaitUntil(rest);
53189
+ default:
53190
+ return;
53191
+ }
53192
+ }
53193
+
51565
53194
  // src/commands/stage.ts
51566
53195
  var ROOT_GLOBALS = new Set(["-v", "--verbose"]);
51567
53196
  function normalizeStageArgs(argv) {
@@ -51577,10 +53206,11 @@ function normalizeStageArgs(argv) {
51577
53206
  }
51578
53207
  function registerStageCommand(program) {
51579
53208
  program.command("stage").description("Run a kk-workflow-stages.ts stage (workflow orchestration steps)").command("run").description("Run one stage by name with positional pass-through").argument("<args...>", "Stage name followed by its positional arguments").allowUnknownOption(true).action((args) => {
51580
- if (args[0]?.startsWith("itc-")) {
53209
+ const stageName = args[0] ?? "";
53210
+ if (stageName.startsWith("itc-") || stageName.startsWith("publish-matrix-")) {
51581
53211
  return (async () => {
51582
- const itcCode = await runItcStage(args);
51583
- const code = itcCode !== undefined ? itcCode : await runStageCancellable(args);
53212
+ const nativeCode = stageName.startsWith("itc-") ? await runItcStage(args) : await runPublishMatrixStage(args);
53213
+ const code = nativeCode !== undefined ? nativeCode : await runStageCancellable(args);
51584
53214
  if (code !== 0) {
51585
53215
  throw new CommanderError(code, "kk.stage.failed", `stage failed with exit code ${code}`);
51586
53216
  }
@@ -51596,7 +53226,7 @@ function registerStageCommand(program) {
51596
53226
  }
51597
53227
 
51598
53228
  // src/commands/store.ts
51599
- import { dirname as dirname12, join as join22, resolve as resolve22 } from "path";
53229
+ import { dirname as dirname13, join as join23, resolve as resolve23 } from "path";
51600
53230
  init_dist3();
51601
53231
  init_commander();
51602
53232
 
@@ -51604,16 +53234,16 @@ init_commander();
51604
53234
  init_dist3();
51605
53235
  import { randomUUID as randomUUID2 } from "crypto";
51606
53236
  import { tmpdir as tmpdir3 } from "os";
51607
- import { join as join21, resolve as resolve21 } from "path";
53237
+ import { join as join22, resolve as resolve22 } from "path";
51608
53238
  var STORE_CATALOG_PATH_REL = "plugins/kk/decisions/kk-items.yaml";
51609
53239
  var SPUR_CONFIG_PATH_REL = ".spur/config.yaml";
51610
53240
  var EVIDENCE_MAX_CHARS = 2000;
51611
53241
  var SUBPROCESS_FALLBACK_AFTER = 2;
51612
53242
  async function resolveCatalogPath(projectRoot, fs = createNodeFileSystem()) {
51613
53243
  const candidates = [
51614
- resolve21(projectRoot, STORE_CATALOG_PATH_REL),
51615
- resolve21(import.meta.dir, "../../..", STORE_CATALOG_PATH_REL),
51616
- resolve21(import.meta.dir, "..", STORE_CATALOG_PATH_REL)
53244
+ resolve22(projectRoot, STORE_CATALOG_PATH_REL),
53245
+ resolve22(import.meta.dir, "../../..", STORE_CATALOG_PATH_REL),
53246
+ resolve22(import.meta.dir, "..", STORE_CATALOG_PATH_REL)
51617
53247
  ];
51618
53248
  for (const candidate of candidates) {
51619
53249
  if (await fs.exists(candidate))
@@ -51633,7 +53263,7 @@ async function loadInjectedHub() {
51633
53263
  }
51634
53264
  async function readDecisionRouting(projectRoot, fs = createNodeFileSystem()) {
51635
53265
  const catalogPath = await resolveCatalogPath(projectRoot, fs);
51636
- const configPath = join21(projectRoot, SPUR_CONFIG_PATH_REL);
53266
+ const configPath = join22(projectRoot, SPUR_CONFIG_PATH_REL);
51637
53267
  if (!await fs.exists(configPath))
51638
53268
  return { catalogPath, routing: { makers: {} } };
51639
53269
  let parsed;
@@ -51717,8 +53347,8 @@ function createBufferedDecisionFetch(inner = fetch) {
51717
53347
  var bufferedDecisionFetch = createBufferedDecisionFetch();
51718
53348
  function runDecisionSubprocess(decisionId, input, maker, fs = createNodeFileSystem()) {
51719
53349
  const command = process.env.KK_SPUR_BIN ?? "spur";
51720
- const scratch = join21(tmpdir3(), `kk-decision-${randomUUID2()}`);
51721
- const evidencePath = join21(scratch, "evidence.md");
53350
+ const scratch = join22(tmpdir3(), `kk-decision-${randomUUID2()}`);
53351
+ const evidencePath = join22(scratch, "evidence.md");
51722
53352
  try {
51723
53353
  fs.writeFile(evidencePath, input.body);
51724
53354
  const args = [
@@ -51752,7 +53382,7 @@ var HEURISTIC_MAKER = "heuristic";
51752
53382
  var LABEL_PLUGIN = "episode-plan-gen";
51753
53383
  var LABEL_SCRATCH_DIR = "label";
51754
53384
  var PROFILE_DIR_REL = "plugins/kk/decisions/extract";
51755
- var CHECKOUT_ROOT3 = resolve22(import.meta.dir, "../../../..");
53385
+ var CHECKOUT_ROOT3 = resolve23(import.meta.dir, "../../../..");
51756
53386
  var CATALOG_PATH_REL = "plugins/kk/decisions/kk-items.yaml";
51757
53387
  var TOPIC_SLUG = /^[a-z0-9][a-z0-9-]*$/;
51758
53388
  var WINDOW_UNITS2 = { s: 1000, m: 60000, h: 3600000, d: 86400000 };
@@ -51833,7 +53463,7 @@ function sourceCountsOf3(docs) {
51833
53463
  return counts;
51834
53464
  }
51835
53465
  async function writeNewIds(fs, path, ids) {
51836
- await fs.ensureDir(dirname12(path));
53466
+ await fs.ensureDir(dirname13(path));
51837
53467
  await fs.writeFile(path, `${JSON.stringify([...new Set(ids)], null, 2)}
51838
53468
  `);
51839
53469
  }
@@ -51954,8 +53584,8 @@ async function readAnnotations(fs, outputPath) {
51954
53584
  async function heuristicAnnotations(fs, scratch, runId, items) {
51955
53585
  if (items.length === 0)
51956
53586
  return new Map;
51957
- const inputPath = join22(scratch, "label-input.json");
51958
- const outputPath = join22(scratch, "label-out.json");
53587
+ const inputPath = join23(scratch, "label-input.json");
53588
+ const outputPath = join23(scratch, "label-out.json");
51959
53589
  await fs.ensureDir(scratch);
51960
53590
  const docs = items.map((item) => ({
51961
53591
  id: item.id,
@@ -52174,7 +53804,7 @@ function parseDecisions(raw) {
52174
53804
  return ids;
52175
53805
  }
52176
53806
  async function readCatalogVersion(fs) {
52177
- const candidates = [resolve22(process.cwd(), CATALOG_PATH_REL), resolve22(CHECKOUT_ROOT3, CATALOG_PATH_REL)];
53807
+ const candidates = [resolve23(process.cwd(), CATALOG_PATH_REL), resolve23(CHECKOUT_ROOT3, CATALOG_PATH_REL)];
52178
53808
  for (const path of candidates) {
52179
53809
  try {
52180
53810
  if (!await fs.exists(path))
@@ -52306,7 +53936,7 @@ async function labelAction(options) {
52306
53936
  const mode = parseLabelMode(options.mode);
52307
53937
  const configPath = resolveKkConfigPath();
52308
53938
  const dbPath = await resolveStorePath(configPath, options.db);
52309
- const scratch = join22(dirname12(plan.kind === "new-ids" ? plan.newIds : dbPath), LABEL_SCRATCH_DIR);
53939
+ const scratch = join23(dirname13(plan.kind === "new-ids" ? plan.newIds : dbPath), LABEL_SCRATCH_DIR);
52310
53940
  const store = await openStore({ path: dbPath });
52311
53941
  const catalogVersion = await readCatalogVersion(fs);
52312
53942
  const labels = (await readStoreConfig(configPath)).labels;
@@ -52351,8 +53981,8 @@ async function resolveProfilePath(fs, topic) {
52351
53981
  throw new Error(`--topic must be a lowercase slug (e.g. ai-news), got '${topic}'`);
52352
53982
  }
52353
53983
  const candidates = [
52354
- resolve22(process.cwd(), PROFILE_DIR_REL, `${topic}.yaml`),
52355
- resolve22(CHECKOUT_ROOT3, PROFILE_DIR_REL, `${topic}.yaml`)
53984
+ resolve23(process.cwd(), PROFILE_DIR_REL, `${topic}.yaml`),
53985
+ resolve23(CHECKOUT_ROOT3, PROFILE_DIR_REL, `${topic}.yaml`)
52356
53986
  ];
52357
53987
  for (const path of candidates) {
52358
53988
  if (await fs.exists(path))
@@ -52389,7 +54019,7 @@ async function extractAction(options) {
52389
54019
  try {
52390
54020
  const policy = (await readStoreConfig(resolveKkConfigPath())).labels;
52391
54021
  const docs = (await store.items.selectByProfile(query, policy)).map(toPoolDoc2);
52392
- await fs.ensureDir(dirname12(options.out));
54022
+ await fs.ensureDir(dirname13(options.out));
52393
54023
  await fs.writeFile(options.out, `${JSON.stringify(docs, null, 2)}
52394
54024
  `);
52395
54025
  consoleOutput.write(JSON.stringify({ count: docs.length }));
@@ -52424,6 +54054,65 @@ async function countPoolItems(fs, path) {
52424
54054
  return 0;
52425
54055
  }
52426
54056
  }
54057
+ var DEFAULT_STATS_DAYS = 14;
54058
+ async function statsAction(options) {
54059
+ try {
54060
+ const days = Number(options.days === "" ? DEFAULT_STATS_DAYS : options.days);
54061
+ if (!Number.isInteger(days) || days <= 0) {
54062
+ throw new Error(`--days must be a positive integer, got '${options.days}'`);
54063
+ }
54064
+ const sinceMs = Date.now() - days * 86400000;
54065
+ const dbPath = await resolveStorePath(resolveKkConfigPath(), options.db);
54066
+ const store = await openStore({ path: dbPath });
54067
+ try {
54068
+ const rows = await store.collectView.channelYield(sinceMs);
54069
+ const sources = [...new Set(rows.map((row) => row.source))].sort();
54070
+ const byDay = new Map;
54071
+ for (const row of rows) {
54072
+ const entry = byDay.get(row.day) ?? new Map;
54073
+ entry.set(row.source, (entry.get(row.source) ?? 0) + row.items);
54074
+ byDay.set(row.day, entry);
54075
+ }
54076
+ const bySource = {};
54077
+ for (const row of rows) {
54078
+ bySource[row.source] = (bySource[row.source] ?? 0) + row.items;
54079
+ }
54080
+ const window = [...byDay.entries()].map(([day, counts]) => ({
54081
+ day,
54082
+ total: [...counts.values()].reduce((sum, n) => sum + n, 0),
54083
+ sources: Object.fromEntries([...counts.entries()].sort(([a], [b]) => a.localeCompare(b)))
54084
+ }));
54085
+ if (options.json === true) {
54086
+ consoleOutput.write(JSON.stringify({ days, byDay: window, bySource }));
54087
+ return;
54088
+ }
54089
+ consoleOutput.write(renderChannelTable(window, sources, bySource));
54090
+ } finally {
54091
+ await store.close();
54092
+ }
54093
+ } catch (error) {
54094
+ failLoud5("kk.store.stats.failed", error);
54095
+ }
54096
+ }
54097
+ function renderChannelTable(window, sources, bySource) {
54098
+ if (window.length === 0)
54099
+ return "no items in the window";
54100
+ const headers = ["day", ...sources, "total"];
54101
+ const body = window.map((entry) => [
54102
+ entry.day,
54103
+ ...sources.map((source) => String(entry.sources[source] ?? 0)),
54104
+ String(entry.total)
54105
+ ]);
54106
+ const footer = [
54107
+ "TOTAL",
54108
+ ...sources.map((source) => String(bySource[source] ?? 0)),
54109
+ String(window.reduce((sum, entry) => sum + entry.total, 0))
54110
+ ];
54111
+ const widths = headers.map((header, i) => Math.max(header.length, ...body.map((row) => (row[i] ?? "").length), (footer[i] ?? "").length));
54112
+ const line = (row) => row.map((cell, i) => i === 0 ? cell.padEnd(widths[i] ?? 0) : cell.padStart(widths[i] ?? 0)).join(" ");
54113
+ return [line(headers), ...body.map(line), line(footer)].join(`
54114
+ `);
54115
+ }
52427
54116
  async function poolCountAction(options) {
52428
54117
  try {
52429
54118
  const min = Number(options.min);
@@ -52543,6 +54232,7 @@ function registerStoreCommand(program) {
52543
54232
  store.command("label").description("Label items as raw observations, heuristic-first; prints the {verdict, confidence, labeled, model, fallback, consults, consultMs} line, or re-label a historical pool with --replay").option("--run <id>", "Collect run id the labels belong to (required with --new-ids)").option("--new-ids <path>", "Input path listing the item ids this pass must decide").option("--replay <selector>", "Re-label a historical pool (mutually exclusive with --new-ids): 'topic:<profile>', 'run:<collect-run-id>' or 'since:<n><s|m|h|d>'; requires --maker").option("--decisions <ids>", `Comma-separated decision ids a --replay routes (default: ${CATALOG_DECISION_IDS.join(",")})`).option("--maker <name>", "Decision maker override for the catalog calls (defaults to .spur/config.yaml decisions.maker; required with --replay)").option("--mode <mode>", "Labeling mode: 'heuristic' (default) asks the model only where the keyword pass is unsure; 'decision' asks it for every item", "heuristic").option("--window <duration>", "Only items first seen within this window ('<n>s|m|h|d') for a 'topic:' replay selector", DEFAULT_EXTRACT_WINDOW).option("--db <path>", "Store database path (default: kk config store.db_path, else .works/kk.db)").action(labelAction);
52544
54233
  store.command("extract").description("Select a topic pool from the stored labels via a profile; writes Doc[] and prints {count}").requiredOption("--topic <topic>", `Topic profile name (reads ${PROFILE_DIR_REL}/<topic>.yaml)`).option("--window <duration>", "Only items first seen within this window ('<n>s|m|h|d')", DEFAULT_EXTRACT_WINDOW).option("--limit <n>", "Pool cap (default: the profile limit, else 20)").option("--include-selected", "Include items whose stories are already selected or published for --topic").requiredOption("--out <path>", "Output Doc[] JSON path (the fan-in blended.json shape)").option("--db <path>", "Store database path (default: kk config store.db_path, else .works/kk.db)").action(extractAction);
52545
54234
  store.command("pool-count").description("Guard predicate: exit 0 when the pool file holds at least --min items (prints {count, min})").argument("<file>", "Pool artifact: a Doc[] JSON file or an object carrying metadata.docs").requiredOption("--min <n>", "Minimum item count for exit 0").action((file, options) => poolCountAction({ file, min: options.min }));
54235
+ store.command("stats").description("New-item yield per local day per source channel (read-only channel-width report)").option("--days <n>", "Trailing window in days", String(DEFAULT_STATS_DAYS)).option("--json", "Print the {days, byDay, bySource} JSON instead of the table").option("--db <path>", "Store database path (default: kk config store.db_path, else .works/kk.db)").action(statsAction);
52546
54236
  store.command("select").description("Record a run\u2019s planned set as selected stories for --topic (idempotent); prints {selected}").requiredOption("--topic <topic>", "Topic profile the selection belongs to (e.g. ai-news)").requiredOption("--workflow <name>", "Workflow that made the selection (provenance)").requiredOption("--run <id>", "Run id the selection belongs to").requiredOption("--in <path>", "Input file: a Doc[] or an object carrying metadata.docs").option("--db <path>", "Store database path (default: kk config store.db_path, else .works/kk.db)").action(selectAction);
52547
54237
  store.command("mark-published").description("Promote a run\u2019s selected rows to published; prints {published} (0 is exit 0)").requiredOption("--topic <topic>", "Topic profile the selection belongs to (e.g. ai-news)").requiredOption("--run <id>", "Run id whose selected rows are promoted").option("--in <path>", "Optional file listing the ids to promote (same shapes as select)").option("--db <path>", "Store database path (default: kk config store.db_path, else .works/kk.db)").action(markPublishedAction);
52548
54238
  store.command("release").description("Drop a run\u2019s selected rows so its items re-enter the pool; prints {released}").requiredOption("--run <id>", "Run id whose selected rows are dropped").option("--topic <topic>", "Narrow the release to one topic (default: every topic the run selected for)").option("--db <path>", "Store database path (default: kk config store.db_path, else .works/kk.db)").action(releaseAction);