@gobing-ai/knowledge-kit 0.1.10 → 0.1.11

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 (46) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +128 -25
  3. package/package.json +1 -1
  4. package/plugins/kk/plugin.json +1 -1
  5. package/plugins/kk/scripts/daily-scheduled-run.ts +298 -0
  6. package/plugins/kk/scripts/daily-stages.ts +1057 -108
  7. package/plugins/kk/scripts/kk-workflow-stages.ts +76 -10
  8. package/plugins/kk/scripts/safety-artifact-check.ts +235 -0
  9. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +168 -121
  10. package/plugins/kk/workflows/kk-daily-safety.yaml +129 -50
  11. package/plugins/publishings/bilibili-pub/dist/index.js +2 -1
  12. package/plugins/publishings/bilibili-pub/dist/profile-check.js +2 -1
  13. package/plugins/publishings/cnblogs-pub/dist/index.js +2 -1
  14. package/plugins/publishings/cnblogs-pub/dist/profile-check.js +2 -1
  15. package/plugins/publishings/csdn-pub/dist/index.js +2 -1
  16. package/plugins/publishings/csdn-pub/dist/profile-check.js +2 -1
  17. package/plugins/publishings/infoq-pub/dist/index.js +2 -1
  18. package/plugins/publishings/infoq-pub/dist/profile-check.js +2 -1
  19. package/plugins/publishings/jike-pub/dist/index.js +2 -1
  20. package/plugins/publishings/jike-pub/dist/profile-check.js +2 -1
  21. package/plugins/publishings/juejin-pub/dist/index.js +2 -1
  22. package/plugins/publishings/juejin-pub/dist/profile-check.js +2 -1
  23. package/plugins/publishings/podcast-pub/dist/index.js +46 -8
  24. package/plugins/publishings/podcast-pub/src/index.ts +62 -5
  25. package/plugins/publishings/shipinhao-pub/dist/index.js +2 -1
  26. package/plugins/publishings/shipinhao-pub/dist/profile-check.js +2 -1
  27. package/plugins/publishings/sspai-pub/dist/index.js +2 -1
  28. package/plugins/publishings/sspai-pub/dist/profile-check.js +2 -1
  29. package/plugins/publishings/substack-pub/dist/index.js +2 -1
  30. package/plugins/publishings/substack-pub/dist/profile-check.js +2 -1
  31. package/plugins/publishings/surfdash-pub/dist/index.js +460 -52
  32. package/plugins/publishings/surfdash-pub/src/digest-check.ts +85 -19
  33. package/plugins/publishings/surfdash-pub/src/index.ts +589 -64
  34. package/plugins/publishings/wechatmp-pub/dist/index.js +933 -98
  35. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +60 -3
  36. package/plugins/publishings/wechatmp-pub/src/flow.ts +1125 -85
  37. package/plugins/publishings/wechatmp-pub/src/index.ts +112 -30
  38. package/plugins/publishings/wechatmp-pub/src/page.ts +105 -0
  39. package/plugins/publishings/weibo-pub/dist/index.js +2 -1
  40. package/plugins/publishings/weibo-pub/dist/profile-check.js +2 -1
  41. package/plugins/publishings/x-pub/dist/index.js +2 -1
  42. package/plugins/publishings/x-pub/dist/profile-check.js +2 -1
  43. package/plugins/publishings/xhs-pub/dist/index.js +2 -1
  44. package/plugins/publishings/xhs-pub/dist/profile-check.js +2 -1
  45. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +2 -1
  46. package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +2 -1
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "plugins": [
8
8
  {
9
- "version": "0.1.10",
9
+ "version": "0.1.11",
10
10
  "name": "kk",
11
11
  "source": "./plugins/kk",
12
12
  "description": "knowledge-kit — a set of toolkit for knowledge management"
package/dist/index.js CHANGED
@@ -37862,9 +37862,11 @@ var init_dist6 = __esm(() => {
37862
37862
  // ../../packages/publish-harness/src/bot-fs.ts
37863
37863
  import {
37864
37864
  chmodSync,
37865
+ closeSync,
37865
37866
  copyFileSync,
37866
37867
  lstatSync,
37867
37868
  mkdirSync as mkdirSync4,
37869
+ openSync,
37868
37870
  readdirSync as readdirSync4,
37869
37871
  realpathSync as realpathSync5,
37870
37872
  rmdirSync
@@ -37906,6 +37908,9 @@ function rmdir(path) {
37906
37908
  function chmod(path, mode) {
37907
37909
  chmodSync(path, mode);
37908
37910
  }
37911
+ function createExclusiveFile(path) {
37912
+ closeSync(openSync(path, "wx", 384));
37913
+ }
37909
37914
  function copyProfileEntry(src, dest, options = {}) {
37910
37915
  const stat = lstat(src);
37911
37916
  if (stat === null)
@@ -38790,6 +38795,8 @@ __export(exports_browser, {
38790
38795
  canonicalize: () => canonicalize,
38791
38796
  captureSessionFacts: () => captureSessionFacts,
38792
38797
  checkChannelSession: () => checkChannelSession,
38798
+ chmod: () => chmod,
38799
+ createExclusiveFile: () => createExclusiveFile,
38793
38800
  createKkBrowserLauncher: () => createKkBrowserLauncher,
38794
38801
  createShadowFinder: () => createShadowFinder,
38795
38802
  enterDelegatedLease: () => enterDelegatedLease,
@@ -38807,6 +38814,7 @@ __export(exports_browser, {
38807
38814
  loginBot: () => loginBot,
38808
38815
  logoutBot: () => logoutBot,
38809
38816
  markdownToHtml: () => markdownToHtml,
38817
+ mkdirExclusive: () => mkdirExclusive,
38810
38818
  normalizeBotDefaultUrl: () => normalizeBotDefaultUrl,
38811
38819
  normalizeSessionExpiry: () => normalizeSessionExpiry,
38812
38820
  normalizeSessionIdentity: () => normalizeSessionIdentity,
@@ -38821,6 +38829,7 @@ __export(exports_browser, {
38821
38829
  resolveBotProfileDir: () => resolveBotProfileDir,
38822
38830
  resolveChannelProfileDir: () => resolveChannelProfileDir,
38823
38831
  resolveSelector: () => resolveSelector,
38832
+ rmdir: () => rmdir,
38824
38833
  runProfileCli: () => runProfileCli,
38825
38834
  uploadFile: () => uploadFile,
38826
38835
  verifyLeaseHeld: () => verifyLeaseHeld,
@@ -39625,6 +39634,7 @@ var init_browser = __esm(() => {
39625
39634
  init_dist();
39626
39635
  init_bot();
39627
39636
  init_bot_fs();
39637
+ init_bot_fs();
39628
39638
  init_bot_transaction();
39629
39639
  init_src();
39630
39640
  init_profile_dir2();
@@ -48182,11 +48192,47 @@ async function prepareAction(options) {
48182
48192
  failLoud2("kk.daily.prepare.failed", error);
48183
48193
  }
48184
48194
  }
48185
- function targetsOf(path) {
48186
- if (!existsSync5(path))
48187
- return [];
48188
- const parsed = parseJson3(readFileSync7(path, "utf-8"));
48189
- return Array.isArray(parsed?.targets) ? parsed.targets.filter((e) => e !== null && typeof e === "object") : [];
48195
+ var PRIMARY_PUBLISH_TARGETS = ["surfdash-pub", "podcast-pub"];
48196
+ function isRecord2(value) {
48197
+ return value !== null && typeof value === "object" && !Array.isArray(value);
48198
+ }
48199
+ function failedPrimaryTarget(target, reason) {
48200
+ return { target, ok: false, status: "failed", error: reason };
48201
+ }
48202
+ function readPrimaryReceipt(path, expectedTarget) {
48203
+ if (!existsSync5(path)) {
48204
+ const issue = `${expectedTarget} receipt file is missing`;
48205
+ return { targets: [failedPrimaryTarget(expectedTarget, issue)], issue };
48206
+ }
48207
+ let parsed;
48208
+ try {
48209
+ parsed = parseJson3(readFileSync7(path, "utf-8"));
48210
+ } catch {
48211
+ const issue = `${expectedTarget} receipt is not valid JSON`;
48212
+ return { targets: [failedPrimaryTarget(expectedTarget, issue)], issue };
48213
+ }
48214
+ if (!isRecord2(parsed) || !Array.isArray(parsed.targets)) {
48215
+ const issue = `${expectedTarget} receipt has no targets array`;
48216
+ return { targets: [failedPrimaryTarget(expectedTarget, issue)], issue };
48217
+ }
48218
+ const rawTargets = parsed.targets;
48219
+ const targets = rawTargets.filter(isRecord2);
48220
+ const issues = [];
48221
+ if (targets.length !== rawTargets.length) {
48222
+ issues.push(`${expectedTarget} receipt contains malformed target records`);
48223
+ }
48224
+ if (targets.length === 0) {
48225
+ issues.push(`${expectedTarget} receipt targets array is empty`);
48226
+ }
48227
+ const observedExpected = targets.some((entry) => entry.target === expectedTarget);
48228
+ if (!observedExpected) {
48229
+ targets.push(failedPrimaryTarget(expectedTarget, `${expectedTarget} receipt has no named target record`));
48230
+ issues.push(`${expectedTarget} receipt has no named target record`);
48231
+ }
48232
+ return { targets, issue: issues.length === 0 ? undefined : issues.join("; ") };
48233
+ }
48234
+ function isPublishedPrimaryTarget(entry) {
48235
+ return PRIMARY_PUBLISH_TARGETS.some((target) => target === entry.target) && entry.ok === true && entry.status === "published";
48190
48236
  }
48191
48237
  async function publishClassifyAction(options) {
48192
48238
  try {
@@ -48196,14 +48242,36 @@ async function publishClassifyAction(options) {
48196
48242
  const statusPath = join7(dir, `${runDate}_17_publish_publish-status.txt`);
48197
48243
  const surfdash = join7(dir, `${runDate}_14_publish-surfdash_result.json`);
48198
48244
  const podcast = join7(dir, `${runDate}_16_publish-podcast_result.json`);
48199
- const bothWritten = existsSync5(surfdash) && existsSync5(podcast);
48200
- const targets = bothWritten ? [...targetsOf(surfdash), ...targetsOf(podcast)] : [];
48201
- const okCount = targets.filter((entry) => entry.ok === true && entry.status === "published").length;
48245
+ const receipts = [readPrimaryReceipt(surfdash, "surfdash-pub"), readPrimaryReceipt(podcast, "podcast-pub")];
48246
+ const targets = receipts.flatMap((receipt) => receipt.targets);
48247
+ const receiptIssues = receipts.flatMap((receipt) => receipt.issue === undefined ? [] : [receipt.issue]);
48248
+ const okCount = targets.filter(isPublishedPrimaryTarget).length;
48202
48249
  const total = targets.length;
48203
- const status = okCount > 0 && okCount === total ? "full" : okCount > 0 ? "partial" : "none";
48250
+ const exactPrimarySet = receiptIssues.length === 0 && targets.length === PRIMARY_PUBLISH_TARGETS.length && PRIMARY_PUBLISH_TARGETS.every((target) => targets.filter((entry) => entry.target === target).length === 1) && targets.every(isPublishedPrimaryTarget);
48251
+ let status = exactPrimarySet ? "full" : okCount > 0 ? "partial" : "none";
48204
48252
  mkdirSync3(dir, { recursive: true });
48205
- writeFileSync4(resultPath, `${JSON.stringify({ ok: okCount === total && total > 0, targets }, null, 2)}`);
48206
- writeFileSync4(statusPath, status);
48253
+ const writeClassification = (completenessError) => {
48254
+ const result = {
48255
+ ok: status === "full",
48256
+ targets,
48257
+ ...receiptIssues.length === 0 ? {} : { issues: receiptIssues },
48258
+ ...completenessError === undefined ? {} : { completenessError }
48259
+ };
48260
+ writeFileSync4(resultPath, `${JSON.stringify(result, null, 2)}`);
48261
+ writeFileSync4(statusPath, status);
48262
+ };
48263
+ writeClassification();
48264
+ const writeSummary = (missing, completenessError) => {
48265
+ writeFileSync4(join7(workDir, "publish-classify.summary.txt"), `${JSON.stringify({
48266
+ status,
48267
+ published: okCount,
48268
+ targets: total,
48269
+ ...receiptIssues.length === 0 ? {} : { issues: receiptIssues },
48270
+ ...completenessError === undefined ? {} : { completenessError },
48271
+ missing
48272
+ })}
48273
+ `);
48274
+ };
48207
48275
  writeFileSync4(join7(workDir, "publish-classify.summary.txt"), `${JSON.stringify({ status, published: okCount, targets: total })}
48208
48276
  `);
48209
48277
  if (options.foldTail === true) {
@@ -48219,20 +48287,45 @@ async function publishClassifyAction(options) {
48219
48287
  failLoud2("kk.daily.publish-classify.patch-failed", new Error(`exit ${code}`));
48220
48288
  }
48221
48289
  }
48222
- runStage(["daily-publish-completeness", workDir, runDate]);
48290
+ let completenessError;
48291
+ let completenessCode = 1;
48292
+ try {
48293
+ completenessCode = runStage(["daily-publish-completeness", workDir, runDate]);
48294
+ } catch (error) {
48295
+ completenessError = `completeness stage failed: ${error instanceof Error ? error.message : String(error)}`;
48296
+ }
48223
48297
  const completenessPath = join7(dir, `${runDate}_17_publish_completeness.json`);
48224
48298
  let missing = [];
48225
- if (existsSync5(completenessPath)) {
48299
+ if (completenessError === undefined && completenessCode !== 0) {
48300
+ completenessError = `completeness stage exited ${completenessCode}`;
48301
+ } else if (completenessError === undefined && !existsSync5(completenessPath)) {
48302
+ completenessError = "completeness result artifact is missing";
48303
+ } else if (completenessError === undefined) {
48226
48304
  try {
48227
48305
  const parsed = parseJson3(readFileSync7(completenessPath, "utf-8"));
48228
- if (Array.isArray(parsed?.missing)) {
48229
- missing = parsed.missing.filter((item) => typeof item === "string");
48306
+ if (!isRecord2(parsed) || !Array.isArray(parsed.missing) || !parsed.missing.every((item) => typeof item === "string")) {
48307
+ completenessError = "completeness result has no valid missing[] probe";
48308
+ } else {
48309
+ missing = parsed.missing;
48230
48310
  }
48231
48311
  } catch {
48232
- missing = [];
48312
+ completenessError = "completeness result is not valid JSON";
48233
48313
  }
48234
48314
  }
48235
- consoleOutput.write(JSON.stringify({ status, published: okCount, targets: total, missing }));
48315
+ if (completenessError !== undefined)
48316
+ missing = ["completeness"];
48317
+ if (completenessError !== undefined && status === "full")
48318
+ status = "partial";
48319
+ writeClassification(completenessError);
48320
+ writeSummary(missing, completenessError);
48321
+ consoleOutput.write(JSON.stringify({
48322
+ status,
48323
+ published: okCount,
48324
+ targets: total,
48325
+ missing,
48326
+ ...receiptIssues.length === 0 ? {} : { issues: receiptIssues },
48327
+ ...completenessError === undefined ? {} : { completenessError }
48328
+ }));
48236
48329
  } catch (error) {
48237
48330
  if (error instanceof CommanderError)
48238
48331
  throw error;
@@ -52761,7 +52854,8 @@ async function readSucceeded(fs, receiptsDir, id) {
52761
52854
  }
52762
52855
  }
52763
52856
  async function readLinkMap(fs, receiptsDir, rows) {
52764
- const map = {};
52857
+ const real = {};
52858
+ const all = {};
52765
52859
  for (const source of LINK_SOURCES) {
52766
52860
  const row = rows.find((candidate) => candidate.plugin === source.plugin && candidate.tier === 0);
52767
52861
  if (row === undefined)
@@ -52771,11 +52865,14 @@ async function readLinkMap(fs, receiptsDir, rows) {
52771
52865
  continue;
52772
52866
  try {
52773
52867
  const receipt = JSON.parse(await fs.readFile(path));
52774
- if (receipt.status === "succeeded" && receipt.url)
52775
- map[source.receipt] = receipt.url;
52868
+ if (receipt.status !== "succeeded" || !receipt.url)
52869
+ continue;
52870
+ all[source.receipt] = receipt.url;
52871
+ if (receipt.dummy !== true)
52872
+ real[source.receipt] = receipt.url;
52776
52873
  } catch {}
52777
52874
  }
52778
- return map;
52875
+ return { real, all };
52779
52876
  }
52780
52877
  async function sendPublishMatrix(args, deps = {}) {
52781
52878
  const fs = deps.fs ?? createNodeFileSystem();
@@ -52826,7 +52923,7 @@ async function sendPublishMatrix(args, deps = {}) {
52826
52923
  }
52827
52924
  continue;
52828
52925
  }
52829
- const links = tier === 0 ? {} : await readLinkMap(fs, receiptsDir, plan.rows);
52926
+ const linkMaps = tier === 0 ? { real: {}, all: {} } : await readLinkMap(fs, receiptsDir, plan.rows);
52830
52927
  const sendRowsInput = rows.map((row) => ({
52831
52928
  key: row.id,
52832
52929
  plugin: row.plugin,
@@ -52851,12 +52948,13 @@ async function sendPublishMatrix(args, deps = {}) {
52851
52948
  if (tier === 0)
52852
52949
  return { payloadPath: row.payloadPath };
52853
52950
  const raw = await fs.exists(row.payloadPath) ? await fs.readFile(row.payloadPath) : "";
52951
+ const links = row.mode === "dummy" ? linkMaps.all : linkMaps.real;
52854
52952
  let resolved = raw;
52855
52953
  for (const source of LINK_SOURCES) {
52856
52954
  const url = links[source.receipt];
52857
52955
  if (url === undefined)
52858
52956
  continue;
52859
- resolved = resolved.split(source.placeholder).join(url);
52957
+ resolved = resolved.split(source.placeholder).join(JSON.stringify(url).slice(1, -1));
52860
52958
  }
52861
52959
  if (LINK_SOURCES.some((source) => resolved.includes(source.placeholder))) {
52862
52960
  return { error: "unresolved-link" };
@@ -52988,7 +53086,12 @@ async function runPublishMatrixRssGate(args, deps = {}) {
52988
53086
  };
52989
53087
  let xml;
52990
53088
  try {
52991
- const read = deps.readFeed ?? (async (url) => (await fetch(url)).text());
53089
+ const read = deps.readFeed ?? (async (url) => {
53090
+ const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
53091
+ if (!res.ok)
53092
+ throw new Error(`HTTP ${res.status}`);
53093
+ return res.text();
53094
+ });
52992
53095
  xml = await read(feedUrl);
52993
53096
  } catch (err) {
52994
53097
  return write({
@@ -53127,7 +53230,7 @@ async function runPublishMatrixBundle(args, deps = {}) {
53127
53230
  }
53128
53231
  const hasOwnedVariant = Object.keys(variants).some((key) => key === "article" || key === "episode");
53129
53232
  if (!hasOwnedVariant) {
53130
- echoError("publish-matrix-bundle: no article/episode variant \u2014 the publication contract needs both");
53233
+ echoError("publish-matrix-bundle: no article/episode variant \u2014 the publication contract needs at least one");
53131
53234
  return 1;
53132
53235
  }
53133
53236
  const articleTitle = await readVariantTitle(fs, workDir, variants);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/knowledge-kit",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "An ingest → create → publish content pipeline CLI (Bun).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "kk",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "knowledge-kit Claude Code plugin: skills, commands, subagents, hooks, and rules"
5
5
  }
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env bun
2
+ /** Supervise one scheduled daily run and persist its terminal outcome even after a deadline. */
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { parseArgs } from 'node:util';
7
+
8
+ const { values } = parseArgs({
9
+ options: {
10
+ repo: { type: 'string', default: process.cwd() },
11
+ 'works-dir': { type: 'string' },
12
+ 'run-id': { type: 'string' },
13
+ 'timeout-ms': { type: 'string', default: '13500000' },
14
+ receipt: { type: 'string' },
15
+ agent: { type: 'string', default: 'inline' },
16
+ 'retry-agent': { type: 'string', default: 'claude' },
17
+ 'required-channels': { type: 'string', default: 'wechat,locales,cms-resync' },
18
+ 'wechat-live': { type: 'boolean', default: false },
19
+ },
20
+ });
21
+ const timeoutMs = Number(values['timeout-ms']);
22
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
23
+ console.error('daily-scheduled-run: --timeout-ms must be a positive integer');
24
+ process.exit(2);
25
+ }
26
+ const repo = resolve(values.repo);
27
+ const now = new Date();
28
+ const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
29
+ const runId = values['run-id'] ?? `daily-ai-voice-${stamp}-${now.getTime()}`;
30
+ const workDir = join(resolve(values['works-dir'] ?? join(repo, '.works')), 'kk-daily-ai-voice2', stamp);
31
+ const receiptPath = resolve(values.receipt ?? join(workDir, 'schedule-receipt.json'));
32
+ const startedAt = now.toISOString();
33
+ mkdirSync(dirname(receiptPath), { recursive: true });
34
+ const vars = {
35
+ publish_enabled: 'true',
36
+ skip_review: 'true',
37
+ agent: values.agent,
38
+ agent_retry: values['retry-agent'],
39
+ wechat_live: values['wechat-live'] === true ? 'true' : 'false',
40
+ work_dir: workDir,
41
+ run_date: stamp,
42
+ };
43
+ writeFileSync(receiptPath, JSON.stringify({ runId, startedAt, status: 'running', workDir }, null, 2));
44
+ let interrupted = false;
45
+ let timedOut = false;
46
+ let spawnError = '';
47
+ let forceKill: ReturnType<typeof setTimeout> | undefined;
48
+ const child = spawn(
49
+ 'spur',
50
+ [
51
+ '--no-logo',
52
+ 'workflow',
53
+ 'run',
54
+ 'plugins/kk/workflows/kk-daily-ai-voice2.yaml',
55
+ '--run-id',
56
+ runId,
57
+ '--vars',
58
+ JSON.stringify(vars),
59
+ '--quiet',
60
+ ],
61
+ { cwd: repo, detached: true, stdio: 'inherit' },
62
+ );
63
+
64
+ const ownedPids = new Set<number>();
65
+ /** Snapshot descendants before the root exits, including workers that created their own groups. */
66
+ function rememberDescendants(): void {
67
+ if (child.pid === undefined) return;
68
+ ownedPids.add(child.pid);
69
+ const snapshot = spawnSync('ps', ['-axo', 'pid=,ppid='], { encoding: 'utf-8', timeout: 5000 });
70
+ const rows = snapshot.stdout
71
+ .trim()
72
+ .split('\n')
73
+ .map((line) => line.trim().split(/\s+/).map(Number));
74
+ const descendants = new Set([child.pid]);
75
+ let added = true;
76
+ while (added) {
77
+ added = false;
78
+ for (const [pid, parent] of rows) {
79
+ if (pid !== undefined && parent !== undefined && descendants.has(parent) && !descendants.has(pid)) {
80
+ descendants.add(pid);
81
+ added = true;
82
+ }
83
+ }
84
+ }
85
+ for (const pid of descendants) ownedPids.add(pid);
86
+ }
87
+
88
+ /** Stop only the recorded owned tree; process-group signals also catch late same-group forks. */
89
+ function signalOwned(signal: NodeJS.Signals): void {
90
+ if (child.pid === undefined) return;
91
+ rememberDescendants();
92
+ for (const pid of [...ownedPids].reverse()) {
93
+ try {
94
+ process.kill(-pid, signal);
95
+ } catch {}
96
+ try {
97
+ process.kill(pid, signal);
98
+ } catch {}
99
+ }
100
+ }
101
+
102
+ function terminate(): void {
103
+ signalOwned('SIGTERM');
104
+ if (forceKill === undefined) {
105
+ forceKill = setTimeout(() => {
106
+ signalOwned('SIGKILL');
107
+ }, 300);
108
+ }
109
+ }
110
+ const onSignal = (): void => {
111
+ interrupted = true;
112
+ terminate();
113
+ };
114
+ process.once('SIGTERM', onSignal);
115
+ process.once('SIGINT', onSignal);
116
+ const deadline = setTimeout(() => {
117
+ timedOut = true;
118
+ terminate();
119
+ }, timeoutMs);
120
+ const exitCode = await new Promise<number>((done) => {
121
+ child.once('error', (error) => {
122
+ spawnError = error.message;
123
+ done(1);
124
+ });
125
+ child.once('close', (code) => done(code ?? 1));
126
+ });
127
+ clearTimeout(deadline);
128
+ if (forceKill !== undefined) clearTimeout(forceKill);
129
+ // A killed shell can leave owned descendants that ignore TERM. Always finish the group before
130
+ // recording a terminal outcome; no detached grandchild may continue to publish after a timeout.
131
+ if (child.pid !== undefined && (timedOut || interrupted || exitCode !== 0)) {
132
+ terminate();
133
+ await Bun.sleep(300);
134
+ signalOwned('SIGKILL');
135
+ await Bun.sleep(100);
136
+ }
137
+ const workflowStatus = timedOut ? 'timed-out' : interrupted ? 'interrupted' : exitCode === 0 ? 'done' : 'failed';
138
+ const primaryPublishStatus = readOptional(join(workDir, '31-publish', `${stamp}_17_publish_publish-status.txt`));
139
+ const runSummary = readOptional(join(workDir, 'run-report.summary.txt'));
140
+ const cleanup: { command: string; exitCode: number | null }[] = [];
141
+ let reservationRecovery: { status: string; reason: string; released?: number } = {
142
+ status: 'retained',
143
+ reason: 'workflow completed; publication settlement owns its reservations',
144
+ };
145
+ if (workflowStatus !== 'done' || runSummary?.startsWith('no-content:')) {
146
+ // The caller gives the nested safety run this exact id. Unknown/already-terminal ids are
147
+ // harmless cleanup failures; they never override the original workflow failure.
148
+ if (workflowStatus !== 'done') {
149
+ for (const id of [`${runId}-safety`, runId]) {
150
+ const result = spawnSync('spur', ['workflow', 'cancel', id, '--json'], {
151
+ cwd: repo,
152
+ encoding: 'utf-8',
153
+ timeout: 20000,
154
+ });
155
+ cleanup.push({ command: `spur workflow cancel ${id}`, exitCode: result.status });
156
+ }
157
+ }
158
+ // Only a complete trace proving no channel send began permits recycling reservations. A
159
+ // missing receipt alone is insufficient: a remote send may have succeeded before it crashed.
160
+ const trace = spawnSync('spur', ['workflow', 'trace', runId, '--json'], {
161
+ cwd: repo,
162
+ encoding: 'utf-8',
163
+ timeout: 20000,
164
+ });
165
+ reservationRecovery = { status: 'retained', reason: 'publication state could not be established' };
166
+ try {
167
+ const parsed = JSON.parse(trace.stdout) as {
168
+ run?: { runId?: string; workflowName?: string; status?: string; startedAt?: string };
169
+ events?: { kind?: string; node?: string; status?: string }[];
170
+ };
171
+ const sendNodes = new Set([
172
+ 'publish-surfdash',
173
+ 'publish-podcast',
174
+ 'xhs-publish',
175
+ 'wechat-publish',
176
+ 'translate-localize',
177
+ 'translate',
178
+ 'translate-retry',
179
+ 'translate-sync',
180
+ 'publish-classify',
181
+ ]);
182
+ const validTrace =
183
+ trace.status === 0 &&
184
+ parsed.run?.runId === runId &&
185
+ parsed.run?.workflowName === 'kk-daily-ai-voice2' &&
186
+ parsed.run?.status === (workflowStatus === 'done' ? 'done' : 'failed') &&
187
+ Date.parse(parsed.run.startedAt ?? '') >= Date.parse(startedAt) - 1000 &&
188
+ Date.parse(parsed.run.startedAt ?? '') <= Date.now() &&
189
+ Array.isArray(parsed.events) &&
190
+ parsed.events.length > 0;
191
+ const sendStarted = parsed.events?.some(
192
+ (event) => event.kind === 'action' && sendNodes.has(event.node ?? '') && event.status !== 'skipped',
193
+ );
194
+ if (validTrace && !sendStarted) {
195
+ // Cancellation is durable before reuse. Require a second stable snapshot after the
196
+ // owned processes are gone, so an in-flight or partially finalized row cannot pass.
197
+ await Bun.sleep(200);
198
+ const secondTrace = spawnSync('spur', ['workflow', 'trace', runId, '--json'], {
199
+ cwd: repo,
200
+ encoding: 'utf-8',
201
+ timeout: 20000,
202
+ });
203
+ const second = JSON.parse(secondTrace.stdout) as typeof parsed;
204
+ if (
205
+ secondTrace.status !== 0 ||
206
+ JSON.stringify(second.run) !== JSON.stringify(parsed.run) ||
207
+ JSON.stringify(second.events) !== JSON.stringify(parsed.events)
208
+ ) {
209
+ throw new Error('failed-run trace is not stable after cancellation');
210
+ }
211
+ const release = spawnSync('kk', ['store', 'release', '--topic', 'ai-news', '--run', runId], {
212
+ cwd: repo,
213
+ encoding: 'utf-8',
214
+ timeout: 20000,
215
+ });
216
+ cleanup.push({ command: 'kk store release (verified pre-publication failure)', exitCode: release.status });
217
+ const result = release.status === 0 ? (JSON.parse(release.stdout) as { released?: unknown }) : null;
218
+ reservationRecovery =
219
+ result !== null && typeof result.released === 'number'
220
+ ? { status: 'released', reason: 'trace proves no channel send began', released: result.released }
221
+ : { status: 'retained', reason: 'reservation release did not produce a valid receipt' };
222
+ } else if (sendStarted) {
223
+ reservationRecovery = { status: 'retained', reason: 'a channel send began; preserve uncertain delivery' };
224
+ }
225
+ } catch {
226
+ // Unreadable/truncated evidence or an unexpected CLI shape must never cause reuse of a
227
+ // potentially aired story. Keep the reservation and the original failure visible.
228
+ }
229
+ }
230
+ const ledger = spawnSync('kk', ['ledger', 'sync', '--workflow', 'kk-daily-ai-voice2', '--work-dir', workDir], {
231
+ cwd: repo,
232
+ encoding: 'utf-8',
233
+ timeout: 20000,
234
+ });
235
+ cleanup.push({ command: 'kk ledger sync', exitCode: ledger.status });
236
+ function readOptional(path: string): string | null {
237
+ try {
238
+ return readFileSync(path, 'utf-8').trim();
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+ const requiredChannels = values['required-channels']
244
+ .split(',')
245
+ .map((channel) => channel.trim())
246
+ .filter(Boolean);
247
+ let channelCompleteness: { channel: string; state: string; detail?: string }[] = [];
248
+ try {
249
+ const parsed = JSON.parse(
250
+ readOptional(join(workDir, '31-publish', `${stamp}_17_publish_completeness.json`)) ?? 'null',
251
+ ) as { rows?: unknown } | null;
252
+ if (Array.isArray(parsed?.rows)) {
253
+ channelCompleteness = parsed.rows.filter(
254
+ (row): row is { channel: string; state: string; detail?: string } =>
255
+ row !== null &&
256
+ typeof row === 'object' &&
257
+ typeof row.channel === 'string' &&
258
+ typeof row.state === 'string',
259
+ );
260
+ }
261
+ } catch {}
262
+ const missingRequiredChannels = requiredChannels.filter((channel) => {
263
+ const rows = channelCompleteness.filter((row) => row.channel === channel);
264
+ return rows.length !== 1 || rows[0]?.state !== 'done';
265
+ });
266
+ // A terminal green workflow can mean "no content" or failed delivery. Make that observable to
267
+ // the scheduler instead of equating the workflow's process exit with a published edition.
268
+ const status =
269
+ workflowStatus !== 'done'
270
+ ? workflowStatus
271
+ : runSummary?.startsWith('no-content:')
272
+ ? 'no-content'
273
+ : primaryPublishStatus === 'full' && missingRequiredChannels.length === 0
274
+ ? 'done'
275
+ : 'incomplete';
276
+ const receipt = {
277
+ runId,
278
+ startedAt,
279
+ completedAt: new Date().toISOString(),
280
+ status,
281
+ exitCode: timedOut ? 124 : interrupted ? 143 : exitCode !== 0 ? exitCode : status === 'done' ? 0 : 4,
282
+ workDir,
283
+ timeoutMs,
284
+ agent: values.agent,
285
+ retryAgent: values['retry-agent'],
286
+ error: spawnError || null,
287
+ primaryPublishStatus,
288
+ channelCompleteness,
289
+ missingRequiredChannels,
290
+ runSummary,
291
+ reservationRecovery,
292
+ cleanup,
293
+ };
294
+ writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
295
+ console.log(JSON.stringify(receipt));
296
+ process.removeListener('SIGTERM', onSignal);
297
+ process.removeListener('SIGINT', onSignal);
298
+ process.exit(receipt.exitCode);