@gobing-ai/knowledge-kit 0.0.26 → 0.0.28

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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "plugins": [
8
8
  {
9
- "version": "0.0.26",
9
+ "version": "0.0.28",
10
10
  "name": "kk",
11
11
  "source": "./plugins/kk",
12
12
  "description": "knowledge-kit — a set of toolkit for knowledge management"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/knowledge-kit",
3
- "version": "0.0.26",
3
+ "version": "0.0.28",
4
4
  "description": "An ingest → create → publish content pipeline CLI (Bun).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22064,19 +22064,22 @@ function stripLeadingTitleEcho(body, title) {
22064
22064
  const wanted = title.replace(TITLE_ECHO_NOISE_RE, "");
22065
22065
  if (wanted.length < 4)
22066
22066
  return body;
22067
- let matched = "";
22068
- let end = -1;
22069
- for (let i = 0;i < body.length && matched.length < wanted.length; i++) {
22070
- const kept = (body[i] ?? "").replace(TITLE_ECHO_NOISE_RE, "");
22071
- if (kept === "")
22072
- continue;
22073
- matched += kept;
22074
- if (matched.length >= wanted.length)
22075
- end = i + 1;
22067
+ let text = body;
22068
+ for (;; ) {
22069
+ let matched = "";
22070
+ let end = -1;
22071
+ for (let i = 0;i < text.length && matched.length < wanted.length; i++) {
22072
+ const kept = (text[i] ?? "").replace(TITLE_ECHO_NOISE_RE, "");
22073
+ if (kept === "")
22074
+ continue;
22075
+ matched += kept;
22076
+ if (matched.length >= wanted.length)
22077
+ end = i + 1;
22078
+ }
22079
+ if (end === -1 || matched !== wanted)
22080
+ return text;
22081
+ text = text.slice(end).replace(/^[\uFF0C\u3002\u3001\uFF1A:\uFF1B;!\uFF01?\uFF1F\u2026\s\u300C\u300D\u300E\u300F\u3010\u3011]+/, "");
22076
22082
  }
22077
- if (end === -1 || matched !== wanted)
22078
- return body;
22079
- return body.slice(end).replace(/^[\uFF0C\u3002\u3001\uFF1A:\uFF1B;!\uFF01?\uFF1F\u2026\s\u300C\u300D\u300E\u300F\u3010\u3011]+/, "");
22080
22083
  }
22081
22084
  var TAG_METADATA_LINE_RE = /^\s*\**\s*\u6807\u7B7E\s*\**\s*[\uFF1A:\uFF0C,]/;
22082
22085
  function stripTagMetadataLines(body) {
@@ -232,20 +232,29 @@ const TITLE_ECHO_NOISE_RE = /[,。、::;;!!??…\s「」『』【】]/
232
232
  * character prefix compare, never a fuzzy/LLM similarity match (anti-pattern list). The body is
233
233
  * returned untouched whenever the normalized prefix does not equal the normalized title, and a
234
234
  * body that is *only* the restatement collapses to the empty string (the lead alone carries it).
235
+ *
236
+ * 20260929 dogfood: plan bodies can restate the title MORE THAN once — a bare first line plus a
237
+ * restated line carrying a parenthetical extension (`title(来源标注日期为…)`). A single pass
238
+ * strips only the first, so the second becomes the new head and the VoiceScript validator rejects
239
+ * the segment. Keep stripping until the head no longer echoes; every accepted pass strictly
240
+ * shortens the body, so the loop terminates.
235
241
  */
236
242
  export function stripLeadingTitleEcho(body: string, title: string): string {
237
243
  const wanted = title.replace(TITLE_ECHO_NOISE_RE, '');
238
244
  if (wanted.length < 4) return body; // a short title would match too much of the body
239
- let matched = '';
240
- let end = -1;
241
- for (let i = 0; i < body.length && matched.length < wanted.length; i++) {
242
- const kept = (body[i] ?? '').replace(TITLE_ECHO_NOISE_RE, '');
243
- if (kept === '') continue; // drift punctuation between title and echo
244
- matched += kept;
245
- if (matched.length >= wanted.length) end = i + 1;
245
+ let text = body;
246
+ for (;;) {
247
+ let matched = '';
248
+ let end = -1;
249
+ for (let i = 0; i < text.length && matched.length < wanted.length; i++) {
250
+ const kept = (text[i] ?? '').replace(TITLE_ECHO_NOISE_RE, '');
251
+ if (kept === '') continue; // drift punctuation between title and echo
252
+ matched += kept;
253
+ if (matched.length >= wanted.length) end = i + 1;
254
+ }
255
+ if (end === -1 || matched !== wanted) return text;
256
+ text = text.slice(end).replace(/^[,。、::;;!!??…\s「」『』【】]+/, '');
246
257
  }
247
- if (end === -1 || matched !== wanted) return body;
248
- return body.slice(end).replace(/^[,。、::;;!!??…\s「」『』【】]+/, '');
249
258
  }
250
259
 
251
260
  /**
@@ -22101,7 +22101,14 @@ function docTimestamp(doc2) {
22101
22101
  }
22102
22102
  return null;
22103
22103
  }
22104
+ var CHINA_POLITICS_RE = /\u4E60\u8FD1\u5E73|\u674E\u514B\u5F3A|\u674E\u5F3A|\u515A\u4E2D\u592E|\u653F\u6CBB\u5C40|\u4E2D\u5357\u6D77|\u653F\u5E9C\u5DE5\u4F5C\u62A5\u544A|\u56FD\u53F0\u529E|\u5916\u4EA4\u90E8\u53D1\u8A00\u4EBA|\u4E24\u4F1A/;
22105
+ function isChinaPolitics(doc2) {
22106
+ return CHINA_POLITICS_RE.test(`${doc2.title ?? ""}
22107
+ ${(doc2.body ?? "").slice(0, 2000)}`);
22108
+ }
22104
22109
  function passesGates(doc2, planDate) {
22110
+ if (isChinaPolitics(doc2))
22111
+ return false;
22105
22112
  const meta3 = asRecord(doc2.metadata);
22106
22113
  const tags = Array.isArray(meta3.tags) ? meta3.tags.filter((t) => typeof t === "string") : [];
22107
22114
  if (tags.some((tag) => NON_NEWS_TAGS.has(tag.toLowerCase())))
@@ -227,7 +227,19 @@ function docTimestamp(doc: Doc): number | null {
227
227
  }
228
228
 
229
229
  /** Freshness + non-news gates applied before scoring/dedup (archived or off-topic items never enter the pool). */
230
+ /**
231
+ * Platform-safety gate (operator rule 2026-09-29): China domestic politics stories never enter
232
+ * the plan. Keyword heuristic — high-precision markers + top-leader names only; widen after
233
+ * reviewing false negatives in the audit trail rather than pre-emptively (overblocking kills
234
+ * legitimate AI-industry coverage).
235
+ */
236
+ const CHINA_POLITICS_RE = /习近平|李克强|李强|党中央|政治局|中南海|政府工作报告|国台办|外交部发言人|两会/;
237
+ function isChinaPolitics(doc: Doc): boolean {
238
+ return CHINA_POLITICS_RE.test(`${doc.title ?? ''}\n${(doc.body ?? '').slice(0, 2000)}`);
239
+ }
240
+
230
241
  function passesGates(doc: Doc, planDate: string): boolean {
242
+ if (isChinaPolitics(doc)) return false;
231
243
  const meta = asRecord(doc.metadata);
232
244
  const tags = Array.isArray(meta.tags) ? meta.tags.filter((t): t is string => typeof t === 'string') : [];
233
245
  if (tags.some((tag) => NON_NEWS_TAGS.has(tag.toLowerCase()))) return false;
@@ -23435,7 +23435,12 @@ header = ${curlQuote(`x-goog-api-key: ${apiKey}`)}
23435
23435
  "-d",
23436
23436
  "@-",
23437
23437
  url2
23438
- ], { input: JSON.stringify(body), encoding: "utf8", timeout: 310000 });
23438
+ ], {
23439
+ input: JSON.stringify(body),
23440
+ encoding: "utf8",
23441
+ timeout: 310000,
23442
+ stdio: ["pipe", "pipe", "pipe"]
23443
+ });
23439
23444
  } catch (error51) {
23440
23445
  const e = error51;
23441
23446
  const details = (typeof e.stderr === "string" ? e.stderr.trim() : "") || e.message || "curl request failed";
@@ -174,6 +174,9 @@ export const createGoogle: ProviderFactory = (options?: ProviderOptions): ImageP
174
174
  // the key and the proxy password into logs. `-sS` (not `-s`) keeps curl's own diagnostic on
175
175
  // stderr, so `details` no longer falls back to that argv-bearing message.
176
176
  //
177
+ // `stdio: ['pipe', 'pipe', 'pipe']` is explicit because Bun's `execFileSync` otherwise
178
+ // tees the child's stderr to the parent's stderr even though it also captures it.
179
+ //
177
180
  // File IO goes through the `@gobing-ai/ts-runtime` seam (`no-direct-fs-io`): the seam is
178
181
  // text-only with no `mode` argument, and the file lives in `os.tmpdir()`, which is a
179
182
  // per-user directory (0700 on macOS).
@@ -199,7 +202,12 @@ export const createGoogle: ProviderFactory = (options?: ProviderOptions): ImageP
199
202
  '@-',
200
203
  url,
201
204
  ],
202
- { input: JSON.stringify(body), encoding: 'utf8', timeout: 310_000 },
205
+ {
206
+ input: JSON.stringify(body),
207
+ encoding: 'utf8',
208
+ timeout: 310_000,
209
+ stdio: ['pipe', 'pipe', 'pipe'],
210
+ },
203
211
  );
204
212
  } catch (error: unknown) {
205
213
  const e = error as { message?: string; stderr?: string | Buffer };
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "kk",
3
- "version": "0.0.26",
3
+ "version": "0.0.28",
4
4
  "description": "knowledge-kit Claude Code plugin: skills, commands, subagents, hooks, and rules"
5
5
  }
@@ -1233,7 +1233,9 @@ function dailyPublishXhs(args: string[]): void {
1233
1233
  * outcome never enters `publish_status` (surfdash + podcast remain the publication contract).
1234
1234
  */
1235
1235
  function dailyPublishWechat(args: string[]): void {
1236
- const [workDir = '', runDate = '', pluginsPath = '', live = 'false'] = args;
1236
+ const [workDirRaw = '', runDate = '', pluginsPath = '', live = 'false', collectionArg = ''] = args;
1237
+ // Metadata carries file paths consumed by the plugin child process (cwd = plugin dir) - never emit relatives.
1238
+ const workDir = resolve(workDirRaw);
1237
1239
  const article = join(workDir, '22-article', `${runDate}_06_article_content.json`);
1238
1240
  const input = join(workDir, '31-publish', `${runDate}_20_publish-wechat_content.json`);
1239
1241
  const result = join(workDir, '31-publish', `${runDate}_20_publish-wechat_result.json`);
@@ -1250,8 +1252,35 @@ function dailyPublishWechat(args: string[]): void {
1250
1252
  return;
1251
1253
  }
1252
1254
  const packaged = requireJson(article, 'publish-wechat');
1255
+ // MP draft enrichment (operator preference 2026-09-29): 原创+作者, 赞赏, 合集, 原文链接
1256
+ // (the surfdash post), AI 生成声明, cover. The brand strings are this daily show's constants.
1257
+ const cover = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1258
+ const audio = join(workDir, '30-audio', `${runDate}_11_generate_content.mp3`);
1259
+ const wechatmp: Record<string, unknown> = {
1260
+ original: true,
1261
+ aiGenerated: true,
1262
+ author: '冰原奔狼',
1263
+ collection: collectionArg || '每日AI资讯',
1264
+ ...(isFile(cover) ? { coverPath: cover } : {}),
1265
+ // Audio block at the article top (operator request 2026-09-29): the daily MP3 goes to
1266
+ // the 素材库 under the article title; the plugin reuses an existing same-title material.
1267
+ ...(isFile(audio)
1268
+ ? {
1269
+ audioPath: audio,
1270
+ audioTitle: typeof packaged.title === 'string' ? packaged.title : '',
1271
+ audioCoverPath: isFile(cover) ? cover : '',
1272
+ }
1273
+ : {}),
1274
+ };
1275
+ // WeChat-only closing line (operator request 2026-09-29): point readers/listeners back to
1276
+ // the show's platforms. Appended to the wechat input body only, never the master article.
1277
+ const outro = '\n\n欲听往期节目,可至小宇宙、Apple Podcast、YouTube 等平台搜索【咸话咸说】。';
1253
1278
  sted('write', input, () =>
1254
- writeJsonText(input, { ...packaged, metadata: { ...metadataOf(packaged), publishStatus: want } }),
1279
+ writeJsonText(input, {
1280
+ ...packaged,
1281
+ body: `${typeof packaged.body === 'string' ? packaged.body : ''}${outro}`,
1282
+ metadata: { ...metadataOf(packaged), publishStatus: want, wechatmp },
1283
+ }),
1255
1284
  );
1256
1285
  // `soft` keeps the WeChat target non-blocking: the plugin still writes its own failed Result
1257
1286
  // and prints the failing step (session preflight, editor paste, save control), which is what
@@ -1575,6 +1604,17 @@ function dailyPublishPrep(args: string[]): void {
1575
1604
  stampId3Tags(audioFile, typeof article.title === 'string' ? article.title : '', runDate, artist, album);
1576
1605
  const resolvedCover = resolve(coverPath);
1577
1606
  const hasCover = existsSync(resolvedCover);
1607
+ // Daily-show itunes convention: season = year, episode = day-of-year. run_date arrives
1608
+ // dashed (`$(date +%F)`) or digits-only (formatRunDate); map.ts forwards numbers only,
1609
+ // so a malformed run_date omits both tags instead of failing the publish.
1610
+ const digits = runDate.replaceAll(/\D/g, '');
1611
+ const season = Number(digits.slice(0, 4));
1612
+ const month = Number(digits.slice(4, 6));
1613
+ const day = Number(digits.slice(6, 8));
1614
+ const episode =
1615
+ digits.length === 8 && month >= 1 && month <= 12 && day >= 1 && day <= 31
1616
+ ? Math.round((Date.UTC(season, month - 1, day) - Date.UTC(season, 0, 1)) / 86_400_000) + 1
1617
+ : 0;
1578
1618
  // podcast-pub's documented contract reads metadata.coverImage (map.ts); options.coverPath
1579
1619
  // alone was silently dropped by the mapper — episodes published without a cover.
1580
1620
  const merged = {
@@ -1584,6 +1624,7 @@ function dailyPublishPrep(args: string[]): void {
1584
1624
  audioFile,
1585
1625
  runDate,
1586
1626
  ...(hasCover ? { coverImage: resolvedCover } : {}),
1627
+ ...(episode > 0 ? { itunes: { season, episode } } : {}),
1587
1628
  },
1588
1629
  options: {
1589
1630
  audioFile,
@@ -1765,7 +1806,7 @@ function dailyPublishPartial(args: string[]): void {
1765
1806
  * Idempotent per post; commits + pushes the surfdash checkout (SURFDASH_AUTO_PUSH=0 keeps commit-only).
1766
1807
  */
1767
1808
  function dailyPatchSurfdash(args: string[]): void {
1768
- const [workDir = '', runDate = '', surfdashRoot = ''] = args;
1809
+ const [workDir = '', runDate = '', surfdashRoot = '', pluginsPath = ''] = args;
1769
1810
  if (!surfdashRoot || !existsSync(surfdashRoot)) {
1770
1811
  console.log(`patch-surfdash: surfdash root '${surfdashRoot}' not found — skip`);
1771
1812
  return;
@@ -1866,6 +1907,39 @@ function dailyPatchSurfdash(args: string[]): void {
1866
1907
  changed++;
1867
1908
  }
1868
1909
  }
1910
+ // 20260929 prod feedback: CMS cards (homepage hero/tag/search) read EmDash rows whose
1911
+ // featured_image is snapshotted at translate-sync time — i.e. the pre-patch
1912
+ // `./assets/cover.png`, which the deployed worker does not serve (404). The patch only
1913
+ // rewrites files, so re-sync the EmDash rows from the patched files through the same
1914
+ // `kk executor` path as translate-sync (its plugin child gets EMDASH_API_TOKEN from kk
1915
+ // config; a direct orchestrator spawn gets 403 from prod dev-bypass). Runs before the
1916
+ // changed===0 early-return so re-runs heal rows an earlier commit left stale
1917
+ // (20260925/20260929). Fixture tests pass no plugins-path and must not shell out.
1918
+ if (pluginsPath === '') {
1919
+ console.log('patch-surfdash: no plugins-path — skip emdash re-sync');
1920
+ } else {
1921
+ const syncIn = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync.json`);
1922
+ const syncResult = join(workDir, '31-publish', `${runDate}_23_patch-surfdash_sync-result.json`);
1923
+ sted('write', syncIn, () =>
1924
+ writeJqText(syncIn, { body: '', options: { operation: 'sync', postPath: join(slugDir, 'index.md') } }),
1925
+ );
1926
+ const status = kk(
1927
+ ['executor', 'run', 'surfdash-pub', '--in', syncIn, '--out', syncResult, ...pluginsArgs(pluginsPath)],
1928
+ { unset: NESTED_SPUR_UNSET, soft: true },
1929
+ );
1930
+ const result = isFile(syncResult) ? readJson(syncResult) : null;
1931
+ const ok =
1932
+ status === 0 &&
1933
+ result !== null &&
1934
+ (result.ok === true || (result.targets as Array<{ ok?: boolean }> | undefined)?.[0]?.ok === true);
1935
+ if (!ok) {
1936
+ console.log(
1937
+ `patch-surfdash: WARNING: emdash re-sync failed (exit ${status}) — CMS cards keep the stale cover; fix: kk executor run surfdash-pub --in ${syncIn} --out ${syncResult} --plugins-path ${pluginsPath}`,
1938
+ );
1939
+ } else {
1940
+ console.log('patch-surfdash: emdash rows re-synced from patched files');
1941
+ }
1942
+ }
1869
1943
  if (changed === 0) {
1870
1944
  console.log('patch-surfdash: nothing to change');
1871
1945
  return;
@@ -76,6 +76,8 @@ vars:
76
76
  # live — 发表 only on an ad-hoc wechat_live="true" run, deliberately, by hand.
77
77
  wechat_enabled: "true"
78
78
  wechat_live: "false"
79
+ # 合集 tag for the MP draft (passed to wechatmp-pub); other shows/collections override per workflow.
80
+ wechat_collection: "每日AI资讯"
79
81
  plan_max_items: "20"
80
82
  plan_translate_enabled: "true"
81
83
  article_write_enabled: "true"
@@ -191,7 +193,7 @@ states:
191
193
  # ADR-115 error tier: keep this prompt <= 1000 chars (0133 R3).
192
194
  input: |
193
195
  Read ${vars.work_dir}/21-plan/${vars.run_date}_03_plan_plan.json — metadata.docs (Doc[]) is the translated plan; don't edit it.
194
- Write a morning-news prose article in natural simplified Chinese: title line "# 每日 AI 资讯 — ${vars.run_date}", an intro paragraph (2–3 sentences), then one section per item (## numbered title, 2–4 sentences of flowing prose, source link [来源:<label>](<sourceUri>) inline), a closing paragraph tying items together.
196
+ Write a morning-news prose article in natural simplified Chinese: title line "# 每日 AI 资讯 — ${vars.run_date}", an intro paragraph (2–3 sentences), then one section per item (## numbered title, 2–4 sentences of flowing prose, source link [来源:<label>](<sourceUri>) inline), a closing paragraph tying items together. 内容合规(平台审核):全文不出现中国现任或历任党、国家、政府最高领导人的姓名,必须提及时以职务代称(如「国家主席」「国务院总理」);不书写中国国内政治相关议题。
195
197
  Narrative register — smooth transitions between items, no list-like bulleting, no scaffold labels (「可做角度」「背景」「潜在影响」), no hedging (「读者宜另行核实」「仍待验证」). Keep facts/URLs from the plan verbatim; product/model names as-is; 「」 quotes.
196
198
  Output the markdown to ${vars.work_dir}/22-article/${vars.run_date}_06_article-write_body.md.
197
199
  expectFile: ${vars.work_dir}/22-article/${vars.run_date}_06_article-write_body.md
@@ -255,7 +257,7 @@ states:
255
257
  input: |
256
258
  Read ${vars.work_dir}/21-plan/${vars.run_date}_03_plan_plan.json — a JSON array of docs; use each doc's `id`; 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 and banned phrases.
257
259
  For each doc write a `lead` (a spoken bridge into the title, not containing it) and an `insight` (why it matters, grounded only in that doc's facts).
258
- Write an `intro` hooking today's actual themes and an `outro`; no greeting or sign-off — the builder adds both.
260
+ Write an `intro` hooking today's actual themes and an `outro`; no greeting or sign-off — the builder adds both. 内容合规:不出现中国现任、历任最高领导人姓名(以职务代称),不涉及中国国内政治。
259
261
  Output only the JSON object {"version":1,"intro":"…","outro":"…","items":[{"id":"…","lead":"…","insight":"…"}]} with one item per doc id to ${vars.work_dir}/24-script/${vars.run_date}_08_script-write_plan.json.
260
262
  expectFile: ${vars.work_dir}/24-script/${vars.run_date}_08_script-write_plan.json
261
263
  allowAppend: false
@@ -355,7 +357,7 @@ states:
355
357
  input: |
356
358
  Read ${vars.work_dir}/21-plan/${vars.run_date}_03_plan_plan.json — a JSON array of news docs; don't edit it.
357
359
  Take item 1 only (its "title" and "metadata.brief") — the lead story.
358
- Write a Xiaohongshu note: "title" (≤20 chars) and "body" (Chinese note ≤1000 chars, ending with 3-5 hashtags).
360
+ Write an XHS note: "title" (≤20 chars) and "body" (Chinese note ≤1000 chars, ending with 3-5 hashtags). 不提领导人姓名,不涉国内政治。
359
361
  Write exactly ${vars.xhs_cards} cards: card 1 role "cover", the rest "content", except a final "closing". Each card carries "role", "copy" (Chinese on-card headline ≤12 chars), "points" (2-4 short Chinese bullets), "layout", "subject" (one concrete English visual scene), "sourcePoint" (the lead-story fact that card uses).
360
362
  Ground every "copy", "points" and "sourcePoint" in that story alone — no invented facts. No text, letters, numbers, logos or real people in "subject".
361
363
  Output strictly valid JSON {"title": "...", "body": "...", "cards": [...]} to ${vars.work_dir}/25-xhs/${vars.run_date}_18_xhs_plan.json.
@@ -383,7 +385,7 @@ states:
383
385
  onError: continue
384
386
  options:
385
387
  command: >-
386
- kk stage daily-publish-wechat "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}" "${vars.wechat_live}"
388
+ kk stage daily-publish-wechat "${vars.work_dir}" "${vars.run_date}" "${vars.plugins_path}" "${vars.wechat_live}" "${vars.wechat_collection}"
387
389
 
388
390
  - id: publish
389
391
  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)"
@@ -398,12 +400,12 @@ states:
398
400
  var: publish_status
399
401
 
400
402
  - id: patch-surfdash
401
- description: "20260917 #4/#6 — patch published surfdash posts: podcast cover URL into image/og_image frontmatter + podcast cross-link (zh/en/ja), commit+push"
403
+ description: "20260917 #4/#6 — patch published surfdash posts: podcast cover URL into image/og_image frontmatter + podcast cross-link (zh/en/ja), re-sync EmDash rows so CMS cards get the CDN cover (20260929), commit+push"
402
404
  onEnter:
403
405
  - kind: shell
404
406
  options:
405
407
  command: >-
406
- kk stage daily-patch-surfdash "${vars.work_dir}" "${vars.run_date}" "${vars.surfdash_root}"
408
+ kk stage daily-patch-surfdash "${vars.work_dir}" "${vars.run_date}" "${vars.surfdash_root}" "${vars.plugins_path}"
407
409
 
408
410
  - id: publish-partial
409
411
  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)"
@@ -604,6 +606,7 @@ transitions:
604
606
  - from: review
605
607
  to: failed
606
608
  description: "VoiceScript rejected -> abort"
609
+ terminalReason: failed-agent
607
610
  guard:
608
611
  kind: shell
609
612
  options:
@@ -684,9 +687,17 @@ transitions:
684
687
  guard:
685
688
  kind: always
686
689
 
690
+ - from: xhs-publish
691
+ to: wechat-publish
692
+ description: "XHS dispatched (or skipped: onError continue) -> chain the WeChat draft when wechat_enabled. 20260929 dogfood: publish-podcast's edges are first-match-wins, so the direct publish-podcast -> wechat-publish edge is unreachable whenever xhs_enabled=true — wechat must hang off the xhs path"
693
+ guard:
694
+ kind: shell
695
+ options:
696
+ command: 'test "${vars.wechat_enabled}" = "true"'
697
+
687
698
  - from: xhs-publish
688
699
  to: publish
689
- description: "XHS dispatched (or skipped: onError continue) -> classify the surfdash/podcast outcomes; XHS contributes no target to the classification"
700
+ description: "XHS dispatched (or skipped: onError continue) -> classify the surfdash/podcast outcomes; XHS contributes no target to the classification (direct edge when wechat_enabled is false)"
690
701
  guard:
691
702
  kind: always
692
703
 
@@ -735,6 +746,7 @@ transitions:
735
746
  - from: publish
736
747
  to: failed
737
748
  description: "Publish none (all targets failed or fan-out crash) -> pipeline failed"
749
+ terminalReason: failed-check
738
750
  guard:
739
751
  kind: shell
740
752
  options: