@gobing-ai/knowledge-kit 0.1.9 → 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 (72) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +132 -25
  3. package/package.json +1 -1
  4. package/plugins/generations/content-gen/dist/index.js +4 -0
  5. package/plugins/generations/core-facts-gen/dist/index.js +4 -0
  6. package/plugins/generations/daily-article-gen/dist/index.js +4 -0
  7. package/plugins/generations/dailynews-gen/dist/index.js +4 -0
  8. package/plugins/generations/episode-plan-gen/dist/index.js +102 -1
  9. package/plugins/generations/episode-plan-gen/src/index.ts +2 -4
  10. package/plugins/generations/gemini-tts/dist/index.js +4 -0
  11. package/plugins/generations/image-gen/dist/index.js +4 -0
  12. package/plugins/generations/news-report-gen/dist/index.js +4 -0
  13. package/plugins/generations/voice-gen/dist/index.js +4 -0
  14. package/plugins/generations/voxcpm-tts/dist/index.js +4 -0
  15. package/plugins/ingestions/aihot-ingest/dist/index.js +4 -0
  16. package/plugins/ingestions/horizon-ingest/dist/index.js +4 -0
  17. package/plugins/ingestions/last30days-ingest/dist/index.js +11 -3
  18. package/plugins/ingestions/last30days-ingest/src/index.ts +14 -3
  19. package/plugins/ingestions/web-search/dist/index.js +4 -0
  20. package/plugins/kk/plugin.json +1 -1
  21. package/plugins/kk/scripts/daily-scheduled-run.ts +298 -0
  22. package/plugins/kk/scripts/daily-stages.ts +1083 -49
  23. package/plugins/kk/scripts/kk-workflow-stages.ts +76 -10
  24. package/plugins/kk/scripts/safety-artifact-check.ts +235 -0
  25. package/plugins/kk/scripts/sentences.ts +134 -0
  26. package/plugins/kk/scripts/series-names.ts +124 -0
  27. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +194 -113
  28. package/plugins/kk/workflows/kk-daily-safety.yaml +160 -36
  29. package/plugins/publishings/bilibili-pub/dist/index.js +6 -1
  30. package/plugins/publishings/bilibili-pub/dist/profile-check.js +2 -1
  31. package/plugins/publishings/cnblogs-pub/dist/index.js +6 -1
  32. package/plugins/publishings/cnblogs-pub/dist/profile-check.js +2 -1
  33. package/plugins/publishings/csdn-pub/dist/index.js +6 -1
  34. package/plugins/publishings/csdn-pub/dist/profile-check.js +6 -1
  35. package/plugins/publishings/emdash-pub/dist/index.js +4 -0
  36. package/plugins/publishings/infoq-pub/dist/index.js +6 -1
  37. package/plugins/publishings/infoq-pub/dist/profile-check.js +6 -1
  38. package/plugins/publishings/jike-pub/dist/index.js +6 -1
  39. package/plugins/publishings/jike-pub/dist/profile-check.js +6 -1
  40. package/plugins/publishings/juejin-pub/dist/index.js +6 -1
  41. package/plugins/publishings/juejin-pub/dist/profile-check.js +6 -1
  42. package/plugins/publishings/medium-pub/dist/index.js +4 -0
  43. package/plugins/publishings/podcast-pub/dist/index.js +151 -16
  44. package/plugins/publishings/podcast-pub/src/index.ts +62 -5
  45. package/plugins/publishings/podcast-pub/src/seo.ts +2 -4
  46. package/plugins/publishings/podcast-pub/src/show-notes.ts +3 -8
  47. package/plugins/publishings/qiita-pub/dist/index.js +4 -0
  48. package/plugins/publishings/shipinhao-pub/dist/index.js +6 -1
  49. package/plugins/publishings/shipinhao-pub/dist/profile-check.js +2 -1
  50. package/plugins/publishings/sspai-pub/dist/index.js +6 -1
  51. package/plugins/publishings/sspai-pub/dist/profile-check.js +2 -1
  52. package/plugins/publishings/substack-pub/dist/index.js +6 -1
  53. package/plugins/publishings/substack-pub/dist/profile-check.js +6 -1
  54. package/plugins/publishings/surfdash-pub/dist/index.js +691 -63
  55. package/plugins/publishings/surfdash-pub/src/digest-check.ts +284 -0
  56. package/plugins/publishings/surfdash-pub/src/index.ts +656 -84
  57. package/plugins/publishings/surfdash-pub/src/patch.ts +44 -7
  58. package/plugins/publishings/threads-pub/dist/index.js +4 -0
  59. package/plugins/publishings/wechatmp-pub/dist/index.js +937 -98
  60. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +64 -3
  61. package/plugins/publishings/wechatmp-pub/src/flow.ts +1125 -85
  62. package/plugins/publishings/wechatmp-pub/src/index.ts +112 -30
  63. package/plugins/publishings/wechatmp-pub/src/page.ts +105 -0
  64. package/plugins/publishings/weibo-pub/dist/index.js +6 -1
  65. package/plugins/publishings/weibo-pub/dist/profile-check.js +6 -1
  66. package/plugins/publishings/x-pub/dist/index.js +6 -1
  67. package/plugins/publishings/x-pub/dist/profile-check.js +6 -1
  68. package/plugins/publishings/xhs-pub/dist/index.js +6 -1
  69. package/plugins/publishings/xhs-pub/dist/profile-check.js +6 -1
  70. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +6 -1
  71. package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +2 -1
  72. package/plugins/publishings/zenn-pub/dist/index.js +4 -0
@@ -1263,7 +1263,7 @@ function dailyPublishCompleteness(args: string[]): void {
1263
1263
  .join('; ');
1264
1264
  console.error(
1265
1265
  `WARNING: publish completeness — ${completeness.missing.join(', ')} did not complete: ${details}. ` +
1266
- 'Independent channels never enter publish_status; re-run their stages for this work dir (see ' +
1266
+ 'Side-channel and locale rows never enter publish_status; re-run the relevant stages for this work dir (see ' +
1267
1267
  `${completenessFile})`,
1268
1268
  );
1269
1269
  }
@@ -1278,9 +1278,10 @@ function targetsOf(path: string): Record<string, unknown>[] {
1278
1278
  }
1279
1279
 
1280
1280
  /**
1281
- * The publish tail's independent channels (locales, XHS, WeChat) and the surfdash CMS re-sync never
1282
- * enter `publish_status` — they are non-blocking by design (ADR-022/025/026). That is also why a
1283
- * tail driven by hand can skip every one of them and still leave a `full` status file behind:
1281
+ * The publish tail's auxiliary channels (locales, XHS, WeChat) and the surfdash CMS re-sync never
1282
+ * enter `publish_status` — only the primary Surfdash + podcast pair does. Locales now validate
1283
+ * before the primary Surfdash publish; XHS/WeChat remain non-blocking by design. A
1284
+ * tail driven by hand can still skip auxiliary channels and leave a `full` status file behind:
1284
1285
  * 20261002 shipped zh-only surfdash with no XHS and no WeChat draft, and nothing in the run's own
1285
1286
  * artifacts said so. Record each channel's state beside the status file so the gap is visible.
1286
1287
  */
@@ -1301,9 +1302,44 @@ function publishCompleteness(
1301
1302
  detail: 'translate markers absent (en/ja never scaffolded)',
1302
1303
  });
1303
1304
  } else {
1304
- const syncResultFile = at('24_translate-sync_sync-result.json');
1305
- const syncResult = isFile(syncResultFile) ? readJson(syncResultFile) : null;
1306
- if (syncResult === null) {
1305
+ const validationResultFile = at('24_translate-validate_result.json');
1306
+ const legacySyncResultFile = at('24_translate-sync_sync-result.json');
1307
+ const hasValidationResult = isFile(validationResultFile);
1308
+ const validationResult = hasValidationResult ? readJson(validationResultFile) : null;
1309
+ const syncResult = isFile(legacySyncResultFile) ? readJson(legacySyncResultFile) : null;
1310
+ if (hasValidationResult) {
1311
+ const primaryPublished = targetsOf(at('14_publish-surfdash_result.json')).some(
1312
+ (entry) => entry.target === 'surfdash-pub' && entry.ok === true && entry.status === 'published',
1313
+ );
1314
+ if (validationResult?.ok !== true) {
1315
+ rows.push({
1316
+ channel: 'locales',
1317
+ state: 'failed',
1318
+ detail: String(validationResult?.error ?? 'translate-validate receipt is missing or malformed'),
1319
+ });
1320
+ } else if (!primaryPublished) {
1321
+ rows.push({
1322
+ channel: 'locales',
1323
+ state: 'failed',
1324
+ detail: 'local validation passed, but final Surfdash publish did not complete',
1325
+ });
1326
+ } else {
1327
+ const untranslated = ['en', 'ja'].filter((locale) => {
1328
+ const marker = readFileSync(at(`20_translate-localize_${locale}-post-path.txt`), 'utf-8').trim();
1329
+ if (!isFile(marker)) return true;
1330
+ const head = readFileSync(marker, 'utf-8').split('---')[1] ?? '';
1331
+ return /^draft:\s*true\s*$/m.test(head);
1332
+ });
1333
+ rows.push({
1334
+ channel: 'locales',
1335
+ state: untranslated.length === 0 ? 'done' : 'failed',
1336
+ detail:
1337
+ untranslated.length === 0
1338
+ ? 'en/ja drafts validated and published with the primary Surfdash sync'
1339
+ : `${untranslated.join(', ')} draft is still a scaffold (draft: true) — not translated, so it stays unpublished`,
1340
+ });
1341
+ }
1342
+ } else if (syncResult === null) {
1307
1343
  rows.push({
1308
1344
  channel: 'locales',
1309
1345
  state: 'not-run',
@@ -1415,8 +1451,24 @@ function dailyPatchSurfdash(args: string[]): void {
1415
1451
  timed(workDir, 'patch-surfdash', () => {
1416
1452
  const sdResult = join(workDir, '31-publish', `${runDate}_14_publish-surfdash_result.json`);
1417
1453
  const podcastResult = join(workDir, '31-publish', `${runDate}_16_publish-podcast_result.json`);
1418
- if (!isFile(sdResult) || !isFile(podcastResult)) {
1419
- failWith(1, 'patch-surfdash: missing surfdash/podcast publish results — run the publish states first');
1454
+ const resultPath = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync-result.json`);
1455
+ const invalidReceipt = (path: string): boolean => {
1456
+ const parsed = readJson(path);
1457
+ return (
1458
+ !isFile(path) ||
1459
+ parsed === null ||
1460
+ !Array.isArray(parsed.targets) ||
1461
+ parsed.targets.length === 0 ||
1462
+ !parsed.targets.every(
1463
+ (target) => target !== null && typeof target === 'object' && !Array.isArray(target),
1464
+ )
1465
+ );
1466
+ };
1467
+ if (invalidReceipt(sdResult) || invalidReceipt(podcastResult)) {
1468
+ const error = 'patch-surfdash: malformed surfdash/podcast publish receipt — nothing to patch';
1469
+ sted('write', resultPath, () => writeJqText(resultPath, { ok: false, target: 'surfdash-pub', error }));
1470
+ console.error(error);
1471
+ return;
1420
1472
  }
1421
1473
  const targetOf = (path: string): Record<string, unknown> => {
1422
1474
  const parsed = readJson(path) as { targets?: Array<Record<string, unknown>> };
@@ -1433,7 +1485,6 @@ function dailyPatchSurfdash(args: string[]): void {
1433
1485
  console.error('patch-surfdash: surfdash result carries no postPath — nothing to patch, skip');
1434
1486
  return;
1435
1487
  }
1436
- const resultPath = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync-result.json`);
1437
1488
  if (process.env.KK_SURFDASH_PATCH_OFFLINE === '1') {
1438
1489
  // The marker keeps the completeness report's cms-resync row honest in offline runs.
1439
1490
  const skipMarker = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_emdash-resync.txt`);
@@ -1727,6 +1778,11 @@ if (import.meta.main)
1727
1778
  dailyPublishSurfdash(rest);
1728
1779
  break;
1729
1780
  }
1781
+ case 'daily-prepare-surfdash': {
1782
+ const { dailyPrepareSurfdash } = await loadDailyStages();
1783
+ dailyPrepareSurfdash(rest);
1784
+ break;
1785
+ }
1730
1786
  case 'daily-show-notes': {
1731
1787
  const { dailyShowNotes } = await loadDailyStages();
1732
1788
  dailyShowNotes(rest);
@@ -1772,11 +1828,21 @@ if (import.meta.main)
1772
1828
  dailyTranslateLocalize(rest);
1773
1829
  break;
1774
1830
  }
1831
+ case 'daily-surfdash-refine': {
1832
+ const { dailySurfdashRefine } = await loadDailyStages();
1833
+ dailySurfdashRefine(rest);
1834
+ break;
1835
+ }
1775
1836
  case 'daily-translate-sync': {
1776
1837
  const { dailyTranslateSync } = await loadDailyStages();
1777
1838
  dailyTranslateSync(rest);
1778
1839
  break;
1779
1840
  }
1841
+ case 'daily-translate-validate': {
1842
+ const { dailyTranslateValidate } = await loadDailyStages();
1843
+ dailyTranslateValidate(rest);
1844
+ break;
1845
+ }
1780
1846
  case 'itc-outline-pick': {
1781
1847
  const { itcOutlinePick } = await loadItcStages();
1782
1848
  itcOutlinePick(rest);
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env bun
2
+ import { spawnSync } from 'node:child_process';
3
+ /**
4
+ * Pure safety-workflow transition predicates.
5
+ *
6
+ * The safety state machine cannot put a condition on an individual action. These predicates keep
7
+ * retry routing outside the agent actions: a successful primary artifact is preserved, while a
8
+ * missing or malformed artifact takes the bounded retry edge. The revision predicate also uses the
9
+ * safety request digest rather than Spur's Git working-tree `requireDiff` contract because daily
10
+ * drafts live under the ignored `.works` tree. The publish predicate keeps a real safety escalation
11
+ * out of unattended publication without mistaking a catalog fallback value for one.
12
+ */
13
+ import { createHash } from 'node:crypto';
14
+ import { readFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+
17
+ const REPORT_DECISIONS = new Set(['OK', 'RISK', 'BLOCK']);
18
+ const CONFIDENCE = new Set(['HIGH', 'MEDIUM', 'LOW']);
19
+ const FINDING_DISPOSITIONS = new Set(['note', 'fix', 'gap', 'escalate']);
20
+ const FINDING_STATUSES = new Set(['pass', 'fail', 'warn']);
21
+ const COMPLIANCE_CRITERIA = new Set([
22
+ 'no-prohibited-claims',
23
+ 'disclosure-presence',
24
+ 'jurisdictional-rules',
25
+ 'data-handling',
26
+ ]);
27
+ const VERDICTS = new Set(['PASS', 'FAIL', 'NEEDS_REVISION']);
28
+
29
+ type JsonObject = Record<string, unknown>;
30
+
31
+ function isObject(value: unknown): value is JsonObject {
32
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
33
+ }
34
+
35
+ function readJson(path: string): JsonObject | null {
36
+ try {
37
+ const value: unknown = JSON.parse(readFileSync(path, 'utf8'));
38
+ return isObject(value) ? value : null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function safetyPath(workDir: string, runDate: string, name: string): string {
45
+ return join(workDir, '24-script', `${runDate}_09_${name}`);
46
+ }
47
+
48
+ function nonEmptyString(value: unknown): value is string {
49
+ return typeof value === 'string' && value.trim() !== '';
50
+ }
51
+
52
+ function stringList(value: unknown): string[] | null {
53
+ if (!Array.isArray(value) || value.some((entry) => !nonEmptyString(entry))) return null;
54
+ return value.map((entry) => String(entry));
55
+ }
56
+
57
+ function sortedUnique(values: string[]): string[] | null {
58
+ const unique = [...new Set(values)];
59
+ return unique.length === values.length ? unique.sort() : null;
60
+ }
61
+
62
+ function reportValid(workDir: string, runDate: string): string | null {
63
+ const request = readJson(safetyPath(workDir, runDate, 'safety-review_request.json'));
64
+ const report = readJson(safetyPath(workDir, runDate, 'safety-review_report.json'));
65
+ if (request === null || report === null) return 'review request or report is missing/invalid JSON';
66
+
67
+ const requested = stringList(request.platforms);
68
+ const platforms = report.platforms;
69
+ if (requested === null || !Array.isArray(platforms) || platforms.length === 0) {
70
+ return 'review request platforms or report platforms[] is missing/empty';
71
+ }
72
+ const requestedNames = sortedUnique(requested);
73
+ const actualNames: string[] = [];
74
+ for (const platform of platforms) {
75
+ if (!isObject(platform) || !nonEmptyString(platform.platform)) return 'report platform entry is malformed';
76
+ const name = String(platform.platform);
77
+ if (!REPORT_DECISIONS.has(String(platform.decision))) return `invalid platform decision for ${name}`;
78
+ if (!Array.isArray(platform.findings)) return `findings[] is missing for ${name}`;
79
+ for (const finding of platform.findings) {
80
+ if (!isObject(finding)) return `finding entry is malformed for ${name}`;
81
+ if (finding.disposition !== undefined && !FINDING_DISPOSITIONS.has(String(finding.disposition))) {
82
+ return `invalid finding disposition for ${name}`;
83
+ }
84
+ if (finding.evidenceGap !== undefined && typeof finding.evidenceGap !== 'boolean') {
85
+ return `evidenceGap must be boolean for ${name}`;
86
+ }
87
+ }
88
+ actualNames.push(name);
89
+ }
90
+ const actualNamesSorted = sortedUnique(actualNames);
91
+ if (
92
+ requestedNames === null ||
93
+ actualNamesSorted === null ||
94
+ requestedNames.join('\0') !== actualNamesSorted.join('\0')
95
+ ) {
96
+ return 'report platforms do not exactly match the requested platforms';
97
+ }
98
+ if (!CONFIDENCE.has(String(report.confidence))) return 'report confidence is missing/invalid';
99
+ if (typeof report.escalate !== 'boolean') return 'report escalate must be boolean';
100
+ return null;
101
+ }
102
+
103
+ function judgeValid(workDir: string, runDate: string): string | null {
104
+ const verdict = readJson(safetyPath(workDir, runDate, 'safety-judge_verdict.json'));
105
+ if (verdict === null) return 'judge verdict is missing/invalid JSON';
106
+ if (!VERDICTS.has(String(verdict.verdict))) return 'judge verdict value is invalid';
107
+ if (!CONFIDENCE.has(String(verdict.confidence))) return 'judge confidence is missing/invalid';
108
+ if (verdict.rubric !== 'compliance') return 'judge rubric must be compliance';
109
+ if (
110
+ typeof verdict.score !== 'number' ||
111
+ !Number.isFinite(verdict.score) ||
112
+ verdict.score < 0 ||
113
+ verdict.score > 1
114
+ ) {
115
+ return 'judge score must be a finite number from 0 through 1';
116
+ }
117
+ if (!nonEmptyString(verdict.summary)) return 'judge summary is missing/empty';
118
+ if (!Array.isArray(verdict.findings) || verdict.findings.length === 0) return 'judge findings[] is missing/empty';
119
+ const criteria = new Set<string>();
120
+ for (const finding of verdict.findings) {
121
+ if (!isObject(finding)) return 'judge finding entry is malformed';
122
+ if (!nonEmptyString(finding.criterion)) return 'judge finding criterion is missing/empty';
123
+ if (!FINDING_STATUSES.has(String(finding.status))) return 'judge finding status is invalid';
124
+ if (!nonEmptyString(finding.detail)) return 'judge finding detail is missing/empty';
125
+ criteria.add(String(finding.criterion));
126
+ }
127
+ if (
128
+ verdict.verdict === 'PASS' &&
129
+ verdict.findings.some((finding) => isObject(finding) && finding.status === 'fail')
130
+ ) {
131
+ return 'PASS verdict cannot contain a fail finding';
132
+ }
133
+ for (const criterion of COMPLIANCE_CRITERIA) {
134
+ if (!criteria.has(criterion)) return `judge findings omit compliance criterion ${criterion}`;
135
+ }
136
+ if (!Array.isArray(verdict.feedback) || verdict.feedback.some((entry) => !nonEmptyString(entry))) {
137
+ return 'judge feedback[] is missing or contains an empty/non-string entry';
138
+ }
139
+ if (!nonEmptyString(verdict.content_ref)) return 'judge content_ref is missing/empty';
140
+ if (!nonEmptyString(verdict.evaluated_at) || !/^\d{4}-\d{2}-\d{2}T/.test(verdict.evaluated_at)) {
141
+ return 'judge evaluated_at must be an ISO-8601 timestamp';
142
+ }
143
+ if (!Number.isFinite(Date.parse(verdict.evaluated_at))) return 'judge evaluated_at is not a valid timestamp';
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * The catalog decision may fall back to `escalate` even when the gate admitted `pass` or `exclude`.
149
+ * Only the gate's persisted status represents a real content-safety escalation, so unattended mode
150
+ * rejects that status while attended mode leaves it for the caller's pause node.
151
+ */
152
+ function publishValid(workDir: string, runDate: string, unattendedArg: string): string | null {
153
+ if (unattendedArg !== 'true' && unattendedArg !== 'false') return 'unattended must be true or false';
154
+ const verdictToken = safetyPath(workDir, runDate, 'safety-verdict.status');
155
+ const gateStatusPath = safetyPath(workDir, runDate, 'safety-judge_status.txt');
156
+ let token = '';
157
+ let gateStatus = '';
158
+ try {
159
+ token = readFileSync(verdictToken, 'utf8').trim();
160
+ gateStatus = readFileSync(gateStatusPath, 'utf8').trim();
161
+ } catch {
162
+ return 'safety verdict token or gate status is missing/unreadable';
163
+ }
164
+ if (token !== 'accept') return `safety verdict token is ${token || 'missing'}, not accept`;
165
+ if (!new Set(['pass', 'exclude', 'escalate']).has(gateStatus)) {
166
+ return `gate status ${gateStatus || 'missing'} is not publish-valid`;
167
+ }
168
+ if (unattendedArg === 'true' && gateStatus === 'escalate') {
169
+ return 'unattended run refuses a real safety escalation; attended review is required';
170
+ }
171
+ return null;
172
+ }
173
+
174
+ function draftDigest(workDir: string, runDate: string): string | null {
175
+ const article = join(workDir, '22-article', `${runDate}_06_article_content.json`);
176
+ const script = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
177
+ try {
178
+ const hash = createHash('sha256');
179
+ hash.update(readFileSync(article)).update('\0');
180
+ hash.update(readFileSync(script)).update('\0');
181
+ return hash.digest('hex');
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ function revisionValid(workDir: string, runDate: string): string | null {
188
+ const request = readJson(safetyPath(workDir, runDate, 'safety-review_request.json'));
189
+ const articlePath = join(workDir, '22-article', `${runDate}_06_article_content.json`);
190
+ const scriptPath = join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`);
191
+ if (request === null || !nonEmptyString(request.digest)) return 'review request digest is missing';
192
+ if (draftDigest(workDir, runDate) === null) return 'revised article or VoiceScript is missing/unreadable';
193
+ if (draftDigest(workDir, runDate) === request.digest) return 'draft digest is unchanged';
194
+
195
+ const article = readJson(articlePath);
196
+ if (article === null || !nonEmptyString(article.title) || !nonEmptyString(article.body)) {
197
+ return 'revised article title/body is missing';
198
+ }
199
+
200
+ const validator = join(import.meta.dir, 'validate-voicescript.ts');
201
+ const result = spawnSync(process.execPath, [validator, '--in', scriptPath], {
202
+ encoding: 'utf8',
203
+ stdio: 'ignore',
204
+ });
205
+ if (result.status !== 0) return 'revised VoiceScript failed content validation';
206
+ return null;
207
+ }
208
+
209
+ function main(): number {
210
+ const [kind = '', workDir = '', runDate = '', unattended = 'false'] = process.argv.slice(2);
211
+ if (!kind || !workDir || !runDate) {
212
+ console.error(
213
+ 'usage: safety-artifact-check.ts <report-valid|judge-valid|revision-valid|publish-valid> <work_dir> <run_date> [unattended]',
214
+ );
215
+ return 2;
216
+ }
217
+ const reason =
218
+ kind === 'report-valid'
219
+ ? reportValid(workDir, runDate)
220
+ : kind === 'judge-valid'
221
+ ? judgeValid(workDir, runDate)
222
+ : kind === 'revision-valid'
223
+ ? revisionValid(workDir, runDate)
224
+ : kind === 'publish-valid'
225
+ ? publishValid(workDir, runDate, unattended)
226
+ : 'unknown predicate';
227
+ if (reason !== null) {
228
+ console.error(`safety-artifact-check: ${kind}: ${reason}`);
229
+ return 1;
230
+ }
231
+ console.log(`safety-artifact-check: ${kind}: ok`);
232
+ return 0;
233
+ }
234
+
235
+ process.exitCode = main();
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Quote-aware sentence splitting (2026-10-24).
3
+ *
4
+ * NOTE: This file mirrors `packages/kk-core/src/sentences.ts`. It exists as a separate script file
5
+ * because `plugins/kk/scripts/daily-stages.ts` runs from the installed package or from standalone
6
+ * runner invocations with Node/Bun builtins only and cannot import `@gobing-ai/kk-core`.
7
+ * Keep the algorithm and behaviors in sync between both files.
8
+ *
9
+ * Rules:
10
+ * - splits after 。!? (CJK) always, and after `.`/`!`/`?` only when followed by whitespace or end of text
11
+ * (so Character.AI, llama.cpp, LWN.net, whatmodel.sh, claude.dev, v1.2, 3.6% never split);
12
+ * - keeps any run of closing quotes/brackets ( ” ’ 」 』 ) ) " ' ) attached to the preceding sentence;
13
+ * - never splits while inside an open 「」, 『』, “”, () pair (track depth; unbalanced → treat the rest as one sentence);
14
+ * - trims each sentence, drops empties; joining the results with '' (zh) loses no non-whitespace characters.
15
+ * - `{ cjkOnly: true }` splits only on 。!? — the zh/ja briefing paths, where an English body must
16
+ * stay one unit (its first-two-sentences cut would otherwise glue "vtables.It" and split "U.S.").
17
+ */
18
+
19
+ const CJK_TERMINATORS = new Set(['。', '!', '?']);
20
+ const LATIN_TERMINATORS = new Set(['.', '!', '?']);
21
+ const ATTACHED_CLOSERS = new Set(['”', '’', '」', '』', ')', ')', '"', "'"]);
22
+
23
+ const OPEN_PAIRS: Record<string, string> = {
24
+ '「': '」',
25
+ '『': '』',
26
+ '“': '”',
27
+ '(': ')',
28
+ '(': ')',
29
+ };
30
+
31
+ /** Splitter options; `cjkOnly` drops the Latin `.!?` terminators (zh/ja split only on 。!?). */
32
+ export interface SplitSentencesOptions {
33
+ cjkOnly?: boolean;
34
+ }
35
+
36
+ const CLOSE_PAIRS: Record<string, string> = {
37
+ '」': '「',
38
+ '』': '『',
39
+ '”': '“',
40
+ ')': '(',
41
+ ')': '(',
42
+ };
43
+
44
+ /**
45
+ * Splits text into complete sentences while respecting CJK/Latin terminators,
46
+ * attached closing quotes, and nested quote/bracket pairs.
47
+ */
48
+ export function splitSentences(text: string, options: SplitSentencesOptions = {}): string[] {
49
+ if (!text || typeof text !== 'string') return [];
50
+ const results: string[] = [];
51
+ let sentenceStart = 0;
52
+ let stack: string[] = [];
53
+ let i = 0;
54
+ const isTerminator = (c: string): boolean =>
55
+ CJK_TERMINATORS.has(c) || (options.cjkOnly !== true && LATIN_TERMINATORS.has(c));
56
+
57
+ while (i < text.length) {
58
+ const ch = text[i] as string;
59
+
60
+ if (isTerminator(ch)) {
61
+ let termEnd = i;
62
+ let hasCjkTerm = false;
63
+ let hasLatinTerm = false;
64
+ while (termEnd < text.length) {
65
+ const termChar = text[termEnd];
66
+ if (!termChar || !isTerminator(termChar)) {
67
+ break;
68
+ }
69
+ if (CJK_TERMINATORS.has(termChar)) hasCjkTerm = true;
70
+ if (LATIN_TERMINATORS.has(termChar)) hasLatinTerm = true;
71
+ termEnd++;
72
+ }
73
+
74
+ let closerEnd = termEnd;
75
+ const tempStack = [...stack];
76
+ while (closerEnd < text.length) {
77
+ const closer = text[closerEnd];
78
+ if (!closer || !ATTACHED_CLOSERS.has(closer)) {
79
+ break;
80
+ }
81
+ const top = tempStack[tempStack.length - 1];
82
+ if (top !== undefined && top === closer) {
83
+ tempStack.pop();
84
+ } else if (CLOSE_PAIRS[closer]) {
85
+ const idx = tempStack.lastIndexOf(closer);
86
+ if (idx >= 0) tempStack.splice(idx, 1);
87
+ }
88
+ closerEnd++;
89
+ }
90
+
91
+ let isBoundary = false;
92
+ if (tempStack.length === 0) {
93
+ if (hasCjkTerm) {
94
+ isBoundary = true;
95
+ } else if (hasLatinTerm) {
96
+ const nextChar = text[closerEnd];
97
+ if (closerEnd === text.length || (nextChar !== undefined && /\s/.test(nextChar))) {
98
+ isBoundary = true;
99
+ }
100
+ }
101
+ }
102
+
103
+ if (isBoundary) {
104
+ const sentence = text.slice(sentenceStart, closerEnd).trim();
105
+ if (sentence.length > 0) {
106
+ results.push(sentence);
107
+ }
108
+ sentenceStart = closerEnd;
109
+ stack = tempStack;
110
+ i = closerEnd;
111
+ continue;
112
+ }
113
+ }
114
+
115
+ const openMatch = OPEN_PAIRS[ch];
116
+ const closeMatch = CLOSE_PAIRS[ch];
117
+ if (openMatch) {
118
+ stack.push(openMatch);
119
+ } else if (closeMatch) {
120
+ const idx = stack.lastIndexOf(ch);
121
+ if (idx >= 0) stack.splice(idx, 1);
122
+ }
123
+ i++;
124
+ }
125
+
126
+ if (sentenceStart < text.length) {
127
+ const tail = text.slice(sentenceStart).trim();
128
+ if (tail.length > 0) {
129
+ results.push(tail);
130
+ }
131
+ }
132
+
133
+ return results;
134
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Canonical daily-series naming for the kk-daily-* pipelines (2026-10-23 regression).
3
+ *
4
+ * The 2026-10-23 surfdash run: the translate agent invented locale series names (ja 毎日AIブレンド,
5
+ * en "Daily AI Medley"/"hodgepodge" phrasing, zh-name listen links inside en/ja posts). Series
6
+ * names are product identity, not translation — this module is the pipeline's single definition of
7
+ * the per-locale series/show names plus the deterministic rewrite that repairs the known bad set
8
+ * before the CMS sync (plugins/kk/scripts/daily-stages.ts translate-sync gate).
9
+ *
10
+ * Pure string work only, no I/O. URL segments (http/https) are never touched — a slug or
11
+ * percent-encoded path may legitimately embed any of these tokens. surfdash-pub's cross-link table
12
+ * (plugins/publishings/surfdash-pub/src/patch.ts) cannot import this file (the kk scripts resolve
13
+ * from the installed package with node builtins only), so it repeats the names; tests assert the
14
+ * two tables agree.
15
+ */
16
+
17
+ /** The locales a daily post is published in (mirrors surfdash-pub's PATCH_LOCALES). */
18
+ export type DailyLocale = 'zh' | 'en' | 'ja';
19
+
20
+ /** One locale's daily-series naming: the series (episode-column) name and the show it runs under. */
21
+ export interface DailySeriesNames {
22
+ series: string;
23
+ show: string;
24
+ }
25
+
26
+ /** The single per-locale table; en/ja names are fixed identity, the zh column name is the source. */
27
+ export const DAILY_SERIES_NAMES: Record<DailyLocale, DailySeriesNames> = {
28
+ zh: { series: '每日AI乱炖', show: '咸话咸说' },
29
+ en: { series: 'Daily AI Stew', show: 'Salty Talk' },
30
+ ja: { series: 'デイリーAIシチュー', show: '咸話咸説' },
31
+ };
32
+
33
+ /**
34
+ * Retired series spellings that must never be emitted in any locale. ごった煮 is deliberately absent:
35
+ * it is ordinary ja vocabulary, so rewriting or failing on it would butcher legitimate prose.
36
+ */
37
+ const RETIRED_SERIES_NAMES = ['毎日AIブレンド', 'Daily AI Medley', 'Daily AI Briefing'] as const;
38
+
39
+ /** All canonical series tokens — each locale's own name is correct there, wrong-locale elsewhere. */
40
+ const SERIES_TOKENS = ['每日AI乱炖', 'Daily AI Stew', 'デイリーAIシチュー'] as const;
41
+
42
+ const escapeRe = (token: string): string => token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
43
+
44
+ /** Latin tokens match case-insensitively with word boundaries; CJK tokens match exactly. */
45
+ const tokenRe = (token: string): RegExp =>
46
+ /^[\x20-\x7E]+$/.test(token) ? new RegExp(`\\b${escapeRe(token)}\\b`, 'gi') : new RegExp(escapeRe(token), 'g');
47
+
48
+ /** Splits markdown into alternating non-URL / URL segments (`split` keeps the capture group). */
49
+ const URL_SPLIT = /(https?:\/\/[^\s<>"')\]]+)/g;
50
+
51
+ function mapOutsideUrls(markdown: string, fn: (text: string) => string): string {
52
+ return markdown
53
+ .split(URL_SPLIT)
54
+ .map((part, index) => (index % 2 === 1 ? part : fn(part)))
55
+ .join('');
56
+ }
57
+
58
+ /** The short/spacing zh form (`AI 乱炖`/`AI乱炖`) as a series name; `每日` already prefixing it is correct zh. */
59
+ const SHORT_ZH_FORM = /(?<!每日)AI ?乱炖/g;
60
+
61
+ /** Every retired or wrong-locale series reference in `text`, as written, deduplicated in order. */
62
+ function retiredIn(text: string, locale: DailyLocale): string[] {
63
+ const canonical = DAILY_SERIES_NAMES[locale].series;
64
+ const patterns: RegExp[] = [
65
+ ...RETIRED_SERIES_NAMES.map(tokenRe),
66
+ ...SERIES_TOKENS.filter((token) => token !== canonical).map(tokenRe),
67
+ SHORT_ZH_FORM,
68
+ /\bhodgepodge\b/gi,
69
+ ];
70
+ const found: string[] = [];
71
+ for (const pattern of patterns) {
72
+ for (const match of text.matchAll(pattern)) {
73
+ if (!found.includes(match[0])) found.push(match[0]);
74
+ }
75
+ }
76
+ return found;
77
+ }
78
+
79
+ /**
80
+ * Retired / wrong-locale series references in `markdown`, as written (URL segments excluded).
81
+ * Detection only — the repair side is {@link normalizeSeriesNames}.
82
+ */
83
+ export function findRetiredSeriesNames(markdown: string, locale: DailyLocale): string[] {
84
+ const found: string[] = [];
85
+ mapOutsideUrls(markdown, (text) => {
86
+ for (const hit of retiredIn(text, locale)) {
87
+ if (!found.includes(hit)) found.push(hit);
88
+ }
89
+ return text;
90
+ });
91
+ return found;
92
+ }
93
+
94
+ /**
95
+ * Rewrite every known retired / wrong-locale series reference to the locale's canonical series
96
+ * name and list what could not be rewritten safely: a bare `hodgepodge` is vocabulary, not a name
97
+ * — rewriting it would butcher prose, so it is reported for hand repair instead. Idempotent: the
98
+ * rewrite only ever produces the locale's own canonical name, which no rule matches.
99
+ */
100
+ export function normalizeSeriesNames(
101
+ markdown: string,
102
+ locale: DailyLocale,
103
+ ): {
104
+ /** The input with every known bad series token rewritten (URL segments untouched). */
105
+ markdown: string;
106
+ /** Retired series references left over that the caller must report or repair by hand. */
107
+ remaining: string[];
108
+ } {
109
+ const canonical = DAILY_SERIES_NAMES[locale].series;
110
+ const next = mapOutsideUrls(markdown, (text) => {
111
+ let out = text;
112
+ if (locale === 'en') {
113
+ // The observed en intro phrase — rewritten as a phrase first so the bare word never
114
+ // produces "daily AI Daily AI Stew".
115
+ out = out.replace(/\byour daily AI hodgepodge\b/gi, 'Your Daily AI Stew');
116
+ out = out.replace(/\bdaily AI hodgepodge\b/gi, 'Daily AI Stew');
117
+ }
118
+ for (const token of [...RETIRED_SERIES_NAMES, ...SERIES_TOKENS.filter((name) => name !== canonical)]) {
119
+ out = out.replace(tokenRe(token), () => canonical);
120
+ }
121
+ return out.replace(SHORT_ZH_FORM, canonical);
122
+ });
123
+ return { markdown: next, remaining: findRetiredSeriesNames(next, locale) };
124
+ }