@gobing-ai/knowledge-kit 0.0.34 → 0.0.36

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 (130) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/dist/board/collect/index.css +42 -0
  4. package/dist/board/collect/index.js +787 -0
  5. package/dist/board/daily-voice/index.css +13 -0
  6. package/dist/board/daily-voice/index.js +902 -0
  7. package/dist/board/itc/index.css +14 -0
  8. package/dist/board/itc/index.js +1026 -0
  9. package/dist/index.js +11769 -9250
  10. package/package.json +10 -9
  11. package/plugins/generations/content-gen/dist/index.js +1015 -10814
  12. package/plugins/generations/content-gen/package.json +1 -1
  13. package/plugins/generations/core-facts-gen/dist/index.js +1002 -10801
  14. package/plugins/generations/core-facts-gen/package.json +1 -1
  15. package/plugins/generations/daily-article-gen/dist/index.js +1019 -10804
  16. package/plugins/generations/daily-article-gen/package.json +1 -1
  17. package/plugins/generations/daily-article-gen/src/index.ts +32 -3
  18. package/plugins/generations/dailynews-gen/dist/index.js +1094 -10892
  19. package/plugins/generations/dailynews-gen/package.json +1 -1
  20. package/plugins/generations/dailynews-gen/src/script-builder.ts +6 -1
  21. package/plugins/generations/episode-plan-gen/dist/index.js +1124 -10923
  22. package/plugins/generations/episode-plan-gen/package.json +1 -1
  23. package/plugins/generations/gemini-tts/dist/index.js +1108 -10906
  24. package/plugins/generations/gemini-tts/package.json +1 -1
  25. package/plugins/generations/image-gen/dist/index.js +1239 -11035
  26. package/plugins/generations/image-gen/package.json +1 -1
  27. package/plugins/generations/news-report-gen/dist/index.js +1048 -10847
  28. package/plugins/generations/news-report-gen/package.json +1 -1
  29. package/plugins/generations/omni-voice-gen/plugin.json +1 -1
  30. package/plugins/generations/voice-gen/dist/index.js +1066 -10864
  31. package/plugins/generations/voice-gen/package.json +1 -1
  32. package/plugins/generations/voice-gen/src/index.ts +9 -3
  33. package/plugins/generations/voxcpm-tts/README.md +72 -0
  34. package/plugins/generations/voxcpm-tts/dist/index.js +14006 -0
  35. package/plugins/generations/voxcpm-tts/package.json +16 -0
  36. package/plugins/generations/voxcpm-tts/plugin.json +6 -0
  37. package/plugins/generations/voxcpm-tts/profiles.json +10 -0
  38. package/plugins/generations/voxcpm-tts/pyproject.toml +9 -0
  39. package/plugins/generations/voxcpm-tts/python/worker.py +353 -0
  40. package/plugins/generations/voxcpm-tts/src/index.ts +42 -0
  41. package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +765 -0
  42. package/plugins/generations/voxcpm-tts/tsconfig.json +8 -0
  43. package/plugins/generations/voxcpm-tts/uv.lock +2571 -0
  44. package/plugins/ingestions/aihot-ingest/dist/index.js +1081 -10628
  45. package/plugins/ingestions/aihot-ingest/package.json +1 -1
  46. package/plugins/ingestions/horizon-ingest/dist/index.js +1013 -10812
  47. package/plugins/ingestions/horizon-ingest/package.json +1 -1
  48. package/plugins/ingestions/karakeep-local/dist/index.js +689 -10774
  49. package/plugins/ingestions/karakeep-local/package.json +1 -1
  50. package/plugins/ingestions/last30days-ingest/dist/index.js +1011 -10810
  51. package/plugins/ingestions/last30days-ingest/package.json +1 -1
  52. package/plugins/ingestions/web-search/dist/index.js +1250 -11033
  53. package/plugins/ingestions/web-search/package.json +1 -1
  54. package/plugins/kk/README.md +29 -2
  55. package/plugins/kk/plugin.json +1 -1
  56. package/plugins/kk/schedules/cron.tmpl +9 -1
  57. package/plugins/kk/schedules/kk-serve.launchd.plist.tmpl +79 -0
  58. package/plugins/kk/schedules/launchd.plist.tmpl +11 -1
  59. package/plugins/kk/scripts/daily-stages.ts +702 -63
  60. package/plugins/kk/scripts/kk-workflow-stages.ts +116 -146
  61. package/plugins/kk/skills/content-safety/references/platforms/wechatmp.md +2 -2
  62. package/plugins/kk/skills/content-safety/references/platforms/xiaoyuzhou.md +4 -2
  63. package/plugins/kk/skills/publish/SKILL.md +8 -4
  64. package/plugins/kk/workflows/feature-verification.yaml +93 -0
  65. package/plugins/kk/workflows/judge-gated-publish-example.yaml +1 -0
  66. package/plugins/kk/workflows/kk-collect.yaml +43 -11
  67. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +36 -9
  68. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +92 -18
  69. package/plugins/kk/workflows/kk-daily-safety.yaml +16 -3
  70. package/plugins/kk/workflows/kk-itc.yaml +8 -1
  71. package/plugins/kk/workflows/kk-solo-podcast.yaml +2 -0
  72. package/plugins/publishings/emdash-pub/dist/index.js +987 -10814
  73. package/plugins/publishings/emdash-pub/package.json +1 -1
  74. package/plugins/publishings/infoq-pub/dist/index.js +1928 -11444
  75. package/plugins/publishings/infoq-pub/dist/profile-check.js +20814 -0
  76. package/plugins/publishings/infoq-pub/package.json +1 -1
  77. package/plugins/publishings/infoq-pub/plugin.json +6 -2
  78. package/plugins/publishings/juejin-pub/dist/index.js +1940 -11456
  79. package/plugins/publishings/juejin-pub/dist/profile-check.js +20800 -0
  80. package/plugins/publishings/juejin-pub/package.json +1 -1
  81. package/plugins/publishings/juejin-pub/plugin.json +6 -2
  82. package/plugins/publishings/medium-pub/dist/index.js +978 -10805
  83. package/plugins/publishings/medium-pub/package.json +1 -1
  84. package/plugins/publishings/podcast-pub/dist/index.js +1016 -10843
  85. package/plugins/publishings/podcast-pub/package.json +1 -1
  86. package/plugins/publishings/qiita-pub/dist/index.js +976 -10803
  87. package/plugins/publishings/qiita-pub/package.json +1 -1
  88. package/plugins/publishings/substack-pub/dist/index.js +1932 -11448
  89. package/plugins/publishings/substack-pub/dist/profile-check.js +20890 -0
  90. package/plugins/publishings/substack-pub/package.json +1 -1
  91. package/plugins/publishings/substack-pub/plugin.json +5 -2
  92. package/plugins/publishings/substack-pub/src/profile-check.ts +36 -3
  93. package/plugins/publishings/surfdash-pub/dist/index.js +1151 -10834
  94. package/plugins/publishings/surfdash-pub/package.json +1 -1
  95. package/plugins/publishings/surfdash-pub/src/index.ts +137 -15
  96. package/plugins/publishings/surfdash-pub/src/patch.ts +144 -0
  97. package/plugins/publishings/threads-pub/dist/index.js +12394 -0
  98. package/plugins/publishings/threads-pub/package.json +17 -0
  99. package/plugins/publishings/threads-pub/plugin.json +7 -0
  100. package/plugins/publishings/threads-pub/src/index.ts +340 -0
  101. package/plugins/publishings/threads-pub/tsconfig.json +4 -0
  102. package/plugins/publishings/wechatmp-pub/dist/index.js +2638 -11650
  103. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +20739 -0
  104. package/plugins/publishings/wechatmp-pub/package.json +1 -1
  105. package/plugins/publishings/wechatmp-pub/plugin.json +6 -2
  106. package/plugins/publishings/wechatmp-pub/src/enrich.ts +297 -163
  107. package/plugins/publishings/wechatmp-pub/src/flow.ts +611 -54
  108. package/plugins/publishings/wechatmp-pub/src/index.ts +24 -3
  109. package/plugins/publishings/wechatmp-pub/src/page.ts +36 -0
  110. package/plugins/publishings/wechatmp-pub/src/profile-check.ts +40 -4
  111. package/plugins/publishings/weibo-pub/dist/index.js +21345 -0
  112. package/plugins/publishings/weibo-pub/dist/profile-check.js +20817 -0
  113. package/plugins/publishings/weibo-pub/package.json +19 -0
  114. package/plugins/publishings/weibo-pub/plugin.json +12 -0
  115. package/plugins/publishings/weibo-pub/src/index.ts +868 -0
  116. package/plugins/publishings/weibo-pub/src/profile-check.ts +20 -0
  117. package/plugins/publishings/weibo-pub/tsconfig.json +4 -0
  118. package/plugins/publishings/x-pub/dist/index.js +1948 -11440
  119. package/plugins/publishings/x-pub/dist/profile-check.js +20806 -0
  120. package/plugins/publishings/x-pub/package.json +1 -1
  121. package/plugins/publishings/x-pub/plugin.json +6 -2
  122. package/plugins/publishings/xhs-pub/dist/index.js +1993 -11509
  123. package/plugins/publishings/xhs-pub/dist/profile-check.js +21023 -0
  124. package/plugins/publishings/xhs-pub/package.json +1 -1
  125. package/plugins/publishings/xhs-pub/plugin.json +6 -2
  126. package/plugins/publishings/xhs-pub/src/profile-check.ts +72 -2
  127. package/plugins/publishings/zenn-pub/dist/index.js +988 -10815
  128. package/plugins/publishings/zenn-pub/package.json +1 -1
  129. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +0 -2
  130. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +0 -6
@@ -55,7 +55,7 @@ import {
55
55
  writeFileSync,
56
56
  } from 'node:fs';
57
57
  import { homedir } from 'node:os';
58
- import { basename, dirname, join, relative, resolve } from 'node:path';
58
+ import { basename, dirname, join, resolve } from 'node:path';
59
59
 
60
60
  export const SCRIPTS_DIR = import.meta.dir;
61
61
  export const VALIDATE_SIDECAR = 'validate-voicescript.ts';
@@ -569,6 +569,30 @@ export function kk(
569
569
  return status;
570
570
  }
571
571
 
572
+ /**
573
+ * `kk()` sibling with `stdio: 'pipe'`: captures the child's stdout/stderr, reprints both to the
574
+ * parent so the operator log is unchanged, and returns the exit status plus the captured stderr.
575
+ * Only stages that branch on the child's stderr use it (0251: the xhs-render overflow retry);
576
+ * every other call keeps `kk()`'s inherit default.
577
+ */
578
+ export function kkCaptured(
579
+ args: string[],
580
+ options: { env?: Record<string, string>; unset?: readonly string[]; soft?: boolean } = {},
581
+ ): { status: number; stderr: string } {
582
+ const env = { ...process.env, ...(options.env ?? {}) };
583
+ for (const name of options.unset ?? []) delete env[name];
584
+ const runner = spawnSync('kk', args, { stdio: 'pipe', encoding: 'utf-8', env });
585
+ if (runner.error !== undefined) {
586
+ console.error(`kk: ${runner.error.message}`);
587
+ failWith(127);
588
+ }
589
+ process.stdout.write(runner.stdout ?? '');
590
+ process.stderr.write(runner.stderr ?? '');
591
+ const status = runner.status ?? 1;
592
+ if (status !== 0 && options.soft !== true) failWith(status);
593
+ return { status, stderr: runner.stderr ?? '' };
594
+ }
595
+
572
596
  export function writeJsonText(path: string, value: unknown): void {
573
597
  // `Bun.write(path, JSON.stringify(value, null, 2))` — no trailing newline.
574
598
  writeFileSync(path, JSON.stringify(value, null, 2));
@@ -1147,6 +1171,22 @@ export function firstPostPath(parsed: Record<string, unknown> | null, target: st
1147
1171
  return '';
1148
1172
  }
1149
1173
 
1174
+ /**
1175
+ * The public URL a target published to, or '' — the sibling of {@link firstPostPath} for consumers
1176
+ * that need the reader-facing address (WeChat's 原文链接 points at the surfdash post).
1177
+ */
1178
+ export function firstTargetUrl(parsed: Record<string, unknown> | null, target: string): string {
1179
+ const targets = parsed?.targets;
1180
+ if (!Array.isArray(targets)) return '';
1181
+ for (const entry of targets) {
1182
+ if (entry === null || typeof entry !== 'object') continue;
1183
+ const record = entry as Record<string, unknown>;
1184
+ if (record.target !== target || record.ok !== true) continue;
1185
+ if (typeof record.url === 'string' && record.url !== '') return record.url;
1186
+ }
1187
+ return '';
1188
+ }
1189
+
1150
1190
  function dailyPublishClassify(args: string[]): void {
1151
1191
  const [workDir = '', runDate = ''] = args;
1152
1192
  const result = join(workDir, '31-publish', `${runDate}_17_publish_result.json`);
@@ -1240,10 +1280,24 @@ function publishCompleteness(
1240
1280
  detail: 'en/ja scaffolded, never synced',
1241
1281
  });
1242
1282
  } else if (syncResult.ok === true) {
1283
+ // A filled marker only proves the locale was SCAFFOLDED. The workflow's `translate` agent is
1284
+ // what publishes it (its prompt flips `draft: true` → `draft: false`), so a run whose
1285
+ // translation failed used to report `locales: done` while the site kept an unpublished
1286
+ // scaffold — the one channel whose loss nothing in the run's artifacts named. Read the draft
1287
+ // the marker points at: still `draft: true` means it was never translated.
1288
+ const untranslated = ['en', 'ja'].filter((locale) => {
1289
+ const marker = readFileSync(at(`20_translate-localize_${locale}-post-path.txt`), 'utf-8').trim();
1290
+ if (!isFile(marker)) return true;
1291
+ const head = readFileSync(marker, 'utf-8').split('---')[1] ?? '';
1292
+ return /^draft:\s*true\s*$/m.test(head);
1293
+ });
1243
1294
  rows.push({
1244
1295
  channel: 'locales',
1245
- state: 'done',
1246
- detail: 'en/ja drafts scaffolded',
1296
+ state: untranslated.length === 0 ? 'done' : 'failed',
1297
+ detail:
1298
+ untranslated.length === 0
1299
+ ? 'en/ja drafts scaffolded and translated'
1300
+ : `${untranslated.join(', ')} draft is still a scaffold (draft: true) — not translated, so it stays unpublished`,
1247
1301
  });
1248
1302
  } else {
1249
1303
  rows.push({
@@ -1287,6 +1341,12 @@ function publishCompleteness(
1287
1341
  function dailyPublishPartial(args: string[]): void {
1288
1342
  const [workDir = '', runDate = ''] = args;
1289
1343
  const result = join(workDir, '31-publish', `${runDate}_17_publish_result.json`);
1344
+ // The marker describes this run, so it belongs under that run's workDir — a cwd-relative path
1345
+ // litters whatever tree the caller stood in and hides the note from the episode it describes.
1346
+ const marker =
1347
+ workDir === ''
1348
+ ? join('.spur', 'run', `publish-partial-${runDate}.txt`)
1349
+ : join(workDir, '.spur', 'run', `publish-partial-${runDate}.txt`);
1290
1350
  timed(workDir, 'publish-partial', () => {
1291
1351
  const failed = targetsOf(result)
1292
1352
  .filter((entry) => entry.ok !== true || entry.status !== 'published')
@@ -1294,10 +1354,10 @@ function dailyPublishPartial(args: string[]): void {
1294
1354
  .join(',');
1295
1355
  const list = failed === '' ? '(unparsed)' : failed;
1296
1356
  console.error(`WARNING: partial publish — failed targets: ${list}`);
1297
- sted('mkdir', '.spur/run', () => mkdirSync('.spur/run', { recursive: true }));
1298
- sted('write', `.spur/run/publish-partial-${runDate}.txt`, () =>
1357
+ sted('mkdir', dirname(marker), () => mkdirSync(dirname(marker), { recursive: true }));
1358
+ sted('write', marker, () =>
1299
1359
  writeFileSync(
1300
- `.spur/run/publish-partial-${runDate}.txt`,
1360
+ marker,
1301
1361
  `${runDate} partial publish: failed targets: ${list} (result.json preserved for kk executor fan-out --retry-from)\n`,
1302
1362
  ),
1303
1363
  );
@@ -1305,10 +1365,16 @@ function dailyPublishPartial(args: string[]): void {
1305
1365
  }
1306
1366
 
1307
1367
  /**
1308
- * 20260917 review #4/#6: after both publishes land, patch the surfdash disk posts —
1309
- * reuse the podcast-hosted cover URL in frontmatter (image/og_image; no re-upload; fallback:
1310
- * copy the local cover into assets/) and append a podcast cross-link at the end of zh/en/ja.
1311
- * Idempotent per post; commits + pushes the surfdash checkout (SURFDASH_AUTO_PUSH=0 keeps commit-only).
1368
+ * Post-publish surfdash healing — a thin delegation to the publisher that owns it (2026-10-12).
1369
+ *
1370
+ * The work itself (podcast cover URL into `image`/`og_image`, the per-locale podcast cross-link, the
1371
+ * local cover's `assets/` fallback, the EmDash re-sync that makes CMS cards carry a servable image,
1372
+ * and the pathspec commit + push) lives in the `surfdash-pub` `patch` operation now: it is the
1373
+ * publisher's job, and every path that publishes a post needs it. This stage only reads the run's
1374
+ * publish results into the operation's payload and reports its outcome.
1375
+ *
1376
+ * `KK_SURFDASH_PATCH_OFFLINE=1` keeps fixture tests hermetic — the plugin would otherwise write to,
1377
+ * commit and push a real checkout.
1312
1378
  */
1313
1379
  function dailyPatchSurfdash(args: string[]): void {
1314
1380
  const [workDir = '', runDate = '', surfdashRoot = '', pluginsPath = ''] = args;
@@ -1329,149 +1395,53 @@ function dailyPatchSurfdash(args: string[]): void {
1329
1395
  const sd = targetOf(sdResult);
1330
1396
  const pod = targetOf(podcastResult);
1331
1397
  const postPath = (sd.metadata as Record<string, unknown> | undefined)?.postPath;
1332
- if (typeof postPath !== 'string' || postPath === '')
1398
+ if (typeof postPath !== 'string' || postPath === '') {
1333
1399
  failWith(1, 'patch-surfdash: surfdash result carries no postPath');
1334
- // Refuse before anything is written: surfdash's deploy.yml publishes from main, so a commit
1335
- // on any other branch is invisible to production. Placed here (after the input checks) so a
1336
- // refusal leaves the working tree untouched.
1337
- const head = spawnSync('git', ['-C', surfdashRoot, 'rev-parse', '--abbrev-ref', 'HEAD'])
1338
- .stdout?.toString()
1339
- .trim();
1340
- if (head !== 'main')
1341
- failWith(1, `patch-surfdash: refusing to commit on '${head}' — surfdash deploys from main`);
1342
- // Paths this run actually wrote, relative to surfdashRoot. `git add`/`commit` take these as a
1343
- // pathspec so the operator's unrelated staged work is never swept into the commit.
1344
- const written: string[] = [];
1345
- const slugDir = dirname(postPath);
1346
- const showNotesUrl = typeof pod.url === 'string' ? pod.url : '';
1347
- const coverUrl = ((pod.metadata as Record<string, unknown> | undefined)?.imageUrl as string | undefined) ?? '';
1348
- const localCover = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1349
- const dateIso = `${runDate.slice(0, 4)}-${runDate.slice(4, 6)}-${runDate.slice(6, 8)}`;
1350
- const crossLink: Record<string, string> = {
1351
- // Task 0189 R4: cross-links carry the column's episode-title pattern, not the retired
1352
- // news-bulletin name (the VoiceScript episode title is episodeTitle(lead, date)).
1353
- zh: `🎧 本期已同步制作为播客节目,[点击收听《每日AI乱炖 · ${dateIso}》](${showNotesUrl})。`,
1354
- en: `🎧 This episode is also available as a podcast: [listen to 每日AI乱炖 · ${dateIso}](${showNotesUrl}).`,
1355
- ja: `🎧 本エピソードはポッドキャストでも配信中:[每日AI乱炖 · ${dateIso} を聴く](${showNotesUrl})。`,
1356
- };
1357
- let changed = 0;
1358
- for (const [locale, dir] of [
1359
- // zh posts live under articles/zh/<slug>; en/ja under articles/<locale>/<slug> —
1360
- // two dirnames up, not one (20260919: `articles/zh/en/...` never existed, so en/ja
1361
- // were silently skipped by the not-published branch).
1362
- ['zh', slugDir],
1363
- ['en', join(dirname(dirname(slugDir)), 'en', basename(slugDir))],
1364
- ['ja', join(dirname(dirname(slugDir)), 'ja', basename(slugDir))],
1365
- ] as const) {
1366
- const indexMd = join(dir, 'index.md');
1367
- if (!existsSync(indexMd)) {
1368
- console.error(`patch-surfdash: ${locale} post not published (${indexMd}) — skip`);
1369
- continue;
1370
- }
1371
- let md = readFileSync(indexMd, 'utf-8');
1372
- const fmEnd = md.indexOf('---', 3); // frontmatter is `---\n ... \n---\n`
1373
- if (fmEnd < 0) failWith(1, `patch-surfdash: no frontmatter in ${indexMd}`);
1374
- // Upsert a single-line `key: 'value'`; the regex consumes folded-scalar continuation
1375
- // lines (`og_image: >-\n https://...`) so multi-line publish output is replaced whole.
1376
- const upsertKey = (src: string, key: string, value: string): string => {
1377
- // YAML single-quoted scalars escape `'` as `''`; the replacement is a function so
1378
- // `$&`/`$1`/`$$` in the value are never expanded. Scope the match to the frontmatter
1379
- // (recomputed per call, since each upsert shifts offsets) so a body line starting with
1380
- // `image:` is not rewritten.
1381
- const end = src.indexOf('\n---', 3);
1382
- const fm = src.slice(0, end);
1383
- const body = src.slice(end);
1384
- const line = `${key}: '${value.replaceAll("'", "''")}'`;
1385
- const re = new RegExp(`^${key}:.*(?:\\n[ \\t]+.*)*`, 'm');
1386
- const next = re.test(fm)
1387
- ? fm.replace(re, () => line)
1388
- : fm.replace(/^publishDate: .*$/m, (m0) => `${m0}\n${line}`);
1389
- return next + body;
1390
- };
1391
- if (coverUrl !== '') {
1392
- // 20260919 prod feedback: the podcast-hosted cover URL wins even when surfdash-pub
1393
- // already wrote `image:` — its resolved og_image points at /content-posts/<slug>/
1394
- // assets/cover.png, which the deployed worker does not serve (404).
1395
- md = upsertKey(md, 'image', coverUrl);
1396
- md = upsertKey(md, 'og_image', coverUrl);
1397
- } else if (!/^image:/m.test(md.slice(0, fmEnd))) {
1398
- // Fallback for runs without a podcast cover: copy the local cover instead.
1399
- if (existsSync(localCover)) {
1400
- const assets = join(dir, 'assets');
1401
- mkdirSync(assets, { recursive: true });
1402
- copyFileSync(localCover, join(assets, 'cover.png'));
1403
- written.push(relative(surfdashRoot, join(assets, 'cover.png')));
1404
- md = upsertKey(md, 'image', './assets/cover.png');
1405
- md = upsertKey(md, 'og_image', './assets/cover.png');
1406
- }
1407
- }
1408
- if (showNotesUrl !== '' && !md.includes(showNotesUrl)) {
1409
- md = `${md.replace(/\s*$/, '')}\n\n${crossLink[locale]}\n`;
1410
- }
1411
- if (md !== readFileSync(indexMd, 'utf-8')) {
1412
- sted('write', indexMd, () => writeFileSync(indexMd, md));
1413
- written.push(relative(surfdashRoot, indexMd));
1414
- changed++;
1415
- }
1416
1400
  }
1417
- // 20260929 prod feedback: CMS cards (homepage hero/tag/search) read EmDash rows whose
1418
- // featured_image is snapshotted at translate-sync time — i.e. the pre-patch
1419
- // `./assets/cover.png`, which the deployed worker does not serve (404). The patch only
1420
- // rewrites files, so re-sync the EmDash rows from the patched files through the same
1421
- // `kk executor` path as translate-sync (its plugin child gets EMDASH_API_TOKEN from kk
1422
- // config; a direct orchestrator spawn gets 403 from prod dev-bypass). Runs before the
1423
- // changed===0 early-return so re-runs heal rows an earlier commit left stale
1424
- // (20260925/20260929). Fixture tests pass no plugins-path and must not shell out.
1425
- if (pluginsPath === '') {
1426
- // A missing plugins-path silently drops the CMS re-sync, and the CMS cards then keep the
1427
- // pre-patch `./assets/cover.png` the deployed worker cannot serve (20261002 lost its
1428
- // cover this way). Leave a marker for the classify step's completeness report.
1401
+ const resultPath = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync-result.json`);
1402
+ if (process.env.KK_SURFDASH_PATCH_OFFLINE === '1') {
1403
+ // The marker keeps the completeness report's cms-resync row honest in offline runs.
1429
1404
  const skipMarker = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_emdash-resync.txt`);
1430
- sted('write', skipMarker, () => writeRaw(skipMarker, 'skipped: no plugins-path\n'));
1431
- console.error('patch-surfdash: no plugins-path — skip emdash re-sync');
1432
- } else {
1433
- const syncIn = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync.json`);
1434
- const syncResult = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync-result.json`);
1435
- sted('write', syncIn, () =>
1436
- writeJqText(syncIn, { body: '', options: { operation: 'sync', postPath: join(slugDir, 'index.md') } }),
1437
- );
1438
- const status = kk(
1439
- ['executor', 'run', 'surfdash-pub', '--in', syncIn, '--out', syncResult, ...pluginsArgs(pluginsPath)],
1440
- { unset: NESTED_SPUR_UNSET, soft: true },
1441
- );
1442
- const result = isFile(syncResult) ? readJson(syncResult) : null;
1443
- const ok =
1444
- status === 0 &&
1445
- result !== null &&
1446
- (result.ok === true || (result.targets as Array<{ ok?: boolean }> | undefined)?.[0]?.ok === true);
1447
- if (!ok) {
1448
- console.error(
1449
- `patch-surfdash: WARNING: emdash re-sync failed (exit ${status}) — CMS cards keep the stale cover; fix: kk executor run surfdash-pub --in ${syncIn} --out ${syncResult} --plugins-path ${pluginsPath}`,
1450
- );
1451
- } else {
1452
- console.error('patch-surfdash: emdash rows re-synced from patched files');
1453
- }
1454
- }
1455
- if (changed === 0) {
1456
- console.error('patch-surfdash: nothing to change');
1405
+ sted('write', skipMarker, () => writeRaw(skipMarker, 'skipped: offline (KK_SURFDASH_PATCH_OFFLINE=1)\n'));
1406
+ console.error('patch-surfdash: offline — skip the plugin patch (KK_SURFDASH_PATCH_OFFLINE=1)');
1457
1407
  return;
1458
1408
  }
1459
- const git = (...a: string[]): void => {
1460
- const r = spawnSync('git', ['-C', surfdashRoot, ...a]);
1461
- if (r.status !== 0) failWith(1, `patch-surfdash: git ${a[0]} failed: ${r.stderr?.toString().trim()}`);
1462
- };
1463
- git('add', '--', ...written);
1464
- git(
1465
- 'commit',
1466
- '-m',
1467
- `feat: cover + podcast cross-link for ${basename(slugDir)} (kk daily patch)`,
1468
- '--',
1469
- ...written,
1409
+ const payload = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_patch.json`);
1410
+ const podcastMeta = (pod.metadata ?? {}) as Record<string, unknown>;
1411
+ sted('write', payload, () =>
1412
+ writeJqText(payload, {
1413
+ body: '',
1414
+ options: {
1415
+ operation: 'patch',
1416
+ postPath,
1417
+ coverUrl: typeof podcastMeta.imageUrl === 'string' ? podcastMeta.imageUrl : '',
1418
+ showNotesUrl: typeof pod.url === 'string' ? pod.url : '',
1419
+ dateIso: `${runDate.slice(0, 4)}-${runDate.slice(4, 6)}-${runDate.slice(6, 8)}`,
1420
+ localCover: join(workDir, '23-cover', `${runDate}_07_cover_cover.png`),
1421
+ },
1422
+ }),
1470
1423
  );
1471
- if (process.env.SURFDASH_AUTO_PUSH !== '0') git('push', 'origin', 'main');
1472
- console.error(
1473
- `patch-surfdash: patched ${changed} post(s), committed${process.env.SURFDASH_AUTO_PUSH !== '0' ? ' + pushed' : ' (push skipped: SURFDASH_AUTO_PUSH=0)'}`,
1424
+ const status = kk(
1425
+ ['executor', 'run', 'surfdash-pub', '--in', payload, '--out', resultPath, ...pluginsArgs(pluginsPath)],
1426
+ { unset: NESTED_SPUR_UNSET, env: { SURFDASH_ROOT: surfdashRoot }, soft: true },
1474
1427
  );
1428
+ const result = isFile(resultPath) ? readJson(resultPath) : null;
1429
+ const meta = (result?.metadata ?? {}) as Record<string, unknown>;
1430
+ if (result?.ok === true) {
1431
+ console.error(
1432
+ `patch-surfdash: ${String(result.message ?? 'patched')} — cms-resync ${String(meta.cmsResync ?? 'ok')}, commit ${String(meta.gitCommit ?? 'unchanged')}${Array.isArray(meta.absentLocales) && meta.absentLocales.length > 0 ? `, no post for ${(meta.absentLocales as string[]).join('/')}` : ''}`,
1433
+ );
1434
+ return;
1435
+ }
1436
+ // The posts are patched and committed; only the EmDash rows keep the unservable cover. That is
1437
+ // the soft channel the completeness report carries, so warn instead of failing the run.
1438
+ if (meta.cmsResync === 'failed') {
1439
+ console.error(
1440
+ `patch-surfdash: WARNING: cms re-sync failed — ${String(result?.error ?? `exit ${status}`)}; fix: kk executor run surfdash-pub --in ${payload} --out ${resultPath}${pluginsArgs(pluginsPath).join(' ')}`,
1441
+ );
1442
+ return;
1443
+ }
1444
+ failWith(1, `patch-surfdash: ${String(result?.error ?? `surfdash-pub patch exited ${status}`)}`);
1475
1445
  });
1476
1446
  }
1477
1447
 
@@ -9,7 +9,7 @@ Review-item IDs here are **this skill's bookkeeping numbers**, not official plat
9
9
  | Effective date | unknown(运营规范版本/生效日期未核验) |
10
10
  | Last verified | 2026-09-29(仅事件层面;规则原文未逐条核验) |
11
11
  | Evidence status | 规则条文**未核验**;2026-09-29 图文下架事件为**用户报告**,平台未给出书面理由 |
12
- | Changelog | 2026-09-29 — file created after the takedown report (task 0189) |
12
+ | Changelog | 2026-09-29 — file created after the takedown report (task 0189) · 2026-10-10 — 操作者裁定:WX-02 降级为 note,不再 escalate |
13
13
 
14
14
  ## Evidence distinctions
15
15
 
@@ -23,7 +23,7 @@ Review-item IDs here are **this skill's bookkeeping numbers**, not official plat
23
23
  | ID | Item | Grade |
24
24
  |----|------|-------|
25
25
  | WX-01 | 时政/公共事务主线:政治、外交/贸易管制、军事、公共政策争议、社会公共事件为主的图文或音频 | block |
26
- | WX-02 | 资质口径:不做新闻资讯播报形态的标题/摘要(未持有相关资质;同小宇宙 XY-01 的教训) | block |
26
+ | WX-02 | 资质口径:不做新闻资讯播报形态的标题/摘要(未持有相关资质;同小宇宙 XY-01 的教训)。**操作者裁定 2026-10-10**:同小宇宙 XY-01 裁定——本号不持有也不声称持有新闻资质,剩余风险只在单集标题/摘要是否真的退回播报形态,按 note 记录,不要因「账号无新闻资质」而 block 或 `escalate: true`;仅当某集实际呈现新闻播报形态时才升级为 block。 | note |
27
27
  | WX-03 | AI 生成声明:图文含 AI 生成内容时按平台入口如实声明(声明入口以平台当前设置为准,未核验 → evidenceGap) | disclose |
28
28
  | WX-04 | 可识别个人信息;标题党/夸大断言(未经核实断言按类别 4 处理) | desensitize / fix |
29
29
 
@@ -9,7 +9,7 @@ Review-item IDs here are **this skill's bookkeeping numbers**, not official plat
9
9
  | Effective date | unknown(协议页面未标注可引用的版本生效日期) |
10
10
  | Last verified | 2026-09-29(任务 0189 期间人工阅读协议页) |
11
11
  | Evidence status | 协议原文已读;**未发现公开的字级禁用词表**;2026-09-29 下架事件为**用户报告的反馈**,非官方说明 |
12
- | Changelog | 2026-09-29 — file created after the daily-episode takedown report (task 0189) |
12
+ | Changelog | 2026-09-29 — file created after the daily-episode takedown report (task 0189) · 2026-10-10 — 操作者裁定:XY-01 降级为 note,不再 escalate |
13
13
 
14
14
  ## Evidence distinctions
15
15
 
@@ -23,7 +23,7 @@ Review-item IDs here are **this skill's bookkeeping numbers**, not official plat
23
23
 
24
24
  | ID | Item | Grade |
25
25
  |----|------|-------|
26
- | XY-01 | 资质口径:简介/标题不得呈现「新闻资讯播报」形态或暗示持有新闻资质(对齐用户报告的下架理由;新闻资质口径为国内平台语境) | block |
26
+ | XY-01 | 资质口径:简介/标题不得呈现「新闻资讯播报」形态或暗示持有新闻资质(对齐用户报告的下架理由;新闻资质口径为国内平台语境)。**操作者裁定 2026-10-10**:本档为个人技术分享播客(栏目「AI 乱炖」),不持有也不声称持有新闻资质,因此不得宣称或暗示该资质这一项本身已满足;剩余风险只在单集文案是否真的退回播报形态,按 note 记录,不要因为「账号无新闻资质」而 block 或 `escalate: true`——它对每一集都一样,逐集 escalate 只会让无人值守的每日任务永远无法收尾。仅当某集实际呈现新闻播报形态时才升级为 block。 | note |
27
27
  | XY-02 | 时政/公共事务主线:政治、外交/贸易管制、军事、公共政策争议、社会公共事件为主线的单集内容 | block |
28
28
  | XY-03 | 字词命中:敏感领域词汇出现时先做上下文判断(hit ≠ verdict);命中且主线无风险 → 记录为 note | note→judge |
29
29
  | XY-04 | 可识别个人信息(简介/shownotes 中听众或第三方的私人信息) | desensitize→block if unfixable |
@@ -33,3 +33,5 @@ Review-item IDs here are **this skill's bookkeeping numbers**, not official plat
33
33
  - 无官方字级词表 → 任何「词命中即下架」的断言都不成立;同理,无词表不等于安全。
34
34
  - 下架真实原因未获官方确认 → 每次提审前按 XY-01/XY-02 复核;`RISK` 或证据缺口一律人工复审
35
35
  (`escalate: true`),不得静默 PASS。
36
+ - 裁定 2026-10-10:XY-01 已按上表降级,资质口径本身不再触发人工复审;XY-02(时政主线)
37
+ 与证据缺口仍照旧 escalate。
@@ -24,10 +24,12 @@ not add a `kk publish` noun.
24
24
  | medium | `medium-pub` | `file-cli` (token API) | `kk executor run medium-pub --in <content.json> --out <result.json>` |
25
25
  | wechatmp | `wechatmp-pub` | `file-cli` (`draft/add`) | `kk executor run wechatmp-pub` (default operation `draft`) |
26
26
  | x post | `x-pub` | `file-cli` (`POST /2/tweets`) | `kk executor run x-pub` (`options.operation` omitted / `post`) |
27
- | x article | `x-pub` | `browser` / `agent` | workflow `agent.run` — not `kk fanout` |
28
- | juejin | `juejin-pub` | `browser` / `agent` | workflow `agent.run` |
29
- | infoq | `infoq-pub` | `browser` / `agent` | workflow `agent.run` |
30
- | substack | `substack-pub` | `browser` / `agent` | workflow `agent.run` |
27
+ | x article | `x-pub` | `browser` / `script` | `kk executor run x-pub --in <content.json> --out <result.json>` (`options.operation: article`); a missing or expired profile refuses (`ok: false`, naming `profile-check.ts login`) before any window opens |
28
+ | juejin | `juejin-pub` | `browser` / `script` | `kk executor run juejin-pub --in <content.json> --out <result.json>` |
29
+ | infoq | `infoq-pub` | `browser` / `script` | `kk executor run infoq-pub --in <content.json> --out <result.json>` |
30
+ | substack | `substack-pub` | `browser` / `script` | `kk executor run substack-pub --in <content.json> --out <result.json>` (needs `SUBSTACK_PUBLICATION_URL`) |
31
+ | threads | `threads-pub` | `file-cli` (Threads Graph, container → `threads_publish`) | `kk executor run threads-pub --in <content.json> --out <result.json>` — text only; needs `THREADS_ACCESS_TOKEN` + `THREADS_USER_ID`, no browser path |
32
+ | weibo | `weibo-pub` | `browser` / `script` | `kk executor run weibo-pub --in <content.json> --out <result.json>`; `options.operation` `article` (头条文章, draft via 保存草稿 by default) or `post` (home composer, which has **no draft** — it requires `metadata.publishStatus: public`) |
31
33
  | xhs | `xhs-pub` | `browser` / `script` | `kk executor run xhs-pub --in <content.json> --out <result.json>` — its default `script` driver uploads the ordered card images with Playwright `setInputFiles`; the `agent` driver fails loud for this channel (ADR-024) |
32
34
 
33
35
  Already-harvested file-cli channels (`qiita-pub`, `zenn-pub`, `surfdash-pub`,
@@ -99,5 +101,7 @@ the confirmed live status; otherwise fail naming the switch.
99
101
  | medium-pub | `MEDIUM_INTEGRATION_TOKEN` |
100
102
  | wechatmp-pub | `WECHAT_APP_ID`, `WECHAT_APP_SECRET` |
101
103
  | x-pub `post` | `X_API_KEY`, `X_API_SECRET`, `X_ACCESS_TOKEN`, `X_ACCESS_TOKEN_SECRET` |
104
+ | threads-pub `post` | `THREADS_ACCESS_TOKEN`, `THREADS_USER_ID` |
105
+ | weibo-pub | none — its session lives in the browser profile (`WEIBO_PROFILE_DIR` overrides the dir; tier: `--profile` > `WEIBO_PROFILE_DIR` > `KK_BROWSER_PROFILE_DIR` > `~/.config/kk/browser-profiles/weibo`) |
102
106
 
103
107
  Missing env → plugin exit 1, `Result.ok false`, error names the variable.
@@ -0,0 +1,93 @@
1
+ # Feature-scoped verification pass (ADR-119, feature D62, task 0872; D63 task
2
+ # 0915 remediation folds the evidence contract into a workflow-owned script).
3
+ #
4
+ # Owns a lifecycle boundary no per-task graph owns: the repo-wide checks whose
5
+ # invariant spans the repository rather than a single task's diff. They run
6
+ # once per feature, deliberately the slow pass; `feature-lifecycle`'s verifying
7
+ # entry invokes this workflow (task 0880 caller wiring), and its verifying→done
8
+ # guard refuses to complete the feature until this pass records PASS (ADR-119
9
+ # consequence: a feature is not done until it passes).
10
+ #
11
+ # Run: spur workflow run feature-verification.yaml --vars '{"featureId":"D63"}'
12
+ #
13
+ # Shape: verify -> done | failed. The onEnter step runs the standard script
14
+ # `feature-verification-steps.ts` (no public CLI verb, D63 task 0915): it
15
+ # resolves this very definition for the verifier identity (name, source path,
16
+ # layer, definition digest), records the RUNNING receipt (run-scoped +
17
+ # feature-latest copies), registers the run-scoped receipt as a run artifact,
18
+ # runs the configured verification command via the safe launch splitter, and
19
+ # completes the receipt PASS/FAIL with the after-pass proof-input digest. A
20
+ # changed tree during the pass records FAIL.
21
+ #
22
+ # Reliability (aligned with task-pipeline / ADR-043): no agent.run nodes; the
23
+ # verification command comes from `vars.verificationCmd` below and is never
24
+ # interpolated through a shell. The always-exit-0 onEnter means missing,
25
+ # corrupt or FAIL evidence routes to `failed` via the guard (fail-closed),
26
+ # never a silent PASS; infrastructure errors exit nonzero and surface.
27
+ "$schema": "@gobing-ai/spur/schemas/state-machine-workflow.schema.json"
28
+ kind: state-machine
29
+ name: feature-verification
30
+ version: "1"
31
+ description: "Feature-scoped verification pass (ADR-119): runs the repo-wide check set once per feature and records a bound PASS/FAIL evidence receipt. Invoked by feature-lifecycle's verifying entry."
32
+ iterationBound: 2
33
+ initialState: verify
34
+ terminalStates:
35
+ - done
36
+ - failed
37
+ failureStates:
38
+ - failed
39
+ vars:
40
+ featureId: "X"
41
+ spurBin: "spur"
42
+ verificationCmd: "bun run spur-check"
43
+ __runId: ""
44
+
45
+ states:
46
+ - id: verify
47
+ description: >
48
+ Resolve this run's installed sp script root via Superskill, then run the
49
+ pinned installed verifier with Bun against the repo-wide check set. The
50
+ verifier records the bound evidence receipt (verifier identity,
51
+ checked-input digest, run identity) and the coarse status file. Both
52
+ actions fail closed — a locator/script failure, an unresolved or
53
+ non-installed root, an absent verifier, or a failing check yields a
54
+ non-PASS status — and always exit 0, so the transition guard routes
55
+ missing/corrupt/FAIL to `failed`.
56
+ onEnter:
57
+ - kind: shell
58
+ options:
59
+ command: >-
60
+ S="$(superskill script path sp script-root.mjs 2>/dev/null)" || S="";
61
+ if [ -n "$S" ] && [ -f "$S" ]; then node "$S" --run-id "$__runId" --spur-bin "$spurBin";
62
+ else echo "script-root failed closed — run 'superskill install sp --marketplace gobing-ai/spur'" >&2;
63
+ fi;
64
+ exit 0
65
+ - kind: shell
66
+ options:
67
+ command: >-
68
+ mkdir -p .spur/run;
69
+ DIR="$(jq -r 'select(.mode == "installed" and (.dir | type == "string") and .dir != "") | .dir' ".spur/run/$__runId-script-root.json" 2>/dev/null)" || DIR="";
70
+ [ -n "$DIR" ] && [ -f "$DIR/feature-verification-steps.mjs" ] && S="$DIR/feature-verification-steps.mjs";
71
+ [ -f "$S" ] && bun "$S" verify --feature-id "$featureId" --run-id "$__runId" --spur-bin "$spurBin" || printf 'FAIL feature-verification failed closed — unresolved root or verifier failure\n' > ".spur/run/$__runId-feature-verification.status";
72
+ exit 0
73
+
74
+ - id: done
75
+ description: Terminal — repo-wide checks passed and the receipt is bound.
76
+
77
+ - id: failed
78
+ description: Terminal — repo-wide checks failed or the evidence is missing/corrupt.
79
+
80
+ transitions:
81
+ - from: verify
82
+ to: done
83
+ description: Repo-wide checks passed — the script completed the receipt with PASS.
84
+ guard:
85
+ kind: shell
86
+ options:
87
+ command: 'test "$(cat .spur/run/$__runId-feature-verification.status 2>/dev/null)" = PASS'
88
+ - from: verify
89
+ to: failed
90
+ terminalReason: failed-check
91
+ description: Repo-wide checks failed (or the evidence is missing/corrupt) — stop at failed.
92
+ guard:
93
+ kind: always
@@ -91,6 +91,7 @@ transitions:
91
91
  # FAIL, out-of-contract verdict, or NEEDS_REVISION with no retries left -> failed.
92
92
  - from: judge
93
93
  to: failed
94
+ terminalReason: failed-guard
94
95
  description: "Judge verdict is FAIL, out-of-contract, or NEEDS_REVISION with retries exhausted -> block publish"
95
96
  guard:
96
97
  kind: shell
@@ -27,6 +27,10 @@ vars:
27
27
  # builtin — a shell command may only reference declared vars). It is the lock owner and the
28
28
  # `collect_runs` / ledger identity.
29
29
  __runId: ""
30
+ # Definition digest of the executed workflow, injected by spur at launch (G8/0252 §9). The
31
+ # Board capture compares it with the public `spur workflow list --json` catalog and reads the
32
+ # topology only on exact agreement; a missing/mismatched digest omits topology with a diagnostic.
33
+ __definitionDigest: ""
30
34
  # Per-source collection parameters, seeded into the plugin source configs by `lock`.
31
35
  limit: "10"
32
36
  last30days_topic: "AI news"
@@ -72,18 +76,34 @@ states:
72
76
  # IS the lock contract, and the guard below reads it back:
73
77
  # `{"acquired":true}` or `{"skipped":"locked","holder":<run>}` (R1).
74
78
  command: >-
75
- kk store lock acquire --name collect --run "${vars.__runId}" --ttl 21600000 > "${vars.work_dir}/lock.json"
79
+ kk lock acquire --name collect --run "${vars.__runId}" --ttl 21600000 > "${vars.work_dir}/lock.json"
76
80
 
77
81
  - id: collect
78
82
  description: >-
79
- Fan-in every ingestion source into the run's blended Doc[] (same sources and timeout overrides as
80
- kk-daily-ai-voice's collect state) and render the core-facts markdown with the existing stage (R3/R4).
83
+ Bind the run to its workspace for the Board (G8/0252 §9), then fan-in every ingestion source into
84
+ the run's blended Doc[] (same sources and timeout overrides as kk-daily-ai-voice's collect state)
85
+ and render the core-facts markdown with the existing stage (R3/R4).
81
86
  onEnter:
82
87
  - kind: shell
83
- # onError: continue on both actions, with the artifact guard below as the positive success
84
- # contract: a state-machine aborts the run on a failed action without taking a transition, so a
85
- # hard failure here would leave the lock behind. The run still ends `failed` — through
86
- # `release-failed`, which releases the lock first.
88
+ # Board capture (G8/0252 §9): revoke any previous workspace owner and bind THIS run to the
89
+ # workspace before any collection output exists. Revocation is mandatory — a refusal writes no
90
+ # board-capture.json, so the artifact guard below routes to `release-failed` and no result is
91
+ # ever stored over another owner's files. An optional capture failure after a successful
92
+ # revocation warns (captured:false) and only disables Board previews.
93
+ # onError: continue keeps the lock-releasing failure path intact instead of a hard abort.
94
+ options:
95
+ onError: continue
96
+ command: >-
97
+ kk board capture --workflow kk-collect --run "${vars.__runId}"
98
+ --work-dir "${vars.work_dir}" --stamp "${vars.stamp}"
99
+ --definition-digest "${vars.__definitionDigest}"
100
+ --setting "limit=${vars.limit}" --setting "horizon_hours=${vars.horizon_hours}"
101
+ > "${vars.work_dir}/board-capture.json"
102
+ - kind: shell
103
+ # onError: continue on both ingestion actions, with the artifact guard below as the positive
104
+ # success contract: a state-machine aborts the run on a failed action without taking a
105
+ # transition, so a hard failure here would leave the lock behind. The run still ends `failed`
106
+ # — through `release-failed`, which releases the lock first.
87
107
  onError: continue
88
108
  options:
89
109
  # horizon-ingest's latency straddles the fan-in 30m per-source default (0098/0099), so it gets
@@ -153,7 +173,7 @@ states:
153
173
  - kind: shell
154
174
  options:
155
175
  command: >-
156
- kk store lock release --name collect --run "${vars.__runId}"
176
+ kk lock release --name collect --run "${vars.__runId}"
157
177
 
158
178
  - id: release-failed
159
179
  description: >-
@@ -164,7 +184,7 @@ states:
164
184
  onError: continue
165
185
  options:
166
186
  command: >-
167
- kk store lock release --name collect --run "${vars.__runId}"
187
+ kk lock release --name collect --run "${vars.__runId}"
168
188
 
169
189
  - id: done
170
190
  description: "Terminal — collection stored; the daily workflow reads it back through `kk store extract`"
@@ -192,13 +212,14 @@ transitions:
192
212
 
193
213
  - from: collect
194
214
  to: store
195
- description: "Blended Doc[] written and core facts rendered -> store it"
215
+ description: "Blended Doc[], core facts and Board capture recorded -> store it"
196
216
  guard:
197
217
  kind: shell
198
218
  options:
199
219
  command: >-
200
220
  test -s "${vars.work_dir}/10-ingest/${vars.stamp}_02_collect_blended.json" &&
201
- test -s "${vars.work_dir}/20-facts/${vars.stamp}_02_collect_core-facts.md"
221
+ test -s "${vars.work_dir}/20-facts/${vars.stamp}_02_collect_core-facts.md" &&
222
+ test -s "${vars.work_dir}/board-capture.json"
202
223
 
203
224
  - from: collect
204
225
  to: release-failed
@@ -232,6 +253,17 @@ transitions:
232
253
  # refusal names the offending value on stderr before the transition falls through to
233
254
  # `release-failed` — fail-closed, and never a deadlock (that state is `always`).
234
255
  # Kept at 3 logical commands: spur's ADR-115 composition lint errors on a guard above 5.
256
+ # Task 0268 — degraded pass is a WARNING, not a gate. `kk store label` prints
257
+ # `degraded: true` and writes `kk.store.label.degraded: fallback: N/M consults (P%)` to
258
+ # stderr whenever the fallback share of its consults exceeds 0.5 — a majority of consults
259
+ # answered `source: default` (spur's `no-backend` degradation). Chosen at one-half because
260
+ # the 2026-10-12 incident measured `fallback: 22/26` (0.85) and `33/45` (0.73), well past
261
+ # it, while a healthy pass stays near zero; the threshold is the operator's signal in
262
+ # launchd.log/the ledger, not a transition. A degraded pass still prints `verdict: PASS`,
263
+ # so this guard releases the lock and the labels it DID write survive for the next tick —
264
+ # making the verdict non-`PASS` instead would route to `release-failed` and, with the
265
+ # label state's `onError: continue`, throw away a whole day's labels for a backend the
266
+ # next tick can reach. The fail-closed defaults themselves are unchanged (task 0268 R3).
235
267
  command: >-
236
268
  test "$(jq -r 'if (.verdict // "") == "PASS" then (.verdict + (if ((.confidence // "") | test("^(HIGH|MEDIUM|LOW)$")) then "" else "!" end)) else (.verdict // "") end' "${vars.work_dir}/label-result.txt" 2>/dev/null)" = PASS ||
237
269
  { echo "kk-collect.label.confidence-refused: verdict '$(jq -r '.verdict // "<none>"' "${vars.work_dir}/label-result.txt" 2>/dev/null)' carries confidence '$(jq -r '.confidence // "<absent>"' "${vars.work_dir}/label-result.txt" 2>/dev/null)', not HIGH|MEDIUM|LOW" >&2; false; }