@gobing-ai/knowledge-kit 0.0.11 → 0.0.12

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 (42) hide show
  1. package/dist/index.js +363 -138
  2. package/package.json +1 -1
  3. package/plugins/generations/content-gen/src/storm.ts +99 -50
  4. package/plugins/generations/core-facts-gen/package.json +17 -0
  5. package/plugins/generations/core-facts-gen/plugin.json +7 -0
  6. package/plugins/generations/core-facts-gen/src/index.ts +116 -0
  7. package/plugins/generations/core-facts-gen/tsconfig.json +4 -0
  8. package/plugins/generations/daily-article-gen/package.json +17 -0
  9. package/plugins/generations/daily-article-gen/plugin.json +7 -0
  10. package/plugins/generations/daily-article-gen/src/index.ts +91 -0
  11. package/plugins/generations/daily-article-gen/tsconfig.json +4 -0
  12. package/plugins/generations/dailynews-gen/src/index.ts +11 -0
  13. package/plugins/generations/dailynews-gen/src/script-builder.ts +1 -1
  14. package/plugins/generations/episode-plan-gen/package.json +17 -0
  15. package/plugins/generations/episode-plan-gen/plugin.json +7 -0
  16. package/plugins/generations/episode-plan-gen/src/index.ts +726 -0
  17. package/plugins/generations/episode-plan-gen/tsconfig.json +4 -0
  18. package/plugins/generations/voice-gen/src/index.ts +102 -11
  19. package/plugins/generations/voice-gen/src/qc.ts +154 -9
  20. package/plugins/ingestions/aihot-ingest/plugin.json +1 -1
  21. package/plugins/ingestions/aihot-ingest/src/index.ts +72 -13
  22. package/plugins/ingestions/aihot-ingest/src/mapper.ts +1 -0
  23. package/plugins/ingestions/aihot-ingest/src/rss.ts +151 -0
  24. package/plugins/ingestions/horizon-ingest/package.json +17 -0
  25. package/plugins/ingestions/horizon-ingest/plugin.json +7 -0
  26. package/plugins/ingestions/horizon-ingest/src/index.ts +205 -0
  27. package/plugins/ingestions/horizon-ingest/tsconfig.json +4 -0
  28. package/plugins/ingestions/last30days-ingest/package.json +17 -0
  29. package/plugins/ingestions/last30days-ingest/plugin.json +7 -0
  30. package/plugins/ingestions/last30days-ingest/src/index.ts +148 -0
  31. package/plugins/ingestions/last30days-ingest/tsconfig.json +4 -0
  32. package/plugins/kk/skills/taste-unslop/SKILL.md +12 -6
  33. package/plugins/kk/skills/taste-unslop/references/pattern-guide.md +128 -48
  34. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +432 -19
  35. package/plugins/publishings/podcast-pub/package.json +17 -0
  36. package/plugins/publishings/podcast-pub/plugin.json +7 -0
  37. package/plugins/publishings/podcast-pub/src/index.ts +538 -0
  38. package/plugins/publishings/podcast-pub/src/map.ts +165 -0
  39. package/plugins/publishings/podcast-pub/src/microfeed-client.ts +196 -0
  40. package/plugins/publishings/podcast-pub/src/show-notes.ts +132 -0
  41. package/plugins/publishings/podcast-pub/tsconfig.json +4 -0
  42. package/plugins/publishings/surfdash-pub/src/index.ts +328 -62
@@ -1,16 +1,42 @@
1
- import { dirname, join } from 'node:path';
1
+ import { existsSync } from 'node:fs';
2
+ import { copyFile, cp } from 'node:fs/promises';
3
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
2
4
  import { parseArgs } from 'node:util';
3
5
  import { type Content, ContentSchema, type Result, ResultSchema } from '@gobing-ai/kk-core';
4
6
  import type { PublishTransport, PublishTransportPayload } from '@gobing-ai/publish-harness';
5
7
  import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
6
8
  import { echoError } from '@gobing-ai/ts-utils';
7
9
 
10
+ /** Result target name for this plugin. */
11
+ const TARGET = 'surfdash';
12
+
13
+ /** Management operations routed via `options.operation` (L1 R7; pattern from podcast-pub). */
14
+ const OPERATIONS = ['publish', 'scaffold-locale', 'sync'] as const;
15
+ type Operation = (typeof OPERATIONS)[number];
16
+
17
+ /** Locales `scaffold-locale` can scaffold; LLM translation stays in the workflow's agent step. */
18
+ const SCAFFOLD_LOCALES = ['en', 'ja'] as const;
19
+ type ScaffoldLocale = (typeof SCAFFOLD_LOCALES)[number];
20
+
21
+ /** Target-specific options carried on the invoke payload's `options` channel. */
22
+ interface SurfdashPubOptions {
23
+ operation?: unknown;
24
+ /** zh `index.md` absolute path inside the surfdash tree; required for `scaffold-locale` and `sync`. */
25
+ postPath?: unknown;
26
+ /** Draft locale to scaffold; required for `scaffold-locale`. */
27
+ target?: unknown;
28
+ }
29
+
8
30
  /**
9
31
  * Options for mapping Content into Surfing Markdown with frontmatter.
10
32
  */
11
33
  export interface MapContentOptions {
12
34
  /** Target publishing site name or base URL. Defaults to 'surfdash'. */
13
35
  targetName?: string;
36
+ /** Frontmatter image value (e.g. `./assets/cover.png`); written to `image` and `og_image`. */
37
+ image?: string;
38
+ /** ISO date for publishDate; defaults to today. */
39
+ publishDate?: string;
14
40
  }
15
41
 
16
42
  /**
@@ -27,7 +53,7 @@ export function slugify(str: string): string {
27
53
  /**
28
54
  * Maps Content object into Surfing frontmatter and body per the publish spec.
29
55
  */
30
- export function mapContentToSurfingMarkdown(content: Content): string {
56
+ export function mapContentToSurfingMarkdown(content: Content, options: MapContentOptions = {}): string {
31
57
  let title = content.title;
32
58
  if (!title) {
33
59
  const match = content.body.match(/^#\s+(.+)$/m);
@@ -56,86 +82,316 @@ export function mapContentToSurfingMarkdown(content: Content): string {
56
82
  .filter((t): t is string => typeof t === 'string' && t.trim().length > 0)
57
83
  .map((t: string) => slugify(t));
58
84
 
59
- const date = new Date().toISOString();
85
+ const isoDate = options.publishDate ?? new Date().toISOString().slice(0, 10);
60
86
  const firstRefUrl = content.references?.[0]?.url;
61
- const canonicalUrl =
87
+ const canonical =
62
88
  typeof firstRefUrl === 'string'
63
89
  ? firstRefUrl
64
90
  : typeof content.metadata?.canonicalUrl === 'string'
65
91
  ? content.metadata.canonicalUrl
66
92
  : undefined;
67
- const published = typeof content.metadata?.published === 'boolean' ? content.metadata.published : true;
93
+ const draft = typeof content.metadata?.published === 'boolean' ? !content.metadata.published : false;
68
94
 
95
+ // Strict surfdash PostSchema (surfdash/src/content/schema.ts): publishDate (ISO date) is
96
+ // required; unknown keys (`date`, `canonical_url`, `published`) are rejected.
69
97
  const frontmatterObj: Record<string, unknown> = {
70
98
  title,
71
- ...(description ? { description } : {}),
99
+ description: description ?? '',
72
100
  tags,
73
- date,
74
- ...(canonicalUrl ? { canonical_url: canonicalUrl } : {}),
75
- published,
101
+ publishDate: isoDate,
102
+ ...(canonical ? { canonical } : {}),
103
+ draft,
104
+ ...(options.image ? { image: options.image, og_image: options.image } : {}),
76
105
  };
77
106
 
78
107
  const yamlLines = ['---', ...Object.entries(frontmatterObj).map(([k, v]) => `${k}: ${JSON.stringify(v)}`), '---'];
79
108
  return `${yamlLines.join('\n')}\n\n${content.body.trim()}\n`;
80
109
  }
81
110
 
111
+ /** `Result` failure helper: ok false + error (podcast-pub pattern). */
112
+ function fail(error: string): Result {
113
+ return ResultSchema.parse({ ok: false, target: TARGET, error });
114
+ }
115
+
116
+ /** Parses and routes `options.operation`; unknown or misplaced values fail loud. */
117
+ function resolveOperation(options: SurfdashPubOptions): { operation: Operation } | { error: string } {
118
+ const raw = options.operation ?? 'publish';
119
+ if (typeof raw !== 'string' || !OPERATIONS.includes(raw as Operation)) {
120
+ return { error: `invalid options.operation "${String(raw)}": expected one of ${OPERATIONS.join(', ')}` };
121
+ }
122
+ return { operation: raw as Operation };
123
+ }
124
+
125
+ /** `options.postPath` (zh index.md abs path inside the surfdash checkout) is required for the path-scoped operations. */
126
+ function resolvePostPath(options: SurfdashPubOptions): { postPath: string } | { error: string } {
127
+ const raw = options.postPath;
128
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
129
+ return { error: `options.postPath is required for the "${String(options.operation)}" operation` };
130
+ }
131
+ const postPath = raw.trim();
132
+ if (!isAbsolute(postPath)) {
133
+ return { error: `options.postPath must be an absolute path inside the surfdash checkout: ${postPath}` };
134
+ }
135
+ return { postPath };
136
+ }
137
+
138
+ /**
139
+ * Containment guard (review fix #1): `resolve(postPath)` must land inside the resolved surfdash
140
+ * checkout — both CLI-backed operations spawn with cwd=surfdashRoot, so a relative or
141
+ * `..`-escaping path would otherwise be existence-checked against one base and executed against
142
+ * another (or act outside the checkout entirely). Array argv + no shell already rule out argv
143
+ * injection; this closes path traversal. Returns the fail-loud error text, or undefined when inside.
144
+ */
145
+ function postPathOutsideRoot(postPath: string, surfdashRoot: string): string | undefined {
146
+ const root = resolve(surfdashRoot);
147
+ if (!resolve(postPath).startsWith(root + sep)) {
148
+ return `options.postPath must resolve inside the surfdash checkout (${root}): ${postPath}`;
149
+ }
150
+ return undefined;
151
+ }
152
+
153
+ /** `options.target` must be one of the scaffoldable locales — no default, fail loud. */
154
+ function resolveScaffoldTarget(options: SurfdashPubOptions): { locale: ScaffoldLocale } | { error: string } {
155
+ const raw = options.target;
156
+ if (typeof raw !== 'string' || !SCAFFOLD_LOCALES.includes(raw as ScaffoldLocale)) {
157
+ return {
158
+ error: `options.target is required for the "scaffold-locale" operation: expected one of ${SCAFFOLD_LOCALES.join(', ')}`,
159
+ };
160
+ }
161
+ return { locale: raw as ScaffoldLocale };
162
+ }
163
+
164
+ /**
165
+ * Derives the locale draft path from the zh post path: the last `/zh/` segment becomes
166
+ * `/<locale>/` (`content/posts/articles/zh/<slug>/index.md` → `.../<locale>/<slug>/index.md`).
167
+ * Returns undefined when the path carries no `/zh/` segment (caller fails loud).
168
+ */
169
+ function deriveLocaleDraftPath(postPath: string, locale: ScaffoldLocale): string | undefined {
170
+ const marker = `${sep}zh${sep}`;
171
+ const idx = postPath.lastIndexOf(marker);
172
+ if (idx < 0) {
173
+ return undefined;
174
+ }
175
+ return `${postPath.slice(0, idx)}${sep}${locale}${sep}${postPath.slice(idx + marker.length)}`;
176
+ }
177
+
178
+ /**
179
+ * Resolves the surfdash checkout root: `SURFDASH_ROOT` explicitly, or derived from a
180
+ * `POSTSURFING_BIN` path inside the checkout (`<root>/scripts/<cli>`). Returns undefined
181
+ * when neither channel yields a root — every operation fails loud on undefined.
182
+ */
183
+ function resolveSurfdashRoot(): string | undefined {
184
+ const postsurfingBin = process.env.POSTSURFING_BIN ?? 'postsurfing';
185
+ return (
186
+ process.env.SURFDASH_ROOT ??
187
+ (postsurfingBin.includes('/') ? resolve(dirname(dirname(postsurfingBin))) : undefined)
188
+ );
189
+ }
190
+
191
+ /** Spawn result shared by the CLI-backed operations. */
192
+ interface CliSpawnResult {
193
+ exitCode: number;
194
+ stdout: string;
195
+ stderr: string;
196
+ }
197
+
198
+ /** One spawn helper for every CLI-backed operation (postsurfing publish / sd translate / orchestrator sync). */
199
+ async function spawnCli(cmd: string[], cwd: string): Promise<CliSpawnResult> {
200
+ const proc = Bun.spawn(cmd, {
201
+ cwd,
202
+ stdout: 'pipe',
203
+ stderr: 'pipe',
204
+ });
205
+ const [exitCode, stdout, stderr] = await Promise.all([
206
+ proc.exited,
207
+ new Response(proc.stdout).text(),
208
+ new Response(proc.stderr).text(),
209
+ ]);
210
+ return { exitCode, stdout, stderr };
211
+ }
212
+
213
+ /** Stderr-first error text for a failed CLI spawn (stderr may be empty → fall back to stdout). */
214
+ function cliErrorText(spawned: CliSpawnResult): string {
215
+ return spawned.stderr.trim() || spawned.stdout.trim() || 'Unknown error';
216
+ }
217
+
218
+ /**
219
+ * Scaffolds a locale draft for an existing zh post (L1 R7 / 0096): runs the surfdash
220
+ * `sd translate` scaffold CLI inside the surfdash checkout, copies the post's `assets/`
221
+ * dir into the draft dir when present, and returns the draft path in `metadata.draftPath`.
222
+ * No LLM translation happens here — the workflow's agent step edits the returned draft.
223
+ */
224
+ async function scaffoldLocale(options: SurfdashPubOptions): Promise<Result> {
225
+ const postPath = resolvePostPath(options);
226
+ if ('error' in postPath) return fail(postPath.error);
227
+ const target = resolveScaffoldTarget(options);
228
+ if ('error' in target) return fail(target.error);
229
+
230
+ const surfdashRoot = resolveSurfdashRoot();
231
+ if (!surfdashRoot) {
232
+ return fail('SURFDASH_ROOT is required when POSTSURFING_BIN is not a path inside the surfdash checkout');
233
+ }
234
+ const outsideRoot = postPathOutsideRoot(postPath.postPath, surfdashRoot);
235
+ if (outsideRoot) return fail(outsideRoot);
236
+ if (!existsSync(postPath.postPath)) {
237
+ return fail(`options.postPath does not exist: ${postPath.postPath}`);
238
+ }
239
+ const draftPath = deriveLocaleDraftPath(postPath.postPath, target.locale);
240
+ if (!draftPath) {
241
+ return fail(
242
+ `options.postPath must be a zh post path containing a "${sep}zh${sep}" segment: ${postPath.postPath}`,
243
+ );
244
+ }
245
+
246
+ try {
247
+ const spawned = await spawnCli(
248
+ ['bun', 'run', 'sd', 'translate', postPath.postPath, '--source', 'zh', '--target', target.locale],
249
+ surfdashRoot,
250
+ );
251
+ if (spawned.exitCode !== 0) {
252
+ return fail(`sd translate failed (exit ${spawned.exitCode}): ${cliErrorText(spawned)}`);
253
+ }
254
+ if (!existsSync(draftPath)) {
255
+ return fail(`sd translate reported success but the draft is missing: ${draftPath}`);
256
+ }
257
+ const assetsDir = join(dirname(postPath.postPath), 'assets');
258
+ if (existsSync(assetsDir)) {
259
+ await cp(assetsDir, join(dirname(draftPath), 'assets'), { recursive: true });
260
+ }
261
+ return ResultSchema.parse({
262
+ ok: true,
263
+ target: TARGET,
264
+ message: `Scaffolded ${target.locale} draft at ${draftPath}`,
265
+ metadata: { draftPath, postPath: postPath.postPath, target: target.locale },
266
+ });
267
+ } catch (error: unknown) {
268
+ return fail(`scaffold-locale transport error: ${error instanceof Error ? error.message : String(error)}`);
269
+ }
270
+ }
271
+
82
272
  /**
83
- * FileCli transport invoking postsurfing CLI.
273
+ * Runs the surfdash publish-orchestrator sync for a post (L1 R7 / 0096): spawns
274
+ * `bun scripts/publish-orchestrator.ts sync <postPath>` inside the surfdash checkout
275
+ * and returns ok/fail with stderr on failure.
276
+ */
277
+ async function syncPost(options: SurfdashPubOptions): Promise<Result> {
278
+ const postPath = resolvePostPath(options);
279
+ if ('error' in postPath) return fail(postPath.error);
280
+
281
+ const surfdashRoot = resolveSurfdashRoot();
282
+ if (!surfdashRoot) {
283
+ return fail('SURFDASH_ROOT is required when POSTSURFING_BIN is not a path inside the surfdash checkout');
284
+ }
285
+ const outsideRoot = postPathOutsideRoot(postPath.postPath, surfdashRoot);
286
+ if (outsideRoot) return fail(outsideRoot);
287
+
288
+ try {
289
+ const spawned = await spawnCli(
290
+ ['bun', 'scripts/publish-orchestrator.ts', 'sync', postPath.postPath],
291
+ surfdashRoot,
292
+ );
293
+ if (spawned.exitCode !== 0) {
294
+ return fail(`publish-orchestrator sync failed (exit ${spawned.exitCode}): ${cliErrorText(spawned)}`);
295
+ }
296
+ return ResultSchema.parse({
297
+ ok: true,
298
+ target: TARGET,
299
+ message: 'publish-orchestrator sync completed',
300
+ metadata: { postPath: postPath.postPath },
301
+ });
302
+ } catch (error: unknown) {
303
+ return fail(`sync transport error: ${error instanceof Error ? error.message : String(error)}`);
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Publish path (behavior unchanged since E3): maps Content into surfdash markdown under
309
+ * `content/posts/<collection>/<locale>/<slug>/index.md` and invokes the postsurfing CLI.
310
+ */
311
+ async function publishPost(payload: PublishTransportPayload): Promise<Result> {
312
+ const postsurfingBin = process.env.POSTSURFING_BIN ?? 'postsurfing';
313
+
314
+ // The publishing FSM requires the markdown at
315
+ // content/posts/<collection>/<locale>/<slug>/index.md inside the surfdash site tree
316
+ // (its refine step rejects other layouts); the root resolution lives in resolveSurfdashRoot().
317
+ const surfdashRoot = resolveSurfdashRoot();
318
+ if (!surfdashRoot) {
319
+ return fail('SURFDASH_ROOT is required when POSTSURFING_BIN is not a path inside the surfdash checkout');
320
+ }
321
+
322
+ const locale = process.env.SURFDASH_LOCALE ?? 'zh';
323
+ // Date-stamped slug (metadata.runDate, e.g. 20260904) keeps same-title daily posts
324
+ // from colliding; Chinese titles slugify to '' and fall back to the bare date.
325
+ const runDate = typeof payload.content.metadata?.runDate === 'string' ? payload.content.metadata.runDate : '';
326
+ const today = new Date().toISOString().slice(0, 10).replaceAll('-', '');
327
+ const dateStamp = runDate || today;
328
+ const titleSlug = slugify(payload.content.title ?? '');
329
+ const slug = titleSlug ? `${dateStamp}-${titleSlug}` : `${dateStamp}-post`;
330
+ const postDir = join(surfdashRoot, 'content', 'posts', 'articles', locale, slug);
331
+ await createNodeFileSystem().ensureDir(postDir);
332
+
333
+ // Cover image: metadata.coverImage (absolute local path) is copied into the post's
334
+ // assets dir and referenced relatively — the surfdash PostSchema accepts ./assets/<file>.
335
+ let imageFrontmatter: string | undefined;
336
+ const coverPath = payload.content.metadata?.coverImage;
337
+ if (typeof coverPath === 'string' && existsSync(coverPath)) {
338
+ const assetsDir = join(postDir, 'assets');
339
+ await createNodeFileSystem().ensureDir(assetsDir);
340
+ const coverName = `cover${coverPath.slice(coverPath.lastIndexOf('.'))}`;
341
+ await copyFile(coverPath, join(assetsDir, coverName));
342
+ imageFrontmatter = `./assets/${coverName}`;
343
+ }
344
+ const markdown = mapContentToSurfingMarkdown(payload.content, {
345
+ image: imageFrontmatter,
346
+ publishDate: runDate ? `${runDate.slice(0, 4)}-${runDate.slice(4, 6)}-${runDate.slice(6, 8)}` : undefined,
347
+ });
348
+ const filePath = join(postDir, 'index.md');
349
+
350
+ try {
351
+ await createNodeFileSystem().writeFile(filePath, markdown);
352
+ const spawned = await spawnCli([postsurfingBin, 'publish', filePath], surfdashRoot);
353
+
354
+ if (spawned.exitCode !== 0) {
355
+ return fail(`postsurfing CLI failed (exit ${spawned.exitCode}): ${cliErrorText(spawned)}`);
356
+ }
357
+
358
+ return ResultSchema.parse({
359
+ ok: true,
360
+ target: TARGET,
361
+ url: `https://surfdash.local/${locale}/posts/${slug}`,
362
+ message: 'Published successfully to Surfdash via postsurfing CLI',
363
+ // Absolute post path lets downstream steps (locale translation + CMS sync)
364
+ // derive the surfdash checkout root without extra configuration.
365
+ metadata: { postPath: filePath },
366
+ });
367
+ } catch (err: unknown) {
368
+ return fail(`Postsurfing transport error: ${err instanceof Error ? err.message : String(err)}`);
369
+ }
370
+ }
371
+
372
+ /**
373
+ * FileCli transport invoking postsurfing CLI for `publish` (default) and the
374
+ * `scaffold-locale` / `sync` surfdash mechanics via `options.operation` (L1 R7).
84
375
  */
85
376
  export class PostsurfingFileCliTransport implements PublishTransport {
86
377
  public readonly kind = 'file-cli';
87
378
 
88
379
  public async publish(payload: PublishTransportPayload): Promise<Result> {
89
- const markdown = mapContentToSurfingMarkdown(payload.content);
90
- const tempDir = join(process.cwd(), '.spur', 'run', '.tmp-surfdash');
91
- await createNodeFileSystem().ensureDir(tempDir);
92
-
93
- const titleSlug = slugify(payload.content.title ?? 'post');
94
- const fileName = `${titleSlug || 'post'}-${Date.now()}.md`;
95
- const filePath = join(tempDir, fileName);
96
-
97
- try {
98
- await createNodeFileSystem().writeFile(filePath, markdown);
99
- const postsurfingBin = process.env.POSTSURFING_BIN ?? 'postsurfing';
100
-
101
- const proc = Bun.spawn([postsurfingBin, 'publish', filePath], {
102
- stdout: 'pipe',
103
- stderr: 'pipe',
104
- });
105
-
106
- const [exitCode, stdout, stderr] = await Promise.all([
107
- proc.exited,
108
- new Response(proc.stdout).text(),
109
- new Response(proc.stderr).text(),
110
- ]);
111
-
112
- if (exitCode !== 0) {
113
- return ResultSchema.parse({
114
- ok: false,
115
- target: 'surfdash',
116
- error: `postsurfing CLI failed (exit ${exitCode}): ${stderr.trim() || stdout.trim() || 'Unknown error'}`,
117
- });
118
- }
119
-
120
- return ResultSchema.parse({
121
- ok: true,
122
- target: 'surfdash',
123
- url: `https://surfdash.local/posts/${fileName}`,
124
- message: 'Published successfully to Surfdash via postsurfing CLI',
125
- });
126
- } catch (err: unknown) {
127
- return ResultSchema.parse({
128
- ok: false,
129
- target: 'surfdash',
130
- error: `Postsurfing transport error: ${err instanceof Error ? err.message : String(err)}`,
131
- });
132
- } finally {
133
- // Best-effort temp cleanup: a failure here must not mask the publish result.
134
- try {
135
- await createNodeFileSystem().deleteFile(filePath);
136
- } catch {
137
- // ignore
138
- }
380
+ const options: SurfdashPubOptions =
381
+ payload.options !== undefined && typeof payload.options === 'object' && payload.options !== null
382
+ ? (payload.options as SurfdashPubOptions)
383
+ : {};
384
+
385
+ const routed = resolveOperation(options);
386
+ if ('error' in routed) return fail(routed.error);
387
+
388
+ switch (routed.operation) {
389
+ case 'scaffold-locale':
390
+ return scaffoldLocale(options);
391
+ case 'sync':
392
+ return syncPost(options);
393
+ case 'publish':
394
+ return publishPost(payload);
139
395
  }
140
396
  }
141
397
  }
@@ -153,6 +409,11 @@ export function getPublishTransport(): PublishTransport {
153
409
 
154
410
  /**
155
411
  * Processes input Content and writes output Result via transport.
412
+ *
413
+ * The input JSON may carry a top-level `options` object (`operation`, `postPath`,
414
+ * `target`) alongside the Content fields; it is forwarded to the transport so
415
+ * `kk executor run` callers can select scaffold-locale/sync without extra flags
416
+ * (podcast-pub pattern, 0096).
156
417
  */
157
418
  export async function processPublishIO(
158
419
  inputPath: string,
@@ -161,9 +422,14 @@ export async function processPublishIO(
161
422
  ): Promise<void> {
162
423
  await createNodeFileSystem().deleteFile(outputPath);
163
424
  const rawInput = await createNodeFileSystem().readFile(inputPath);
164
- const content = ContentSchema.parse(JSON.parse(rawInput));
425
+ const raw = JSON.parse(rawInput) as Record<string, unknown>;
426
+ const content = ContentSchema.parse(raw);
427
+ const options =
428
+ raw.options !== undefined && typeof raw.options === 'object' && raw.options !== null
429
+ ? (raw.options as Record<string, unknown>)
430
+ : undefined;
165
431
 
166
- const result = await transport.publish({ content });
432
+ const result = await transport.publish({ content, options });
167
433
  const validatedResult = ResultSchema.parse(result);
168
434
 
169
435
  const outDir = dirname(outputPath);