@gobing-ai/knowledge-kit 0.0.6 → 0.0.8

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 (50) hide show
  1. package/dist/index.js +21643 -11933
  2. package/package.json +4 -1
  3. package/plugins/generations/content-gen/package.json +2 -1
  4. package/plugins/generations/content-gen/src/index.ts +9 -8
  5. package/plugins/generations/content-gen/src/storm.ts +12 -4
  6. package/plugins/generations/voice-gen/package.json +17 -0
  7. package/plugins/generations/voice-gen/plugin.json +6 -0
  8. package/plugins/generations/voice-gen/src/concat.ts +218 -0
  9. package/plugins/generations/voice-gen/src/index.ts +213 -0
  10. package/plugins/generations/voice-gen/src/voicebox-client.ts +223 -0
  11. package/plugins/generations/voice-gen/src/voicescript.ts +365 -0
  12. package/plugins/generations/voice-gen/tsconfig.json +8 -0
  13. package/plugins/ingestions/karakeep-local/package.json +17 -0
  14. package/plugins/ingestions/karakeep-local/src/index.ts +31 -26
  15. package/plugins/ingestions/karakeep-local/tsconfig.json +4 -0
  16. package/plugins/ingestions/web-search/package.json +4 -1
  17. package/plugins/ingestions/web-search/src/index.ts +299 -17
  18. package/plugins/kk/README.md +12 -1
  19. package/plugins/kk/commands/workflow-run.md +202 -0
  20. package/plugins/kk/config.example.yaml +34 -0
  21. package/plugins/kk/scripts/render-md.ts +150 -0
  22. package/plugins/kk/skills/{kk-judge → content-judge}/SKILL.md +13 -14
  23. package/plugins/kk/skills/{kk-judge → content-judge}/references/rubrics.md +2 -2
  24. package/plugins/kk/skills/{kk-judge → content-judge}/references/workflow-integration.md +17 -15
  25. package/plugins/kk/skills/itc-generating/SKILL.md +147 -0
  26. package/plugins/kk/skills/itc-generating/references/generic-craft.md +80 -0
  27. package/plugins/kk/skills/itc-generating/references/platform-english.md +72 -0
  28. package/plugins/kk/skills/itc-generating/references/platform-wechat.md +60 -0
  29. package/plugins/kk/skills/itc-generating/references/skill-authoring.md +62 -0
  30. package/plugins/kk/skills/storm-research/SKILL.md +173 -0
  31. package/plugins/kk/skills/{kk-topic → topic}/SKILL.md +4 -4
  32. package/plugins/kk/workflows/judge-gated-publish-example.yaml +101 -0
  33. package/plugins/kk/workflows/kk-ingest-generate-publish.yaml +72 -0
  34. package/plugins/kk/workflows/kk-itc.yaml +285 -0
  35. package/plugins/kk/workflows/kk-solo-podcast.yaml +374 -0
  36. package/plugins/kk/workflows/kk-storm-research.yaml +184 -0
  37. package/plugins/kk/workflows/validate-voicescript.ts +226 -0
  38. package/plugins/publishings/emdash-pub/package.json +17 -0
  39. package/plugins/publishings/emdash-pub/plugin.json +7 -0
  40. package/plugins/publishings/emdash-pub/src/index.ts +450 -0
  41. package/plugins/publishings/emdash-pub/tsconfig.json +4 -0
  42. package/plugins/publishings/qiita-pub/package.json +2 -1
  43. package/plugins/publishings/qiita-pub/src/index.ts +9 -9
  44. package/plugins/publishings/surfdash-pub/package.json +2 -1
  45. package/plugins/publishings/surfdash-pub/src/index.ts +16 -11
  46. package/plugins/publishings/zenn-pub/package.json +2 -1
  47. package/plugins/publishings/zenn-pub/src/index.ts +11 -11
  48. package/plugins/kk/agents/kk-judge-compliance.md +0 -37
  49. package/plugins/kk/agents/kk-judge-tech.md +0 -35
  50. package/plugins/kk/agents/kk-judge-tone.md +0 -37
@@ -1,8 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
3
- import { dirname, join, relative } from 'node:path';
2
+ import { dirname, relative } from 'node:path';
4
3
  import { parseArgs } from 'node:util';
5
4
  import type { Doc } from '@gobing-ai/kk-core';
5
+ import { getLogger, initializeLogger } from '@gobing-ai/ts-infra';
6
+ import { createNodeFileSystem, walkDir } from '@gobing-ai/ts-runtime';
7
+ import { echoError } from '@gobing-ai/ts-utils';
8
+
9
+ const log = getLogger('kk.ingestion.karakeep-local');
6
10
 
7
11
  interface ParsedMeta {
8
12
  title?: string;
@@ -17,27 +21,26 @@ function computeHash(str: string): string {
17
21
  }
18
22
 
19
23
  async function collectFiles(dir: string): Promise<string[]> {
20
- const entries = await readdir(dir, { recursive: true });
21
- const files: string[] = [];
22
- for (const entry of entries) {
23
- const fullPath = join(dir, entry);
24
- const st = await stat(fullPath);
25
- if (st.isFile()) {
26
- files.push(fullPath);
27
- }
28
- }
29
- return files.sort();
24
+ // walkDir is cycle-safe and root-confined: a directory symlink pointing back at
25
+ // an ancestor is walked at most once, and one escaping `dir` is skipped.
26
+ return (await walkDir(dir, createNodeFileSystem())).sort();
30
27
  }
31
28
 
32
29
  export async function processKarakeepFolder(inDir: string): Promise<Doc[]> {
30
+ const fs = createNodeFileSystem();
33
31
  try {
34
- const inStat = await stat(inDir);
32
+ // The FileSystem seam returns null for a missing path where node's stat throws;
33
+ // both cases must still surface as "Inaccessible source path" below.
34
+ const inStat = await fs.stat(inDir);
35
+ if (inStat === null) {
36
+ throw new Error(`Source path does not exist: ${inDir}`);
37
+ }
35
38
  if (!inStat.isDirectory()) {
36
39
  throw new Error(`Source path is not a directory: ${inDir}`);
37
40
  }
38
41
  } catch (err: unknown) {
39
42
  const msg = `Inaccessible source path: ${inDir}`;
40
- console.error(`Error: ${msg}`, err);
43
+ echoError(`Error: ${msg}`);
41
44
  throw new Error(msg, { cause: err });
42
45
  }
43
46
 
@@ -62,14 +65,14 @@ export async function processKarakeepFolder(inDir: string): Promise<Doc[]> {
62
65
 
63
66
  if (contentMd) {
64
67
  processedFiles.add(contentMd);
65
- const mdText = await readFile(contentMd, 'utf-8');
68
+ const mdText = await fs.readFile(contentMd);
66
69
  let metaObj: ParsedMeta = {};
67
70
  if (metaJson) {
68
71
  processedFiles.add(metaJson);
69
72
  try {
70
- metaObj = JSON.parse(await readFile(metaJson, 'utf-8')) as ParsedMeta;
73
+ metaObj = JSON.parse(await fs.readFile(metaJson)) as ParsedMeta;
71
74
  } catch (err: unknown) {
72
- console.error(`Warning: corrupt/unparsable meta file at ${metaJson}`, err);
75
+ log.warn(`Warning: corrupt/unparsable meta file at ${metaJson}`, { error: err });
73
76
  }
74
77
  }
75
78
 
@@ -99,7 +102,7 @@ export async function processKarakeepFolder(inDir: string): Promise<Doc[]> {
99
102
 
100
103
  if (f.endsWith('.json')) {
101
104
  try {
102
- const jsonText = await readFile(f, 'utf-8');
105
+ const jsonText = await fs.readFile(f);
103
106
  const jsonObj = JSON.parse(jsonText) as ParsedMeta;
104
107
  const { title, sourceUri, mediaType, body, ...extraMeta } = jsonObj;
105
108
  if (typeof body === 'string') {
@@ -112,13 +115,13 @@ export async function processKarakeepFolder(inDir: string): Promise<Doc[]> {
112
115
  metadata: Object.keys(extraMeta).length > 0 ? extraMeta : undefined,
113
116
  });
114
117
  } else {
115
- console.error(`Warning: JSON entry missing string body at ${f}`);
118
+ log.warn(`Warning: JSON entry missing string body at ${f}`);
116
119
  }
117
120
  } catch (err: unknown) {
118
- console.error(`Warning: corrupt/unparsable JSON file at ${f}`, err);
121
+ log.warn(`Warning: corrupt/unparsable JSON file at ${f}`, { error: err });
119
122
  }
120
123
  } else if (f.endsWith('.md')) {
121
- const mdText = await readFile(f, 'utf-8');
124
+ const mdText = await fs.readFile(f);
122
125
  docs.push({
123
126
  id,
124
127
  body: mdText,
@@ -140,31 +143,33 @@ export async function main() {
140
143
  });
141
144
 
142
145
  if (!values.in || !values.out) {
143
- console.error('Error: Missing required arguments --in or --out');
146
+ echoError('Error: Missing required arguments --in or --out');
144
147
  process.exit(1);
145
148
  }
146
149
 
150
+ const fs = createNodeFileSystem();
147
151
  try {
148
152
  const candidateFiles = await collectFiles(values.in);
149
153
  const docs = await processKarakeepFolder(values.in);
150
154
 
151
155
  if (docs.length === 0 && candidateFiles.length > 0) {
152
- console.error(`Fatal: source yielded zero ingestible documents at ${values.in}`);
156
+ echoError(`Fatal: source yielded zero ingestible documents at ${values.in}`);
153
157
  process.exit(1);
154
158
  }
155
159
 
156
160
  const outDir = dirname(values.out);
157
161
  if (outDir && outDir !== '.') {
158
- await mkdir(outDir, { recursive: true });
162
+ await fs.ensureDir(outDir);
159
163
  }
160
164
 
161
- await writeFile(values.out, JSON.stringify(docs, null, 2), 'utf-8');
165
+ await fs.writeFile(values.out, JSON.stringify(docs, null, 2));
162
166
  } catch (err: unknown) {
163
- console.error('Fatal error during karakeep-local ingestion:', err);
167
+ echoError(`Fatal error during karakeep-local ingestion: ${err instanceof Error ? err.message : String(err)}`);
164
168
  process.exit(1);
165
169
  }
166
170
  }
167
171
 
168
172
  if (import.meta.main) {
173
+ await initializeLogger({ console: process.env.NODE_ENV !== 'test', json: true, level: 'warn' });
169
174
  main();
170
175
  }
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../../tooling/typescript/base.json",
3
+ "include": ["src", "tests"]
4
+ }
@@ -6,7 +6,10 @@
6
6
  "typecheck": "tsc --noEmit"
7
7
  },
8
8
  "dependencies": {
9
- "@gobing-ai/kk-core": "workspace:*"
9
+ "@gobing-ai/kk-core": "workspace:*",
10
+ "@gobing-ai/ts-infra": "catalog:",
11
+ "@gobing-ai/ts-runtime": "catalog:",
12
+ "@gobing-ai/ts-utils": "catalog:"
10
13
  },
11
14
  "devDependencies": {
12
15
  "@types/bun": "1.3.14"
@@ -1,8 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
- import { dirname } from 'node:path';
2
+ import { dirname, join } from 'node:path';
4
3
  import { parseArgs } from 'node:util';
5
4
  import { type Doc, DocListSchema } from '@gobing-ai/kk-core';
5
+ import { getLogger, initializeLogger } from '@gobing-ai/ts-infra';
6
+ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
7
+ import { echoError } from '@gobing-ai/ts-utils';
8
+
9
+ const log = getLogger('kk.ingestion.web-search');
6
10
 
7
11
  /** Validated `--in` input per the 0054 contract (`topic` required; `maxResults`/`fixture` optional). */
8
12
  interface InInput {
@@ -40,11 +44,269 @@ const FIXTURE_ENTRIES: FixtureEntry[] = [
40
44
  },
41
45
  ];
42
46
 
43
- /** Deterministic Doc id: sha256(sourceUri) hex prefix, same idea as karakeep-local (0054 Block 4). */
44
- function computeId(uri: string): string {
47
+ /** Deterministic Doc id: sha256(sourceUri) hex prefix, same idea as karakeep-local (0054 Block 4).
48
+ * Exported so 0059's local-Doc mapping can stay consistent if a later helper imports it (R3). */
49
+ export function computeId(uri: string): string {
45
50
  return createHash('sha256').update(uri).digest('hex').slice(0, 16);
46
51
  }
47
52
 
53
+ /** Fetch signature the live path accepts, so tests can inject a fake (CI never hits the network). */
54
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
55
+
56
+ /** markitdown normalizer — HTML in, markdown out (ADR-013 D3). Throws on non-zero exit or
57
+ * empty output; callers treat a throw as unusable content (per-URL fallback, D5). */
58
+ export type MarkitdownRunner = (html: string) => Promise<string>;
59
+
60
+ /** Live-path dependencies — fetch, env, clock, scrape, and markitdown are all injectable for tests. */
61
+ export interface SearchDeps {
62
+ fetch: FetchLike;
63
+ env: Record<string, string | undefined>;
64
+ now: () => Date;
65
+ /** Scrape transport; defaults to `deps.fetch` so tests can route both Firecrawl calls separately. */
66
+ scrape?: FetchLike;
67
+ /** markitdown runner; when absent in live mode, `main` probes PATH and fails loud (R6). */
68
+ markitdown?: MarkitdownRunner;
69
+ }
70
+
71
+ /** Real markitdown runner: `markitdown -x .html`, HTML on stdin, markdown on stdout. The
72
+ * `-x .html` extension hint is required — bare stdin passes HTML through unconverted.
73
+ * Spawns the absolute path resolved via Bun.which (with an explicit PATH option — plain
74
+ * argv[0] lookup can resolve against a startup PATH snapshot, missing runtime edits). */
75
+ export const realMarkitdown: MarkitdownRunner = async (html: string): Promise<string> => {
76
+ const bin = Bun.which('markitdown', { PATH: process.env.PATH });
77
+ if (!bin) {
78
+ throw new Error('markitdown is not on PATH');
79
+ }
80
+ const proc = Bun.spawn([bin, '-x', '.html'], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' });
81
+ proc.stdin.write(html);
82
+ await proc.stdin.end();
83
+ const [stdout, stderrText, exitCode] = await Promise.all([
84
+ new Response(proc.stdout).text(),
85
+ new Response(proc.stderr).text(),
86
+ proc.exited,
87
+ ]);
88
+ if (exitCode !== 0) {
89
+ const detail = stderrText.trim();
90
+ throw new Error(`markitdown exited ${exitCode}${detail ? `: ${detail}` : ''}`);
91
+ }
92
+ const markdown = stdout.trim();
93
+ if (markdown.length === 0) {
94
+ throw new Error('markitdown produced empty output');
95
+ }
96
+ return markdown;
97
+ };
98
+
99
+ /** One Firecrawl `data.web[]` item — the subset of fields 0054 Block 3/4 consume. */
100
+ interface FirecrawlWebItem {
101
+ url?: unknown;
102
+ title?: unknown;
103
+ markdown?: unknown;
104
+ position?: unknown;
105
+ }
106
+
107
+ interface FirecrawlSearchResponse {
108
+ success?: unknown;
109
+ error?: unknown;
110
+ data?: { web?: FirecrawlWebItem[] | null } | null;
111
+ }
112
+
113
+ /** One Firecrawl `/v2/scrape` body — the subset the cache pass consumes. */
114
+ interface FirecrawlScrapeResponse {
115
+ success?: unknown;
116
+ data?: { html?: unknown; markdown?: unknown } | null;
117
+ }
118
+
119
+ /**
120
+ * Live Firecrawl search → Doc[] (0054 Blocks 2–5). Throws an Error whose message is the
121
+ * fail-loud diagnostic; `main` prefixes it with `web-search failed: ` and returns 1.
122
+ * Never writes `--out` on a thrown path (no DocListSchema.parse, no file write here).
123
+ */
124
+ export async function searchFirecrawl(input: InInput, deps: SearchDeps): Promise<Doc[]> {
125
+ const key = deps.env.FIRECRAWL_API_KEY;
126
+ if (!key) {
127
+ // 0054 Block 5 row 2 — key required by this plugin's contract, not by Firecrawl.
128
+ throw new Error('FIRECRAWL_API_KEY is unset');
129
+ }
130
+
131
+ let res: Response;
132
+ try {
133
+ res = await deps.fetch('https://api.firecrawl.dev/v2/search', {
134
+ method: 'POST',
135
+ headers: {
136
+ 'Content-Type': 'application/json',
137
+ Authorization: `Bearer ${key}`,
138
+ },
139
+ body: JSON.stringify({
140
+ query: input.topic,
141
+ limit: input.maxResults,
142
+ sources: ['web'],
143
+ scrapeOptions: { formats: ['markdown'] },
144
+ }),
145
+ });
146
+ } catch (err: unknown) {
147
+ // 0054 Block 5 row 3 — transport error (DNS, refused, TLS, fetch throws).
148
+ throw new Error(`Firecrawl network error: ${err instanceof Error ? err.message : String(err)}`);
149
+ }
150
+
151
+ let bodyText: string;
152
+ try {
153
+ bodyText = await res.text();
154
+ } catch {
155
+ throw new Error(`Firecrawl HTTP ${res.status}: empty response body`);
156
+ }
157
+
158
+ if (!res.ok || res.status !== 200) {
159
+ // 0054 Block 5 rows 4–5 — 401/402/400/404/408/429/5xx. No silent retry in v1.
160
+ throw new Error(`Firecrawl HTTP ${res.status}: ${extractError(bodyText)}`);
161
+ }
162
+
163
+ let parsed: FirecrawlSearchResponse;
164
+ try {
165
+ parsed = JSON.parse(bodyText) as FirecrawlSearchResponse;
166
+ } catch {
167
+ // 200 but not JSON → malformed response (row 5).
168
+ throw new Error('Firecrawl malformed response: invalid JSON');
169
+ }
170
+
171
+ if (parsed.success === false) {
172
+ // 0054 Block 5 row 5 — `success:false` in a 200 body.
173
+ throw new Error(`Firecrawl HTTP 200: ${extractError(bodyText)}`);
174
+ }
175
+
176
+ if (parsed.success !== true) {
177
+ throw new Error('Firecrawl malformed response: success missing');
178
+ }
179
+
180
+ // Row 5 (P4): a 200 `success:true` with `data` absent/null is malformed, not zero hits.
181
+ if (parsed.data === null || parsed.data === undefined) {
182
+ throw new Error('Firecrawl malformed response: data missing');
183
+ }
184
+
185
+ const web = parsed.data.web ?? [];
186
+
187
+ // Row 6 — true zero hits: write `[]`, exit 0.
188
+ if (web.length === 0) {
189
+ return [];
190
+ }
191
+
192
+ const docs: Doc[] = [];
193
+ let skipped = 0;
194
+ for (const [rank, item] of web.entries()) {
195
+ const url = typeof item.url === 'string' ? item.url : undefined;
196
+ const markdown = typeof item.markdown === 'string' ? item.markdown : undefined;
197
+ const body = markdown?.trim() ?? '';
198
+ if (!url || body.length === 0) {
199
+ // Row 7 — partial item failure: skip + stderr warn, continue.
200
+ skipped += 1;
201
+ log.warn(`web-search: skipping result without usable markdown (url=${url ?? 'missing'})`);
202
+ continue;
203
+ }
204
+ docs.push({
205
+ id: computeId(url),
206
+ body,
207
+ title: typeof item.title === 'string' ? item.title : undefined,
208
+ sourceUri: url,
209
+ mediaType: 'text/markdown',
210
+ metadata: {
211
+ query: input.topic,
212
+ rank,
213
+ position: typeof item.position === 'number' ? item.position : undefined,
214
+ scraped_at: deps.now().toISOString(),
215
+ },
216
+ });
217
+ }
218
+
219
+ // Row 8 — hits > 0 but every item skipped: deliberate departure from C1 (0054 Block 6b).
220
+ if (docs.length === 0 && skipped > 0) {
221
+ throw new Error('Firecrawl returned hits but no usable markdown bodies');
222
+ }
223
+
224
+ return docs;
225
+ }
226
+
227
+ /** Best-effort `error` message from a Firecrawl error body; empty when unavailable. */
228
+ function extractError(bodyText: string): string {
229
+ try {
230
+ const parsed = JSON.parse(bodyText) as { error?: unknown };
231
+ const error = parsed.error;
232
+ if (typeof error === 'string' && error.length > 0) {
233
+ return error;
234
+ }
235
+ if (error !== undefined && error !== null) {
236
+ return JSON.stringify(error);
237
+ }
238
+ } catch {
239
+ // Non-JSON error body — fall through to empty.
240
+ }
241
+ return '';
242
+ }
243
+
244
+ /** Scrape + normalize + cache pass over the search results (ADR-013 D1–D6). Sequential
245
+ * per Doc — deliberate, no parallel fan-out (rate-limit friendly, deterministic). Cache hit
246
+ * (`references/<doc-id>.md` exists) reuses the file with no new /v2/scrape call (R3/R4);
247
+ * otherwise scrape → normalize (html via markitdown, markdown as-is) → write cache → body
248
+ * swap. Per-URL failure or unusable content warns and keeps the snippet body (R5/R11) — never
249
+ * fatal. Returns a new Doc[]; input order and non-body fields are untouched. */
250
+ export async function scrapeAndCacheDocs(docs: Doc[], deps: SearchDeps, outPath: string): Promise<Doc[]> {
251
+ const fs = createNodeFileSystem();
252
+ const cacheDir = join(dirname(outPath), 'references');
253
+ const scrapeFetch = deps.scrape ?? deps.fetch;
254
+ const out: Doc[] = [];
255
+ for (const doc of docs) {
256
+ const uri = doc.sourceUri;
257
+ if (!uri) {
258
+ out.push(doc);
259
+ continue;
260
+ }
261
+ const cacheFile = join(cacheDir, `${doc.id}.md`);
262
+ try {
263
+ const cached = await fs.readFile(cacheFile);
264
+ if (cached.trim().length > 0) {
265
+ out.push({ ...doc, body: cached });
266
+ continue;
267
+ }
268
+ } catch {
269
+ // Cache miss — fall through to the live scrape.
270
+ }
271
+ try {
272
+ const key = deps.env.FIRECRAWL_API_KEY;
273
+ if (!key) throw new Error('FIRECRAWL_API_KEY is unset');
274
+ const res = await scrapeFetch('https://api.firecrawl.dev/v2/scrape', {
275
+ method: 'POST',
276
+ headers: {
277
+ 'Content-Type': 'application/json',
278
+ Authorization: `Bearer ${key}`,
279
+ },
280
+ body: JSON.stringify({ url: uri, formats: ['html', 'markdown'] }),
281
+ });
282
+ if (!res.ok || res.status !== 200) {
283
+ throw new Error(`Firecrawl HTTP ${res.status}`);
284
+ }
285
+ const parsed = JSON.parse(await res.text()) as FirecrawlScrapeResponse;
286
+ if (parsed.success !== true || parsed.data === null || parsed.data === undefined) {
287
+ throw new Error('Firecrawl malformed scrape response');
288
+ }
289
+ const html = typeof parsed.data.html === 'string' ? parsed.data.html : undefined;
290
+ const markdown = typeof parsed.data.markdown === 'string' ? parsed.data.markdown : undefined;
291
+ let normalized: string;
292
+ if (html && html.trim().length > 0) {
293
+ normalized = await (deps.markitdown ?? realMarkitdown)(html);
294
+ } else if (markdown && markdown.trim().length > 0) {
295
+ normalized = markdown.trim();
296
+ } else {
297
+ throw new Error('scrape returned no usable content');
298
+ }
299
+ await fs.ensureDir(cacheDir);
300
+ await fs.writeFile(cacheFile, normalized);
301
+ out.push({ ...doc, body: normalized });
302
+ } catch (err: unknown) {
303
+ log.warn(`web-search: scrape failed (url=${uri}): ${err instanceof Error ? err.message : String(err)}`);
304
+ out.push(doc);
305
+ }
306
+ }
307
+ return out;
308
+ }
309
+
48
310
  /**
49
311
  * Build the fixture `Doc[]` for a topic (0054 Block 4 mapping). Always emits
50
312
  * canned docs — the v1 plugin has no live Firecrawl path.
@@ -95,6 +357,7 @@ export function parseInput(raw: string): InInput {
95
357
  }
96
358
 
97
359
  export async function main(): Promise<number> {
360
+ const fs = createNodeFileSystem();
98
361
  const { values } = parseArgs({
99
362
  options: {
100
363
  in: { type: 'string' },
@@ -103,42 +366,61 @@ export async function main(): Promise<number> {
103
366
  });
104
367
 
105
368
  if (!values.in || !values.out) {
106
- console.error('Error: Missing required arguments --in and --out');
369
+ echoError('Error: Missing required arguments --in and --out');
107
370
  return 1;
108
371
  }
109
372
 
110
373
  let input: InInput;
111
374
  try {
112
- input = parseInput(await readFile(values.in, 'utf-8'));
375
+ input = parseInput(await fs.readFile(values.in));
113
376
  } catch (err: unknown) {
114
- console.error(`web-search failed: ${err instanceof Error ? err.message : String(err)}`);
377
+ echoError(`web-search failed: ${err instanceof Error ? err.message : String(err)}`);
115
378
  return 1;
116
379
  }
117
380
 
118
381
  if (!input.fixture) {
119
- // 0054 Block 1/Block 8: `fixture` absent or false selects the LIVE Firecrawl path; v1 has no
120
- // live client. Fail loud (Block 5 row-2 semantics: exit 1, no --out) instead of silently
121
- // fabricating research docs — an obvious failure beats a surprising recovery.
122
- console.error(
123
- 'web-search failed: live Firecrawl not implemented in v1 (pass "fixture": true to use the fixture stub)',
124
- );
125
- return 1;
382
+ // 0054 Block 1/Block 8: `fixture` absent/false = LIVE Firecrawl path.
383
+ try {
384
+ const deps: SearchDeps = {
385
+ fetch: globalThis.fetch,
386
+ env: process.env,
387
+ now: () => new Date(),
388
+ };
389
+ // Bun.which with an explicit PATH — without the option it can ignore runtime
390
+ // process.env.PATH changes (observed on Bun 1.3.14).
391
+ if (!deps.markitdown && !Bun.which('markitdown', { PATH: deps.env.PATH })) {
392
+ // R6 — live mode requires the normalizer; fail before any network call.
393
+ throw new Error('markitdown is not on PATH');
394
+ }
395
+ const docs = await searchFirecrawl(input, deps);
396
+ const cached = await scrapeAndCacheDocs(docs, deps, values.out);
397
+ const outDir = dirname(values.out);
398
+ if (outDir && outDir !== '.') {
399
+ await fs.ensureDir(outDir);
400
+ }
401
+ await fs.writeFile(values.out, JSON.stringify(DocListSchema.parse(cached), null, 2));
402
+ return 0;
403
+ } catch (err: unknown) {
404
+ echoError(`web-search failed: ${err instanceof Error ? err.message : String(err)}`);
405
+ return 1;
406
+ }
126
407
  }
127
408
 
128
409
  try {
129
410
  const docs = DocListSchema.parse(buildFixtureDocs(input.topic, input.maxResults));
130
411
  const outDir = dirname(values.out);
131
412
  if (outDir && outDir !== '.') {
132
- await mkdir(outDir, { recursive: true });
413
+ await fs.ensureDir(outDir);
133
414
  }
134
- await writeFile(values.out, JSON.stringify(docs, null, 2), 'utf-8');
415
+ await fs.writeFile(values.out, JSON.stringify(docs, null, 2));
135
416
  return 0;
136
417
  } catch (err: unknown) {
137
- console.error(`web-search failed: ${err instanceof Error ? err.message : String(err)}`);
418
+ echoError(`web-search failed: ${err instanceof Error ? err.message : String(err)}`);
138
419
  return 1;
139
420
  }
140
421
  }
141
422
 
142
423
  if (import.meta.main) {
424
+ await initializeLogger({ console: process.env.NODE_ENV !== 'test', json: true, level: 'warn' });
143
425
  process.exit(await main());
144
426
  }
@@ -7,11 +7,22 @@ It is a Claude Code / Superskill plugin, **not** a kk-core product plugin.
7
7
  |------|--------|
8
8
  | `skills/` | Fat skills (`SKILL.md`) |
9
9
  | `commands/` | Thin slash-command wrappers |
10
- | `agents/` | Thin subagent wrappers |
10
+ | `agents/` | Thin subagent wrappers (currently empty — see below) |
11
11
  | `hooks/` | Hook definitions |
12
12
  | `rules/` | Agent rules |
13
+ | `workflows/` | Design-time product YAML SSOT (ADR-009). Runtime dest is `$HOME/.config/kk/workflows` via `/workflow-run` |
14
+
15
+ `agents/` is reserved for thin wrappers of fat skills. It is empty: `judge-tech` /
16
+ `judge-tone` / `judge-compliance` were retired (2026-08-19). Rubric selection lives on
17
+ `content-judge` (`agent.run` prompt or `--judge` on `itc-generating`). Do not re-add a
18
+ persona wrapper unless a new skill owns a distinct run procedure.
13
19
 
14
20
  `discoverPlugins` skips this directory. Product plugins (`ingestion` / `generator` /
15
21
  `publish`) live under `plugins/ingestions|generations|publishings/<name>/`.
16
22
 
17
23
  Install: `superskill install kk`.
24
+
25
+ Capability files **omit a leading `kk-`**. `superskill install` prefixes the plugin name, so
26
+ `skills/content-judge` installs as `kk:content-judge` (not `kk:kk-content-judge`). Same for `topic`,
27
+ `storm-research`, `itc-generating`, and `/workflow-run`. Product workflow YAML (`kk-storm-research.yaml`)
28
+ keeps its existing name — that is a workflow stem, not an installable agent capability.