@gobing-ai/knowledge-kit 0.0.13 → 0.0.14

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 (109) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +20 -0
  3. package/package.json +1 -1
  4. package/plugins/generations/content-gen/dist/index.js +20 -0
  5. package/plugins/generations/core-facts-gen/dist/index.js +20 -0
  6. package/plugins/generations/daily-article-gen/dist/index.js +29 -1
  7. package/plugins/generations/daily-article-gen/src/index.ts +13 -1
  8. package/plugins/generations/dailynews-gen/dist/index.js +20 -0
  9. package/plugins/generations/episode-plan-gen/dist/index.js +24 -0
  10. package/plugins/generations/episode-plan-gen/src/index.ts +11 -0
  11. package/plugins/generations/image-gen/dist/index.js +2296 -53
  12. package/plugins/generations/image-gen/src/providers/agnes.ts +110 -0
  13. package/plugins/generations/image-gen/src/providers/azure.ts +153 -0
  14. package/plugins/generations/image-gen/src/providers/codex-cli.ts +170 -0
  15. package/plugins/generations/image-gen/src/providers/dashscope.ts +485 -0
  16. package/plugins/generations/image-gen/src/providers/google.ts +268 -0
  17. package/plugins/generations/image-gen/src/providers/huggingface.ts +59 -0
  18. package/plugins/generations/image-gen/src/providers/jimeng.ts +259 -0
  19. package/plugins/generations/image-gen/src/providers/minimax.ts +171 -0
  20. package/plugins/generations/image-gen/src/providers/openai.ts +319 -0
  21. package/plugins/generations/image-gen/src/providers/openrouter.ts +257 -0
  22. package/plugins/generations/image-gen/src/providers/refs.ts +24 -0
  23. package/plugins/generations/image-gen/src/providers/replicate.ts +279 -0
  24. package/plugins/generations/image-gen/src/providers/seedream.ts +1 -1
  25. package/plugins/generations/image-gen/src/providers/types.ts +52 -51
  26. package/plugins/generations/image-gen/src/providers/zai.ts +237 -0
  27. package/plugins/generations/news-report-gen/dist/index.js +22193 -0
  28. package/plugins/generations/news-report-gen/package.json +17 -0
  29. package/plugins/generations/news-report-gen/plugin.json +7 -0
  30. package/plugins/generations/news-report-gen/src/index.ts +308 -0
  31. package/plugins/generations/news-report-gen/tsconfig.json +4 -0
  32. package/plugins/generations/omni-voice-gen/Makefile +14 -0
  33. package/plugins/generations/omni-voice-gen/README.md +112 -0
  34. package/plugins/generations/omni-voice-gen/bin/omni-voice-gen +2 -0
  35. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +2 -0
  36. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +6 -0
  37. package/plugins/generations/omni-voice-gen/plugin.json +6 -0
  38. package/plugins/generations/omni-voice-gen/profiles.json +12 -0
  39. package/plugins/generations/omni-voice-gen/pyproject.toml +25 -0
  40. package/plugins/generations/omni-voice-gen/scripts/coverage_gate.py +74 -0
  41. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__init__.py +1 -0
  42. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__main__.py +39 -0
  43. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/audio.py +190 -0
  44. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/backend.py +150 -0
  45. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/contract.py +76 -0
  46. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/mp3.py +60 -0
  47. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +289 -0
  48. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/profiles.py +100 -0
  49. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +234 -0
  50. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/voicescript.py +352 -0
  51. package/plugins/generations/omni-voice-gen/uv.lock +3510 -0
  52. package/plugins/generations/voice-gen/dist/index.js +30 -2
  53. package/plugins/generations/voice-gen/src/index.ts +16 -1
  54. package/plugins/generations/voice-gen/src/voicebox-client.ts +3 -1
  55. package/plugins/ingestions/aihot-ingest/dist/index.js +20 -0
  56. package/plugins/ingestions/horizon-ingest/dist/index.js +20 -0
  57. package/plugins/ingestions/last30days-ingest/dist/index.js +20 -0
  58. package/plugins/ingestions/web-search/dist/index.js +20 -0
  59. package/plugins/kk/commands/image-extract.md +40 -0
  60. package/plugins/kk/commands/image-generate.md +18 -1
  61. package/plugins/kk/plugin.json +1 -1
  62. package/plugins/kk/skills/image-authoring/SKILL.md +10 -3
  63. package/plugins/kk/skills/image-authoring/references/format-drafting.md +57 -0
  64. package/plugins/kk/skills/image-authoring/references/style-extraction.md +14 -9
  65. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +130 -30
  66. package/plugins/publishings/emdash-pub/dist/index.js +20 -0
  67. package/plugins/publishings/podcast-pub/dist/index.js +57 -3
  68. package/plugins/publishings/podcast-pub/src/index.ts +18 -2
  69. package/plugins/publishings/podcast-pub/src/show-notes.ts +56 -9
  70. package/plugins/publishings/qiita-pub/dist/index.js +20 -0
  71. package/plugins/publishings/surfdash-pub/dist/index.js +96 -6
  72. package/plugins/publishings/surfdash-pub/src/index.ts +109 -9
  73. package/plugins/publishings/zenn-pub/dist/index.js +20 -0
  74. package/plugins/sp/scripts/batch-preflight.mjs +346 -0
  75. package/plugins/sp/scripts/batch-preflight.ts +459 -0
  76. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +615 -0
  77. package/plugins/sp/scripts/daily-summary/daily-summary.ts +846 -0
  78. package/plugins/sp/scripts/daily-summary/logger.ts +28 -0
  79. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +223 -0
  80. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +367 -0
  81. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +132 -0
  82. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +169 -0
  83. package/plugins/sp/scripts/feature-dev-precheck.mjs +171 -0
  84. package/plugins/sp/scripts/feature-dev-precheck.ts +238 -0
  85. package/plugins/sp/scripts/feature-sync-bounded.mjs +285 -0
  86. package/plugins/sp/scripts/feature-sync-bounded.ts +478 -0
  87. package/plugins/sp/scripts/history-anatomy-cache.mjs +902 -0
  88. package/plugins/sp/scripts/history-anatomy-cache.ts +1028 -0
  89. package/plugins/sp/scripts/idea-handoff.mjs +22 -0
  90. package/plugins/sp/scripts/idea-handoff.ts +44 -0
  91. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +185 -0
  92. package/plugins/sp/scripts/inline-run-setup.ts +198 -0
  93. package/plugins/sp/scripts/pr-reviewing.mjs +769 -0
  94. package/plugins/sp/scripts/pr-reviewing.ts +925 -0
  95. package/plugins/sp/scripts/quality-gate.mjs +179 -0
  96. package/plugins/sp/scripts/quality-gate.ts +217 -0
  97. package/plugins/sp/scripts/script-contract-check.ts +319 -0
  98. package/plugins/sp/scripts/stage-registry-adapter.ts +1533 -0
  99. package/plugins/sp/scripts/surface-drift-inventory.ts +929 -0
  100. package/plugins/sp/scripts/task-evidence-precheck.ts +181 -0
  101. package/plugins/sp/scripts/task-size-precheck.ts +175 -0
  102. package/plugins/sp/scripts/transition-shim-check.ts +238 -0
  103. package/plugins/sp/scripts/validate-commands.ts +689 -0
  104. package/plugins/sp/scripts/validate-flag-contracts.ts +878 -0
  105. package/plugins/sp/scripts/verify-answer-lint.ts +530 -0
  106. package/plugins/sp/scripts/workflow-step-profile.mjs +316 -0
  107. package/plugins/sp/scripts/workflow-step-profile.ts +456 -0
  108. package/plugins/sp/scripts/wrapup-steps.mjs +373 -0
  109. package/plugins/sp/scripts/wrapup-steps.ts +466 -0
@@ -19,6 +19,7 @@ vars:
19
19
  limit: "10"
20
20
  language: "zh"
21
21
  voice_profile: "robin-news"
22
+ voice_generator: "voice-gen"
22
23
  title: ""
23
24
  cursor: ""
24
25
  state_file: ""
@@ -54,6 +55,7 @@ states:
54
55
  - kind: shell
55
56
  options:
56
57
  command: >-
58
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"prepare\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
57
59
  test -n "${vars.work_dir}" || { echo "missing required variable: work_dir" >&2 && exit 1; };
58
60
  mkdir -p "${vars.work_dir}/1-ingest" "${vars.work_dir}/2-facts" "${vars.work_dir}/2-plan" "${vars.work_dir}/2-article" "${vars.work_dir}/2-cover" "${vars.work_dir}/2-script" "${vars.work_dir}/3-audio" "${vars.work_dir}/3-publish";
59
61
  date +%Y%m%d > "${vars.work_dir}/run-date.txt" &&
@@ -75,11 +77,13 @@ states:
75
77
  # fan-in 30m per-source default and making empty episodes stochastic — horizon gets a
76
78
  # 45m leash; aihot/last30days keep the default.
77
79
  command: >-
80
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"collect\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
78
81
  if [ -f "${vars.work_dir}/1-ingest/blended.json" ]; then echo "collect cached: ${vars.work_dir}/1-ingest/blended.json"; exit 0; fi;
79
82
  kk executor fan-in --source "aihot-ingest:${vars.work_dir}/1-ingest/source.json" --source "last30days-ingest:${vars.work_dir}/1-ingest/last30days.json" --source "horizon-ingest:${vars.work_dir}/1-ingest/horizon.json" --source-timeout-override "horizon-ingest:2700000" --out "${vars.work_dir}/1-ingest/blended.json"
80
83
  - kind: shell
81
84
  options:
82
85
  command: >-
86
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"collect\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
83
87
  set --;
84
88
  if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
85
89
  if [ -f "${vars.work_dir}/2-facts/content.json" ] && [ -f "${vars.work_dir}/2-facts/core-facts.md" ]; then echo "core facts cached: ${vars.work_dir}/2-facts/core-facts.md"; exit 0; fi;
@@ -92,6 +96,7 @@ states:
92
96
  - kind: shell
93
97
  options:
94
98
  command: >-
99
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"plan\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
95
100
  set --;
96
101
  if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
97
102
  PLAN_CONTENT="${vars.work_dir}/2-plan/content.json"; PLAN_DOCS="${vars.work_dir}/2-plan/plan.json";
@@ -106,6 +111,7 @@ states:
106
111
  - kind: shell
107
112
  options:
108
113
  command: >-
114
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"quality-control-content\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
109
115
  set --;
110
116
  if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
111
117
  if [ -f "${vars.work_dir}/2-plan/candidates.json" ]; then echo "candidates cached: ${vars.work_dir}/2-plan/candidates.json"; exit 0; fi;
@@ -136,23 +142,30 @@ states:
136
142
  - kind: shell
137
143
  options:
138
144
  command: >-
145
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"article\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
139
146
  set --;
140
147
  if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
141
148
  CONTENT_OUT="${vars.work_dir}/2-article/content-${vars.run_date}.json"; ARTICLE_MD="${vars.work_dir}/2-article/article-${vars.run_date}.md";
142
149
  if [ -f "$ARTICLE_MD" ]; then echo "article cached: $ARTICLE_MD"; else PLAN_IN="${vars.work_dir}/2-plan/plan.json"; PLAN_OK=1; bun -e 'try { JSON.parse(await Bun.file(process.argv[1]).text()); process.exit(0); } catch (e) { console.warn("article: plan.json invalid JSON — replacing with untranslated plan (downstream states read this file): " + e.message); const docs = JSON.parse(await Bun.file(process.argv[2]).text()).metadata?.docs; if (!Array.isArray(docs) || docs.length === 0) { console.error("article: candidates.json metadata.docs missing"); process.exit(2); } await Bun.write(process.argv[1], JSON.stringify(docs, null, 2)); process.exit(1); }' "$PLAN_IN" "${vars.work_dir}/2-plan/candidates.json"; case $? in 0|1) PLAN_OK=0;; esac; test "$PLAN_OK" -eq 0 || { echo "article: no usable plan input"; exit 1; }; ARTICLE_DATE="${vars.run_date}" kk executor run daily-article-gen --in "$PLAN_IN" --out "$CONTENT_OUT" "$@" && bun -e 'await Bun.write(process.argv[2], (await Bun.file(process.argv[1]).json()).body)' "$CONTENT_OUT" "$ARTICLE_MD"; fi
143
150
 
144
151
  - id: cover
145
- description: "Generate the article cover image via the wt-image-cover skill (agent.run)"
152
+ description: "Generate the article cover image via the kk:image-authoring skill (agent.run)"
146
153
  onEnter:
147
154
  - kind: agent.run
148
155
  options:
149
156
  role: scribe
150
157
  agent: ${vars.agent}
151
158
  input: |
152
- Invoke the wt-image-cover skill.
153
- Generate a 21:9 cover image for the article at ${vars.work_dir}/2-article/article-${vars.run_date}.md and save it to ${vars.work_dir}/2-cover/cover-${vars.run_date}.png.
154
- Use style "news". If no image-generation backend is available, a procedurally rendered cover (e.g. PIL: news palette, headline typography) is an acceptable, expected deliverable — not a fake; generate it and write the file. Do not wait for operator confirmation.
155
- expectFile: ${vars.work_dir}/2-cover/cover-${vars.run_date}.png
159
+ Invoke the kk:image-authoring skill (generate operation).
160
+ This node is a non-interactive workflow step: run every image command with KK_NONINTERACTIVE=1 and KK_IMAGE_AUTO_CONFIRM=1 in the environment (this workflow approves the image spend via cover_enabled) — never prompt, never open a question.
161
+ Command: --format cover --content ${vars.work_dir}/2-article/article-${vars.run_date}.md --out ${vars.work_dir}/2-cover
162
+ Derive the cover template variables (title, subtitle, topics) from the article as the skill owns; style is the format's default — do not pass --style.
163
+ The plugin writes ${vars.work_dir}/2-cover/content.json plus the artifact ${vars.work_dir}/2-cover/content-cover-01.<ext>, where <ext> follows the elected provider's native output (payload honesty: minimax returns JPEG, google/ark return PNG). If generation fails, report the error verbatim and stop — no procedural fallback.
164
+ Run 20260911 context: only MINIMAX_API_KEY is credentialed (ARK out of token, google key unset), so the elected provider is minimax and the artifact lands as content-cover-01.jpg.
165
+ expectFile: ${vars.work_dir}/2-cover/content-cover-01.jpg
166
+ - kind: shell
167
+ options:
168
+ command: 'cd "${vars.work_dir}/2-cover" && src="content-cover-01.png"; test -f "$src" || src="content-cover-01.jpg"; test -f "$src" || { echo "cover normalize: no content-cover-01.{png,jpg} artifact" >&2; exit 1; }; case "$src" in *.jpg) command -v sips >/dev/null 2>&1 || { echo "cover normalize: sips not found; cannot transcode jpg -> png" >&2; exit 1; }; sips -s format png "$src" --out "cover-${vars.run_date}.png" >/dev/null;; *) mv "$src" "cover-${vars.run_date}.png";; esac; rm -f content-cover-01.jpg content-cover-01.png; echo "cover normalized: cover-${vars.run_date}.png"'
156
169
 
157
170
  - id: script
158
171
  description: "Compile the episode plan Doc[] into structured broadcast VoiceScript YAML Content"
@@ -160,6 +173,7 @@ states:
160
173
  - kind: shell
161
174
  options:
162
175
  command: >-
176
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"script\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
163
177
  set --;
164
178
  if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
165
179
  SCRIPT_CONTENT_OUT="${vars.work_dir}/2-script/content-${vars.run_date}.json"; VOICESCRIPT_YAML="${vars.work_dir}/2-script/voicescript-${vars.run_date}.yaml";
@@ -180,18 +194,20 @@ states:
180
194
  - kind: shell
181
195
  options:
182
196
  command: >-
197
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"wrap-docs\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
183
198
  bun -e 'const scriptUrl = new URL(process.argv[1], "file://" + process.cwd() + "/"); const scriptPath = decodeURIComponent(scriptUrl.pathname); const doc = { id: new Bun.CryptoHasher("sha256").update(scriptUrl.href).digest("hex").slice(0, 16), title: "Daily AI News VoiceScript", sourceUri: scriptUrl.href, body: await Bun.file(scriptPath).text(), mediaType: "application/yaml", metadata: { voiceProfile: process.argv[3] || process.env.VOICEBOX_DEFAULT_PROFILE || "robin-news" } }; await Bun.write(process.argv[2], JSON.stringify([doc], null, 2))' "${vars.work_dir}/2-script/voicescript-${vars.run_date}.yaml" "${vars.work_dir}/3-audio/docs.json" "${vars.voice_profile}"
184
199
 
185
200
  - id: generate
186
- description: "Invoke voice-gen generator plugin to generate audio"
201
+ description: "Invoke voice generator plugin (voice_generator var) to generate audio"
187
202
  onEnter:
188
203
  - kind: shell
189
204
  options:
190
205
  command: >-
206
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"generate\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
191
207
  set --;
192
208
  if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
193
209
  if [ -f "${vars.work_dir}/3-audio/content.json" ] && jq -e '.metadata.qc.passed == true' "${vars.work_dir}/3-audio/content.json" > /dev/null 2>&1; then echo "audio cached: ${vars.work_dir}/3-audio/content.json (QC passed)"; exit 0; fi;
194
- VOICE_GEN_MP3="${vars.transcode_mp3}" kk executor run voice-gen --in "${vars.work_dir}/3-audio/docs.json" --out "${vars.work_dir}/3-audio/content.json" "$@"
210
+ VOICE_GEN_MP3="${vars.transcode_mp3}" kk executor run ${vars.voice_generator} --in "${vars.work_dir}/3-audio/docs.json" --out "${vars.work_dir}/3-audio/content.json" "$@"
195
211
 
196
212
  - id: quality-control
197
213
  description: "Verify audio quality, speech rate, and silence/repetition QC metrics"
@@ -199,63 +215,117 @@ states:
199
215
  - kind: shell
200
216
  options:
201
217
  command: >-
218
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"quality-control\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
202
219
  bun -e 'const content = JSON.parse(await Bun.file(process.argv[1]).text()); const qc = content.metadata?.qc; console.log("=== Voice Quality Control Report ==="); console.log("Overall Score: " + (qc ? qc.overallScore : 100) + "/100 | Passed: " + (qc ? qc.passed : true)); if (qc && qc.criticalIssues && qc.criticalIssues.length) { console.warn("QC Issues:\n" + qc.criticalIssues.map(function(i) { return " - " + i; }).join("\n")); } else { console.log("All audio segments passed quality control checks cleanly."); }' "${vars.work_dir}/3-audio/content.json"
203
220
 
221
+ - id: publish-prep
222
+ description: "Merge article Content with audio (podcast assembly facts ride the content options channel for podcast-pub's absorbed assembly; runDate kept for surfdash slug)"
223
+ onEnter:
224
+ - kind: shell
225
+ options:
226
+ command: >-
227
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"publish-prep\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
228
+ bun -e 'const article = JSON.parse(await Bun.file(process.argv[1]).text()); const audio = JSON.parse(await Bun.file(process.argv[2]).text()); const audioMeta = audio.metadata ?? {}; const rawFile = audioMeta.mp3Path ?? audioMeta.audioPath; if (!rawFile) { console.error("publish-prep: voice content carries no mp3Path/audioPath"); process.exit(1); } const { resolve } = await import("node:path"); const { existsSync } = await import("node:fs"); const audioFile = resolve(rawFile); const coverPath = resolve(process.argv[4]); const merged = { ...article, metadata: { ...(article.metadata ?? {}), audioFile, runDate: process.argv[5] }, options: { audioFile, durationSec: audioMeta.duration, coverPath: existsSync(coverPath) ? coverPath : undefined } }; await Bun.write(process.argv[3], JSON.stringify(merged, null, 2))' "${vars.work_dir}/2-article/content-${vars.run_date}.json" "${vars.work_dir}/3-audio/content.json" "${vars.work_dir}/3-publish/content.json" "${vars.work_dir}/2-cover/cover-${vars.run_date}.png" "${vars.run_date}"
229
+
230
+ - id: publish-surfdash
231
+ description: "Publish the merged Content to surfdash (single target); result lands in result.json — surfdash publishes first so show-notes can link the article (task 0118 seam reorder)"
232
+ onEnter:
233
+ - kind: shell
234
+ options:
235
+ command: >-
236
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"publish-surfdash\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
237
+ set --;
238
+ if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
239
+ RESULT="${vars.work_dir}/3-publish/result-surfdash.json";
240
+ if jq -e '.targets[]? | select(.target == "surfdash-pub" and .ok == true and .status == "published")' "$RESULT" > /dev/null 2>&1; then echo "publish-surfdash cached: surfdash target ok in $RESULT"; exit 0; fi;
241
+ env -u SPUR_WORKFLOW_RUN_ACTIVE kk executor fan-out --content "${vars.work_dir}/3-publish/content.json" --target surfdash-pub --out "$RESULT" "$@" || true;
242
+ echo "publish-surfdash: surfdash target dispatched; classification happens after podcast publish"
243
+
204
244
  - id: show-notes
205
- description: "Render uniform show notes from the episode plan (podcast-pub show-notes operation; PODCAST_PUB_TITLE carries the effective title vars.title || article.title)"
245
+ description: "Render timeline show notes from the episode plan (podcast-pub show-notes op, briefMode; article URL from the surfdash result postPath — surfdash failure degrades to legacy full-body layout)"
206
246
  onEnter:
207
247
  - kind: shell
208
248
  options:
209
249
  command: >-
250
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"show-notes\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
210
251
  set --;
211
252
  if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
212
253
  TITLE_ARG="${vars.title}";
213
254
  EFFECTIVE_TITLE="$(jq -r '.title // ""' "${vars.work_dir}/2-article/content-${vars.run_date}.json")";
214
255
  if [ -z "$TITLE_ARG" ]; then TITLE_ARG="$EFFECTIVE_TITLE"; fi;
215
- PODCAST_PUB_TITLE="$TITLE_ARG" PODCAST_PUB_RUN_DATE="${vars.run_date}" kk executor run podcast-pub --in "${vars.work_dir}/2-plan/plan.json" --out "${vars.work_dir}/3-publish/show-notes.md" "$@"
256
+ RESULT="${vars.work_dir}/3-publish/result.json";
257
+ POST_PATH="$(jq -r '[(.targets // [])[] | select(.target == "surfdash-pub" and .ok == true) | .metadata.postPath // empty] | first // ""' "$RESULT" 2>/dev/null || true)";
258
+ PODCAST_PUB_TITLE="$TITLE_ARG" PODCAST_PUB_RUN_DATE="${vars.run_date}" PODCAST_PUB_BRIEF_MODE=true PODCAST_PUB_ARTICLE_POST_PATH="$POST_PATH" kk executor run podcast-pub --in "${vars.work_dir}/2-plan/plan.json" --out "${vars.work_dir}/3-publish/show-notes.md" "$@"
216
259
 
217
- - id: publish-prep
218
- description: "Merge article Content with audio (podcast assembly facts ride the content options channel for podcast-pub's absorbed assembly; runDate kept for surfdash slug)"
260
+ - id: publish-podcast
261
+ description: "Publish the episode to podcast-pub with the rendered show notes (metadata.showNotes carries the brief/timeline markdown; map.ts falls back to content.body when absent — task 0118 R3)"
219
262
  onEnter:
220
263
  - kind: shell
221
264
  options:
222
265
  command: >-
223
- bun -e 'const article = JSON.parse(await Bun.file(process.argv[1]).text()); const audio = JSON.parse(await Bun.file(process.argv[2]).text()); const audioMeta = audio.metadata ?? {}; const rawFile = audioMeta.mp3Path ?? audioMeta.audioPath; if (!rawFile) { console.error("publish-prep: voice content carries no mp3Path/audioPath"); process.exit(1); } const { resolve } = await import("node:path"); const { existsSync } = await import("node:fs"); const audioFile = resolve(rawFile); const coverPath = resolve(process.argv[4]); const merged = { ...article, metadata: { ...(article.metadata ?? {}), audioFile, runDate: process.argv[5] }, options: { audioFile, durationSec: audioMeta.duration, coverPath: existsSync(coverPath) ? coverPath : undefined } }; await Bun.write(process.argv[3], JSON.stringify(merged, null, 2))' "${vars.work_dir}/2-article/content-${vars.run_date}.json" "${vars.work_dir}/3-audio/content.json" "${vars.work_dir}/3-publish/content.json" "${vars.work_dir}/2-cover/cover-${vars.run_date}.png" "${vars.run_date}"
266
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"publish-podcast\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
267
+ set --;
268
+ if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
269
+ RESULT="${vars.work_dir}/3-publish/result-podcast.json";
270
+ if jq -e '.targets[]? | select(.target == "podcast-pub" and .ok == true and .status == "published")' "$RESULT" > /dev/null 2>&1; then echo "publish-podcast cached: podcast target ok in $RESULT"; exit 0; fi;
271
+ bun -e 'const content = JSON.parse(await Bun.file(process.argv[1]).text()); const notes = await Bun.file(process.argv[2]).text(); content.metadata = { ...(content.metadata ?? {}), showNotes: notes }; await Bun.write(process.argv[1], JSON.stringify(content, null, 2))' "${vars.work_dir}/3-publish/content.json" "${vars.work_dir}/3-publish/show-notes.md" &&
272
+ env -u SPUR_WORKFLOW_RUN_ACTIVE kk executor fan-out --content "${vars.work_dir}/3-publish/content.json" --target podcast-pub --out "$RESULT" "$@" || true;
273
+ echo "publish-podcast: podcast target dispatched"
224
274
 
225
275
  - id: publish
226
- description: "Fan out the merged Content to surfdash-pub and podcast-pub; classify the outcome full|partial|none into publish-status.txt (exit 0 in all three — routing is transition-owned)"
276
+ description: "Classify the sequenced surfdash → podcast outcomes full|partial|none into publish-status.txt (exit 0 in all three — routing is transition-owned; task 0118: per-target classification over the merged result.json)"
227
277
  onEnter:
228
278
  - kind: shell
229
279
  options:
230
280
  command: >-
231
- set --;
232
- if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
281
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"publish\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
233
282
  RESULT="${vars.work_dir}/3-publish/result.json"; STATUS_FILE="${vars.work_dir}/3-publish/publish-status.txt";
234
- if jq -e '.ok == true and ([.targets[]? | select(.ok == true and .status == "published")] | length) == 2' "$RESULT" > /dev/null 2>&1; then echo "publish cached: $RESULT (2 targets ok)"; printf 'full' > "$STATUS_FILE"; exit 0; fi;
235
- OK_COUNT=0; TOTAL=0; STATUS="none";
236
- env -u SPUR_WORKFLOW_RUN_ACTIVE kk executor fan-out --content "${vars.work_dir}/3-publish/content.json" --target surfdash-pub --target podcast-pub --out "$RESULT" "$@" || true;
237
- if [ -f "$RESULT" ]; then OK_COUNT="$(jq '[.targets[]? | select(.ok == true and .status == "published")] | length' "$RESULT")"; TOTAL="$(jq '[.targets[]?] | length' "$RESULT")"; if [ "$OK_COUNT" -gt 0 ] && [ "$OK_COUNT" -eq "$TOTAL" ]; then STATUS="full"; elif [ "$OK_COUNT" -gt 0 ]; then STATUS="partial"; fi; else echo "publish: fan-out wrote no result.json — routing to none" >&2; fi;
283
+ TARGETS="$(jq -s '[(.[0].targets // []), (.[1].targets // [])] | add' "${vars.work_dir}/3-publish/result-surfdash.json" "${vars.work_dir}/3-publish/result-podcast.json" 2>/dev/null || echo '[]')";
284
+ OK_COUNT="$(printf '%s' "$TARGETS" | jq '[.[]? | select(.ok == true and .status == "published")] | length')";
285
+ TOTAL="$(printf '%s' "$TARGETS" | jq 'length')";
286
+ if [ "$OK_COUNT" -gt 0 ] && [ "$OK_COUNT" -eq "$TOTAL" ]; then STATUS="full"; elif [ "$OK_COUNT" -gt 0 ]; then STATUS="partial"; else STATUS="none"; fi;
287
+ jq -n --argjson targets "$TARGETS" --argjson ok "$OK_COUNT" --argjson total "$TOTAL" '{ok: ($ok == $total and $total > 0), targets: $targets}' > "$RESULT";
238
288
  printf '%s' "$STATUS" > "$STATUS_FILE";
239
289
  echo "publish: status=$STATUS ($OK_COUNT/$TOTAL targets published); routing is transition-owned"
240
290
 
241
291
  - id: publish-partial
242
- description: "Partial publish — at least one target published, at least one failed; echo warning, write a .spur/run marker, preserve result.json for --retry-from, continue to done"
292
+ description: "Partial publish — at least one target published, at least one failed; echo warning, write a .spur/run marker, preserve result.json for --retry-from, continue to done (task 0118: surfdash failure = no-URL fallback notes; podcast failure = its own partial)"
243
293
  onEnter:
244
294
  - kind: shell
245
295
  options:
246
296
  command: >-
297
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"publish-partial\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
247
298
  FAILED="$(jq -r '[.targets[]? | select(.ok != true or .status != "published") | .target] | join(",")' "${vars.work_dir}/3-publish/result.json")";
248
299
  if [ -z "$FAILED" ]; then FAILED="(unparsed)"; fi;
249
300
  echo "WARNING: partial publish — failed targets: $FAILED" >&2;
250
301
  mkdir -p .spur/run &&
251
302
  printf '%s partial publish: failed targets: %s (result.json preserved for kk executor fan-out --retry-from)\n' "${vars.run_date}" "$FAILED" > ".spur/run/publish-partial-${vars.run_date}.txt"
252
303
 
304
+ - id: run-report
305
+ description: "Assemble the per-run report (scores+aggregates+rejected counts+step timing) from existing artifacts — news-report-gen; output-only, never throws; 0117"
306
+ onEnter:
307
+ - kind: shell
308
+ options:
309
+ command: >-
310
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"run-report\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
311
+ REPORT_MD="${vars.work_dir}/run-report-${vars.run_date}.md";
312
+ if [ -f "$REPORT_MD" ]; then echo "run report cached: $REPORT_MD"; exit 0; fi;
313
+ CANDS="${vars.work_dir}/2-plan/episode-plan.json";
314
+ test -f "${vars.work_dir}/2-plan/candidates.json" || { echo "run-report: no candidates.json (run ended below plan step) — skip"; exit 0; };
315
+ jq '.metadata.docs // []' "${vars.work_dir}/2-plan/candidates.json" > "$CANDS";
316
+ set --;
317
+ if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
318
+ QC_MIN_QUALITY="${vars.qc_min_quality}" QC_MIN_IMPORTANCE="${vars.qc_min_importance}" QC_MIN_URGENCY="${vars.qc_min_urgency}" QC_MIN_IMPACT="${vars.qc_min_impact}" NEWS_REPORT_REJECTED_FILE="${vars.work_dir}/2-plan/candidates.rejected.json" NEWS_REPORT_TIMING_FILE="${vars.work_dir}/step-timing.json" NEWS_REPORT_DATE="${vars.run_date}" kk executor run news-report-gen --in "$CANDS" --out "${vars.work_dir}/run-report.json" "$@" &&
319
+ bun -e 'await Bun.write(process.argv[2], (await Bun.file(process.argv[1]).json()).body)' "${vars.work_dir}/run-report.json" "$REPORT_MD" &&
320
+ echo "run report written: $REPORT_MD"
321
+
253
322
  - id: translate-en
254
323
  description: "Scaffold en draft from the zh article via surfdash-pub scaffold-locale (postPath from publish result; done-no-content/none-publish runs carry no zh path and skip)"
255
324
  onEnter:
256
325
  - kind: shell
257
326
  options:
258
327
  command: >-
328
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"translate-en\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
259
329
  RESULT="${vars.work_dir}/3-publish/result.json";
260
330
  ZH="$(jq -r '[(.targets // [])[] | select(.target == "surfdash-pub" and .ok == true) | .metadata.postPath // empty] | first // ""' "$RESULT")";
261
331
  case "$ZH" in *index.md) ;; *) ZH="";; esac;
@@ -298,6 +368,7 @@ states:
298
368
  - kind: shell
299
369
  options:
300
370
  command: >-
371
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"translate-ja\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
301
372
  ZH="$(cat "${vars.work_dir}/3-publish/zh-post-path.txt" 2>/dev/null || printf '')";
302
373
  JA="$(printf '%s' "$ZH" | sed 's|/zh/|/ja/|')";
303
374
  printf '%s' "$JA" > "${vars.work_dir}/3-publish/ja-post-path.txt";
@@ -334,6 +405,7 @@ states:
334
405
  - kind: shell
335
406
  options:
336
407
  command: >-
408
+ __STAMP_T0="$(date -u +%FT%TZ)"; trap '[ -n "${vars.work_dir}" ] && printf "{\"step\":\"translate-sync\",\"startedAt\":\"%s\",\"endedAt\":\"%s\"}\n" "$__STAMP_T0" "$(date -u +%FT%TZ)" >> "${vars.work_dir}/step-timing.json"' EXIT;
337
409
  test -n "${vars.surfdash_post_path}" || { echo "no zh post path — skip translate-sync"; exit 0; };
338
410
  set --;
339
411
  if [ -n "${vars.plugins_path}" ]; then set -- --plugins-path "${vars.plugins_path}"; fi;
@@ -461,8 +533,8 @@ transitions:
461
533
  kind: always
462
534
 
463
535
  - from: quality-control
464
- to: show-notes
465
- description: "publish enabled AND QC passed -> render show notes, then publish"
536
+ to: publish-prep
537
+ description: "publish enabled AND QC passed -> merge article + audio for the publish seam"
466
538
  guard:
467
539
  kind: shell
468
540
  options:
@@ -482,21 +554,33 @@ transitions:
482
554
  guard:
483
555
  kind: always
484
556
 
557
+ - from: publish-prep
558
+ to: publish-surfdash
559
+ description: "Merged content ready -> surfdash publishes first (task 0118 seam reorder: article URLs must exist before show-notes render)"
560
+ guard:
561
+ kind: always
562
+
563
+ - from: publish-surfdash
564
+ to: show-notes
565
+ description: "Surfdash dispatched (ok or failed — failure degrades to no-URL notes) -> render timeline show notes"
566
+ guard:
567
+ kind: always
568
+
485
569
  - from: show-notes
486
- to: publish-prep
487
- description: "Show notes rendered -> merge article + audio for the publish seam"
570
+ to: publish-podcast
571
+ description: "Show notes rendered (briefMode timeline or legacy fallback) -> podcast publish carries them via metadata.showNotes"
488
572
  guard:
489
573
  kind: always
490
574
 
491
- - from: publish-prep
575
+ - from: publish-podcast
492
576
  to: publish
493
- description: "Merged content ready -> fan out to surfdash-pub + podcast-pub"
577
+ description: "Podcast dispatched -> classify the per-target outcomes full|partial|none"
494
578
  guard:
495
579
  kind: always
496
580
 
497
581
  - from: publish
498
- to: translate-en
499
- description: "Publish full (all targets published) -> scaffold + translate en locale"
582
+ to: run-report
583
+ description: "Publish full (all targets published) -> assemble run report first"
500
584
  guard:
501
585
  kind: shell
502
586
  options:
@@ -519,11 +603,27 @@ transitions:
519
603
  command: 'test "$(cat "${vars.work_dir}/3-publish/publish-status.txt")" = "none"'
520
604
 
521
605
  - from: publish-partial
522
- to: done
523
- description: "Partial publish recorded (warning + .spur/run marker) -> done; retry failed targets later via kk executor fan-out --retry-from"
606
+ to: run-report
607
+ description: "Partial publish recorded (warning + .spur/run marker) -> still assemble the run report"
524
608
  guard:
525
609
  kind: always
526
610
 
611
+ - from: run-report
612
+ to: translate-en
613
+ description: "Run report assembled after full publish -> scaffold + translate en locale"
614
+ guard:
615
+ kind: shell
616
+ options:
617
+ command: 'test "$(cat "${vars.work_dir}/3-publish/publish-status.txt")" = "full"'
618
+
619
+ - from: run-report
620
+ to: done
621
+ description: "Run report assembled after partial publish -> done; retry failed targets later via kk executor fan-out --retry-from"
622
+ guard:
623
+ kind: shell
624
+ options:
625
+ command: 'test "$(cat "${vars.work_dir}/3-publish/publish-status.txt")" = "partial"'
626
+
527
627
  - from: translate-en
528
628
  to: translate-en-agent
529
629
  description: "zh post published and en draft scaffolded -> agent translates en draft in place"
@@ -21848,6 +21848,26 @@ var ResultSchema = exports_external.object({
21848
21848
  metadata: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
21849
21849
  });
21850
21850
  var DocListSchema = exports_external.array(DocSchema);
21851
+ var omniVoiceGenContentSchema = ContentSchema.extend({
21852
+ format: exports_external.literal("audio"),
21853
+ metadata: exports_external.looseObject({
21854
+ generator: exports_external.literal("kk:omni-voice-gen"),
21855
+ audioPath: exports_external.string(),
21856
+ duration: exports_external.number(),
21857
+ segments: exports_external.array(exports_external.looseObject({
21858
+ generationId: exports_external.string(),
21859
+ profile: exports_external.string(),
21860
+ speaker: exports_external.string().optional(),
21861
+ emotion: exports_external.string().optional(),
21862
+ duration: exports_external.number(),
21863
+ verifyRetries: exports_external.number(),
21864
+ transcription: exports_external.string().optional(),
21865
+ loudnessDip: exports_external.boolean().optional()
21866
+ })),
21867
+ mp3Path: exports_external.string().optional(),
21868
+ qc: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
21869
+ })
21870
+ });
21851
21871
 
21852
21872
  // ../../packages/kk-core/src/kinds.ts
21853
21873
  var PLUGIN_KINDS = ["ingestion", "generator", "publish"];
@@ -21849,6 +21849,26 @@ var ResultSchema = exports_external.object({
21849
21849
  metadata: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
21850
21850
  });
21851
21851
  var DocListSchema = exports_external.array(DocSchema);
21852
+ var omniVoiceGenContentSchema = ContentSchema.extend({
21853
+ format: exports_external.literal("audio"),
21854
+ metadata: exports_external.looseObject({
21855
+ generator: exports_external.literal("kk:omni-voice-gen"),
21856
+ audioPath: exports_external.string(),
21857
+ duration: exports_external.number(),
21858
+ segments: exports_external.array(exports_external.looseObject({
21859
+ generationId: exports_external.string(),
21860
+ profile: exports_external.string(),
21861
+ speaker: exports_external.string().optional(),
21862
+ emotion: exports_external.string().optional(),
21863
+ duration: exports_external.number(),
21864
+ verifyRetries: exports_external.number(),
21865
+ transcription: exports_external.string().optional(),
21866
+ loudnessDip: exports_external.boolean().optional()
21867
+ })),
21868
+ mp3Path: exports_external.string().optional(),
21869
+ qc: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
21870
+ })
21871
+ });
21852
21872
 
21853
21873
  // ../../packages/kk-core/src/kinds.ts
21854
21874
  var PLUGIN_KINDS = ["ingestion", "generator", "publish"];
@@ -21962,7 +21982,12 @@ function renderShowNotesMarkdown(docs, opts) {
21962
21982
  const s = t.toLowerCase().replace(/[^\w\s-]/g, "").trim().replace(/[\s_]+/g, "-");
21963
21983
  return s.length > 0 ? s : "post";
21964
21984
  };
21965
- const articleUrl = `${opts.articleBaseUrl ?? "https://surfing.salty.vip"}/zh/posts/${opts.runDate}-${slugify2(opts.title)}`;
21985
+ const baseUrl = opts.articleBaseUrl ?? "https://surfing.salty.vip";
21986
+ const segments = opts.articlePostPath?.split(/[\\/]/).filter((s) => s.length > 0) ?? [];
21987
+ const postSlug = segments.length >= 2 ? segments[segments.length - 2] ?? "" : "";
21988
+ const postLocale = segments.find((s) => ["zh", "en", "ja"].includes(s)) ?? "zh";
21989
+ const articleUrl = postSlug.length > 0 ? `${baseUrl}/${postLocale}/posts/${postSlug}` : `${baseUrl}/zh/posts/${opts.runDate}-${slugify2(opts.title)}`;
21990
+ const briefMode = opts.briefMode === true && postSlug.length > 0;
21966
21991
  const sourceLabel = (d) => {
21967
21992
  const m = d.metadata ?? {};
21968
21993
  if (typeof m.sourceName === "string" && m.sourceName.trim())
@@ -21973,11 +21998,31 @@ function renderShowNotesMarkdown(docs, opts) {
21973
21998
  return "\u6765\u6E90";
21974
21999
  }
21975
22000
  };
22001
+ const itemBrief = (d) => {
22002
+ const meta3 = d.metadata ?? {};
22003
+ if (typeof meta3.brief === "string" && meta3.brief.trim().length > 0)
22004
+ return meta3.brief.trim();
22005
+ return (d.body ?? "").trim().split(/(?<=[\u3002\uFF01\uFF1F])\s*/).slice(0, 2).join("");
22006
+ };
22007
+ const itemTimestamp = (d) => {
22008
+ const meta3 = d.metadata ?? {};
22009
+ const raw = meta3.publishAt;
22010
+ const t = typeof raw === "string" ? Date.parse(raw) : typeof raw === "number" ? raw : Number.NaN;
22011
+ if (!Number.isFinite(t))
22012
+ return "--:--";
22013
+ const at = new Date(t);
22014
+ return `${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`;
22015
+ };
21976
22016
  const lines = [`# ${opts.title}`, ""];
21977
22017
  docs.forEach((d, i) => {
21978
22018
  const body = (d.body ?? "").trim();
21979
22019
  const sentences = body.split(/(?<=[\u3002\uFF01\uFF1F])\s*/).slice(0, 2).join("");
21980
- lines.push(`## ${i + 1}. ${d.title ?? `\u6761\u76EE ${i + 1}`}`, "", sentences, "", `[\u6765\u6E90\uFF1A${sourceLabel(d)}](${d.sourceUri ?? ""})`, "");
22020
+ lines.push(`## ${i + 1}. ${d.title ?? `\u6761\u76EE ${i + 1}`}`, "");
22021
+ if (briefMode) {
22022
+ lines.push(`${itemTimestamp(d)} \xB7 ${itemBrief(d)}`, "", `[\u6765\u6E90\uFF1A${sourceLabel(d)}](${d.sourceUri ?? ""}) \xB7 [\u6587\u7AE0](${articleUrl})`, "");
22023
+ } else {
22024
+ lines.push(sentences, "", `[\u6765\u6E90\uFF1A${sourceLabel(d)}](${d.sourceUri ?? ""})`, "");
22025
+ }
21981
22026
  });
21982
22027
  lines.push(`\u8BE6\u60C5\u89C1\u672C\u671F\u5B8C\u6574\u6587\u7AE0\uFF1A[ ${opts.title} ](${articleUrl})`, "");
21983
22028
  return lines.join(`
@@ -22262,8 +22307,17 @@ function resolveShowNotesRenderOptions(options) {
22262
22307
  return { error: 'options.runDate (or PODCAST_PUB_RUN_DATE) is required for the "show-notes" operation' };
22263
22308
  }
22264
22309
  const articleBaseUrl = resolveOptionString(options.articleBaseUrl, "PODCAST_PUB_ARTICLE_BASE_URL");
22310
+ const briefModeRaw = options.briefMode ?? process.env.PODCAST_PUB_BRIEF_MODE;
22311
+ const briefMode = briefModeRaw === true || briefModeRaw === "true" || briefModeRaw === "1";
22312
+ const articlePostPath = resolveOptionString(options.articlePostPath, "PODCAST_PUB_ARTICLE_POST_PATH");
22265
22313
  return {
22266
- renderOptions: { title, runDate, ...articleBaseUrl !== undefined ? { articleBaseUrl } : {} }
22314
+ renderOptions: {
22315
+ title,
22316
+ runDate,
22317
+ ...articleBaseUrl !== undefined ? { articleBaseUrl } : {},
22318
+ ...briefMode ? { briefMode: true } : {},
22319
+ ...articlePostPath !== undefined ? { articlePostPath } : {}
22320
+ }
22267
22321
  };
22268
22322
  }
22269
22323
  function renderShowNotesFromOptions(options) {
@@ -28,6 +28,10 @@ interface PodcastPubOptions {
28
28
  title?: unknown;
29
29
  runDate?: unknown;
30
30
  articleBaseUrl?: unknown;
31
+ /** Brief/timeline render flag (task 0118). */
32
+ briefMode?: unknown;
33
+ /** Surfdash publish result `metadata.postPath` (briefMode URL source). */
34
+ articlePostPath?: unknown;
31
35
  /** Episode-assembly options for the `publish` op (absorb publish-prep's merge; additive). */
32
36
  audioFile?: unknown;
33
37
  coverPath?: unknown;
@@ -76,7 +80,10 @@ function resolveItemId(options: PodcastPubOptions): { itemId: string } | { error
76
80
  }
77
81
 
78
82
  /** Options the `show-notes` operation reads on the payload (a subset of {@link PodcastPubOptions}). */
79
- type ShowNotesOpOptions = Pick<PodcastPubOptions, 'docs' | 'title' | 'runDate' | 'articleBaseUrl'>;
83
+ type ShowNotesOpOptions = Pick<
84
+ PodcastPubOptions,
85
+ 'docs' | 'title' | 'runDate' | 'articleBaseUrl' | 'briefMode' | 'articlePostPath'
86
+ >;
80
87
 
81
88
  /**
82
89
  * Option-then-env resolution for the show-notes render options: `kk executor run`
@@ -103,8 +110,17 @@ function resolveShowNotesRenderOptions(
103
110
  return { error: 'options.runDate (or PODCAST_PUB_RUN_DATE) is required for the "show-notes" operation' };
104
111
  }
105
112
  const articleBaseUrl = resolveOptionString(options.articleBaseUrl, 'PODCAST_PUB_ARTICLE_BASE_URL');
113
+ const briefModeRaw = options.briefMode ?? process.env.PODCAST_PUB_BRIEF_MODE;
114
+ const briefMode = briefModeRaw === true || briefModeRaw === 'true' || briefModeRaw === '1';
115
+ const articlePostPath = resolveOptionString(options.articlePostPath, 'PODCAST_PUB_ARTICLE_POST_PATH');
106
116
  return {
107
- renderOptions: { title, runDate, ...(articleBaseUrl !== undefined ? { articleBaseUrl } : {}) },
117
+ renderOptions: {
118
+ title,
119
+ runDate,
120
+ ...(articleBaseUrl !== undefined ? { articleBaseUrl } : {}),
121
+ ...(briefMode ? { briefMode: true } : {}),
122
+ ...(articlePostPath !== undefined ? { articlePostPath } : {}),
123
+ },
108
124
  };
109
125
  }
110
126
 
@@ -82,6 +82,20 @@ export interface ShowNotesRenderOptions {
82
82
  runDate: string;
83
83
  /** Article base URL; defaults to the production site (surfing.salty.vip). */
84
84
  articleBaseUrl?: string;
85
+ /**
86
+ * Brief/timeline mode (task 0118): per-item 1–2-sentence briefing + `HH:MM`
87
+ * timestamp + per-item article link. When no surfdash article path is
88
+ * available the render degrades to the legacy full-body layout — never
89
+ * throws, never invents a URL.
90
+ */
91
+ briefMode?: boolean;
92
+ /**
93
+ * Absolute surfdash `metadata.postPath` from the publish result
94
+ * (`content/posts/articles/<locale>/<slug>/index.md`); the article URL is
95
+ * derived O(1) from its slug+locale — authoritative over the title/runDate
96
+ * slug construction, which stays the fallback.
97
+ */
98
+ articlePostPath?: string;
85
99
  }
86
100
 
87
101
  /**
@@ -101,7 +115,18 @@ export function renderShowNotesMarkdown(docs: Doc[], opts: ShowNotesRenderOption
101
115
  .replace(/[\s_]+/g, '-');
102
116
  return s.length > 0 ? s : 'post';
103
117
  };
104
- const articleUrl = `${opts.articleBaseUrl ?? 'https://surfing.salty.vip'}/zh/posts/${opts.runDate}-${slugify(opts.title)}`;
118
+ const baseUrl = opts.articleBaseUrl ?? 'https://surfing.salty.vip';
119
+ // O(1) postPath derivation (task 0118): the published slug + locale come from
120
+ // the real path, not from re-slugging the title. Falls back to the legacy
121
+ // title/runDate construction when no postPath exists (surfdash failed).
122
+ const segments = opts.articlePostPath?.split(/[\\/]/).filter((s) => s.length > 0) ?? [];
123
+ const postSlug = segments.length >= 2 ? (segments[segments.length - 2] ?? '') : '';
124
+ const postLocale = segments.find((s) => ['zh', 'en', 'ja'].includes(s)) ?? 'zh';
125
+ const articleUrl =
126
+ postSlug.length > 0
127
+ ? `${baseUrl}/${postLocale}/posts/${postSlug}`
128
+ : `${baseUrl}/zh/posts/${opts.runDate}-${slugify(opts.title)}`;
129
+ const briefMode = opts.briefMode === true && postSlug.length > 0;
105
130
  const sourceLabel = (d: Doc): string => {
106
131
  const m = d.metadata ?? {};
107
132
  if (typeof m.sourceName === 'string' && m.sourceName.trim()) return m.sourceName.trim();
@@ -111,6 +136,23 @@ export function renderShowNotesMarkdown(docs: Doc[], opts: ShowNotesRenderOption
111
136
  return '来源';
112
137
  }
113
138
  };
139
+ const itemBrief = (d: Doc): string => {
140
+ const meta = d.metadata ?? {};
141
+ if (typeof meta.brief === 'string' && meta.brief.trim().length > 0) return meta.brief.trim();
142
+ return (d.body ?? '')
143
+ .trim()
144
+ .split(/(?<=[。!?])\s*/)
145
+ .slice(0, 2)
146
+ .join('');
147
+ };
148
+ const itemTimestamp = (d: Doc): string => {
149
+ const meta = d.metadata ?? {};
150
+ const raw = meta.publishAt;
151
+ const t = typeof raw === 'string' ? Date.parse(raw) : typeof raw === 'number' ? raw : Number.NaN;
152
+ if (!Number.isFinite(t)) return '--:--';
153
+ const at = new Date(t);
154
+ return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`;
155
+ };
114
156
  const lines: string[] = [`# ${opts.title}`, ''];
115
157
  docs.forEach((d, i) => {
116
158
  const body = (d.body ?? '').trim();
@@ -118,14 +160,19 @@ export function renderShowNotesMarkdown(docs: Doc[], opts: ShowNotesRenderOption
118
160
  .split(/(?<=[。!?])\s*/)
119
161
  .slice(0, 2)
120
162
  .join('');
121
- lines.push(
122
- `## ${i + 1}. ${d.title ?? `条目 ${i + 1}`}`,
123
- '',
124
- sentences,
125
- '',
126
- `[来源:${sourceLabel(d)}](${d.sourceUri ?? ''})`,
127
- '',
128
- );
163
+ lines.push(`## ${i + 1}. ${d.title ?? `条目 ${i + 1}`}`, '');
164
+ if (briefMode) {
165
+ // Timeline entry: HH:MM + 1–2-sentence briefing; the trailing link pair
166
+ // ends with the surfdash article URL (task 0118 R2.1–R2.4).
167
+ lines.push(
168
+ `${itemTimestamp(d)} · ${itemBrief(d)}`,
169
+ '',
170
+ `[来源:${sourceLabel(d)}](${d.sourceUri ?? ''}) · [文章](${articleUrl})`,
171
+ '',
172
+ );
173
+ } else {
174
+ lines.push(sentences, '', `[来源:${sourceLabel(d)}](${d.sourceUri ?? ''})`, '');
175
+ }
129
176
  });
130
177
  lines.push(`详情见本期完整文章:[ ${opts.title} ](${articleUrl})`, '');
131
178
  return lines.join('\n');