@gobing-ai/knowledge-kit 0.1.12 → 0.1.13

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 (25) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +16 -4
  3. package/package.json +1 -1
  4. package/plugins/kk/plugin.json +1 -1
  5. package/plugins/kk/publish-presets/daily-ai-voice-migration.json +3 -3
  6. package/plugins/kk/publish-presets/daily-ai-voice.json +4 -4
  7. package/plugins/kk/schedules/kk-daily-publish3-cn-peak.cron.tmpl +2 -2
  8. package/plugins/kk/scripts/daily-scheduled-run.ts +91 -41
  9. package/plugins/kk/scripts/daily-stages.ts +52 -9
  10. package/plugins/kk/skills/content-safety/references/platforms/podcast-salty.md +41 -0
  11. package/plugins/kk/skills/content-safety/references/platforms/surfdash.md +36 -0
  12. package/plugins/kk/skills/content-safety/references/platforms/xhs.md +39 -0
  13. package/plugins/kk/workflows/kk-collect.yaml +3 -3
  14. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +12 -12
  15. package/plugins/kk/workflows/kk-daily-ai-voice3.yaml +2 -2
  16. package/plugins/publishings/surfdash-pub/dist/index.js +52 -23
  17. package/plugins/publishings/surfdash-pub/src/index.ts +69 -24
  18. package/plugins/publishings/wechatmp-pub/dist/index.js +171 -30
  19. package/plugins/publishings/wechatmp-pub/src/flow.ts +287 -49
  20. package/plugins/publishings/weibo-pub/dist/index.js +100 -7
  21. package/plugins/publishings/weibo-pub/src/index.ts +111 -11
  22. package/plugins/publishings/xhs-pub/dist/index.js +164 -12
  23. package/plugins/publishings/xhs-pub/dist/profile-check.js +11 -1
  24. package/plugins/publishings/xhs-pub/src/flow.ts +194 -23
  25. package/plugins/publishings/xhs-pub/src/page.ts +26 -0
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "plugins": [
8
8
  {
9
- "version": "0.1.12",
9
+ "version": "0.1.13",
10
10
  "name": "kk",
11
11
  "source": "./plugins/kk",
12
12
  "description": "knowledge-kit — a set of toolkit for knowledge management"
package/dist/index.js CHANGED
@@ -42458,11 +42458,22 @@ function pluginEnvFromConfig(kind, name, configFile = resolveKkConfigPath()) {
42458
42458
  if (!isPlainObject5(collectionNode)) {
42459
42459
  return {};
42460
42460
  }
42461
+ const out = {};
42462
+ if (kind === "publish") {
42463
+ for (const sibling of Object.values(collectionNode)) {
42464
+ if (!isPlainObject5(sibling) || !isPlainObject5(sibling.env))
42465
+ continue;
42466
+ const ch = stringifyEnvValue(sibling.env.KK_BROWSER_CHANNEL);
42467
+ if (ch !== undefined && ch !== "") {
42468
+ out.KK_BROWSER_CHANNEL = ch;
42469
+ break;
42470
+ }
42471
+ }
42472
+ }
42461
42473
  const pluginNode = collectionNode[name];
42462
42474
  if (!isPlainObject5(pluginNode) || !isPlainObject5(pluginNode.env)) {
42463
- return {};
42475
+ return out;
42464
42476
  }
42465
- const out = {};
42466
42477
  for (const [key, value] of Object.entries(pluginNode.env)) {
42467
42478
  const str = stringifyEnvValue(value);
42468
42479
  if (str === undefined || str === "") {
@@ -53755,11 +53766,12 @@ async function runPublishMatrixWaitUntil(args, deps = {}) {
53755
53766
  const localHour = part("hour") % 24;
53756
53767
  const localMinute = part("minute");
53757
53768
  const already = localHour * 60 + localMinute;
53758
- if (already >= hours * 60 + minutes) {
53769
+ const targetMinutes = hours * 60 + minutes;
53770
+ if (already >= targetMinutes || targetMinutes - already > 180) {
53759
53771
  echo(`publish-matrix-wait-until: ${zone} is already past ${target}`);
53760
53772
  return 0;
53761
53773
  }
53762
- let wait = (hours * 60 + minutes - already) * 60000;
53774
+ let wait = (targetMinutes - already) * 60000;
53763
53775
  const delta = offsetAt(now() + wait) - offsetAt(now());
53764
53776
  wait -= delta * 60000;
53765
53777
  if (wait <= 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/knowledge-kit",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "An ingest → create → publish content pipeline CLI (Bun).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "kk",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "knowledge-kit Claude Code plugin: skills, commands, subagents, hooks, and rules"
5
5
  }
@@ -29,7 +29,7 @@
29
29
  "variant": "episode",
30
30
  "tier": 1,
31
31
  "window": "after-generate",
32
- "mode": "draft"
32
+ "mode": "off"
33
33
  },
34
34
  {
35
35
  "id": "xhs.podcast",
@@ -59,7 +59,7 @@
59
59
  "variant": "article",
60
60
  "tier": 2,
61
61
  "window": "after-generate",
62
- "mode": "dummy"
62
+ "mode": "draft"
63
63
  },
64
64
  {
65
65
  "id": "xhs.cards",
@@ -86,7 +86,7 @@
86
86
  "variant": "article",
87
87
  "tier": 2,
88
88
  "window": "cn-peak",
89
- "mode": "draft"
89
+ "mode": "off"
90
90
  },
91
91
  {
92
92
  "id": "x.post",
@@ -26,7 +26,7 @@
26
26
  "variant": "episode",
27
27
  "tier": 1,
28
28
  "window": "after-generate",
29
- "mode": "draft"
29
+ "mode": "off"
30
30
  },
31
31
  {
32
32
  "plugin": "xhs-pub",
@@ -53,7 +53,7 @@
53
53
  "variant": "article",
54
54
  "tier": 2,
55
55
  "window": "after-generate",
56
- "mode": "dummy"
56
+ "mode": "draft"
57
57
  },
58
58
  {
59
59
  "plugin": "xhs-pub",
@@ -61,7 +61,7 @@
61
61
  "variant": "cards",
62
62
  "tier": 3,
63
63
  "window": "after-generate",
64
- "mode": "off"
64
+ "mode": "draft"
65
65
  },
66
66
  {
67
67
  "plugin": "weibo-pub",
@@ -77,7 +77,7 @@
77
77
  "variant": "article",
78
78
  "tier": 2,
79
79
  "window": "cn-peak",
80
- "mode": "draft"
80
+ "mode": "off"
81
81
  },
82
82
  {
83
83
  "plugin": "x-pub",
@@ -7,7 +7,7 @@
7
7
  #
8
8
  # `live_channels` is baked in: this installed line IS the operator's ADR-022 consent for a live
9
9
  # send. Nothing in the workflow or the preset can produce `live` on its own. The window is
10
- # consent-scoped to tokens with a cn-peak row (weibo-pub, x-pub — both draft). Every live launch
10
+ # consent-scoped to tokens with a cn-peak row (weibo-pub). Every live launch
11
11
  # stops at the `send-confirm` gate and waits for the operator's manual resume; the entry never
12
12
  # delivers unattended.
13
13
  #
@@ -27,4 +27,4 @@
27
27
  #
28
28
  # `run_date` is computed at tick time in America/Los_Angeles by the launcher: a run that starts
29
29
  # at 17:00 PDT is still "today" for the day dir the 04:30 generation wrote.
30
- 0 17 * * * cd {{repo}} && mkdir -p .works/kk-daily-publish3 && PATH="{{home}}/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" TZ=America/Los_Angeles bun {{repo}}/plugins/kk/scripts/voice3-publish-window.ts --window cn-peak --preset daily-ai-voice --live-channels weibo-pub,x-pub >> {{repo}}/.works/kk-daily-publish3/cn-peak.log 2>&1
30
+ 0 17 * * * cd {{repo}} && mkdir -p .works/kk-daily-publish3 && PATH="{{home}}/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" TZ=America/Los_Angeles bun {{repo}}/plugins/kk/scripts/voice3-publish-window.ts --window cn-peak --preset daily-ai-voice --live-channels weibo-pub >> {{repo}}/.works/kk-daily-publish3/cn-peak.log 2>&1
@@ -16,6 +16,9 @@ const { values } = parseArgs({
16
16
  'retry-agent': { type: 'string', default: 'claude' },
17
17
  'required-channels': { type: 'string', default: 'wechat,locales,cms-resync' },
18
18
  'wechat-live': { type: 'boolean', default: false },
19
+ workflow: { type: 'string', default: 'kk-daily-ai-voice2' },
20
+ window: { type: 'string' },
21
+ 'publish-preset': { type: 'string' },
19
22
  },
20
23
  });
21
24
  const timeoutMs = Number(values['timeout-ms']);
@@ -31,15 +34,28 @@ const workDir = join(resolve(values['works-dir'] ?? join(repo, '.works')), 'kk-d
31
34
  const receiptPath = resolve(values.receipt ?? join(workDir, 'schedule-receipt.json'));
32
35
  const startedAt = now.toISOString();
33
36
  mkdirSync(dirname(receiptPath), { recursive: true });
34
- const vars = {
35
- publish_enabled: 'true',
36
- skip_review: 'true',
37
- agent: values.agent,
38
- agent_retry: values['retry-agent'],
39
- wechat_live: values['wechat-live'] === true ? 'true' : 'false',
40
- work_dir: workDir,
41
- run_date: stamp,
42
- };
37
+ // voice3 shares the voice2 workspace (F08); only the vars and the publish seam differ.
38
+ const isVoice3 = values.workflow === 'kk-daily-ai-voice3';
39
+ const vars = isVoice3
40
+ ? {
41
+ auto_publish: 'true',
42
+ ...(values['publish-preset'] !== undefined ? { publish_preset: values['publish-preset'] } : {}),
43
+ ...(values.window ? { window: values.window } : {}),
44
+ skip_review: 'true',
45
+ agent: values.agent,
46
+ agent_retry: values['retry-agent'],
47
+ work_dir: workDir,
48
+ run_date: stamp,
49
+ }
50
+ : {
51
+ publish_enabled: 'true',
52
+ skip_review: 'true',
53
+ agent: values.agent,
54
+ agent_retry: values['retry-agent'],
55
+ wechat_live: values['wechat-live'] === true ? 'true' : 'false',
56
+ work_dir: workDir,
57
+ run_date: stamp,
58
+ };
43
59
  writeFileSync(receiptPath, JSON.stringify({ runId, startedAt, status: 'running', workDir }, null, 2));
44
60
  let interrupted = false;
45
61
  let timedOut = false;
@@ -51,7 +67,7 @@ const child = spawn(
51
67
  '--no-logo',
52
68
  'workflow',
53
69
  'run',
54
- 'plugins/kk/workflows/kk-daily-ai-voice2.yaml',
70
+ `plugins/kk/workflows/${values.workflow}.yaml`,
55
71
  '--run-id',
56
72
  runId,
57
73
  '--vars',
@@ -135,7 +151,9 @@ if (child.pid !== undefined && (timedOut || interrupted || exitCode !== 0)) {
135
151
  await Bun.sleep(100);
136
152
  }
137
153
  const workflowStatus = timedOut ? 'timed-out' : interrupted ? 'interrupted' : exitCode === 0 ? 'done' : 'failed';
138
- const primaryPublishStatus = readOptional(join(workDir, '31-publish', `${stamp}_17_publish_publish-status.txt`));
154
+ const primaryPublishStatus = isVoice3
155
+ ? readOptional(join(workDir, '4-publish', 'publish-status.after-generate.txt'))
156
+ : readOptional(join(workDir, '31-publish', `${stamp}_17_publish_publish-status.txt`));
139
157
  const runSummary = readOptional(join(workDir, 'run-report.summary.txt'));
140
158
  const cleanup: { command: string; exitCode: number | null }[] = [];
141
159
  let reservationRecovery: { status: string; reason: string; released?: number } = {
@@ -168,21 +186,23 @@ if (workflowStatus !== 'done' || runSummary?.startsWith('no-content:')) {
168
186
  run?: { runId?: string; workflowName?: string; status?: string; startedAt?: string };
169
187
  events?: { kind?: string; node?: string; status?: string }[];
170
188
  };
171
- const sendNodes = new Set([
172
- 'publish-surfdash',
173
- 'publish-podcast',
174
- 'xhs-publish',
175
- 'wechat-publish',
176
- 'translate-localize',
177
- 'translate',
178
- 'translate-retry',
179
- 'translate-sync',
180
- 'publish-classify',
181
- ]);
189
+ const sendNodes = isVoice3
190
+ ? new Set(['publish', 'send', 'send-retry', 'repair', 'send-confirm'])
191
+ : new Set([
192
+ 'publish-surfdash',
193
+ 'publish-podcast',
194
+ 'xhs-publish',
195
+ 'wechat-publish',
196
+ 'translate-localize',
197
+ 'translate',
198
+ 'translate-retry',
199
+ 'translate-sync',
200
+ 'publish-classify',
201
+ ]);
182
202
  const validTrace =
183
203
  trace.status === 0 &&
184
204
  parsed.run?.runId === runId &&
185
- parsed.run?.workflowName === 'kk-daily-ai-voice2' &&
205
+ parsed.run?.workflowName === values.workflow &&
186
206
  parsed.run?.status === (workflowStatus === 'done' ? 'done' : 'failed') &&
187
207
  Date.parse(parsed.run.startedAt ?? '') >= Date.parse(startedAt) - 1000 &&
188
208
  Date.parse(parsed.run.startedAt ?? '') <= Date.now() &&
@@ -227,7 +247,7 @@ if (workflowStatus !== 'done' || runSummary?.startsWith('no-content:')) {
227
247
  // potentially aired story. Keep the reservation and the original failure visible.
228
248
  }
229
249
  }
230
- const ledger = spawnSync('kk', ['ledger', 'sync', '--workflow', 'kk-daily-ai-voice2', '--work-dir', workDir], {
250
+ const ledger = spawnSync('kk', ['ledger', 'sync', '--workflow', values.workflow, '--work-dir', workDir], {
231
251
  cwd: repo,
232
252
  encoding: 'utf-8',
233
253
  timeout: 20000,
@@ -244,33 +264,63 @@ const requiredChannels = values['required-channels']
244
264
  .split(',')
245
265
  .map((channel) => channel.trim())
246
266
  .filter(Boolean);
247
- let channelCompleteness: { channel: string; state: string; detail?: string }[] = [];
267
+ let channelCompleteness: { channel: string; state: string; detail?: string; required?: boolean; dummy?: boolean }[] =
268
+ [];
248
269
  try {
249
- const parsed = JSON.parse(
250
- readOptional(join(workDir, '31-publish', `${stamp}_17_publish_completeness.json`)) ?? 'null',
251
- ) as { rows?: unknown } | null;
252
- if (Array.isArray(parsed?.rows)) {
253
- channelCompleteness = parsed.rows.filter(
254
- (row): row is { channel: string; state: string; detail?: string } =>
255
- row !== null &&
256
- typeof row === 'object' &&
257
- typeof row.channel === 'string' &&
258
- typeof row.state === 'string',
259
- );
270
+ if (isVoice3) {
271
+ const outcome = JSON.parse(
272
+ readOptional(join(workDir, '4-publish', 'outcome.after-generate.json')) ?? 'null',
273
+ ) as { rows?: unknown } | null;
274
+ if (Array.isArray(outcome?.rows)) {
275
+ channelCompleteness = outcome.rows.flatMap(
276
+ (row): { channel: string; state: string; required: boolean; dummy: boolean }[] => {
277
+ if (row === null || typeof row !== 'object') return [];
278
+ const r = row as { id?: unknown; required?: unknown; dummy?: unknown; status?: unknown };
279
+ if (typeof r.id !== 'string' || typeof r.status !== 'string') return [];
280
+ return [
281
+ {
282
+ channel: r.id,
283
+ state: r.status === 'succeeded' ? 'done' : r.status,
284
+ required: r.required === true,
285
+ dummy: r.dummy === true,
286
+ },
287
+ ];
288
+ },
289
+ );
290
+ }
291
+ } else {
292
+ const parsed = JSON.parse(
293
+ readOptional(join(workDir, '31-publish', `${stamp}_17_publish_completeness.json`)) ?? 'null',
294
+ ) as { rows?: unknown } | null;
295
+ if (Array.isArray(parsed?.rows)) {
296
+ channelCompleteness = parsed.rows.filter(
297
+ (row): row is { channel: string; state: string; detail?: string } =>
298
+ row !== null &&
299
+ typeof row === 'object' &&
300
+ typeof row.channel === 'string' &&
301
+ typeof row.state === 'string',
302
+ );
303
+ }
260
304
  }
261
305
  } catch {}
262
- const missingRequiredChannels = requiredChannels.filter((channel) => {
263
- const rows = channelCompleteness.filter((row) => row.channel === channel);
264
- return rows.length !== 1 || rows[0]?.state !== 'done';
265
- });
306
+ const missingRequiredChannels = isVoice3
307
+ ? channelCompleteness
308
+ .filter((row) => row.required === true && row.dummy !== true)
309
+ .filter((row) => row.state !== 'done')
310
+ .map((row) => row.channel)
311
+ : requiredChannels.filter((channel) => {
312
+ const rows = channelCompleteness.filter((row) => row.channel === channel);
313
+ return rows.length !== 1 || rows[0]?.state !== 'done';
314
+ });
266
315
  // A terminal green workflow can mean "no content" or failed delivery. Make that observable to
267
316
  // the scheduler instead of equating the workflow's process exit with a published edition.
317
+ // A voice3 outcome classified 'full' already proves every non-dummy row succeeded (0326 R2).
268
318
  const status =
269
319
  workflowStatus !== 'done'
270
320
  ? workflowStatus
271
321
  : runSummary?.startsWith('no-content:')
272
322
  ? 'no-content'
273
- : primaryPublishStatus === 'full' && missingRequiredChannels.length === 0
323
+ : primaryPublishStatus === 'full' && !(!isVoice3 && missingRequiredChannels.length > 0)
274
324
  ? 'done'
275
325
  : 'incomplete';
276
326
  const receipt = {
@@ -1726,7 +1726,15 @@ export function buildWechatArticleContent(
1726
1726
  return {
1727
1727
  ...article,
1728
1728
  body: options.appendOutro === false ? body : `${body}${WECHAT_OUTRO}`,
1729
- metadata: { ...metadataOf(article), publishStatus: options.publishStatus, wechatmp },
1729
+ metadata: {
1730
+ ...metadataOf(article),
1731
+ // wechatmp-pub's 播客 operation resolves the episode mp3 through the generic media key
1732
+ // `metadata.audio` (`modes.ts` resolveMediaPath), while the 文章 audio-embed rail keeps
1733
+ // reading `wechatmp.audioPath` — carry both spellings so one variant serves both rows.
1734
+ ...(options.audioPath === undefined ? {} : { audio: options.audioPath }),
1735
+ publishStatus: options.publishStatus,
1736
+ wechatmp,
1737
+ },
1730
1738
  };
1731
1739
  }
1732
1740
 
@@ -1800,9 +1808,24 @@ export function buildPodcastContent(
1800
1808
  *
1801
1809
  * surfdash is the article's own publisher, so it consumes what `22-article` already composed — the
1802
1810
  * builder exists so the variant set is uniform, not because it reshapes anything.
1811
+ *
1812
+ * surfdash-pub's `post` operation refuses a digest post without `metadata.coverImage`
1813
+ * (surfdash-pub/src/index.ts), so the run's cover rides along exactly as the episode/wechat
1814
+ * builders stamp it. voice2's prepare mode never exercised this gate; the frozen-bundle matrix
1815
+ * send surfaced it on 2026-11-05 (empty run → 'digest post has no cover image').
1803
1816
  */
1804
- export function buildSurfdashContent(article: Record<string, unknown>, _links: PublishLinks): Record<string, unknown> {
1805
- return { ...article };
1817
+ export function buildSurfdashContent(
1818
+ article: Record<string, unknown>,
1819
+ _links: PublishLinks,
1820
+ options: { coverPath?: string } = {},
1821
+ ): Record<string, unknown> {
1822
+ return {
1823
+ ...article,
1824
+ metadata: {
1825
+ ...metadataOf(article),
1826
+ ...(options.coverPath === undefined ? {} : { coverImage: options.coverPath }),
1827
+ },
1828
+ };
1806
1829
  }
1807
1830
 
1808
1831
  /**
@@ -1933,7 +1956,7 @@ export function dailyBundleVariants(args: string[]): void {
1933
1956
  const cardsPath = join(workDir, '25-xhs', 'content.json');
1934
1957
  const coverPath = join(workDir, '23-cover', `${runDate}_07_cover_cover.png`);
1935
1958
  const socialDir = join(workDir, '26-social');
1936
- const cover = coverEnabledArg !== 'false' && isFile(coverPath) ? coverPath : undefined;
1959
+ const cover = coverEnabledArg !== 'false' && isFile(coverPath) ? resolve(coverPath) : undefined;
1937
1960
  const links = PLACEHOLDER_LINKS;
1938
1961
  const written: string[] = [];
1939
1962
  const skipped: string[] = [];
@@ -1950,8 +1973,8 @@ export function dailyBundleVariants(args: string[]): void {
1950
1973
  // shared copy must exist for the channels the preset does not specialise.
1951
1974
  if (article === null) skipped.push('article (no 22-article content)');
1952
1975
  else {
1953
- write('article', buildSurfdashContent(article, links));
1954
- write('article@surfdash-pub', buildSurfdashContent(article, links));
1976
+ write('article', buildSurfdashContent(article, links, { coverPath: cover }));
1977
+ write('article@surfdash-pub', buildSurfdashContent(article, links, { coverPath: cover }));
1955
1978
  write('article@xhs-pub', buildXhsArticleContent(article, { publishStatus: 'draft' }));
1956
1979
  write(
1957
1980
  'article@wechatmp-pub',
@@ -3618,9 +3641,13 @@ export function dailySafetyPrep(args: string[]): void {
3618
3641
  if (isFile(scriptPath))
3619
3642
  sted('write', snapshot, () => writeRaw(snapshot, readFileSync(scriptPath, 'utf-8')));
3620
3643
  } else {
3621
- // The remediation budget belongs to this scope's attempt, not to the day.
3644
+ // The remediation budget belongs to this scope's attempt, not to the day. The machine
3645
+ // re-enters this state once per remediation loop, so initializing unconditionally
3646
+ // reset the budget every cycle and the loop could never classify its residue
3647
+ // (20261105 dogfood: the nested variants review revised forever). Like the drafts
3648
+ // scope, only the FIRST entry initializes.
3622
3649
  const counter = scopeCounter(workDir, scopeDir);
3623
- sted('write', counter, () => writeFileSync(counter, '0\n'));
3650
+ if (!isFile(counter)) sted('write', counter, () => writeFileSync(counter, '0\n'));
3624
3651
  }
3625
3652
  for (const name of [
3626
3653
  'safety-review_report.json',
@@ -3693,7 +3720,14 @@ export function dailySafetyRevise(args: string[]): void {
3693
3720
  if (!drafts) {
3694
3721
  // Variants scope: every manifest-listed file must stay parseable Content with the
3695
3722
  // fields the publish half reads; the structural VoiceScript machinery does not apply.
3696
- for (const entry of Object.values(readManifest(workDir).variants)) {
3723
+ // Refresh each entry's sha256 to the post-remediation bytes: the manifest is the
3724
+ // contract the request/digest/stamp resolve through, so leaving the pre-remediation
3725
+ // digests in place made every re-review report a request-integrity mismatch and the
3726
+ // loop spin (20261105 dogfood). Structural drift above the hash level stays owned by
3727
+ // daily-bundle-variants (never run here).
3728
+ const manifestPath = join(workDir, SAFETY_VARIANTS_DIR, 'manifest.json');
3729
+ const manifest = readManifest(workDir);
3730
+ for (const [key, entry] of Object.entries(manifest.variants)) {
3697
3731
  const path = join(workDir, entry.path);
3698
3732
  const value = requireJson(path, 'safety-revise') as {
3699
3733
  title?: unknown;
@@ -3704,7 +3738,16 @@ export function dailySafetyRevise(args: string[]): void {
3704
3738
  failWith(1, `safety-revise: variant ${path} ${field} must be a non-empty string`);
3705
3739
  }
3706
3740
  }
3741
+ entry.sha256 = createHash('sha256').update(readFileSync(path)).digest('hex');
3742
+ manifest.variants[key] = entry;
3707
3743
  }
3744
+ sted('write', manifestPath, () =>
3745
+ writeJsonText(manifestPath, {
3746
+ schemaVersion: manifest.schemaVersion,
3747
+ runDate: manifest.runDate,
3748
+ variants: manifest.variants,
3749
+ }),
3750
+ );
3708
3751
  const counter = scopeCounter(workDir, scopeDir);
3709
3752
  const next = readScopeCounter(workDir, scopeDir) + 1;
3710
3753
  sted('write', counter, () => writeFileSync(counter, `${next}\n`));
@@ -0,0 +1,41 @@
1
+ # Salty Podcast (podcast-salty) — platform reference
2
+
3
+ `podcast.salty.vip` 是本项目自有的播客站点(Robin Min 自有域名,microfeed 实例,节目「每日AI乱炖」,RSS `/rss/`)。
4
+ 这是**自有渠道**,不是第三方分发平台:没有平台审核队列、字级词表或收听量干预可引用。
5
+ 本文件存在的意义是把「自有播客宿主没有平台规则」这件事写清楚,避免审核流程把「无三方平台规则」
6
+ 误判成证据缺口并默认 BLOCK。
7
+
8
+ | Field | Value |
9
+ |-------|-------|
10
+ | Official source | 自有站点,无第三方协议页;本文件由站点维护者(Robin Min)本人核定 |
11
+ | Scope | `podcast.salty.vip` 节目标题、音频、show notes、封面(RSS 输出随 item 状态变化) |
12
+ | Effective date | 本文件创建日即生效(自有平台规则由维护者裁定) |
13
+ | Last verified | 2026-11-05(创建) |
14
+ | Evidence status | 维护者本人口径,非三方引用;无字级禁用词表 |
15
+
16
+ ## Evidence distinctions
17
+
18
+ - **维护者裁定(本文件的权威来源)**:播客节目是自有域名下的小型日更节目,采用与本项目其他
19
+ 渠一致的通用内容纪律;没有平台方提供的可引用条款。
20
+ - **与 apple-podcasts 的边界(AP-06 不在此生效)**:Apple Podcasts 目录收录是第三方目录的准入
21
+ 问题,由维护者人工决定,不由本 pipeline 控制;本 host 的 RSS 只是自有分发的出口。对未核实
22
+ 的估值/数字,节目文案应保持项目既有的 hedging 与出处注明(这是事实纪律,不是平台准入规则),
23
+ 但**不要**因为「聚合页转述未读原文」而 block——那属于 sourcing note,由维护者自行复核。
24
+ - **通用纪律(沿用技能默认)**:AI 声明、来源可靠性、隐私/PII、医疗/投资类内容红线全部沿用
25
+ 技能 default;本文件不新增平台规则。
26
+
27
+ ## Review items
28
+
29
+ | ID | Item | Grade |
30
+ |----|------|-------|
31
+ | PS-01 | 来源诚实性:show notes 中的对外引用(媒体/论文/Blog)须给出真实出处;转述链保持既有 hedging;未读原文的聚合转述记 note,不 push block。 | note |
32
+ | PS-02 | 数字与事实核对:与源链接一致的数字/日期/机构名冲突时按技能通用规则处理(correctable → revise;冲突无解 → block)。 | note→judge |
33
+ | PS-03 | 可识别个人信息(听众或第三方的私人信息) | desensitize→block if unfixable |
34
+ | PS-04 | 医疗/投资劝诱内容技能默认红线不变(未核实估值→保持 hedging 并注明出处,而非删除条目)。 | default |
35
+
36
+ ## Open questions / escalation
37
+
38
+ - 自有播客无第三方审核 → 本文件不应产生 `evidenceGap: true` 类 BLOCK;任何「podcast-salty 无
39
+ reference 文件」的发现都指向本文件缺失,而不是内容风险。
40
+ - 若未来节目上线商业化、赞助或接入第三方目录(Apple/小宇宙实际分配),本文件需要为相应渠道
41
+ 追加准入条款。
@@ -0,0 +1,36 @@
1
+ # Salty Talk / Surfing (surfdash) — platform reference
2
+
3
+ `surfing.salty.vip` 是本项目自有的静态发布站点(Robin Min 自有域名,栏目「AI 乱炖」,zh/en/ja 三语)。
4
+ 这是**自有渠道**,不是第三方分发平台:没有平台审核队列、字级词表或 AI 标注接口可引用。
5
+ 本文件存在的意义是把「自有站点没有平台规则」这件事写清楚,避免审核流程把「无三方平台规则」
6
+ 误判成证据缺口并默认 BLOCK。
7
+
8
+ | Field | Value |
9
+ |-------|-------|
10
+ | Official source | 自有站点,无第三方协议页;本文件由站点维护者(Robin Min)本人核定 |
11
+ | Scope | `surfing.salty.vip` 文章页(正文、frontmatter 的 title/description/key_points/cover) |
12
+ | Effective date | 本文件创建日即生效(自有平台规则由维护者裁定) |
13
+ | Last verified | 2026-11-05(创建) |
14
+ | Evidence status | 维护者本人口径,非三方引用;无字级禁用词表 |
15
+
16
+ ## Evidence distinctions
17
+
18
+ - **维护者裁定(本文件的权威来源)**:站点是自有域名下的小型技术/资讯栏目,采用与本项目其他
19
+ 台一致的通用内容纪律;没有平台方提供的可引用条款。
20
+ - **通用纪律(沿用技能默认)**:披露/AI 声明、来源可靠性、隐私/PII、医疗/投资类内容红线全部
21
+ 沿用技能 default;本文件不新增平台规则。
22
+
23
+ ## Review items
24
+
25
+ | ID | Item | Grade |
26
+ |----|------|-------|
27
+ | SD-01 | 来源诚实性:对外引用(媒体/论文/Blog)须给出真实出处链接;「综述性转述未读原文」是 sourcing gap,按 note 记录并在站点正文保持既有 hedging;**不要**因为转述来自聚合页/未读原文而 block——这与 apple-podcasts 的 AP-06 严格度不同,自有站点由维护者自行判断并复核。 | note |
28
+ | SD-02 | 数字与事实核对:与源链接一致的数字/日期/机构名出现冲突时按技能通用规则处理(correctable → revise;冲突无解 → block)。 | note→judge |
29
+ | SD-03 | 可识别个人信息(听众或第三方的私人信息、联系方式、住址等) | desensitize→block if unfixable |
30
+ | SD-04 | roku/医疗/投资劝诱内容技能默认红线不变(本文件不放开任何默认红线)。 | default |
31
+
32
+ ## Open questions / escalation
33
+
34
+ - 自有站点无第三方审核 → 本文件不应产生 `evidenceGap: true` 类 BLOCK;任何「surfdash 无 reference
35
+ 文件」的发现都指向本文件缺失,而不是内容风险。
36
+ - 若未来站点引入广告或国家安全审查类规则,本文件需要更新。
@@ -0,0 +1,39 @@
1
+ # 小红书 (Xiaohongshu / xhs) — platform reference
2
+
3
+ Review-item IDs here are **this skill's bookkeeping numbers**, not official platform rule codes.
4
+ 与 xiaoyuzhou.md 同一格式约定:这是本技能基于操作者已核验证据的保守立场,不是平台条文。
5
+
6
+ | Field | Value |
7
+ |-------|-------|
8
+ | Official source | <https://www.xiaohongshu.com/community/>(社区公约/创作细则入口,多级页面) |
9
+ | Scope | 笔记(图文/视频长文)、封面图、话题标签、昵称简介 |
10
+ | Effective date | unknown(社区公约页面未标注可引用的版本生效日期) |
11
+ | Last verified | 2026-11-05(写本文件时按操作者已有账号审查经验整理,未逐字读官方更新后全文) |
12
+ | Evidence status | **未获官方逐字条文**;以下审核项是本技能的保守立场,不是平台条文;与小宇宙 0189 事件类似,任何具体下架理由以官方为主,本技能按保守项判断 |
13
+
14
+ ## Evidence distinctions
15
+
16
+ - **官方规则(已知存在,未逐字核对)**:小红书有《社区公约》与《创作规范》,对违法违规、医疗
17
+ 健康、财产投资、隐私、低质同质化、AI 生成内容有原则性条款;公开文本不提供逐字词表。
18
+ - **操作者经验(保守立场来源)**:账号为个人技术分享(栏目「AI 乱炖」衍生),非新闻机构、无
19
+ 医疗/金融资质、不带货;发帖以图文长文/播客预告为主。
20
+ - **编辑建议(本技能)**:以下审核项是本技能基于上述证据的保守立场,不是平台条文。
21
+
22
+ ## Review items
23
+
24
+ | ID | Item | Grade |
25
+ |----|------|-------|
26
+ | XH-01 | 资质口径:不得呈现「新闻资讯播报」形态或暗示持有新闻资质(技术/科普类分享不受影响;同 xiaoyuzhou.md XY-01 的操作者裁定 2026-10-10,按 note 记录,不逐集 escalate)。 | note |
27
+ | XH-02 | 医疗健康/药品/诊疗建议:以医疗结论、疗效、用药指导为主线的内容(本栏目主题为 AI 技术/产业资讯,正常不会触发;一旦触发按 block)。 | block |
28
+ | XH-03 | 投资/理财:以个股买卖建议、收益承诺为主线的内容(对 quadrature/Anthropic 等估值表述属于普通事实转述,不是建议;命中后再 judge)。 | block |
29
+ | XH-04 | AI 生成内容标识:平台对 AI 生成内容有标注要求(** 未逐字核对当前接口规则,按公共常见要求保守处理**)。本栏目发布为「AI 辅助创作」的播客预告/图文 —— 操作者已在账号简介披露 AI 辅助;单条内容不再逐条 escalate。 | note |
30
+ | XH-05 | 可识别个人信息(听众或第三方的私人信息、联系方式、住址等) | desensitize→block if unfixable |
31
+ | XH-06 | 时政/公共事务主线:同 xiaoyuzhou.md XY-02(政策管制、军事、公共政策争议为主线的内容按 block)。 | block |
32
+
33
+ ## Open questions / escalation
34
+
35
+ - 无官方字级词表 → 任何「词命中即下架」的断言都不成立;同理,无词表不等于安全。
36
+ - AI 标注、内容审核新规变化频繁 → 每次提审前按 XH-04 复核;`RISK` 或证据缺口一律人工复审
37
+ (`escalate: true`),不得静默 PASS。
38
+ - 资质口径、时政主线与个人信息的红线全部沿用技能默认,本文件只 diminute XH-04 的逐条 escalate
39
+ (业务上固定披露已存在)。
@@ -48,10 +48,10 @@ states:
48
48
  onEnter:
49
49
  - kind: shell
50
50
  options:
51
- # Reporting only: a ledger failure must never abort a tick before the lock is even taken.
52
- onError: continue
53
51
  command: >-
54
52
  kk ledger sync --workflow kk-collect
53
+ # Reporting only: a ledger failure must never abort a tick before the lock is even taken.
54
+ onError: continue
55
55
  - kind: shell
56
56
  options:
57
57
  # stamp: the pinned var, else the UTC minute (R4). work_dir: `.works/kk-collect/<stamp>` (R4),
@@ -92,13 +92,13 @@ states:
92
92
  # revocation warns (captured:false) and only disables Board previews.
93
93
  # onError: continue keeps the lock-releasing failure path intact instead of a hard abort.
94
94
  options:
95
- onError: continue
96
95
  command: >-
97
96
  kk board capture --workflow kk-collect --run "${vars.__runId}"
98
97
  --work-dir "${vars.work_dir}" --stamp "${vars.stamp}"
99
98
  --definition-digest "${vars.__definitionDigest}"
100
99
  --setting "limit=${vars.limit}" --setting "horizon_hours=${vars.horizon_hours}"
101
100
  > "${vars.work_dir}/board-capture.json"
101
+ onError: continue
102
102
  - kind: shell
103
103
  # onError: continue on both ingestion actions, with the artifact guard below as the positive
104
104
  # success contract: a state-machine aborts the run on a failed action without taking a