@gobing-ai/knowledge-kit 0.1.8 → 0.1.10

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 (78) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/board/collect/index.js +725 -1101
  3. package/dist/board/daily-voice/index.js +722 -101
  4. package/dist/board/itc/index.js +13 -0
  5. package/dist/index.js +1796 -102
  6. package/package.json +1 -1
  7. package/plugins/generations/content-gen/dist/index.js +4 -0
  8. package/plugins/generations/core-facts-gen/dist/index.js +4 -0
  9. package/plugins/generations/daily-article-gen/dist/index.js +4 -0
  10. package/plugins/generations/dailynews-gen/dist/index.js +4 -0
  11. package/plugins/generations/episode-plan-gen/dist/index.js +102 -1
  12. package/plugins/generations/episode-plan-gen/src/index.ts +2 -4
  13. package/plugins/generations/gemini-tts/dist/index.js +4 -0
  14. package/plugins/generations/image-gen/dist/index.js +4 -0
  15. package/plugins/generations/news-report-gen/dist/index.js +4 -0
  16. package/plugins/generations/voice-gen/dist/index.js +4 -0
  17. package/plugins/generations/voxcpm-tts/dist/index.js +4 -0
  18. package/plugins/ingestions/aihot-ingest/dist/index.js +4 -0
  19. package/plugins/ingestions/horizon-ingest/dist/index.js +28 -1
  20. package/plugins/ingestions/horizon-ingest/src/index.ts +34 -0
  21. package/plugins/ingestions/last30days-ingest/dist/index.js +11 -3
  22. package/plugins/ingestions/last30days-ingest/src/index.ts +14 -3
  23. package/plugins/ingestions/web-search/dist/index.js +4 -0
  24. package/plugins/kk/config.example.yaml +8 -0
  25. package/plugins/kk/plugin.json +1 -1
  26. package/plugins/kk/publish-presets/daily-ai-voice.json +123 -0
  27. package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +20 -0
  28. package/plugins/kk/schedules/kk-daily-publish3-overseas.cron.tmpl +17 -0
  29. package/plugins/kk/scripts/daily-stages.ts +665 -78
  30. package/plugins/kk/scripts/kk-workflow-stages.ts +11 -1
  31. package/plugins/kk/scripts/sentences.ts +134 -0
  32. package/plugins/kk/scripts/series-names.ts +124 -0
  33. package/plugins/kk/scripts/topic-boundary.json +18 -83
  34. package/plugins/kk/workflows/kk-collect.yaml +2 -0
  35. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +112 -9
  36. package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +842 -0
  37. package/plugins/kk/workflows/kk-daily-publish3.yaml +282 -0
  38. package/plugins/kk/workflows/kk-daily-safety.yaml +50 -5
  39. package/plugins/publishings/bilibili-pub/dist/index.js +4 -0
  40. package/plugins/publishings/cnblogs-pub/dist/index.js +4 -0
  41. package/plugins/publishings/csdn-pub/dist/index.js +4 -0
  42. package/plugins/publishings/csdn-pub/dist/profile-check.js +4 -0
  43. package/plugins/publishings/emdash-pub/dist/index.js +4 -0
  44. package/plugins/publishings/infoq-pub/dist/index.js +4 -0
  45. package/plugins/publishings/infoq-pub/dist/profile-check.js +4 -0
  46. package/plugins/publishings/jike-pub/dist/index.js +4 -0
  47. package/plugins/publishings/jike-pub/dist/profile-check.js +4 -0
  48. package/plugins/publishings/juejin-pub/dist/index.js +4 -0
  49. package/plugins/publishings/juejin-pub/dist/profile-check.js +4 -0
  50. package/plugins/publishings/medium-pub/dist/index.js +4 -0
  51. package/plugins/publishings/podcast-pub/README.md +65 -0
  52. package/plugins/publishings/podcast-pub/dist/index.js +1906 -374
  53. package/plugins/publishings/podcast-pub/plugin.json +1 -1
  54. package/plugins/publishings/podcast-pub/src/backfill.ts +465 -0
  55. package/plugins/publishings/podcast-pub/src/index.ts +211 -34
  56. package/plugins/publishings/podcast-pub/src/microfeed-client.ts +32 -3
  57. package/plugins/publishings/podcast-pub/src/seo.ts +1079 -0
  58. package/plugins/publishings/podcast-pub/src/show-notes.ts +74 -14
  59. package/plugins/publishings/qiita-pub/dist/index.js +4 -0
  60. package/plugins/publishings/shipinhao-pub/dist/index.js +4 -0
  61. package/plugins/publishings/sspai-pub/dist/index.js +4 -0
  62. package/plugins/publishings/substack-pub/dist/index.js +4 -0
  63. package/plugins/publishings/substack-pub/dist/profile-check.js +4 -0
  64. package/plugins/publishings/surfdash-pub/dist/index.js +313 -32
  65. package/plugins/publishings/surfdash-pub/src/digest-check.ts +218 -0
  66. package/plugins/publishings/surfdash-pub/src/index.ts +170 -39
  67. package/plugins/publishings/surfdash-pub/src/patch.ts +44 -7
  68. package/plugins/publishings/threads-pub/dist/index.js +4 -0
  69. package/plugins/publishings/wechatmp-pub/dist/index.js +4 -0
  70. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +4 -0
  71. package/plugins/publishings/weibo-pub/dist/index.js +4 -0
  72. package/plugins/publishings/weibo-pub/dist/profile-check.js +4 -0
  73. package/plugins/publishings/x-pub/dist/index.js +4 -0
  74. package/plugins/publishings/x-pub/dist/profile-check.js +4 -0
  75. package/plugins/publishings/xhs-pub/dist/index.js +4 -0
  76. package/plugins/publishings/xhs-pub/dist/profile-check.js +4 -0
  77. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +4 -0
  78. package/plugins/publishings/zenn-pub/dist/index.js +4 -0
@@ -0,0 +1,842 @@
1
+ ---
2
+ name: kk-daily-ai-voice3
3
+ kind: dag
4
+ description: >-
5
+ The generate half of voice3 (design docs/design/kk-daily-ai-voice3.md §4, task 0324): topic →
6
+ QC/translate → drafts → content safety → operator review → audio ∥ images → quality, then the
7
+ generate-side channel payloads (`social-write` + `daily-bundle-variants`) and the frozen
8
+ `bundle.json` that `kk-daily-publish3` consumes. It publishes nothing: every channel decision and
9
+ every send lives in the publish preset and the publish workflow, which is why this graph carries
10
+ no `xhs_*` / `wechat_*` / `publish_enabled` switch. voice2 stays the production path until the
11
+ operator swaps the 04:30 schedule entry (§8).
12
+
13
+ vars:
14
+
15
+ # ---- voice3 additions (task 0324 R4) ----
16
+ # `auto_publish` launches publish3 nested when true; off by default exactly like voice2's
17
+ # publish_enabled, and the operator turns it on in the schedule entry after the shadow period.
18
+ # Card-series authoring/rendering (billed image generation). The cards ROW in the publish preset
19
+ # is `off` by default, so this stays false until an operator turns both on.
20
+ cards_enabled: "false"
21
+ # Card count (voice2's `xhs_cards`, minus the channel name): the card plan targets this many.
22
+ cards: "6"
23
+ auto_publish: "false"
24
+ publish_window: "after-generate"
25
+ publish_preset: ""
26
+ publish_dummy: "false"
27
+ # Composed by the `prepare` node (`kk daily prepare`), then read back into vars through the
28
+ # single-node read chain below — shell actions cannot set vars, and a concurrent `setVars` merge
29
+ # is last-writer-wins, so the DAG exchanges data by fixed paths under work_dir, never by vars.
30
+ work_dir: ""
31
+ # Digits-only run-date stamp the artifact names carry; projected from <work_dir>/run-date.txt.
32
+ run_date: ""
33
+ # Store extract window (kk store extract --window). The old workflow's ingestion window was
34
+ # intrinsic to the fan-in; here it is the pool's freshness bound (kk-collect refreshes every 6h).
35
+ window: "24h"
36
+ language: "zh"
37
+ voice_profile: "robin-news"
38
+ # Voice driver switch — voxcpm (voxcpm-tts) | gemini (gemini-tts) | voicebox (voice-gen)
39
+ # | ominivoice (omni-voice-gen). The generate tier below mirrors the fallback order, and the
40
+ # fit/duration gates take their spoken-char rate from this var — switching drivers here is enough.
41
+ # 2026-10-20 (operator decision): voxcpm STAYS the primary. It is audibly echoey - a 40 ms periodic
42
+ # reflection on renders longer than ~30 s plus a pause floor only 16-22 dB below speech, measured by
43
+ # `kk stage audio-quality` on every episode's raw model output since 10-15 (cepstrum 40 ms @ 26-31 dB
44
+ # / pauseFloor -16..-22 dB, versus gemini's 20-25 ms @ 19-20 dB / -34..-37 dB) - and no exposed knob
45
+ # (cfg_value, inference_timesteps, normalize, denoise) or usable checkpoint fixes it (VoxCPM1.5:
46
+ # 80 ms @ 31 dB; VoxCPM-0.5B loses the lattice but measures -15.5 dB at 16 kHz). The operator keeps
47
+ # the local driver for its speed and cost and will monitor it episode by episode until a proper fix
48
+ # exists; flipping this one var to "gemini" is the escape hatch (measured dry: 20-25 ms / -34..-37 dB)
49
+ # at the price of a 1h47m-2h21m remote render.
50
+ vdriver: "voxcpm"
51
+ intro_music: "~/.config/kk/voices/AI每日资讯_8秒播客片头.mp3"
52
+ surfdash_root: "/Users/robin/xprojects/surfdash"
53
+ title: ""
54
+ skip_review: "false"
55
+ transcode_mp3: "true"
56
+ podcast_artist: "Robin Min"
57
+ podcast_album: "咸话咸说"
58
+ cover_enabled: "true"
59
+ cover_provider: "minimax"
60
+ cover_model: "image-01"
61
+ cover_fallback: ""
62
+ # 0219 R3: image-gen vendor fallback for the XHS render; "" restores strict fail-fast.
63
+ xhs_fallback: "true"
64
+ # 2026-10-21 operator decision: OFF for now (was on since 2026-09-29). The plugin reported today's
65
+ # draft as saved (`草稿箱` 12 -> 13 plus a scoped `保存成功` toast) while the console showed the
66
+ # operator no draft, so the channel's success evidence is not trustworthy until that is resolved.
67
+ # Card-image fallback to the aspect-only request when the exact canvas is refused.
68
+ # launch-time switch and nothing else in the run depends on this target.
69
+ xhs_style: "xhs-knowledge"
70
+ xhs_provider: "minimax"
71
+ xhs_model: "image-01"
72
+ # The 合集 tag a channel payload carries is that channel's preset `options` (the shipped preset
73
+ # pins 「每日AI资讯」), not a generate-side var: voice3 does not know which channels exist.
74
+ plan_max_items: "20"
75
+ plan_translate_enabled: "true"
76
+ article_write_enabled: "true"
77
+ script_write_enabled: "true"
78
+ plugins_path: ""
79
+ # Content-safety budget and platforms, forwarded to the nested kk-daily-safety state machine.
80
+ safety_max_revise: "2"
81
+ safety_platforms: "xiaoyuzhou,apple-podcasts,wechatmp"
82
+ agent: "inline"
83
+ # 0238 R2: executor for the `-retry` siblings of the two hard-fail agent.run producers. The
84
+ # siblings declare `role: reviewer` (capable-1), two rungs above the primary's `scribe`, so the
85
+ # default `inline` resolves the retry above any rung the primary reaches by climbing past an
86
+ # unusable cheap executor (ac5-live-1). The operator may still pin a known-good executor here.
87
+ agent_retry: "inline"
88
+ # QC pins — lockstep with episode-plan-gen's DEFAULT_FILTER_CONFIG (env wins), same as voice1.
89
+ qc_categories: "ai-news,ai-technical,ai-research,ai-industry"
90
+ qc_min_quality: "2"
91
+ qc_min_importance: "3"
92
+ qc_min_urgency: "2"
93
+ qc_min_impact: "1"
94
+ qc_min_episode_items: "8"
95
+ episode_min_minutes: "15"
96
+ # 2026-10-09 operator decision: ingest bodies are spoken in full and today's pools render 22-46
97
+ # minutes at the current item counts, so the 20-minute ceiling refused real episodes before any
98
+ # TTS request. The pair drives the pre-spend estimates (`daily-qc-content`'s pool gate, and
99
+ # `daily-generate`'s script gate, which measures the authored VoiceScript) and the measured gate
100
+ # in `daily-episode-duration`.
101
+ episode_max_minutes: "25"
102
+ # Run id, injected by spur at launch (0674 R5/R8) — the selections / ledger / mark-published
103
+ # identity. A shell command may only reference declared vars.
104
+ __runId: ""
105
+ # Executed definition digest, injected by spur at launch (G8 §9). The Board capture compares it
106
+ # with the named definition and records the topology only on exact agreement.
107
+ __definitionDigest: ""
108
+
109
+ # One action per node (engine: DagNodeDef.action), no back-edges (ADR-031). A node whose deps are
110
+ # ALL skipped is skipped; a done+skipped mix proceeds — which is how the empty-pool path still
111
+ # reaches run-report and ledger (they list `extract` in dependsOn, G11 R3).
112
+ #
113
+ # NODES ARE DECLARED IN TOPOLOGICAL ORDER (every node after all of its `dependsOn`). The engine
114
+ # settles skips in one sweeping pass over this list, in declaration order, and stops as soon as a
115
+ # pass finds no ready node — a node declared before a dependency that becomes `skipped` later in
116
+ # that same pass stays `pending` and the run ends `dag-unreachable-nodes` instead of `done`.
117
+ #
118
+ # Fail-fast is the other half: any failed node fails the run, so every non-blocking channel (xhs,
119
+ # wechat, locales) keeps its exit-0-on-soft-failure kk stage. `onError: continue` sits where voice1 had
120
+ # it (cover-brief, script-write) and, since 0238 R2, on the two hard-fail agent.run producers
121
+ # (plan-translate, article-write), whose `-retry` siblings below carry the loud second attempt.
122
+ # Wave-level waiting is the accepted ceiling (design §6): a node starts when its own deps settle,
123
+ # but its whole wave is awaited before the next wave is dispatched.
124
+ nodes:
125
+ - id: prepare
126
+ description: >-
127
+ Sync the workflow ledger and compose today's workspace: `kk daily prepare` writes the effective
128
+ work_dir to .spur/runs/kk-daily-ai-voice2/work-dir.txt, the run date to <work_dir>/run-date.txt,
129
+ the artifact directories and the safety revise budget. `kk ledger sync` records runs that
130
+ already finished without a ledger row (failed ones included) — a fail-fast DAG never reaches
131
+ its own `ledger` node, so the next run's prepare is the backfill (design §7).
132
+ The `daily-preflight` stage runs FIRST: the scheduler executes this checkout's YAML while
133
+ `kk stage` resolves its stage runner from the installed package, so a run whose YAML names a
134
+ stage the installed runner lacks must fail here, naming the stage, instead of 29 minutes later
135
+ inside the nested safety machine (2026-10-20: `unknown stage: daily-safety-verdict`, nothing
136
+ published).
137
+ action:
138
+ kind: shell
139
+ options:
140
+ command: >-
141
+ kk stage daily-preflight plugins/kk/workflows/kk-daily-ai-voice2.yaml
142
+ plugins/kk/workflows/kk-daily-safety.yaml
143
+ && kk daily prepare --workflow kk-daily-ai-voice2 --run-date "${vars.run_date}" --ledger-sync
144
+ --run "${vars.__runId}" --definition-digest "${vars.__definitionDigest}"
145
+ --setting window="${vars.window}" --setting language="${vars.language}" --setting vdriver="${vars.vdriver}"
146
+ --setting skip_review="${vars.skip_review}" --setting transcode_mp3="${vars.transcode_mp3}"
147
+ --setting cover_enabled="${vars.cover_enabled}" --setting plan_translate_enabled="${vars.plan_translate_enabled}"
148
+ --setting article_write_enabled="${vars.article_write_enabled}"
149
+ --setting script_write_enabled="${vars.script_write_enabled}" --setting plan_max_items="${vars.plan_max_items}"
150
+ --setting safety_max_revise="${vars.safety_max_revise}" --setting qc_min_quality="${vars.qc_min_quality}"
151
+ --setting qc_min_importance="${vars.qc_min_importance}" --setting qc_min_urgency="${vars.qc_min_urgency}"
152
+ --setting qc_min_impact="${vars.qc_min_impact}" --setting qc_min_episode_items="${vars.qc_min_episode_items}"
153
+ --setting episode_min_minutes="${vars.episode_min_minutes}" --setting episode_max_minutes="${vars.episode_max_minutes}"
154
+
155
+ - id: read-work-dir
156
+ description: "Project the effective work_dir into vars (single-node wave: nothing has fanned out yet)"
157
+ dependsOn: [prepare]
158
+ action:
159
+ kind: file.read.into-var
160
+ options:
161
+ path: .spur/runs/kk-daily-ai-voice2/work-dir.txt
162
+ var: work_dir
163
+
164
+ - id: read-run-date
165
+ description: "Project the digits-only run-date stamp into vars (single-node wave, after work_dir is known)"
166
+ dependsOn: [read-work-dir]
167
+ action:
168
+ kind: file.read.into-var
169
+ options:
170
+ path: ${vars.work_dir}/run-date.txt
171
+ var: run_date
172
+
173
+ # 20261012: a dead browser session must be named BEFORE the run's ~40 minutes of generation, not
174
+ # after. On 2026-10-12 the saved MP session was refused server-side (unexpired cookies, but the
175
+ # console served its QR login and /cgi-bin/home carried no token), so `wechat-publish` failed soft
176
+ # at the very end and the operator only learned it by noticing the missing draft. A leaf off the
177
+ # head chain on purpose: `extract` keeps `read-run-date` as its only dependency, so the head chain
178
+ # stays single-node waves, and nothing depends on this node — it warns, it never routes.
179
+
180
+ - id: session-preflight
181
+ description: >-
182
+ Probe each enabled publish channel's saved browser session up front and WARN when one is
183
+ dead, naming the operator action. The probe set is derived from the publish PRESET (0325), not
184
+ from workflow vars: this workflow no longer knows which channels exist. Warning-only by
185
+ construction (onError: continue) — the channel's own publish receipt stays the authority.
186
+ dependsOn: [read-run-date]
187
+ action:
188
+ kind: shell
189
+ options:
190
+ onError: continue
191
+ command: >-
192
+ kk stage run publish-matrix-check "${vars.work_dir}" --all-windows
193
+ || echo "session-preflight: a publish channel's saved session was refused — fix it with: kk bot login <channel>"
194
+
195
+ - id: extract
196
+ description: >-
197
+ Select today's episode pool from the kk store: the ai-news topic profile over the labels
198
+ kk-collect wrote, minus every story already selected or published for the ai-news topic (the
199
+ store's `selections` ledger, written by the `select` node below). Writes
200
+ <work_dir>/10-collect/pool.json (the fan-in blended.json shape) and prints {count}. An empty
201
+ pool writes [] and exits 0 — the run's no-content path, not a failure. The `daily-extract`
202
+ stage widens the window (×2, ×3, capped at 72h) only when the configured window's pool cannot
203
+ reach `episode_min_minutes` (2026-10-19): a published episode consumes more stories than a day
204
+ of collection supplies, so the tight window alone closed 2026-10-18/19 as no-content while
205
+ older unselected items sat in the store.
206
+ dependsOn: [read-run-date]
207
+ action:
208
+ kind: shell
209
+ options:
210
+ command: >-
211
+ kk stage daily-extract "${vars.work_dir}" ai-news "${vars.window}" "${vars.episode_min_minutes}" "${vars.vdriver}"
212
+
213
+ - id: plan
214
+ description: >-
215
+ Score/dedup/select + annotate the pool into the episode plan (episode-plan-gen; annotate rides
216
+ inside planEpisode). The labels extract inlined into metadata replace its keyword re-scoring.
217
+ Guarded on the pool: an empty pool skips the whole content subtree, and run-report/ledger
218
+ still run.
219
+ dependsOn: [extract]
220
+ condition:
221
+ kind: shell
222
+ options:
223
+ command: 'kk store pool-count "${vars.work_dir}/10-collect/pool.json" --min 1'
224
+ action:
225
+ kind: shell
226
+ options:
227
+ command: >-
228
+ kk stage daily-plan "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}" "${vars.plan_max_items}" "${vars.plan_translate_enabled}"
229
+
230
+ - id: qc-content
231
+ description: >-
232
+ Content QC: filter the plan's annotated candidates by category allowlist + score thresholds
233
+ (episode-plan-gen --mode filter). Writes 21-plan/<date>_04_quality-control-content_candidates.json,
234
+ whose length guards the whole content fan-out below — a pool that filtered to zero items ends
235
+ the run as no-content instead of building an empty episode. The stage also applies the
236
+ pre-spend floor gate (2026-10-12): a kept pool whose estimated spoken length cannot reach
237
+ `episode_min_minutes` is emptied here, so a thin day closes as no-content instead of paying
238
+ for a synthesis pass that `daily-episode-duration` then refuses.
239
+ dependsOn: [plan]
240
+ action:
241
+ kind: shell
242
+ options:
243
+ command: >-
244
+ kk stage daily-qc-content "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}" "${vars.qc_categories}" "${vars.qc_min_quality}" "${vars.qc_min_importance}" "${vars.qc_min_urgency}" "${vars.qc_min_impact}" "${vars.qc_min_episode_items}" "${vars.episode_min_minutes}" "${vars.vdriver}"
245
+
246
+ - id: select
247
+ description: >-
248
+ Record today's planned set in the store's topic-scoped `selections` ledger (0228). It runs at
249
+ QC, the moment the run has committed to its episode set, so the cross-day exclusion no longer
250
+ depends on the DAG reaching its tail: a run that fails, pauses at review or skips publish still
251
+ excludes what it planned. Guarded on the QC candidates — a pool that filtered to zero items
252
+ selects nothing.
253
+ dependsOn: [qc-content]
254
+ condition:
255
+ kind: shell
256
+ options:
257
+ # 0324 R3: the pool verdict is `pool-gate`'s token, never a repeated `kk store pool-count`.
258
+ command: 'test "$(cat ".spur/run/${vars.__runId}-pool-gate.status" 2>/dev/null)" = PASS'
259
+ action:
260
+ kind: shell
261
+ options:
262
+ command: >-
263
+ kk store select --topic ai-news --workflow kk-daily-ai-voice2 --run "${vars.__runId}"
264
+ --in "${vars.work_dir}/21-plan/${vars.run_date}_04_quality-control-content_candidates.json"
265
+
266
+ # ---- content branches: translate / prose article / cover / script author (concurrent) ----
267
+ #
268
+ # 0238 R2: `plan-translate` and `article-write` are the two HARD-FAIL agent.run producers — a
269
+ # missing expectFile fails the run (R4 property, kept). The resolved executor is stochastic: a run
270
+ # answered a greeting with zero tool calls and exited 0 (0238 evidence). The engine has no node
271
+ # retry, so each producer carries a `-retry` sibling on a DIFFERENT tier (`agent_retry`, role
272
+ # reviewer) with the SAME input/expectFile; the primary becomes `onError: continue` and the sibling
273
+ # (no onError) is the loud gate. Downstream readers depend on BOTH, so the done+skipped mix of a
274
+ # successful primary still proceeds. Delete the siblings when spur grows an agent.run retry.
275
+
276
+ - id: plan-translate
277
+ description: "Translate the needsTranslation plan items into the output plan.json via agent.run; onError: continue — plan-translate-retry below is the loud gate (0238 R2)"
278
+ dependsOn: [qc-content]
279
+ condition:
280
+ kind: shell
281
+ options:
282
+ # 0324 R3: the pool verdict is `pool-gate`'s token, never a repeated `kk store pool-count`.
283
+ command: >-
284
+ test "$(cat ".spur/run/${vars.__runId}-pool-gate.status" 2>/dev/null)" = PASS
285
+ && test "${vars.plan_translate_enabled}" = "true"
286
+ && ! jq -e --argjson n "$(jq '.metadata.docs|length' "${vars.work_dir}/21-plan/${vars.run_date}_04_quality-control-content_candidates.json")" 'type=="array" and length==$n and all(.[];type=="object")' "${vars.work_dir}/21-plan/${vars.run_date}_03_plan_plan.json"
287
+ action:
288
+ kind: agent.run
289
+ # 0238 R2: a stochastic executor no-op must not end the run here — the retry sibling decides.
290
+ onError: continue
291
+ options:
292
+ role: scribe
293
+ agent: ${vars.agent}
294
+ # ADR-115 error tier: keep this prompt <= 1000 chars (0133 R3; 0138 R4a copy discipline).
295
+ # The anchor is shared with plan-translate-retry so the two attempts cannot drift apart.
296
+ # 20261015 forensics: headless agy's terminal sandbox denies workspace writes for EVERY
297
+ # shell command (command() rules are promptless-but-sandboxed; unsandboxed() is dead), so
298
+ # the producer must deliver via the file-write tool; and the condition gate checks JSON
299
+ # validity (array, length == candidates docs, all objects) because a pi token-cap staged
300
+ # write once flowed 4/12 items downstream. Keep both when editing prompt or condition.
301
+ input: &plan-translate-input |
302
+ Read ${vars.work_dir}/21-plan/${vars.run_date}_04_quality-control-content_candidates.json; metadata.docs (Doc[]) is the plan.
303
+ For needsTranslation items adapt "title", "body" and "metadata.brief" into spoken simplified Chinese — keep every fact, number, name, quote, hedge (口播全长改编,不摘要不省略), body ≈0.6–0.7× source.
304
+ Titles read as news; keep product/model names and body URLs verbatim, use 「」 not ASCII quotes.
305
+ Copy discipline: reader-facing news copy — no scaffold labels (「可做角度」「为什么现在值得注意」「社区讨论」「背景」「潜在影响」「影响」), no hedging labels (「读者宜另行核实」「仍待验证」) — merge or drop.
306
+ Keep id/sourceUri/mediaType/metadata as-is; no comments/trailing commas.
307
+ Deliver with your file-write tool (headless agy sandbox blocks shell writes); the file must end as ONE valid JSON array — no split markers/placeholders.
308
+ Output the JSON array — same length and order — to ${vars.work_dir}/21-plan/${vars.run_date}_03_plan_plan.json.
309
+ expectFile: ${vars.work_dir}/21-plan/${vars.run_date}_03_plan_plan.json
310
+ allowAppend: false
311
+
312
+ - id: plan-translate-retry
313
+ description: >-
314
+ Bounded recovery for plan-translate (0238 R2): runs only when the primary settled without a
315
+ VALID expectFile (missing or contract-violating JSON), retries the same input on a different
316
+ tier, and carries NO onError — a second miss fails the run loudly instead of letting an
317
+ untranslated or truncated plan flow downstream.
318
+ dependsOn: [plan-translate]
319
+ condition:
320
+ kind: shell
321
+ options:
322
+ # 0324 R3: the pool verdict is `pool-gate`'s token, never a repeated `kk store pool-count`.
323
+ command: >-
324
+ test "$(cat ".spur/run/${vars.__runId}-pool-gate.status" 2>/dev/null)" = PASS
325
+ && test "${vars.plan_translate_enabled}" = "true"
326
+ && ! jq -e --argjson n "$(jq '.metadata.docs|length' "${vars.work_dir}/21-plan/${vars.run_date}_04_quality-control-content_candidates.json")" 'type=="array" and length==$n and all(.[];type=="object")' "${vars.work_dir}/21-plan/${vars.run_date}_03_plan_plan.json"
327
+ action:
328
+ kind: agent.run
329
+ options:
330
+ role: reviewer
331
+ agent: ${vars.agent_retry}
332
+ input: *plan-translate-input
333
+ expectFile: ${vars.work_dir}/21-plan/${vars.run_date}_03_plan_plan.json
334
+ allowAppend: false
335
+
336
+ - id: article-write
337
+ description: "Write the tech-sharing prose article from the translated plan via agent.run (3-format split: surfdash gets prose, not a news list); onError: continue — article-write-retry below is the loud gate (0238 R2)"
338
+ dependsOn: [qc-content, plan-translate, plan-translate-retry]
339
+ condition:
340
+ kind: shell
341
+ options:
342
+ # 0324 R3: the pool verdict is `pool-gate`'s token, never a repeated `kk store pool-count`.
343
+ command: >-
344
+ test "$(cat ".spur/run/${vars.__runId}-pool-gate.status" 2>/dev/null)" = PASS
345
+ && test "${vars.article_write_enabled}" = "true"
346
+ && ! test -s "${vars.work_dir}/22-article/${vars.run_date}_06_article-write_body.md"
347
+ action:
348
+ kind: agent.run
349
+ # 0238 R2: a stochastic executor no-op must not end the run here — the retry sibling decides.
350
+ onError: continue
351
+ options:
352
+ role: scribe
353
+ agent: ${vars.agent}
354
+ # ADR-115 error tier: keep this prompt <= 1000 chars. No title line: the canonical episode
355
+ # title is computed by daily-article-gen (0189 R4).
356
+ # The anchor is shared with article-write-retry so the two attempts cannot drift apart.
357
+ input: &article-write-input |
358
+ Read ${vars.work_dir}/21-plan/${vars.run_date}_03_plan_plan.json — metadata.docs (Doc[]) is the translated plan; don't edit it.
359
+ Write vivid simplified-Chinese tech news for 咸话咸说之每日AI乱炖 (column 每日AI乱炖, host Robin). No title line. Open with exactly「这里是由Robin为你带来的咸话咸说之每日AI乱炖」。Never「我是咸话咸说」or an AI-production footer. Each ## section: what happened, why it matters, one concrete detail — a human reporter, not a telegram. Inline [来源:<label>](<sourceUri>). Close by tying the items together.
360
+ 选题边界:只写技术内容;某条主线若属政治/外交或贸易管制/军事/公共政策争议/社会公共事件,跳过该条并在文末加一行「> 注:<N> 条条目因选题边界未写入:<id>(<原因>)、<id>(<原因>)」。
361
+ 事实纪律:每个事实取自该条计划内容,不虚构、不引他处;数字与主体不得放大;引语只能归属其原句主语;保留「据报道/称/承诺」等限定词;导语/收束不加计划外程度词;材料不足就少写或不写,不凑数。
362
+ Narrative register — smooth transitions, no list-like bulleting, no scaffold labels (「可做角度」「背景」「潜在影响」), no hedging (「读者宜另行核实」「仍待验证」). 「」 quotes.
363
+ Output the markdown to ${vars.work_dir}/22-article/${vars.run_date}_06_article-write_body.md.
364
+ expectFile: ${vars.work_dir}/22-article/${vars.run_date}_06_article-write_body.md
365
+ allowAppend: false
366
+
367
+ - id: article-write-retry
368
+ description: >-
369
+ Bounded recovery for article-write (0238 R2): runs only when the primary settled without its
370
+ expectFile, retries the same input on a different tier, and carries NO onError — a second miss
371
+ fails the run loudly instead of publishing an article nobody wrote.
372
+ dependsOn: [article-write]
373
+ condition:
374
+ kind: shell
375
+ options:
376
+ # 0324 R3: the pool verdict is `pool-gate`'s token, never a repeated `kk store pool-count`.
377
+ command: >-
378
+ test "$(cat ".spur/run/${vars.__runId}-pool-gate.status" 2>/dev/null)" = PASS
379
+ && test "${vars.article_write_enabled}" = "true"
380
+ && ! test -s "${vars.work_dir}/22-article/${vars.run_date}_06_article-write_body.md"
381
+ action:
382
+ kind: agent.run
383
+ options:
384
+ role: reviewer
385
+ agent: ${vars.agent_retry}
386
+ input: *article-write-input
387
+ expectFile: ${vars.work_dir}/22-article/${vars.run_date}_06_article-write_body.md
388
+ allowAppend: false
389
+
390
+ - id: article
391
+ description: >-
392
+ Generate the dated daily article Content from the episode plan (daily-article-gen template).
393
+ Depends on the QC candidates (so the prose/translate nodes can be disabled without skipping
394
+ this node: a done dependency in the mix keeps it runnable) as well as on both authoring nodes
395
+ AND their retry siblings, and it carries the same candidates guard — a pool that filtered to
396
+ zero items must not build an empty article.
397
+ dependsOn: [qc-content, plan-translate, plan-translate-retry, article-write, article-write-retry]
398
+ condition:
399
+ kind: shell
400
+ options:
401
+ # 0324 R3: the pool verdict is `pool-gate`'s token, never a repeated `kk store pool-count`.
402
+ command: 'test "$(cat ".spur/run/${vars.__runId}-pool-gate.status" 2>/dev/null)" = PASS'
403
+ action:
404
+ kind: shell
405
+ options:
406
+ command: >-
407
+ kk stage daily-article "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}"
408
+
409
+ - id: cover-brief
410
+ description: "Author the cover subject from the lead plan item via agent.run (F614; onError: continue — a bad brief falls back to the format default subject in the render stage)"
411
+ # 0238 R3: reads 21-plan/<date>_03_plan_plan.json, so it must not start before the plan
412
+ # producers settle. `qc-content` stays in the list: a done dependency in the mix keeps this node
413
+ # runnable when the plan-authoring nodes are disabled (all-skipped deps skip a node).
414
+ dependsOn: [qc-content, plan-translate, plan-translate-retry]
415
+ condition:
416
+ kind: shell
417
+ options:
418
+ # 0324 R3: the pool verdict is `pool-gate`'s token, never a repeated `kk store pool-count`.
419
+ command: >-
420
+ test "$(cat ".spur/run/${vars.__runId}-pool-gate.status" 2>/dev/null)" = PASS
421
+ && test "${vars.cover_enabled}" = "true"
422
+ && test ! -f "${vars.work_dir}/23-cover/${vars.run_date}_07_cover_cover.png"
423
+ action:
424
+ kind: agent.run
425
+ # A bad or failed brief must never halt the run: the stage falls back to the format's default
426
+ # subject and keeps the same pinned format/style/provider/model.
427
+ onError: continue
428
+ options:
429
+ role: scribe
430
+ agent: ${vars.agent}
431
+ # ADR-115 error tier: keep this prompt <= 1000 chars.
432
+ input: |
433
+ Read ${vars.work_dir}/21-plan/${vars.run_date}_03_plan_plan.json — a JSON array of plan docs; don't edit it.
434
+ Take item 1 only (its "title" and "metadata.brief") — the episode's lead story. If item 1's mainline is public affairs (politics/diplomacy, trade controls, military, policy disputes, social events), take the first in-scope item instead.
435
+ Write one concrete visual scene in English, ≤240 characters, that a viewer would connect to that story: objects, settings, symbols, metaphor.
436
+ No text, letters, numbers, logos, brand marks, flags, or recognizable real people. No style, color, lighting, or composition words — the preset owns those.
437
+ Output strictly valid JSON {"subject": "..."} to ${vars.work_dir}/23-cover/${vars.run_date}_07_cover-brief.json.
438
+ expectFile: ${vars.work_dir}/23-cover/${vars.run_date}_07_cover-brief.json
439
+ allowAppend: false
440
+
441
+ - id: cover
442
+ description: "Render the daily cover through the pinned daily-news presets (F614)"
443
+ dependsOn: [cover-brief]
444
+ action:
445
+ kind: shell
446
+ options:
447
+ command: >-
448
+ kk stage daily-cover "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}" "${vars.cover_provider}" "${vars.cover_model}" "${vars.cover_fallback}"
449
+
450
+ - id: cover-normalize
451
+ description: "Normalize the rendered cover (dimensions/format pins) so every consumer sees the same asset"
452
+ dependsOn: [cover]
453
+ action:
454
+ kind: shell
455
+ options:
456
+ command: >-
457
+ kk stage daily-cover-normalize "${vars.work_dir}" "${vars.run_date}"
458
+
459
+ - id: script-style
460
+ description: "Snapshot the shipped zh style contract into the work dir (separate node: it is also an audit input for the revision loop)"
461
+ dependsOn: [qc-content]
462
+ condition:
463
+ kind: shell
464
+ options:
465
+ # 0324 R3: the pool verdict is `pool-gate`'s token, never a repeated `kk store pool-count`.
466
+ command: 'test "$(cat ".spur/run/${vars.__runId}-pool-gate.status" 2>/dev/null)" = PASS'
467
+ action:
468
+ kind: shell
469
+ options:
470
+ command: >-
471
+ kk stage daily-script-style "${vars.work_dir}" "${vars.run_date}"
472
+
473
+ - id: script-write
474
+ description: "Author the per-item script plan (intro/outro + lead/insight per doc id) from the plan via agent.run; onError: continue — a failed plan falls back to templates (F613)"
475
+ # 0238 R3: reads 21-plan/<date>_03_plan_plan.json, so it must not start before the plan
476
+ # producers settle. `script-style` stays first in the list: a done dependency in the mix keeps
477
+ # this node runnable when the plan-authoring nodes are disabled (all-skipped deps skip a node).
478
+ dependsOn: [script-style, plan-translate, plan-translate-retry]
479
+ condition:
480
+ kind: shell
481
+ options:
482
+ # 0324 R3: the pool verdict is `pool-gate`'s token, never a repeated `kk store pool-count`.
483
+ command: >-
484
+ test "$(cat ".spur/run/${vars.__runId}-pool-gate.status" 2>/dev/null)" = PASS
485
+ && test "${vars.script_write_enabled}" = "true"
486
+ && test "${vars.language}" = "zh"
487
+ && ! test -s "${vars.work_dir}/24-script/${vars.run_date}_08_script-write_plan.json"
488
+ action:
489
+ kind: agent.run
490
+ onError: continue
491
+ options:
492
+ role: scribe
493
+ agent: ${vars.agent}
494
+ # ADR-115 error tier: keep this prompt <= 1000 chars.
495
+ input: |
496
+ Read ${vars.work_dir}/21-plan/${vars.run_date}_03_plan_plan.json — a JSON array of docs; don't edit it. Also read the style contract ${vars.work_dir}/24-script/${vars.run_date}_08_script-write_style.md and obey its char caps, duration budget and banned phrases.
497
+ For each doc write a `lead` (a spoken bridge, not containing the title) and an `insight` (a take, not a recap: the narration just read that doc's facts — never restate its facts, numbers or wording; give only your angle, judgment or what-to-watch, no new facts, keep hedges(据报道), no new degree words).
498
+ intro/outro hook only in-scope items; no greeting or sign-off — the builder adds both. 选题边界:政治/外交、贸易管制、军事、公共政策争议、社会公共事件条目绝不复述;读文章 22-article/*_06_article-write_body.md,其文末注记点名的 id 一并进 excluded,各配中文原因。
499
+ Output only JSON {"version":1,"intro":"…","outro":"…","items":[{"id":"…","lead":"…","insight":"…"}],"excluded":[{"id":"…","reason":"…"}]} to ${vars.work_dir}/24-script/${vars.run_date}_08_script-write_plan.json. Exactly one `items` entry per doc not in `excluded`.
500
+ expectFile: ${vars.work_dir}/24-script/${vars.run_date}_08_script-write_plan.json
501
+ allowAppend: false
502
+
503
+ - id: script
504
+ description: >-
505
+ Compile the episode plan Doc[] into structured broadcast VoiceScript YAML Content. Depends on
506
+ `article` so the node still runs when the plan-authoring nodes were disabled or skipped (the
507
+ builder falls back to templates) — but not when the whole content branch is skipped.
508
+ dependsOn: [article, script-style, script-write]
509
+ condition:
510
+ kind: shell
511
+ options:
512
+ # 0324 R3: the pool verdict is `pool-gate`'s token, never a repeated `kk store pool-count`.
513
+ command: 'test "$(cat ".spur/run/${vars.__runId}-pool-gate.status" 2>/dev/null)" = PASS'
514
+ action:
515
+ kind: shell
516
+ options:
517
+ command: >-
518
+ kk stage daily-script "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}" "${vars.language}" "${vars.title}" "${vars.voice_profile}" "${vars.episode_max_minutes}" "${vars.vdriver}"
519
+
520
+ # ---- content safety: a nested state machine (no sub-workflow action exists; ADR-031) ----
521
+
522
+ - id: safety
523
+ description: >-
524
+ Content safety as a nested state machine (kk-daily-safety.yaml): review → judge → bounded
525
+ revise loop → clean|block. It blocks here (and only here), so a non-zero exit means the drafts
526
+ must not publish — `block` or an integrity failure. The launcher vars the parent run exports
527
+ are stripped at the nested-spur boundary (the same two `NESTED_SPUR_UNSET` vars the stage
528
+ runner strips, kk-workflow-stages.ts): without it `spur workflow run` refuses to start inside
529
+ an active run. voice3 always runs the gate: it is the only check between an LLM draft and a
530
+ published post.
531
+ dependsOn: [script]
532
+ action:
533
+ kind: shell
534
+ options:
535
+ command: >-
536
+ env -u SPUR_WORKFLOW_RUN_ACTIVE -u SPUR_EXPECTED_DEFINITION_DIGEST
537
+ spur workflow run plugins/kk/workflows/kk-daily-safety.yaml
538
+ --vars "{\"work_dir\":\"${vars.work_dir}\",\"run_date\":\"${vars.run_date}\",\"safety_max_revise\":\"${vars.safety_max_revise}\",\"safety_platforms\":\"${vars.safety_platforms}\",\"agent\":\"${vars.agent}\"}"
539
+ --quiet --no-plan
540
+
541
+ - id: review
542
+ description: >-
543
+ Operator review of the safety-gated drafts: `pause: true` stops the run here and RESUME is the
544
+ approval (the engine marks a paused node done on resume, so this node carries no action);
545
+ cancelling the run is the rejection. Same contract as voice1's HITL confirmation, now a node
546
+ instead of a state. Skipped when skip_review=true or when there is nothing to publish.
547
+ dependsOn: [safety]
548
+ pause: true
549
+ condition:
550
+ kind: shell
551
+ options:
552
+ command: 'test "${vars.skip_review}" != "true"'
553
+
554
+ # ---- audio branch ----
555
+ # wrap-docs must be DECLARED BEFORE generate: the DAG requires nodes in topological order, and
556
+ # generate consumes wrap-docs's _10_wrap-docs_docs.json as its TTS input (0212 dogfood finding —
557
+ # an edge to a later-declared node wedges the dispatcher with generate never dispatched).
558
+
559
+ - id: wrap-docs
560
+ description: "Wrap VoiceScript into Doc[] input for the voice generator plugin (the generate-side variant input)"
561
+ dependsOn: [script, review]
562
+ condition:
563
+ kind: shell
564
+ options:
565
+ # 0324 R3: the pool verdict is `pool-gate`'s token, never a repeated `kk store pool-count`.
566
+ command: 'test "$(cat ".spur/run/${vars.__runId}-pool-gate.status" 2>/dev/null)" = PASS'
567
+ action:
568
+ kind: shell
569
+ options:
570
+ command: >-
571
+ kk stage daily-wrap-docs "${vars.work_dir}" "${vars.run_date}" "${vars.voice_profile}"
572
+
573
+ - id: generate
574
+ description: "Voice tier, primary driver (vars.vdriver — voxcpm; its 40 ms lattice is known and monitored, see the vdriver comment). onError: continue — the fallback siblings below are the loud chain (same pattern as 0238 R2)."
575
+ # wrap-docs is a data dependency, not a sibling: daily-generate reads its _10_wrap-docs_docs.json
576
+ # as the TTS input. Without this edge the two nodes race in the same wave (0212 dogfood finding).
577
+ dependsOn: [script, safety, review, wrap-docs]
578
+ condition:
579
+ kind: shell
580
+ options:
581
+ # 0324 R3: the pool verdict is `pool-gate`'s token, never a repeated `kk store pool-count`.
582
+ command: 'test "$(cat ".spur/run/${vars.__runId}-pool-gate.status" 2>/dev/null)" = PASS'
583
+ action:
584
+ kind: shell
585
+ options:
586
+ onError: continue
587
+ command: >-
588
+ OMNIVOICE_SPEED=1.06 kk stage daily-generate "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}" "${vars.transcode_mp3}" "${vars.vdriver}" "1" "${vars.episode_max_minutes}" "${vars.episode_min_minutes}"
589
+
590
+ # Driver fallback chain (2026-10-15, operator decision): voxcpm -> gemini -> omni. Each sibling
591
+ # runs the same stage with a pinned driver; its condition re-checks the pool AND that NO upstream
592
+ # driver left a valid, passing whole-document Content (the stage stamps metadata.generator with
593
+ # its plugin id and the cache ignores foreign-driver sections, so a failed attempt can neither be
594
+ # relabeled nor mix voices). Non-final siblings continue on error; the last one is loud.
595
+
596
+ - id: generate-fallback
597
+ description: "Voice tier fallback 1: gemini (gemini-tts) when the primary driver left no valid Content — the measured-dry escape hatch from voxcpm's lattice"
598
+ dependsOn: [generate]
599
+ condition:
600
+ kind: shell
601
+ options:
602
+ # 0324 R3: the pool verdict is `pool-gate`'s token, never a repeated `kk store pool-count`.
603
+ command: >-
604
+ test "$(cat ".spur/run/${vars.__runId}-pool-gate.status" 2>/dev/null)" = PASS
605
+ && ! jq -e '(.metadata.generator == "kk:voxcpm-tts" or .metadata.generator == "kk:gemini-tts") and .metadata.qc.passed == true' "${vars.work_dir}/30-audio/${vars.run_date}_11_generate_content.json"
606
+ action:
607
+ kind: shell
608
+ options:
609
+ onError: continue
610
+ command: >-
611
+ kk stage daily-generate "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}" "${vars.transcode_mp3}" "gemini" "1" "${vars.episode_max_minutes}" "${vars.episode_min_minutes}"
612
+
613
+ - id: generate-fallback2
614
+ description: "Voice tier fallback 2 (last, loud): omni-voice-gen when neither voxcpm nor gemini delivered"
615
+ dependsOn: [generate-fallback]
616
+ condition:
617
+ kind: shell
618
+ options:
619
+ # 0324 R3: the pool verdict is `pool-gate`'s token, never a repeated `kk store pool-count`.
620
+ command: >-
621
+ test "$(cat ".spur/run/${vars.__runId}-pool-gate.status" 2>/dev/null)" = PASS
622
+ && ! jq -e '(.metadata.generator == "kk:voxcpm-tts" or .metadata.generator == "kk:gemini-tts" or .metadata.generator == "kk:omni-voice-gen") and .metadata.qc.passed == true' "${vars.work_dir}/30-audio/${vars.run_date}_11_generate_content.json"
623
+ action:
624
+ kind: shell
625
+ options:
626
+ command: >-
627
+ OMNIVOICE_SPEED=1.06 kk stage daily-generate "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}" "${vars.transcode_mp3}" "ominivoice" "1" "${vars.episode_max_minutes}" "${vars.episode_min_minutes}"
628
+
629
+ - id: intro
630
+ description: "Prepend the 8s intro sting (intro_music var; empty = skip), update duration/audioPath"
631
+ dependsOn: [generate, generate-fallback, generate-fallback2]
632
+ action:
633
+ kind: shell
634
+ options:
635
+ command: >-
636
+ kk stage daily-intro "${vars.work_dir}" "${vars.run_date}" "${vars.intro_music}"
637
+
638
+ # ---- advisory voice audit (2026-10-16): never fails the run ----
639
+ # The 20261015 episode shipped 59 different speakers because an instruct-mode profile pins none,
640
+ # and nothing but a human listening caught it. This grades the final render by per-segment median
641
+ # F0 (a speaker change swings it tens of Hz) and prints the table plus a PASS/FAIL line. It is
642
+ # deliberately advisory: `|| true` keeps its non-zero exit out of the run, and onError covers a
643
+ # missing venv (the check needs librosa from the voxcpm plugin's environment).
644
+
645
+ - id: voice-audit
646
+ description: "Advisory speaker-consistency audit of the joined episode (warn-only, never fails the run)"
647
+ dependsOn: [intro]
648
+ condition:
649
+ kind: shell
650
+ options:
651
+ command: >-
652
+ test -f "${vars.work_dir}/30-audio/${vars.run_date}_11_generate_content.json"
653
+ action:
654
+ kind: shell
655
+ options:
656
+ command: >-
657
+ kk stage daily-voice-audit "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}"
658
+ onError: continue
659
+
660
+ - id: duration
661
+ description: "Measured duration gate: under episode_min_minutes the run refuses and closes as no-content (refusal artifact + emptied candidates, exit 0 — 0266); above episode_max_minutes it still fails loud. Duration is authoritative."
662
+ dependsOn: [intro]
663
+ action:
664
+ kind: shell
665
+ options:
666
+ command: >-
667
+ kk stage daily-episode-duration "${vars.work_dir}" "${vars.run_date}" "${vars.episode_min_minutes}" "${vars.episode_max_minutes}" "${vars.vdriver}"
668
+
669
+ - id: quality
670
+ description: >-
671
+ Verify audio quality, speech rate, and silence/repetition QC metrics over the assembled
672
+ episode. Depends on the audio tail and wrap-docs: voice3 no longer packages a publish-prep
673
+ Content, so the audited bytes are the generated episode itself.
674
+ dependsOn: [duration, wrap-docs]
675
+ action:
676
+ kind: shell
677
+ options:
678
+ command: >-
679
+ kk stage daily-quality-report "${vars.work_dir}" "${vars.run_date}"
680
+
681
+ - id: show-notes
682
+ description: >-
683
+ Render timeline show notes from the episode plan — content, not publishing: the publish half
684
+ ships them as the episode variant's `metadata.showNotes`, and the link placeholders stay
685
+ unresolved until a send resolves them."
686
+ dependsOn: [quality]
687
+ condition:
688
+ kind: shell
689
+ options:
690
+ # 0324 R3: the pool verdict is `pool-gate`'s token, never a repeated `kk store pool-count`.
691
+ command: 'test "$(cat ".spur/run/${vars.__runId}-pool-gate.status" 2>/dev/null)" = PASS'
692
+ action:
693
+ kind: shell
694
+ options:
695
+ command: >-
696
+ kk stage daily-show-notes "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}" "${vars.title}"
697
+ - id: cards-write
698
+ description: "Plan the card series across the episode's plan items via agent.run; onError: continue — a failed plan skips the card series, never the run"
699
+ dependsOn: [script, review, pool-gate]
700
+ condition:
701
+ kind: shell
702
+ options:
703
+ command: 'test "${vars.cards_enabled}" = "true"'
704
+ action:
705
+ kind: agent.run
706
+ onError: continue
707
+ options:
708
+ role: scribe
709
+ agent: ${vars.agent}
710
+ # ADR-115 error tier: keep this prompt <= 1000 chars.
711
+ input: |
712
+ Read ${vars.work_dir}/21-plan/${vars.run_date}_03_plan_plan.json — a JSON array of news docs; don't edit it.
713
+ Plan the whole episode, not the lead item alone. Card 1 is the episode hook.
714
+ Write a card note: "title" (≤20 chars) and "body" (Chinese lead paragraph + 3-5 hashtags) — the stage appends one numbered line per image below it. 选题边界:公共事务主线不写入,只写技术内容。
715
+ Write exactly ${vars.cards} cards in plan order: card 1 "cover"; one "content" card per plan item, up to ${vars.cards} - 2 of them; the last "closing". Each card carries "role", "copy" (Chinese headline ≤12 chars), "points" (2-4 short Chinese bullets), "layout", "subject" (one concrete English visual scene), "sourcePoint" (that item's fact) and "itemId" (its "id").
716
+ Ground every field in that item — no invented facts. No writing, letters, numbers, logos or people; screens stay blank.
717
+ Output strictly valid JSON {"title": "...", "body": "...", "cards": [...]} to ${vars.work_dir}/25-xhs/${vars.run_date}_18_xhs_plan.json.
718
+ expectFile: ${vars.work_dir}/25-xhs/${vars.run_date}_18_xhs_plan.json
719
+ allowAppend: false
720
+
721
+ - id: cards-render
722
+ description: "Render the card series through the pinned xhs-card/xhs-knowledge presets, attach the episode cover last and pair every image with its numbered line (0235 note_scope=episode); onError: continue — the publish node names the missing series and stays soft"
723
+ dependsOn: [cards-write]
724
+ # A skipped cards-write still admits its dependents, so each card node keeps its own switch:
725
+ # without it a run with cards_enabled=false would re-package a stale same-day 25-xhs/ series.
726
+ # The pool guard is `pool-gate`'s alone in voice3 (0324 R3).
727
+ condition:
728
+ kind: shell
729
+ options:
730
+ command: 'test "${vars.cards_enabled}" = "true"'
731
+ action:
732
+ kind: shell
733
+ onError: continue
734
+ options:
735
+ command: >-
736
+ kk stage daily-xhs-render "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}" "${vars.cards}" "${vars.xhs_style}" "${vars.xhs_provider}" "${vars.xhs_model}" "${vars.xhs_fallback}" "episode"
737
+
738
+ # ---- generate-side payloads and the frozen bundle (0324 R2/R3) ----
739
+
740
+ # One guard for the whole pool (0324 R3): every node that used to repeat `kk store pool-count`
741
+ # depends on this one, so a refused pool empties the candidates file once and the rest of the
742
+ # graph skips through the engine's all-deps-skipped rule.
743
+ - id: pool-gate
744
+ description: >-
745
+ The single pool guard (0324 R3): `kk store pool-count --min 1` over the candidates file that
746
+ `qc-content` wrote, recorded once as a token at `.spur/run/<runId>-pool-gate.status`. voice2
747
+ repeated the same guard on twenty-one conditions. A token, not a bare dependency, because the
748
+ engine skips a node only when ALL its dependencies were skipped — so a failed gate beside a
749
+ done dependency would still admit the node. Everything downstream reads the token.
750
+ dependsOn: [qc-content]
751
+ action:
752
+ kind: shell
753
+ options:
754
+ command: >-
755
+ OUT=".spur/run/${vars.__runId}-pool-gate.status";
756
+ if kk store pool-count "${vars.work_dir}/21-plan/${vars.run_date}_04_quality-control-content_candidates.json" --min 1;
757
+ then echo PASS > "$OUT";
758
+ else echo EMPTY > "$OUT"; exit 1; fi
759
+
760
+ - id: social-write
761
+ description: >-
762
+ Author one short post per social platform (x, weibo, threads) from the approved plan, in zh,
763
+ inside each platform's character cap. `daily-bundle-variants` truncates as a backstop, so a
764
+ missed cap degrades rather than fails; a missing file simply skips that platform's variant.
765
+ dependsOn: [quality]
766
+ action:
767
+ kind: agent.run
768
+ onError: continue
769
+ options:
770
+ role: scribe
771
+ agent: ${vars.agent}
772
+ input: |
773
+ Read the approved episode plan at ${vars.work_dir}/21-plan/${vars.run_date}_03_plan_plan.json.
774
+ Write three short zh posts, one per file:
775
+ - ${vars.work_dir}/26-social/x.md — at most 280 weighted characters (this feed counts CJK as 2, so ~140 汉字 or 280 Latin letters)
776
+ - ${vars.work_dir}/26-social/weibo.md — at most 2000 characters
777
+ - ${vars.work_dir}/26-social/threads.md — at most 500 characters
778
+ Each post: the day's single most interesting item, one concrete number or name from the
779
+ plan, no hashtag spam, no emoji, no "点击链接", no invented facts. Plain text, no markdown
780
+ headings. Deliver with your file-write tool; each file must be non-empty.
781
+ expectFile: ${vars.work_dir}/26-social/x.md
782
+
783
+ - id: bundle-variants
784
+ description: >-
785
+ Build the channel payloads from the run's own artifacts (0327 builders): `30-variants/<key>`
786
+ for article / episode / cards plus the channel-specific keys and the social posts, all with
787
+ `{{article_url}}` / `{{podcast_url}}` placeholders because nothing is published yet.
788
+ dependsOn: [quality, social-write]
789
+ action:
790
+ kind: shell
791
+ options:
792
+ command: >-
793
+ kk stage run daily-bundle-variants "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}"
794
+
795
+ - id: bundle-freeze
796
+ description: >-
797
+ Freeze `bundle.json` (0321 `BundleSchema`): every `30-variants/*` file with its sha256 plus the
798
+ episode title, so the publish half can run later, or three times a day, against exactly these
799
+ bytes. Fails when the article or episode variant is missing — a bundle never advertises what
800
+ generate did not produce.
801
+ dependsOn: [bundle-variants]
802
+ action:
803
+ kind: shell
804
+ options:
805
+ command: >-
806
+ kk stage run publish-matrix-bundle "${vars.work_dir}" --run-date "${vars.run_date}" --run-id "${vars.__runId}"
807
+
808
+ - id: generation-report
809
+ description: "The generate half's report (the publish half writes its own, per window)."
810
+ dependsOn: [bundle-freeze]
811
+ action:
812
+ kind: shell
813
+ options:
814
+ command: >-
815
+ kk daily run-report --work-dir "${vars.work_dir}" --run-date "${vars.run_date}" --skip-store
816
+
817
+ - id: publish
818
+ description: >-
819
+ Nested `kk-daily-publish3` launch when `auto_publish=true` (0324 R4): the after-generate
820
+ window against the bundle this run just froze. The launcher vars are stripped at the nested
821
+ boundary (the same two voice2 strips for the safety machine), and the nested run's exit code is
822
+ surfaced — a failed publish window is a failed generate run's last step, never a silent one.
823
+ dependsOn: [generation-report]
824
+ condition:
825
+ kind: shell
826
+ options:
827
+ command: 'test "${vars.auto_publish}" = "true"'
828
+ action:
829
+ kind: shell
830
+ options:
831
+ command: >-
832
+ env -u SPUR_WORKFLOW_RUN_ACTIVE -u SPUR_EXPECTED_DEFINITION_DIGEST
833
+ spur workflow run plugins/kk/workflows/kk-daily-publish3.yaml
834
+ --vars "{\"work_dir\":\"${vars.work_dir}\",\"run_date\":\"${vars.run_date}\",\"window\":\"${vars.publish_window}\",\"preset\":\"${vars.publish_preset}\",\"dummy\":\"${vars.publish_dummy}\"}"
835
+
836
+ - id: ledger
837
+ description: "Append this run to the workflow ledger; bookkeeping never fails a generation."
838
+ dependsOn: [generation-report, publish]
839
+ action:
840
+ kind: shell
841
+ options:
842
+ command: 'kk ledger append --workflow kk-daily-ai-voice3 --work-dir "${vars.work_dir}" --run "${vars.__runId}" || true'