@gobing-ai/knowledge-kit 0.0.29 → 0.0.31

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 (73) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +114 -38
  3. package/package.json +1 -1
  4. package/plugins/generations/daily-article-gen/dist/index.js +24 -7
  5. package/plugins/generations/daily-article-gen/src/index.ts +36 -12
  6. package/plugins/generations/dailynews-gen/dist/index.js +20 -3
  7. package/plugins/generations/dailynews-gen/src/index.ts +7 -0
  8. package/plugins/generations/dailynews-gen/src/script-builder.ts +4 -2
  9. package/plugins/generations/dailynews-gen/src/script-plan.ts +21 -3
  10. package/plugins/generations/episode-plan-gen/dist/index.js +66 -5
  11. package/plugins/generations/episode-plan-gen/src/index.ts +103 -4
  12. package/plugins/generations/image-gen/dist/index.js +122 -20
  13. package/plugins/generations/image-gen/src/bytes.ts +106 -18
  14. package/plugins/generations/image-gen/src/index.ts +46 -6
  15. package/plugins/generations/image-gen/src/job.ts +6 -0
  16. package/plugins/generations/image-gen/src/providers/minimax.ts +12 -4
  17. package/plugins/generations/image-gen/src/providers/types.ts +12 -1
  18. package/plugins/kk/plugin.json +1 -1
  19. package/plugins/kk/scripts/daily-voice-style.zh.md +5 -5
  20. package/plugins/kk/scripts/itc-stages.ts +21 -3
  21. package/plugins/kk/scripts/kk-workflow-stages.ts +199 -7
  22. package/plugins/kk/scripts/topic-boundary.json +3 -12
  23. package/plugins/kk/skills/content-judge/SKILL.md +32 -0
  24. package/plugins/kk/skills/content-judge/references/workflow-integration.md +10 -2
  25. package/plugins/kk/skills/publish/SKILL.md +1 -1
  26. package/plugins/kk/workflows/judge-gated-publish-example.yaml +18 -10
  27. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +39 -14
  28. package/plugins/kk/workflows/kk-itc.yaml +21 -8
  29. package/plugins/publishings/xhs-pub/dist/index.js +1 -1
  30. package/plugins/publishings/xhs-pub/src/automation-guard.ts +2 -2
  31. package/plugins/publishings/xhs-pub/src/index.ts +1 -1
  32. package/plugins/publishings/xhs-pub/src/profile-check.ts +2 -2
  33. package/plugins/sp/lib/env.ts +0 -46
  34. package/plugins/sp/lib/inline-run.generated.mjs +0 -1489
  35. package/plugins/sp/scripts/batch-preflight.mjs +0 -346
  36. package/plugins/sp/scripts/batch-preflight.ts +0 -459
  37. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +0 -615
  38. package/plugins/sp/scripts/daily-summary/daily-summary.ts +0 -846
  39. package/plugins/sp/scripts/daily-summary/logger.ts +0 -28
  40. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +0 -223
  41. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +0 -367
  42. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +0 -132
  43. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +0 -169
  44. package/plugins/sp/scripts/feature-dev-precheck.mjs +0 -171
  45. package/plugins/sp/scripts/feature-dev-precheck.ts +0 -238
  46. package/plugins/sp/scripts/feature-sync-bounded.mjs +0 -285
  47. package/plugins/sp/scripts/feature-sync-bounded.ts +0 -478
  48. package/plugins/sp/scripts/feature-verification-steps.ts +0 -275
  49. package/plugins/sp/scripts/history-anatomy-cache.mjs +0 -902
  50. package/plugins/sp/scripts/history-anatomy-cache.ts +0 -1028
  51. package/plugins/sp/scripts/idea-handoff.mjs +0 -22
  52. package/plugins/sp/scripts/idea-handoff.ts +0 -44
  53. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +0 -185
  54. package/plugins/sp/scripts/inline-run-setup.ts +0 -722
  55. package/plugins/sp/scripts/pr-reviewing.mjs +0 -769
  56. package/plugins/sp/scripts/pr-reviewing.ts +0 -925
  57. package/plugins/sp/scripts/quality-gate.mjs +0 -179
  58. package/plugins/sp/scripts/quality-gate.ts +0 -217
  59. package/plugins/sp/scripts/script-contract-check.ts +0 -319
  60. package/plugins/sp/scripts/stage-registry-adapter.ts +0 -1533
  61. package/plugins/sp/scripts/surface-drift-inventory.ts +0 -929
  62. package/plugins/sp/scripts/task-evidence-precheck.ts +0 -181
  63. package/plugins/sp/scripts/task-size-precheck.ts +0 -175
  64. package/plugins/sp/scripts/transition-shim-check.ts +0 -238
  65. package/plugins/sp/scripts/validate-commands.ts +0 -689
  66. package/plugins/sp/scripts/validate-flag-contracts.ts +0 -878
  67. package/plugins/sp/scripts/verify-answer-lint.ts +0 -530
  68. package/plugins/sp/scripts/workflow-step-profile.mjs +0 -316
  69. package/plugins/sp/scripts/workflow-step-profile.ts +0 -456
  70. package/plugins/sp/scripts/wrapup-drift-probe.mjs +0 -190
  71. package/plugins/sp/scripts/wrapup-drift-probe.ts +0 -258
  72. package/plugins/sp/scripts/wrapup-steps.mjs +0 -444
  73. package/plugins/sp/scripts/wrapup-steps.ts +0 -552
@@ -32,7 +32,11 @@ export interface ImageRequest {
32
32
  model: string;
33
33
  /** e.g. '21:9' — from FormatPreset.aspectRatio (0102). */
34
34
  aspectRatio?: string;
35
- /** Optional exact pixels; providers that only take an aspect ratio ignore them. */
35
+ /**
36
+ * Declared preset canvas (0182 R1). Sent only when the resolved provider's
37
+ * `exactSize(model, width, height)` returns true — with `aspectRatio` omitted;
38
+ * every other provider receives the aspect-only request shape and never sees them.
39
+ */
36
40
  width?: number;
37
41
  height?: number;
38
42
  quality?: ImageQuality;
@@ -64,6 +68,13 @@ export interface ImageProvider {
64
68
  isAvailable(): boolean;
65
69
  /** Queryable ref-image capability; 0105 routes `--ref` work on this fact. */
66
70
  supportsReferenceImages(model: string): boolean;
71
+ /**
72
+ * Exact-size capability (0182 R1/R4): can this adapter deliver the exact pixel canvas
73
+ * for this model? Absent = aspect-only — `processGeneratorIO` then sends only
74
+ * `aspectRatio`, byte-for-byte as before 0182. Adapters opt in one at a time, on
75
+ * measured evidence (task 0182 Q&A, closed).
76
+ */
77
+ exactSize?(model: string, width: number, height: number): boolean;
67
78
  /** Throw on an impossible combination (e.g. Imagen + referenceImages) before spending money. */
68
79
  validate?(req: ImageRequest): void;
69
80
  generate(req: ImageRequest): Promise<ImageResult>;
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "kk",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
4
4
  "description": "knowledge-kit Claude Code plugin: skills, commands, subagents, hooks, and rules"
5
5
  }
@@ -25,10 +25,10 @@
25
25
 
26
26
  ## 段落角色
27
27
 
28
- - `intro`:开场导语,串起今天几个主题,接在固定问候之后,不超过 120 字。
29
- - `lead`:一句话口语桥段,引出下一个主题的【标题】;只做铺垫,不得复述标题本身,不超过 60 字。
30
- - `insight`:只基于这一条自身可核事实的 1–2 句点评,说明它为什么重要或怎么用得上;不得挪用其他条目的话题,不超过 160 字。
31
- - `outro`:简短收束语,接在固定致谢之前,不超过 120 字。
28
+ - `intro`:开场导语,串起今天几个主题,接在固定问候之后,不超过 150 字。
29
+ - `lead`:一句话口语桥段,引出下一个主题的【标题】;只做铺垫,不得复述标题本身,不超过 80 字。
30
+ - `insight`:只基于这一条自身可核事实的点评(3–6 句),展开这一条自身的机制→意义→实用角度;不得挪用其他条目的话题,不超过 380 字。时长契约:全片按 15–20 分钟口播设计,insight 应写到接近上限——宁可深挖这一条的事实,也不要浅尝辄止。字数按配音驱动折算:gemini ≈3.3 字/秒(正文约 4300–5800 字);Voicebox robin-news ≈6.3 字/秒(正文约 5700–7600 字,或增加条目数)。
31
+ - `outro`:简短收束语,接在固定致谢之前,不超过 150 字。
32
32
 
33
33
  ## 机器可读契约
34
34
 
@@ -39,7 +39,7 @@
39
39
 
40
40
  ```json
41
41
  {
42
- "caps": { "intro": 120, "outro": 120, "lead": 60, "insight": 160 },
42
+ "caps": { "intro": 150, "outro": 150, "lead": 80, "insight": 380 },
43
43
  "banned": [
44
44
  "在各种技术光环背后,正是这样专注而不知疲倦的底层攻坚者,为今天整个大模型生态奠定了最坚实的技术基石。",
45
45
  "这其实释放了一个非常明确的信号:AI竞争的下半场,已经不再只是单纯拼模型参数,更是电力、能源和全栈工业基础设施的硬核较量。",
@@ -131,7 +131,18 @@ export function itcReport(args: string[]): void {
131
131
  const [workDir = '', maxRevisions = ''] = args;
132
132
  if (!workDir) fail('usage: itc-report <work_dir> [max_revisions]');
133
133
  const verdictPath = join(workDir, '.itc-verdict.json');
134
- const verdict = existsSync(verdictPath) ? ((readJson(verdictPath) as { verdict?: unknown }).verdict ?? '') : '';
134
+ const verdictJson = existsSync(verdictPath)
135
+ ? (readJson(verdictPath) as { verdict?: unknown; confidence?: unknown })
136
+ : {};
137
+ const verdict = verdictJson.verdict ?? '';
138
+ // Same enum as the workflow guards and `daily-safety-gate`: a PASS without a HIGH/MEDIUM/LOW
139
+ // confidence never cleared the judge gate, so the report must not claim a publish-ready draft.
140
+ // An absent verdict file stays the dry-run PASS convention (verdict '').
141
+ const unusablePass =
142
+ verdict === 'PASS' &&
143
+ verdictJson.confidence !== 'HIGH' &&
144
+ verdictJson.confidence !== 'MEDIUM' &&
145
+ verdictJson.confidence !== 'LOW';
135
146
  // Auto runs count on .auto-revise-count; interactive runs keep the existing .revise-count.
136
147
  const revisions = readCount(join(workDir, '.auto-revise-count')) ?? readCount(join(workDir, '.revise-count')) ?? 0;
137
148
  // Exhaustion only fails the run when the last verdict still asks for another revision —
@@ -186,7 +197,8 @@ export function itcReport(args: string[]): void {
186
197
  }
187
198
 
188
199
  const publishFailed = targets.some((t) => !t.ok);
189
- const outcome = verdict === 'FAIL' || exhausted || publishFailed ? 'failed' : declined ? 'declined' : 'done';
200
+ const outcome =
201
+ verdict === 'FAIL' || unusablePass || exhausted || publishFailed ? 'failed' : declined ? 'declined' : 'done';
190
202
  const reportDir = join(workDir, '5-report');
191
203
  const jsonPath = join(reportDir, 'report.json');
192
204
  const publish = hasPlan ? { targets, retryCommands } : undefined;
@@ -198,7 +210,13 @@ export function itcReport(args: string[]): void {
198
210
  );
199
211
  writeFileSync(
200
212
  join(reportDir, 'report.md'),
201
- `# kk-itc run report\n\n- Outcome: ${outcome}\n- Verdict: ${verdict === '' ? 'PASS (empty)' : verdict}\n- Revisions: ${revisions}\n` +
213
+ `# kk-itc run report\n\n- Outcome: ${outcome}\n- Verdict: ${
214
+ verdict === ''
215
+ ? 'PASS (empty)'
216
+ : unusablePass
217
+ ? 'PASS (no HIGH/MEDIUM/LOW confidence — the judge gate never cleared)'
218
+ : verdict
219
+ }\n- Revisions: ${revisions}\n` +
202
220
  (declined
203
221
  ? '\n## Publish\n\n- Declined by the operator — nothing was sent.\n'
204
222
  : targets.length > 0
@@ -840,6 +840,7 @@ function dailyQcContent(args: string[]): void {
840
840
  minImportance = '',
841
841
  minUrgency = '',
842
842
  minImpact = '',
843
+ minEpisodeItems = '',
843
844
  ] = args;
844
845
  const candidates = join(workDir, '21-plan', `${runDate}_04_quality-control-content_candidates.json`);
845
846
  const annotated = join(workDir, '21-plan', `${runDate}_04_quality-control-content_annotated.json`);
@@ -870,6 +871,7 @@ function dailyQcContent(args: string[]): void {
870
871
  QC_MIN_IMPORTANCE: minImportance,
871
872
  QC_MIN_URGENCY: minUrgency,
872
873
  QC_MIN_IMPACT: minImpact,
874
+ QC_MIN_EPISODE_ITEMS: minEpisodeItems,
873
875
  },
874
876
  },
875
877
  );
@@ -1524,16 +1526,21 @@ function safetyPath(workDir: string, runDate: string, name: string): string {
1524
1526
  */
1525
1527
  function safetyDigest(workDir: string, runDate: string): string {
1526
1528
  const hash = createHash('sha256');
1527
- for (const path of [
1528
- join(workDir, '22-article', `${runDate}_06_article_content.json`),
1529
- join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`),
1530
- ]) {
1529
+ for (const path of safetyDraftPaths(workDir, runDate)) {
1531
1530
  if (!isFile(path)) failWith(1, `safety-review: required draft not found: ${path}`);
1532
1531
  hash.update(readFileSync(path)).update('\0');
1533
1532
  }
1534
1533
  return hash.digest('hex');
1535
1534
  }
1536
1535
 
1536
+ /** The drafts under review — the digest's bytes, and the quarantine set on a quality block. */
1537
+ function safetyDraftPaths(workDir: string, runDate: string): [string, string] {
1538
+ return [
1539
+ join(workDir, '22-article', `${runDate}_06_article_content.json`),
1540
+ join(workDir, '24-script', `${runDate}_08_script_voicescript.yaml`),
1541
+ ];
1542
+ }
1543
+
1537
1544
  /**
1538
1545
  * Open a content-safety review of the current drafts: drop every artifact of an earlier review
1539
1546
  * (a retry must be re-reviewed on its final version) and write the request the two agent.run
@@ -1581,17 +1588,39 @@ function dailySafetyGate(args: string[]): void {
1581
1588
  const digest = safetyDigest(workDir, runDate);
1582
1589
  let status = 'block';
1583
1590
  let reason = 'verdict PASS on unchanged drafts, no BLOCK/RISK';
1584
- if (request?.digest !== digest) reason = 'drafts changed after the review was requested';
1585
- else if (decisions.length === 0 || decisions.some((d) => d !== 'OK' && d !== 'RISK' && d !== 'BLOCK'))
1591
+ // A digest-mismatch block means the drafts were deliberately edited (HITL): keep them so
1592
+ // a re-run of safety-prep re-reviews the edited version. Any other block means the
1593
+ // content itself is rejected — quarantine the drafts below.
1594
+ let edited = false;
1595
+ if (request?.digest !== digest) {
1596
+ reason = 'drafts changed after the review was requested';
1597
+ edited = true;
1598
+ } else if (decisions.length === 0 || decisions.some((d) => d !== 'OK' && d !== 'RISK' && d !== 'BLOCK'))
1586
1599
  reason = 'content-safety report missing or malformed';
1587
1600
  else if (decisions.includes('BLOCK')) reason = 'content-safety report carries a BLOCK decision';
1588
1601
  else if (verdict?.verdict !== 'PASS')
1589
1602
  reason = `content-judge verdict is ${typeof verdict?.verdict === 'string' ? verdict.verdict : 'missing or unreadable'}`;
1603
+ else if (verdict?.confidence !== 'HIGH' && verdict?.confidence !== 'MEDIUM' && verdict?.confidence !== 'LOW')
1604
+ reason = 'content-judge verdict is missing a HIGH/MEDIUM/LOW confidence level';
1590
1605
  else if (decisions.includes('RISK') || report?.escalate === true) {
1591
1606
  status = 'escalate';
1592
1607
  reason = 'RISK decision or escalation — human review required';
1593
1608
  } else status = 'pass';
1594
1609
  if (status !== 'block') sted('write', stampFile, () => writeJsonText(stampFile, { digest, status }));
1610
+ if (status === 'block' && !edited) {
1611
+ // A quality block poisons the isFile() draft cache: every same-day retry would
1612
+ // re-fail on unchanged bytes (20261002 dogfood — two judge-fail runs replayed until
1613
+ // the drafts were moved by hand). Quarantine (mv, never rm) so the next run
1614
+ // regenerates from scratch; the 09_* verdict artifacts stay in 24-script for audit.
1615
+ const q = join(workDir, '24-script', `.quarantine-${runDate}-judge-block`);
1616
+ mkdirSync(q, { recursive: true });
1617
+ for (const path of safetyDraftPaths(workDir, runDate)) {
1618
+ if (!isFile(path)) continue;
1619
+ let dest = join(q, basename(path));
1620
+ for (let n = 2; isFile(dest); n += 1) dest = join(q, `${n}-${basename(path)}`);
1621
+ renameSync(path, dest);
1622
+ }
1623
+ }
1595
1624
  sted('write', statusFile, () => writeRaw(statusFile, status));
1596
1625
  console.log(`safety: status=${status} (${reason}); routing is transition-owned`);
1597
1626
  });
@@ -1657,6 +1686,54 @@ function dailyGenerate(args: string[]): void {
1657
1686
  * `intro_music` empty -> skip. Writes `<stem>.intro.wav` (original render kept), points
1658
1687
  * `audioPath` at the merged file, extends `duration`, marks `introMerged` (idempotent).
1659
1688
  */
1689
+ /**
1690
+ * Episode duration gate (standing requirement, 20261002): a daily episode must run 15–20 minutes
1691
+ * (duration is authoritative; the 8–10-item figure is only an estimate). Reads the final audio
1692
+ * content's `metadata.duration` — after the intro sting merged, this is the true episode length —
1693
+ * and fails loud outside `[min, max]` seconds-worth of minutes. A missing duration is a failure,
1694
+ * not a pass: the gate must never silently let an under-length episode through again.
1695
+ */
1696
+ function dailyEpisodeDuration(args: string[]): void {
1697
+ const [workDir = '', runDate = '', minArg = '', maxArg = ''] = args;
1698
+ if (!workDir) fail('missing work_dir');
1699
+ if (!runDate) fail('missing run_date');
1700
+ const minMinutes = Number(minArg);
1701
+ const maxMinutes = Number(maxArg);
1702
+ if (!Number.isFinite(minMinutes) || minMinutes <= 0) fail(`invalid min_minutes: ${minArg || '(empty)'}`);
1703
+ if (!Number.isFinite(maxMinutes) || maxMinutes < minMinutes) fail(`invalid max_minutes: ${maxArg || '(empty)'}`);
1704
+ const contentPath = join(workDir, '30-audio', `${runDate}_11_generate_content.json`);
1705
+ if (!isFile(contentPath)) failWith(1, `episode-duration: audio content not found: ${contentPath}`);
1706
+ let raw: unknown;
1707
+ try {
1708
+ const parsed = JSON.parse(readFileSync(contentPath, 'utf-8')) as { metadata?: { duration?: unknown } };
1709
+ raw = parsed.metadata?.duration;
1710
+ } catch (error) {
1711
+ if ((error as { code?: string }).code !== undefined) {
1712
+ sted('read', contentPath, () => readFileSync(contentPath, 'utf-8'));
1713
+ }
1714
+ raw = undefined;
1715
+ }
1716
+ const seconds = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN;
1717
+ if (!Number.isFinite(seconds) || seconds <= 0) {
1718
+ failWith(1, `episode-duration: audio metadata.duration missing or not a number in ${contentPath}`);
1719
+ }
1720
+ const minutes = seconds / 60;
1721
+ const shown = minutes.toFixed(1);
1722
+ if (minutes < minMinutes) {
1723
+ failWith(
1724
+ 1,
1725
+ `episode-duration: episode runs ${shown} min, below the ${minMinutes}–${maxMinutes} min requirement (duration is authoritative). The candidate pool is too thin — deepen per-item treatment or add items; do not ship a short episode silently.`,
1726
+ );
1727
+ }
1728
+ if (minutes > maxMinutes) {
1729
+ failWith(
1730
+ 1,
1731
+ `episode-duration: episode runs ${shown} min, above the ${minMinutes}–${maxMinutes} min requirement. Trim items or per-item treatment.`,
1732
+ );
1733
+ }
1734
+ console.log(`episode-duration ok: ${shown} min within ${minMinutes}–${maxMinutes} min`);
1735
+ }
1736
+
1660
1737
  function dailyIntro(args: string[]): void {
1661
1738
  const [workDir = '', runDate = '', introSrc = ''] = args;
1662
1739
  if (!introSrc || introSrc.trim() === '') {
@@ -1928,8 +2005,47 @@ function dailyShowNotes(args: string[]): void {
1928
2005
  timed(workDir, 'show-notes', () => {
1929
2006
  const effective = readJson(article)?.title;
1930
2007
  const titleArg = title !== '' ? title : typeof effective === 'string' ? effective : '';
2008
+ // Boundary-excluded docs must not render into show notes (judge finding 2026-10-02): the
2009
+ // article body's 未写入 note names the dropped ids; filter the plan copy podcast-pub sees.
2010
+ // Inlined parse — this file must not import @gobing-ai/* (header contract); the same note
2011
+ // grammar lives in daily-article-gen's excludedIdsFromNote.
1931
2012
  const postPath = firstPostPath(readJson(result), 'surfdash-pub');
1932
- kk(['executor', 'run', 'podcast-pub', '--in', plan, '--out', out, ...pluginsArgs(pluginsPath)], {
2013
+ const articleBody = readJson(article)?.body;
2014
+ const excluded = new Set<string>();
2015
+ if (typeof articleBody === 'string') {
2016
+ for (const note of articleBody.matchAll(/未写入[::](.*)/g)) {
2017
+ for (const id of note[1].matchAll(/(?:[a-zA-Z0-9_-]+:)?[0-9a-f]{16}/g)) {
2018
+ excluded.add(id[0]);
2019
+ const colon = id[0].indexOf(':');
2020
+ if (colon !== -1) excluded.add(id[0].slice(colon + 1));
2021
+ }
2022
+ }
2023
+ }
2024
+ let planIn = plan;
2025
+ if (excluded.size > 0) {
2026
+ const parsed: unknown = readJson(plan);
2027
+ if (Array.isArray(parsed)) {
2028
+ const kept = parsed.filter(
2029
+ (d) =>
2030
+ !(
2031
+ d !== null &&
2032
+ typeof d === 'object' &&
2033
+ (excluded.has(String((d as Record<string, unknown>).id)) ||
2034
+ (String((d as Record<string, unknown>).id).includes(':') &&
2035
+ excluded.has(
2036
+ String((d as Record<string, unknown>).id).slice(
2037
+ String((d as Record<string, unknown>).id).indexOf(':') + 1,
2038
+ ),
2039
+ )))
2040
+ ),
2041
+ );
2042
+ if (kept.length !== parsed.length) {
2043
+ planIn = plan.replace(/\.json$/, '-shownotes.json');
2044
+ writeJsonText(planIn, kept);
2045
+ }
2046
+ }
2047
+ }
2048
+ kk(['executor', 'run', 'podcast-pub', '--in', planIn, '--out', out, ...pluginsArgs(pluginsPath)], {
1933
2049
  env: {
1934
2050
  PODCAST_PUB_TITLE: titleArg,
1935
2051
  PODCAST_PUB_RUN_DATE: runDate,
@@ -2018,7 +2134,22 @@ function dailyPublishClassify(args: string[]): void {
2018
2134
  const status = okCount > 0 && okCount === total ? 'full' : okCount > 0 ? 'partial' : 'none';
2019
2135
  sted('write', result, () => writeJqText(result, { ok: okCount === total && total > 0, targets }));
2020
2136
  sted('write', statusFile, () => writeRaw(statusFile, status));
2137
+ const completeness = publishCompleteness(workDir, runDate);
2138
+ const completenessFile = join(workDir, '31-publish', `${runDate}_17_publish_completeness.json`);
2139
+ sted('write', completenessFile, () => writeJqText(completenessFile, { runDate, ...completeness }));
2021
2140
  console.log(`publish: status=${status} (${okCount}/${total} targets published); routing is transition-owned`);
2141
+ console.log(`publish-completeness: ${completeness.rows.map((row) => `${row.channel}=${row.state}`).join(' ')}`);
2142
+ if (completeness.missing.length > 0) {
2143
+ const details = completeness.rows
2144
+ .filter((row) => row.state !== 'done')
2145
+ .map((row) => `${row.channel}=${row.state}${row.detail === '' ? '' : ` (${row.detail})`}`)
2146
+ .join('; ');
2147
+ console.error(
2148
+ `WARNING: publish completeness — ${completeness.missing.join(', ')} did not complete: ${details}. ` +
2149
+ 'Independent channels never enter publish_status; re-run their stages for this work dir (see ' +
2150
+ `${completenessFile})`,
2151
+ );
2152
+ }
2022
2153
  });
2023
2154
  }
2024
2155
 
@@ -2029,6 +2160,59 @@ function targetsOf(path: string): Record<string, unknown>[] {
2029
2160
  : [];
2030
2161
  }
2031
2162
 
2163
+ /**
2164
+ * The publish tail's independent channels (locales, XHS, WeChat) and the surfdash CMS re-sync never
2165
+ * enter `publish_status` — they are non-blocking by design (ADR-022/025/026). That is also why a
2166
+ * tail driven by hand can skip every one of them and still leave a `full` status file behind:
2167
+ * 20261002 shipped zh-only surfdash with no XHS and no WeChat draft, and nothing in the run's own
2168
+ * artifacts said so. Record each channel's state beside the status file so the gap is visible.
2169
+ */
2170
+ function publishCompleteness(
2171
+ workDir: string,
2172
+ runDate: string,
2173
+ ): { rows: { channel: string; state: string; detail: string }[]; missing: string[] } {
2174
+ const at = (name: string): string => join(workDir, '31-publish', `${runDate}_${name}`);
2175
+ const markerFilled = (path: string): boolean => isFile(path) && readFileSync(path, 'utf-8').trim() !== '';
2176
+ const rows: { channel: string; state: string; detail: string }[] = [];
2177
+ const localesDone = ['en', 'ja'].every((locale) =>
2178
+ markerFilled(at(`20_translate-localize_${locale}-post-path.txt`)),
2179
+ );
2180
+ rows.push({
2181
+ channel: 'locales',
2182
+ state: localesDone ? 'done' : 'not-run',
2183
+ detail: localesDone ? 'en/ja drafts scaffolded' : 'translate markers absent (en/ja never scaffolded)',
2184
+ });
2185
+ const resultChannels: [string, string, string][] = [
2186
+ ['xhs', '19_publish-xhs_result.json', ''],
2187
+ ['wechat', '20_publish-wechat_result.json', ''],
2188
+ ['cms-resync', '23_patch-surfdash_sync-result.json', '23_patch-surfdash_emdash-resync.txt'],
2189
+ ];
2190
+ for (const [channel, resultName, skipMarkerName] of resultChannels) {
2191
+ const result = isFile(at(resultName)) ? readJson(at(resultName)) : null;
2192
+ if (result !== null) {
2193
+ const metadata = (result.metadata ?? {}) as Record<string, unknown>;
2194
+ rows.push({
2195
+ channel,
2196
+ state: result.ok === true ? 'done' : 'failed',
2197
+ detail:
2198
+ result.ok === true
2199
+ ? typeof metadata.publishStatus === 'string'
2200
+ ? metadata.publishStatus
2201
+ : 'ok'
2202
+ : String(result.error ?? 'ok=false'),
2203
+ });
2204
+ continue;
2205
+ }
2206
+ const skipMarker = skipMarkerName === '' ? '' : at(skipMarkerName);
2207
+ rows.push(
2208
+ skipMarker !== '' && isFile(skipMarker)
2209
+ ? { channel, state: 'skipped', detail: readFileSync(skipMarker, 'utf-8').trim() }
2210
+ : { channel, state: 'not-run', detail: 'no result artifact' },
2211
+ );
2212
+ }
2213
+ return { rows, missing: rows.filter((row) => row.state !== 'done').map((row) => row.channel) };
2214
+ }
2215
+
2032
2216
  function dailyPublishPartial(args: string[]): void {
2033
2217
  const [workDir = '', runDate = ''] = args;
2034
2218
  const result = join(workDir, '31-publish', `${runDate}_17_publish_result.json`);
@@ -2168,6 +2352,11 @@ function dailyPatchSurfdash(args: string[]): void {
2168
2352
  // changed===0 early-return so re-runs heal rows an earlier commit left stale
2169
2353
  // (20260925/20260929). Fixture tests pass no plugins-path and must not shell out.
2170
2354
  if (pluginsPath === '') {
2355
+ // A missing plugins-path silently drops the CMS re-sync, and the CMS cards then keep the
2356
+ // pre-patch `./assets/cover.png` the deployed worker cannot serve (20261002 lost its
2357
+ // cover this way). Leave a marker for the classify step's completeness report.
2358
+ const skipMarker = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_emdash-resync.txt`);
2359
+ sted('write', skipMarker, () => writeRaw(skipMarker, 'skipped: no plugins-path\n'));
2171
2360
  console.log('patch-surfdash: no plugins-path — skip emdash re-sync');
2172
2361
  } else {
2173
2362
  const syncIn = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync.json`);
@@ -2460,6 +2649,9 @@ if (import.meta.main)
2460
2649
  case 'daily-intro':
2461
2650
  dailyIntro(rest);
2462
2651
  break;
2652
+ case 'daily-episode-duration':
2653
+ dailyEpisodeDuration(rest);
2654
+ break;
2463
2655
  case 'daily-quality-report':
2464
2656
  dailyQualityReport(rest);
2465
2657
  break;
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "version": 1,
3
- "note": "Task 0189 R2 topic admission for the AI 乱炖 column. Excludes items whose mainline is political/diplomatic-trade, military, public-policy dispute, or a social public event; records every exclusion with id, rule, reason and the matched keyword. Sources are never deleted or modified. Scan covers the title plus the first scanChars chars of the body. Precision first: these keywords are a floor, not a sieve — a rare false negative is caught by the script-write/article-write topic-boundary prompts and the content-safety skill review, and a suspected false positive is restored by hand with a recorded reason (manual review), never silently.",
3
+ "note": "Task 0189 R2 topic admission for the AI 乱炖 column, with the 2026-10-02 operator scope correction: the political-diplomatic rule is China-only (中国政治/外交主线). Foreign public-affairs items are NOT floor-excluded — they flow to the plan and are handled by the article-write/script-write/cover 选题边界 prompts (skip + note / plan `excluded`) and the content-safety review. Excluded items are recorded with id, rule, reason and the matched keyword. Sources are never deleted or modified. Scan covers the title plus the first scanChars chars of the body. Precision first: these keywords are a floor, not a sieve — a rare false negative is caught by the prompt-layer boundaries and the content-safety skill review, and a suspected false positive is restored by hand with a recorded reason (manual review), never silently.",
4
4
  "scanChars": 200,
5
5
  "rules": [
6
6
  {
7
7
  "id": "political-diplomatic",
8
- "reason": "政治/外交主线:涉及政治人物、政府机构、选举、外交或双边关系的条目,不属于 AI 乱炖的技术分享范围(0189 R2)。",
8
+ "reason": "中国政治/外交主线:涉及中国党政机构、领导人、外交部门或台海/中美关系的条目,不属于 AI 乱炖的技术分享范围(0189 R2;2026-10-02 操作者裁定本规则仅限中国,外国政治条目由选题边界提示与内容安全评审处理)。",
9
9
  "keywords": [
10
10
  "领导人",
11
11
  "主席访",
@@ -16,18 +16,9 @@
16
16
  "国事访问",
17
17
  "政治局",
18
18
  "中南海",
19
- "总统",
20
- "白宫",
21
- "国会",
22
- "议会",
23
- "选举",
24
19
  "台海",
25
20
  "两岸关系",
26
- "中美关系",
27
- "president",
28
- "parliament",
29
- "election",
30
- "diplomat"
21
+ "中美关系"
31
22
  ]
32
23
  },
33
24
  {
@@ -30,6 +30,7 @@ Every evaluation writes a JSON verdict file. The default path is
30
30
  ```json
31
31
  {
32
32
  "verdict": "PASS | FAIL | NEEDS_REVISION",
33
+ "confidence": "HIGH | MEDIUM | LOW",
33
34
  "rubric": "<rubric-id>",
34
35
  "score": 0.0,
35
36
  "summary": "<one-paragraph reasoning summary>",
@@ -57,6 +58,37 @@ Every evaluation writes a JSON verdict file. The default path is
57
58
  `PASS` is the only verdict that unblocks publish. The shell guard treats `FAIL` and
58
59
  `NEEDS_REVISION` identically (exit 1); the distinction is for the feedback loop, not the gate.
59
60
 
61
+ ### Confidence level
62
+
63
+ Every verdict carries a `confidence` level — `HIGH`, `MEDIUM`, or `LOW` — self-assessed by the
64
+ judge from evidence coverage:
65
+
66
+ - `HIGH` — every criterion grounded in directly verified evidence (sources opened, claims
67
+ cross-checked); no unverified rule text or assumption entered the reasoning.
68
+ - `MEDIUM` — verdict grounded but with known evidence gaps (e.g. unverified platform rule text,
69
+ single-source claims), none strong enough to flip the verdict.
70
+ - `LOW` — verdict rests on assumptions or unverified evidence that could plausibly flip it.
71
+
72
+ Every judge-verdict **consumer** enforces the enum, so a `PASS` whose `confidence` is missing or
73
+ outside `HIGH|MEDIUM|LOW` is not a usable verdict anywhere:
74
+
75
+ - `kk stage daily-safety-gate` blocks the run (`missing a HIGH/MEDIUM/LOW confidence level`).
76
+ - The spur workflow gates (`kk-itc`, `judge-gated-publish-example`) read the verdict through a
77
+ confidence-aware `jq` filter that turns such a `PASS` into an out-of-contract value (`PASS!`),
78
+ which no accept guard matches and the state machine's catch-all routes to `failed` — the import
79
+ neither publishes nor deadlocks.
80
+ - `kk stage itc-report` reports `outcome: failed` (and says why in `report.md`) for a `PASS` without
81
+ a valid confidence; an absent verdict file keeps the dry-run convention.
82
+
83
+ Add that filter to any new judge gate; `references/workflow-integration.md` carries the pattern and
84
+ `plugins/kk/tests/verdict-confidence.test.ts` fails on either half of the contract: a workflow that
85
+ accepts a judge `PASS` without checking the enum, and this skill drifting out of the producer side
86
+ (enum line, per-level definition, or a reference snippet whose filter no longer matches the guards
87
+ the consumer scan collects).
88
+
89
+ The level itself does not change routing — escalation stays owned by the review's `RISK` decisions
90
+ and `escalate` flag; treat `LOW` as a signal to demand human review before publishing.
91
+
60
92
  ## Evaluation procedure
61
93
 
62
94
  1. **Read the Content** from the path given in the evaluation request (the `content.json` written
@@ -33,7 +33,11 @@ if [ ! -f "$verdict_path" ]; then
33
33
  echo "judge: verdict file not found at $verdict_path"
34
34
  exit 1
35
35
  fi
36
- verdict=$(jq -r '.verdict' "$verdict_path")
36
+ # Confidence-aware read: a PASS without a HIGH|MEDIUM|LOW confidence becomes `PASS!`, which no
37
+ # accept guard matches and the catch-all below rejects. Keep this filter byte-identical wherever a
38
+ # guard reads a judge verdict — and to this snippet: `plugins/kk/tests/verdict-confidence.test.ts`
39
+ # collects every guard's filter plus this one and fails unless they are byte-identical.
40
+ verdict=$(jq -r 'if (.verdict // "") == "PASS" then (.verdict + (if ((.confidence // "") | test("^(HIGH|MEDIUM|LOW)$")) then "" else "!" end)) else (.verdict // "") end' "$verdict_path")
37
41
  case "$verdict" in
38
42
  PASS)
39
43
  echo "judge: PASS"
@@ -45,7 +49,7 @@ case "$verdict" in
45
49
  exit 1
46
50
  ;;
47
51
  *)
48
- echo "judge: unrecognized verdict '$verdict' in $verdict_path"
52
+ echo "judge: unrecognized verdict or a PASS without a valid confidence ('$verdict') in $verdict_path"
49
53
  exit 1
50
54
  ;;
51
55
  esac
@@ -57,6 +61,10 @@ esac
57
61
  2. **Unrecognized value → block.** Only `PASS` unblocks; anything else exits 1.
58
62
  3. **`FAIL` and `NEEDS_REVISION` both block** — the distinction is for the feedback loop, not the
59
63
  gate. The workflow topology decides whether to retry (NEEDS_REVISION) or halt (FAIL).
64
+ 4. **A `PASS` without a `HIGH | MEDIUM | LOW` confidence is not a verdict.** The confidence-aware
65
+ read above makes it arrive as `PASS!`, so it fails rule 2 like any other out-of-contract value.
66
+ 5. **Leave a catch-all.** A guard set with no matching transition deadlocks the machine; every
67
+ judge state needs a terminal `*`/catch-all edge for verdicts outside the contract.
60
68
 
61
69
  ## Retry topology (NEEDS_REVISION)
62
70
 
@@ -50,7 +50,7 @@ Overrides: `--profile <dir>`, `--timeout <ms>`; env fallback `XHS_PROFILE_DIR` /
50
50
  **ADR-025 (2026-09-28, after an XHS warning about automated activity).** Publish now runs this same
51
51
  check as a **preflight** before opening anything headed — so a missing or expired session is a fast
52
52
  refusal (`ok: false`) rather than a headed window sitting on the login gate for 120 s — and both
53
- console visits are capped at **one headed publish cycle + one session check per 24 h**
53
+ console visits are capped at **one headed publish cycle + one session check per 2 h**
54
54
  (`~/.config/kk/xhs-automation.json`, override `KK_XHS_AUTOMATION_STATE`; a manual `check` draws on
55
55
  the same `check` budget). A capped or sessionless publish fails with `ok: false` and a named reason.
56
56
  `login` is never automated and never capped. Do not schedule XHS publishing: publish deliberately,
@@ -25,7 +25,7 @@ states:
25
25
  # Seed the verdict so the judge gate guards can be evaluated in --dry-run
26
26
  # (dry-run skips actions but still evaluates shell guards). A real run's
27
27
  # judge step overwrites this file before the gate is read.
28
- command: 'echo ''{"verdict":"PASS","score":0.0}'' > ${vars.verdictFile} && echo ''generating content...'''
28
+ command: 'echo ''{"verdict":"PASS","confidence":"HIGH","score":0.0}'' > ${vars.verdictFile} && echo ''generating content...'''
29
29
  - id: judge
30
30
  description: "Evaluate generated content via the judge skill (agent.run)"
31
31
  onEnter:
@@ -51,7 +51,7 @@ states:
51
51
  - id: done
52
52
  description: "Publish completed successfully"
53
53
  - id: failed
54
- description: "Judge rejected content (FAIL) or retries exhausted (NEEDS_REVISION)"
54
+ description: "Judge rejected content (FAIL or an out-of-contract verdict) or retries exhausted (NEEDS_REVISION)"
55
55
 
56
56
  transitions:
57
57
  - from: generate
@@ -63,14 +63,18 @@ transitions:
63
63
  # PASS -> publish.
64
64
  - from: judge
65
65
  to: publish
66
- description: "Judge verdict is PASS (or file absent under --dry-run) -> proceed to publish"
66
+ description: "Judge verdict is PASS with a HIGH/MEDIUM/LOW confidence (or file absent under --dry-run) -> proceed to publish"
67
67
  guard:
68
68
  kind: shell
69
69
  options:
70
- # Fail-closed on an explicit non-PASS verdict; fail-open only when the file is
71
- # absent, which happens under --dry-run (actions are skipped, so the judge
72
- # never writes its verdict). A real run always has the file.
73
- command: 'v="$(jq -r .verdict ${vars.verdictFile} 2>/dev/null)"; test -z "$v" -o "$v" = PASS'
70
+ # Fail-closed on an explicit non-PASS verdict or a PASS without a valid confidence level;
71
+ # fail-open only when the file is absent, which happens under --dry-run (actions are skipped,
72
+ # so the judge never writes its verdict). A real run always has the file.
73
+ # The confidence-aware read appends `!` to an out-of-contract PASS, so the guards below treat
74
+ # it as a rejection instead of letting it through.
75
+ command: >-
76
+ v="$(jq -r 'if (.verdict // "") == "PASS" then (.verdict + (if ((.confidence // "") | test("^(HIGH|MEDIUM|LOW)$")) then "" else "!" end)) else (.verdict // "") end' ${vars.verdictFile} 2>/dev/null)";
77
+ test -z "$v" -o "$v" = PASS
74
78
 
75
79
  # NEEDS_REVISION -> back to generate with a bounded retry budget.
76
80
  - from: judge
@@ -84,14 +88,18 @@ transitions:
84
88
  # responsible for incrementing attempt on each loop in a real run.
85
89
  command: 'v="$(jq -r .verdict ${vars.verdictFile} 2>/dev/null)"; test "$v" = NEEDS_REVISION -a "$attempt" -lt "$maxRetries"'
86
90
 
87
- # FAIL, or NEEDS_REVISION with no retries left -> failed.
91
+ # FAIL, out-of-contract verdict, or NEEDS_REVISION with no retries left -> failed.
88
92
  - from: judge
89
93
  to: failed
90
- description: "Judge verdict is FAIL, or NEEDS_REVISION with retries exhausted -> block publish"
94
+ description: "Judge verdict is FAIL, out-of-contract, or NEEDS_REVISION with retries exhausted -> block publish"
91
95
  guard:
92
96
  kind: shell
93
97
  options:
94
- command: 'v="$(jq -r .verdict ${vars.verdictFile} 2>/dev/null)"; test -n "$v" && { test "$v" = FAIL || test "$v" = NEEDS_REVISION -a "$attempt" -ge "$maxRetries"; }'
98
+ # Anything that is neither a usable PASS nor a retryable NEEDS_REVISION fails closed.
99
+ command: >-
100
+ v="$(jq -r 'if (.verdict // "") == "PASS" then (.verdict + (if ((.confidence // "") | test("^(HIGH|MEDIUM|LOW)$")) then "" else "!" end)) else (.verdict // "") end' ${vars.verdictFile} 2>/dev/null)";
101
+ { test -n "$v" -a "$v" != PASS -a "$v" != NEEDS_REVISION; } ||
102
+ { test "$v" = NEEDS_REVISION -a "$attempt" -ge "$maxRetries"; }
95
103
 
96
104
  - from: publish
97
105
  to: done