@gobing-ai/knowledge-kit 0.0.18 → 0.0.21

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.
@@ -12,22 +12,32 @@
12
12
  * wrap [<override>] --in <voicescript.yaml> --out <docs.json> [--profile <name>]
13
13
  * duration <work_dir> <target_duration_min>
14
14
  * render-storm [<override>] --in <content.json> --out <content.md>
15
+ * itc-outline-pick <work_dir> (0149, body in the sibling itc-stages.ts)
16
+ * itc-report <work_dir> [max_revisions] (0149, body in the sibling itc-stages.ts)
17
+ * itc-materialize <work_dir> <publish_live> (0150, sibling)
18
+ * itc-publish-plan <work_dir> <targets> <source_locale> <publish_live> (0150, sibling)
19
+ * itc-publish-fanout <work_dir> [--retry] (0151, sibling)
20
+ * itc-publish-next <work_dir> (0151, sibling)
15
21
  *
16
22
  * `validate` / `wrap` / `render-storm` resolve the shipped sibling sidecars and delegate to them;
17
23
  * nothing about those sidecars changed. The `prepare-*` verbs and `duration` keep the logic that
18
24
  * used to live inline in `plugins/kk/workflows/*.yaml`, message-for-message.
19
25
  *
20
- * Node/Bun builtins only — no @gobing-ai/* and no relative imports (the file is resolved from the
21
- * installed package or from a checkout).
26
+ * Node/Bun builtins only — no @gobing-ai/* and no cross-directory relative imports (the file is
27
+ * resolved from the installed package or from a checkout). The itc-* stages delegate to their
28
+ * same-directory sibling `itc-stages.ts`, imported lazily so a runner copy without the sibling
29
+ * still loads and fails loud with the remediation path.
22
30
  *
23
31
  * The YAML prelude resolves this file as `$pkg/plugins/kk/scripts/kk-workflow-stages.ts` where `pkg`
24
32
  * comes from `realpath "$(command -v kk)"` (the installed package root), and falls back to the
25
33
  * cwd-relative `plugins/kk/scripts/kk-workflow-stages.ts` when that copy is absent — the checkout
26
- * tier kk-storm-research's render has always relied on, preserved on purpose.
34
+ * tier kk-storm-research's render has always relied on, preserved on purpose. The daily-voice
35
+ * workflow reaches the same script through `kk stage` (apps/cli/src/stage.ts), which applies the
36
+ * same two resolution tiers in TypeScript.
27
37
  *
28
- * SYNC: plugins/kk/workflows/{kk-solo-podcast,kk-itc,kk-storm-research,kk-daily-ai-voice}.yaml shell
29
- * this script. Keep message substrings stable — apps/cli/tests/kk-workflow-stages.test.ts and the
30
- * four workflow static tests match them.
38
+ * SYNC: plugins/kk/workflows/{kk-solo-podcast,kk-itc,kk-storm-research}.yaml shell this script
39
+ * (kk-daily-ai-voice goes through `kk stage`). Keep message substrings stable —
40
+ * apps/cli/tests/kk-workflow-stages.test.ts and the four workflow static tests match them.
31
41
  */
32
42
  import { spawnSync } from 'node:child_process';
33
43
  import { createHash } from 'node:crypto';
@@ -43,6 +53,7 @@ import {
43
53
  unlinkSync,
44
54
  writeFileSync,
45
55
  } from 'node:fs';
56
+ import { homedir } from 'node:os';
46
57
  import { basename, dirname, join, resolve } from 'node:path';
47
58
 
48
59
  const SCRIPTS_DIR = import.meta.dir;
@@ -60,6 +71,21 @@ function fail(message: string): never {
60
71
  process.exit(1);
61
72
  }
62
73
 
74
+ /**
75
+ * Load the itc-* helper sibling lazily: this runner is resolved (and copied alone, e.g. by
76
+ * apps/cli/tests/kk-workflow-stages.test.ts) from installs where the sibling may be absent, so
77
+ * the itc-* stages fail loud with a remediation path instead of breaking every stage at load.
78
+ */
79
+ async function loadItcStages(): Promise<typeof import('./itc-stages.ts')> {
80
+ try {
81
+ return await import('./itc-stages.ts');
82
+ } catch {
83
+ return fail(
84
+ 'itc-stages.ts not found next to kk-workflow-stages.ts — install or refresh the kk package (bun link in a checkout)',
85
+ );
86
+ }
87
+ }
88
+
63
89
  /** coreutils-style reason for the errno codes the staged file operations can hit. */
64
90
  const ERRNO_REASONS: Record<string, string> = {
65
91
  EACCES: 'Permission denied',
@@ -534,15 +560,29 @@ function dailyPrepare(args: string[]): void {
534
560
  last30daysEnabled = '',
535
561
  topic = '',
536
562
  horizonHours = '',
563
+ runDateArg = '',
537
564
  ] = args;
538
- const workDir = resolve(workDirArg);
539
- timed(workDirArg, 'prepare', () => {
540
- if (!workDirArg) failWith(1, 'missing required variable: work_dir');
565
+ // Empty work_dir composes the real workspace from the operator's works_dir
566
+ // (workflow-run.md §2 precedence: KK_WORKS_DIR > config works_dir > ~/.config/kk/works):
567
+ // $works_dir/kk-daily-ai-voice/<stamp>
568
+ // The stamp is digits-only (dashes stripped from the caller's run_date, else today) so a
569
+ // resumed/retried run whose persisted run_date var already carries the digits-only
570
+ // run-date.txt value re-derives the same directory.
571
+ const dirDate = runDateArg ? runDateArg.replaceAll('-', '') : formatRunDate();
572
+ const workDir = resolve(workDirArg || join(resolveWorksDir(), 'kk-daily-ai-voice', dirDate));
573
+ timed(workDir, 'prepare', () => {
541
574
  for (const dir of DAILY_DIRS)
542
575
  sted('mkdir', join(workDir, dir), () => mkdirSync(join(workDir, dir), { recursive: true }));
543
576
  sted('write', join(workDir, 'run-date.txt'), () =>
544
- writeFileSync(join(workDir, 'run-date.txt'), `${formatRunDate()}\n`),
577
+ writeFileSync(join(workDir, 'run-date.txt'), `${dirDate}\n`),
545
578
  );
579
+ // Anchor for the workflow: shell actions cannot set vars, so the YAML reads the
580
+ // effective work_dir back into vars.work_dir from this cwd-relative file.
581
+ const anchorDir = join('.spur', 'runs', 'kk-daily-ai-voice');
582
+ sted('write', join(anchorDir, 'work-dir.txt'), () => {
583
+ mkdirSync(anchorDir, { recursive: true });
584
+ writeFileSync(join(anchorDir, 'work-dir.txt'), `${workDir}\n`);
585
+ });
546
586
  sted('write', join(workDir, '1-ingest/source.json'), () =>
547
587
  writeJsonText(join(workDir, '1-ingest/source.json'), {
548
588
  limit: Number(limit),
@@ -572,6 +612,35 @@ function formatRunDate(): string {
572
612
  return `${now.getFullYear()}${month}${day}`;
573
613
  }
574
614
 
615
+ /** Expand the `~/` and `$HOME/` prefixes the config examples use. */
616
+ function expandHome(p: string): string {
617
+ if (p.startsWith('~/')) return join(homedir(), p.slice(2));
618
+ if (p.startsWith('$HOME/')) return join(homedir(), p.slice('$HOME/'.length));
619
+ return p;
620
+ }
621
+
622
+ /**
623
+ * works_dir for composed daily workspaces — workflow-run.md §2 precedence:
624
+ * KK_WORKS_DIR env > config works_dir > compiled default ~/.config/kk/works.
625
+ * Unreadable/unparseable config falls through to the default (pluginEnvFromConfig
626
+ * tolerates the same), never fails the run.
627
+ */
628
+ function resolveWorksDir(): string {
629
+ const fromEnv = process.env.KK_WORKS_DIR;
630
+ if (fromEnv) return fromEnv;
631
+ const configPath = process.env.KK_CONFIG || join(homedir(), '.config/kk/config.yaml');
632
+ if (existsSync(configPath)) {
633
+ try {
634
+ const parsed = Bun.YAML.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown> | null;
635
+ const worksDir = parsed !== null && typeof parsed === 'object' ? parsed.works_dir : undefined;
636
+ if (typeof worksDir === 'string' && worksDir !== '') return expandHome(worksDir);
637
+ } catch {
638
+ // fall through to the compiled default
639
+ }
640
+ }
641
+ return join(homedir(), '.config/kk/works');
642
+ }
643
+
575
644
  function dailyCollectFacts(args: string[]): void {
576
645
  const [workDir = '', runDate = '', pluginsPath = ''] = args;
577
646
  const content = join(workDir, '2-facts', `${runDate}_02_collect_content.json`);
@@ -780,7 +849,9 @@ function dailyArticle(args: string[]): void {
780
849
  {
781
850
  env: {
782
851
  ARTICLE_DATE: runDate,
783
- ...(isFile(proseBody) ? { ARTICLE_BODY_FILE: proseBody } : {}),
852
+ // Plugin entries run with cwd = plugin dir (invoke.ts), so relative
853
+ // paths in env must be resolved or the plugin sees ENOENT.
854
+ ...(isFile(proseBody) ? { ARTICLE_BODY_FILE: resolve(proseBody) } : {}),
784
855
  },
785
856
  },
786
857
  );
@@ -856,7 +927,14 @@ function dailyScript(args: string[]): void {
856
927
  sted('write', yamlOut, () => writeRaw(yamlOut, body));
857
928
  // R3 (task 0139): fresh writes are validated here; the script cache path is covered by
858
929
  // wrap-docs, which runs on every path that reaches audio.
859
- assertScriptContent(yamlOut);
930
+ try {
931
+ assertScriptContent(yamlOut);
932
+ } catch (err) {
933
+ // A rejected fresh write would otherwise poison the isFile(yamlOut) cache: every
934
+ // resume skips regeneration and re-fails at wrap-docs. Drop it so retry regenerates.
935
+ unlinkSync(yamlOut);
936
+ throw err;
937
+ }
860
938
  });
861
939
  }
862
940
 
@@ -1270,9 +1348,12 @@ function dailyPatchSurfdash(args: string[]): void {
1270
1348
  };
1271
1349
  let changed = 0;
1272
1350
  for (const [locale, dir] of [
1351
+ // zh posts live under articles/zh/<slug>; en/ja under articles/<locale>/<slug> —
1352
+ // two dirnames up, not one (20260919: `articles/zh/en/...` never existed, so en/ja
1353
+ // were silently skipped by the not-published branch).
1273
1354
  ['zh', slugDir],
1274
- ['en', join(dirname(slugDir), 'en', basename(slugDir))],
1275
- ['ja', join(dirname(slugDir), 'ja', basename(slugDir))],
1355
+ ['en', join(dirname(dirname(slugDir)), 'en', basename(slugDir))],
1356
+ ['ja', join(dirname(dirname(slugDir)), 'ja', basename(slugDir))],
1276
1357
  ] as const) {
1277
1358
  const indexMd = join(dir, 'index.md');
1278
1359
  if (!existsSync(indexMd)) {
@@ -1282,17 +1363,28 @@ function dailyPatchSurfdash(args: string[]): void {
1282
1363
  let md = readFileSync(indexMd, 'utf-8');
1283
1364
  const fmEnd = md.indexOf('---', 3); // frontmatter is `---\n ... \n---\n`
1284
1365
  if (fmEnd < 0) failWith(1, `patch-surfdash: no frontmatter in ${indexMd}`);
1285
- if (!/^image:/m.test(md.slice(0, fmEnd))) {
1286
- let image = coverUrl;
1287
- if (image === '' && existsSync(localCover)) {
1288
- // Fallback for pre-#6 runs: copy the local cover instead of a hosted URL.
1366
+ // Upsert a single-line `key: 'value'`; the regex consumes folded-scalar continuation
1367
+ // lines (`og_image: >-\n https://...`) so multi-line publish output is replaced whole.
1368
+ const upsertKey = (src: string, key: string, value: string): string => {
1369
+ const line = `${key}: '${value}'`;
1370
+ const re = new RegExp(`^${key}:.*(?:\\n[ \\t]+.*)*`, 'm');
1371
+ if (re.test(src)) return src.replace(re, line);
1372
+ return src.replace(/^publishDate: .*$/m, (m0) => `${m0}\n${line}`);
1373
+ };
1374
+ if (coverUrl !== '') {
1375
+ // 20260919 prod feedback: the podcast-hosted cover URL wins even when surfdash-pub
1376
+ // already wrote `image:` — its resolved og_image points at /content-posts/<slug>/
1377
+ // assets/cover.png, which the deployed worker does not serve (404).
1378
+ md = upsertKey(md, 'image', coverUrl);
1379
+ md = upsertKey(md, 'og_image', coverUrl);
1380
+ } else if (!/^image:/m.test(md.slice(0, fmEnd))) {
1381
+ // Fallback for runs without a podcast cover: copy the local cover instead.
1382
+ if (existsSync(localCover)) {
1289
1383
  const assets = join(dir, 'assets');
1290
1384
  mkdirSync(assets, { recursive: true });
1291
1385
  copyFileSync(localCover, join(assets, 'cover.png'));
1292
- image = './assets/cover.png';
1293
- }
1294
- if (image !== '') {
1295
- md = md.replace(/^publishDate: .*$/m, (m0) => `${m0}\nimage: '${image}'\nog_image: '${image}'`);
1386
+ md = upsertKey(md, 'image', './assets/cover.png');
1387
+ md = upsertKey(md, 'og_image', './assets/cover.png');
1296
1388
  }
1297
1389
  }
1298
1390
  if (showNotesUrl !== '' && !md.includes(showNotesUrl)) {
@@ -1578,8 +1670,43 @@ if (import.meta.main)
1578
1670
  case 'daily-translate-sync':
1579
1671
  dailyTranslateSync(rest);
1580
1672
  break;
1673
+ case 'itc-outline-pick': {
1674
+ const { itcOutlinePick } = await loadItcStages();
1675
+ itcOutlinePick(rest);
1676
+ break;
1677
+ }
1678
+ case 'itc-report': {
1679
+ const { itcReport } = await loadItcStages();
1680
+ itcReport(rest);
1681
+ break;
1682
+ }
1683
+ case 'itc-materialize': {
1684
+ const { itcMaterialize } = await loadItcStages();
1685
+ itcMaterialize(rest);
1686
+ break;
1687
+ }
1688
+ case 'itc-publish-plan': {
1689
+ const { itcPublishPlan } = await loadItcStages();
1690
+ itcPublishPlan(rest);
1691
+ break;
1692
+ }
1693
+ case 'itc-publish-fanout': {
1694
+ const { itcPublishFanout } = await loadItcStages();
1695
+ itcPublishFanout(rest);
1696
+ break;
1697
+ }
1698
+ case 'itc-publish-next': {
1699
+ const { itcPublishNext } = await loadItcStages();
1700
+ itcPublishNext(rest);
1701
+ break;
1702
+ }
1703
+ case 'itc-adapt-verify': {
1704
+ const { itcAdaptVerify } = await loadItcStages();
1705
+ itcAdaptVerify(rest);
1706
+ break;
1707
+ }
1581
1708
  default:
1582
1709
  fail(
1583
- `unknown stage: ${stage ?? '(none)'} (expected prepare-itc|prepare-solo|prepare-storm|validate|wrap|duration|render-storm|daily-*)`,
1710
+ `unknown stage: ${stage ?? '(none)'} (expected prepare-itc|prepare-solo|prepare-storm|validate|wrap|duration|render-storm|itc-*|daily-*)`,
1584
1711
  );
1585
1712
  }
@@ -0,0 +1,85 @@
1
+ ---
2
+ name: article-adapt
3
+ # Description rules: front-load the leading identity phrase; one trigger per genuine
4
+ # branch (collapse synonym triggers into one); never restate the body's identity line.
5
+ description: This skill should be used when a kk-itc publish plan contains variants with adapt=true and per-variant Content files must be written — "adapt the article", "translate for the target profile", "write content.zh-article.json", or when a spur workflow agent.run step invokes kk:article-adapt. Translates title and body to the variant locale, applies the plan limits, and preserves code blocks, URLs and references; it holds no platform table.
6
+ ---
7
+
8
+ # article-adapt
9
+
10
+
11
+ This skill adapts one published article into one Content file per `publish-plan.json` variant with
12
+ `adapt: true`. It is deliberately **thin**: the plan (built from `TARGET_PROFILES` in
13
+ `plugins/kk/scripts/itc-stages.ts`) carries the per-variant locale, format and limits — this skill
14
+ holds **no platform table** and never decides which variants exist.
15
+
16
+ ## Inputs
17
+
18
+ - `<work_dir>/4-publish/publish-plan.json` — `{live, sourceLocale, variants, fanout, browser}` where
19
+ each `variants[<locale>-<format>]` entry is
20
+ `{file, adapt, limits?, switches}`.
21
+ - `<work_dir>/4-publish/content.json` — the source article (the `sourceLocale` materialized draft).
22
+
23
+ ## Output
24
+
25
+ For every `variants[*]` entry with `adapt: true`, write
26
+ `<work_dir>/4-publish/<entry.file>` (e.g. `content.zh-article.json`) as a Content JSON object:
27
+
28
+ ```json
29
+ {
30
+ "title": "<translated title>",
31
+ "format": "<variant format suffix, e.g. article | wechat-article | short-note | short-post>",
32
+ "body": "<translated body>",
33
+ "metadata": { "coverImage": "...", "tags": [], "description": "..." },
34
+ "references": [{ "url": "...", "title": "...", "cite": "id" }]
35
+ }
36
+ ```
37
+
38
+ ## Procedure
39
+
40
+ 1. Read the plan. Collect the variants whose `adapt` is `true`. If there are none, report that and
41
+ stop — do not write any file.
42
+ 2. For each such variant, read `content.json` and produce the variant file:
43
+ - **Translate** `title` and `body` into the variant locale (the locale is the part of the variant
44
+ key before the first `-`). Keep every code block, URL and inline link verbatim — never
45
+ translate code, identifiers, commands or URLs.
46
+ - **Carry over** `references[]`, `metadata.coverImage`, `metadata.tags`, `metadata.keywords` and
47
+ `metadata.description` unchanged.
48
+ - **Apply the limits** from the entry's `limits` (e.g. a max character count): trim the body
49
+ within the limit without dropping code blocks or breaking markdown; state in one closing line
50
+ that the full article is available at the source URL when content had to be cut.
51
+ - **Set `format`** to the variant key's format suffix (the part after the first `-`).
52
+ 3. Write the file with `metadata` present (at minimum `{}`). A valid Content object always has a
53
+ non-empty `title` and `body`.
54
+ 4. Report each written path as the last line of stdout.
55
+
56
+ ## Invariants
57
+
58
+ - **Never set draft or live switch keys.** Do not write `metadata.published`,
59
+ `metadata.publishStatus` or `metadata.operation`: `itc-publish-fanout` stamps the plan's
60
+ `switches` into each variant's metadata at send time (V3 — the switch is deterministic code, not
61
+ model output).
62
+ - **No platform knowledge.** If the plan names a variant, adapt it; if a limit is missing, adapt
63
+ without a limit. Never hard-code target names, locales or limits in this skill.
64
+ - **Faithful translation.** Do not add, remove or reorder sections relative to the source body; the
65
+ approved outline's structure is preserved.
66
+
67
+ ## Gotchas
68
+
69
+ 1. **Read the plan, not the target list** — the plan, not `publish_targets`, is the adaptation
70
+ contract.
71
+ 2. **A missing source `content.json` is a blocking error** — the materialize state always writes it
72
+ before publish-plan; if it is absent, fail loudly instead of inventing content.
73
+ 3. **One file per adapt variant, exact path** — write `<entry.file>` under `4-publish/`; a
74
+ follow-up `kk stage itc-adapt-verify` step fails the workflow when a variant file is missing or
75
+ lacks `title`/`body`.
76
+
77
+ ## Platform Notes
78
+
79
+ ### Claude Code
80
+
81
+ Invoked from a spur workflow `agent.run` step; the prompt supplies the work_dir path.
82
+
83
+ ### Codex / OpenClaw / OpenCode / Antigravity
84
+
85
+ Run file reads/writes via the available file tools; arguments arrive in the step prompt.