@gobing-ai/knowledge-kit 0.0.5 → 0.0.7

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.
package/dist/index.js CHANGED
@@ -16682,6 +16682,8 @@ function isDirectory(path2) {
16682
16682
  function getByName(result, name) {
16683
16683
  return result.plugins.find((p) => p.name === name);
16684
16684
  }
16685
+ // ../../packages/kk-core/src/render-md.ts
16686
+ if (false) {}
16685
16687
  // src/fanin.ts
16686
16688
  var EXIT_USAGE = 2;
16687
16689
  var EXIT_INPUT = 1;
@@ -17438,7 +17440,7 @@ function createProgram() {
17438
17440
  }
17439
17441
 
17440
17442
  // src/main.ts
17441
- async function main(argv = process.argv, options = {}) {
17443
+ async function main2(argv = process.argv, options = {}) {
17442
17444
  const program2 = createProgram();
17443
17445
  program2.exitOverride();
17444
17446
  program2.configureOutput({
@@ -17468,5 +17470,5 @@ async function main(argv = process.argv, options = {}) {
17468
17470
 
17469
17471
  // src/index.ts
17470
17472
  if (import.meta.main) {
17471
- process.exit(await main());
17473
+ process.exit(await main2());
17472
17474
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/knowledge-kit",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "An ingest → create → publish content pipeline CLI (Bun).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -7,7 +7,8 @@
7
7
  },
8
8
  "dependencies": {
9
9
  "@gobing-ai/kk-core": "workspace:*",
10
- "@gobing-ai/utils": "workspace:*"
10
+ "@gobing-ai/utils": "workspace:*",
11
+ "zod": "4.4.3"
11
12
  },
12
13
  "devDependencies": {
13
14
  "@types/bun": "1.3.14"
@@ -40,11 +40,161 @@ const FIXTURE_ENTRIES: FixtureEntry[] = [
40
40
  },
41
41
  ];
42
42
 
43
- /** Deterministic Doc id: sha256(sourceUri) hex prefix, same idea as karakeep-local (0054 Block 4). */
44
- function computeId(uri: string): string {
43
+ /** Deterministic Doc id: sha256(sourceUri) hex prefix, same idea as karakeep-local (0054 Block 4).
44
+ * Exported so 0059's local-Doc mapping can stay consistent if a later helper imports it (R3). */
45
+ export function computeId(uri: string): string {
45
46
  return createHash('sha256').update(uri).digest('hex').slice(0, 16);
46
47
  }
47
48
 
49
+ /** Fetch signature the live path accepts, so tests can inject a fake (CI never hits the network). */
50
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
51
+
52
+ /** Live-path dependencies — fetch, env, and clock are all injectable for tests. */
53
+ export interface SearchDeps {
54
+ fetch: FetchLike;
55
+ env: Record<string, string | undefined>;
56
+ now: () => Date;
57
+ }
58
+
59
+ /** One Firecrawl `data.web[]` item — the subset of fields 0054 Block 3/4 consume. */
60
+ interface FirecrawlWebItem {
61
+ url?: unknown;
62
+ title?: unknown;
63
+ markdown?: unknown;
64
+ position?: unknown;
65
+ }
66
+
67
+ interface FirecrawlSearchResponse {
68
+ success?: unknown;
69
+ error?: unknown;
70
+ data?: { web?: FirecrawlWebItem[] | null } | null;
71
+ }
72
+
73
+ /**
74
+ * Live Firecrawl search → Doc[] (0054 Blocks 2–5). Throws an Error whose message is the
75
+ * fail-loud diagnostic; `main` prefixes it with `web-search failed: ` and returns 1.
76
+ * Never writes `--out` on a thrown path (no DocListSchema.parse, no file write here).
77
+ */
78
+ export async function searchFirecrawl(input: InInput, deps: SearchDeps): Promise<Doc[]> {
79
+ const key = deps.env.FIRECRAWL_API_KEY;
80
+ if (!key) {
81
+ // 0054 Block 5 row 2 — key required by this plugin's contract, not by Firecrawl.
82
+ throw new Error('FIRECRAWL_API_KEY is unset');
83
+ }
84
+
85
+ let res: Response;
86
+ try {
87
+ res = await deps.fetch('https://api.firecrawl.dev/v2/search', {
88
+ method: 'POST',
89
+ headers: {
90
+ 'Content-Type': 'application/json',
91
+ Authorization: `Bearer ${key}`,
92
+ },
93
+ body: JSON.stringify({
94
+ query: input.topic,
95
+ limit: input.maxResults,
96
+ sources: ['web'],
97
+ scrapeOptions: { formats: ['markdown'] },
98
+ }),
99
+ });
100
+ } catch (err: unknown) {
101
+ // 0054 Block 5 row 3 — transport error (DNS, refused, TLS, fetch throws).
102
+ throw new Error(`Firecrawl network error: ${err instanceof Error ? err.message : String(err)}`);
103
+ }
104
+
105
+ let bodyText: string;
106
+ try {
107
+ bodyText = await res.text();
108
+ } catch {
109
+ throw new Error(`Firecrawl HTTP ${res.status}: empty response body`);
110
+ }
111
+
112
+ if (!res.ok || res.status !== 200) {
113
+ // 0054 Block 5 rows 4–5 — 401/402/400/404/408/429/5xx. No silent retry in v1.
114
+ throw new Error(`Firecrawl HTTP ${res.status}: ${extractError(bodyText)}`);
115
+ }
116
+
117
+ let parsed: FirecrawlSearchResponse;
118
+ try {
119
+ parsed = JSON.parse(bodyText) as FirecrawlSearchResponse;
120
+ } catch {
121
+ // 200 but not JSON → malformed response (row 5).
122
+ throw new Error('Firecrawl malformed response: invalid JSON');
123
+ }
124
+
125
+ if (parsed.success === false) {
126
+ // 0054 Block 5 row 5 — `success:false` in a 200 body.
127
+ throw new Error(`Firecrawl HTTP 200: ${extractError(bodyText)}`);
128
+ }
129
+
130
+ if (parsed.success !== true) {
131
+ throw new Error('Firecrawl malformed response: success missing');
132
+ }
133
+
134
+ // Row 5 (P4): a 200 `success:true` with `data` absent/null is malformed, not zero hits.
135
+ if (parsed.data === null || parsed.data === undefined) {
136
+ throw new Error('Firecrawl malformed response: data missing');
137
+ }
138
+
139
+ const web = parsed.data.web ?? [];
140
+
141
+ // Row 6 — true zero hits: write `[]`, exit 0.
142
+ if (web.length === 0) {
143
+ return [];
144
+ }
145
+
146
+ const docs: Doc[] = [];
147
+ let skipped = 0;
148
+ for (const [rank, item] of web.entries()) {
149
+ const url = typeof item.url === 'string' ? item.url : undefined;
150
+ const markdown = typeof item.markdown === 'string' ? item.markdown : undefined;
151
+ const body = markdown?.trim() ?? '';
152
+ if (!url || body.length === 0) {
153
+ // Row 7 — partial item failure: skip + stderr warn, continue.
154
+ skipped += 1;
155
+ console.warn(`web-search: skipping result without usable markdown (url=${url ?? 'missing'})`);
156
+ continue;
157
+ }
158
+ docs.push({
159
+ id: computeId(url),
160
+ body,
161
+ title: typeof item.title === 'string' ? item.title : undefined,
162
+ sourceUri: url,
163
+ mediaType: 'text/markdown',
164
+ metadata: {
165
+ query: input.topic,
166
+ rank,
167
+ position: typeof item.position === 'number' ? item.position : undefined,
168
+ scraped_at: deps.now().toISOString(),
169
+ },
170
+ });
171
+ }
172
+
173
+ // Row 8 — hits > 0 but every item skipped: deliberate departure from C1 (0054 Block 6b).
174
+ if (docs.length === 0 && skipped > 0) {
175
+ throw new Error('Firecrawl returned hits but no usable markdown bodies');
176
+ }
177
+
178
+ return docs;
179
+ }
180
+
181
+ /** Best-effort `error` message from a Firecrawl error body; empty when unavailable. */
182
+ function extractError(bodyText: string): string {
183
+ try {
184
+ const parsed = JSON.parse(bodyText) as { error?: unknown };
185
+ const error = parsed.error;
186
+ if (typeof error === 'string' && error.length > 0) {
187
+ return error;
188
+ }
189
+ if (error !== undefined && error !== null) {
190
+ return JSON.stringify(error);
191
+ }
192
+ } catch {
193
+ // Non-JSON error body — fall through to empty.
194
+ }
195
+ return '';
196
+ }
197
+
48
198
  /**
49
199
  * Build the fixture `Doc[]` for a topic (0054 Block 4 mapping). Always emits
50
200
  * canned docs — the v1 plugin has no live Firecrawl path.
@@ -116,13 +266,23 @@ export async function main(): Promise<number> {
116
266
  }
117
267
 
118
268
  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;
269
+ // 0054 Block 1/Block 8: `fixture` absent/false = LIVE Firecrawl path.
270
+ try {
271
+ const docs = await searchFirecrawl(input, {
272
+ fetch: globalThis.fetch,
273
+ env: process.env,
274
+ now: () => new Date(),
275
+ });
276
+ const outDir = dirname(values.out);
277
+ if (outDir && outDir !== '.') {
278
+ await mkdir(outDir, { recursive: true });
279
+ }
280
+ await writeFile(values.out, JSON.stringify(DocListSchema.parse(docs), null, 2), 'utf-8');
281
+ return 0;
282
+ } catch (err: unknown) {
283
+ console.error(`web-search failed: ${err instanceof Error ? err.message : String(err)}`);
284
+ return 1;
285
+ }
126
286
  }
127
287
 
128
288
  try {
@@ -15,3 +15,8 @@ It is a Claude Code / Superskill plugin, **not** a kk-core product plugin.
15
15
  `publish`) live under `plugins/ingestions|generations|publishings/<name>/`.
16
16
 
17
17
  Install: `superskill install kk`.
18
+
19
+ Capability files **omit a leading `kk-`**. `superskill install` prefixes the plugin name, so
20
+ `skills/judge` installs as `kk:judge` (not `kk:kk-judge`). Same for `topic`, `storm-research`,
21
+ `/workflow-run`, and the `judge-*` subagents. Product workflow YAML (`kk-storm-research.yaml`)
22
+ keeps its existing name — that is a workflow stem, not an installable agent capability.
@@ -1,18 +1,18 @@
1
1
  ---
2
- name: kk-judge-compliance
3
- description: This specialized judge agent should be used when evaluating generated content for regulatory, legal, or safety compliance — prohibited claims, required disclosures, jurisdictional rules, and PII/sensitive-data leakage. Triggers on "compliance check", "legal review of content", "check prohibited claims", "verify disclosures", "regulatory gate", or when a spur workflow needs a compliance evaluation gate before publishing. Delegates all evaluation logic to the kk-judge fat skill with the compliance rubric pre-selected.
2
+ name: judge-compliance
3
+ description: This specialized judge agent should be used when evaluating generated content for regulatory, legal, or safety compliance — prohibited claims, required disclosures, jurisdictional rules, and PII/sensitive-data leakage. Triggers on "compliance check", "legal review of content", "check prohibited claims", "verify disclosures", "regulatory gate", or when a spur workflow needs a compliance evaluation gate before publishing. Delegates all evaluation logic to the judge fat skill with the compliance rubric pre-selected.
4
4
  tools: Read, Grep, Glob
5
5
  ---
6
6
 
7
- # kk-judge-compliance — compliance judge persona
7
+ # judge-compliance — compliance judge persona
8
8
 
9
- Thin wrapper around the **kk-judge** fat skill. Fixes the rubric to `compliance` and forwards
9
+ Thin wrapper around the **judge** fat skill. Fixes the rubric to `compliance` and forwards
10
10
  evaluation. Do not duplicate evaluation logic here — the skill owns it.
11
11
 
12
12
  ## Behavior
13
13
 
14
14
  1. Receive the content path and the jurisdiction/policy config from the caller or workflow step.
15
- 2. Invoke the `kk-judge` skill procedure with `rubric: compliance`.
15
+ 2. Invoke the `judge` skill procedure with `rubric: compliance`.
16
16
  3. Return the verdict JSON path; the skill writes the verdict file.
17
17
 
18
18
  ## Invocation contract
@@ -20,10 +20,10 @@ evaluation. Do not duplicate evaluation logic here — the skill owns it.
20
20
  When invoked by an `agent.run` workflow step:
21
21
 
22
22
  ```
23
- You are the kk-judge-compliance persona.
24
- Load the kk-judge skill from plugins/kk/skills/kk-judge/SKILL.md.
23
+ You are the judge-compliance persona.
24
+ Load the judge skill from plugins/kk/skills/judge/SKILL.md.
25
25
  Evaluate the content at {{content_path}} against the compliance rubric
26
- (plugins/kk/skills/kk-judge/references/rubrics.md, section "compliance").
26
+ (plugins/kk/skills/judge/references/rubrics.md, section "compliance").
27
27
  Jurisdiction/policy: {{jurisdiction}} # e.g. US-SEC, EU-GDPR, CN-CAC; or "unspecified"
28
28
  Write the verdict JSON to {{verdict_path}} per the contract in SKILL.md.
29
29
  Print the verdict path as the last line of stdout.
@@ -1,18 +1,18 @@
1
1
  ---
2
- name: kk-judge-tech
3
- description: This specialized judge agent should be used when evaluating generated technical content for factual accuracy, citation integrity, and code correctness. Triggers on "check technical accuracy", "verify tech content", "fact-check technical", "review code samples in content", or when a spur workflow needs a technical-accuracy evaluation gate. Delegates all evaluation logic to the kk-judge fat skill with the tech-accuracy rubric pre-selected.
2
+ name: judge-tech
3
+ description: This specialized judge agent should be used when evaluating generated technical content for factual accuracy, citation integrity, and code correctness. Triggers on "check technical accuracy", "verify tech content", "fact-check technical", "review code samples in content", or when a spur workflow needs a technical-accuracy evaluation gate. Delegates all evaluation logic to the judge fat skill with the tech-accuracy rubric pre-selected.
4
4
  tools: Read, Grep, Glob
5
5
  ---
6
6
 
7
- # kk-judge-tech — technical-accuracy judge persona
7
+ # judge-tech — technical-accuracy judge persona
8
8
 
9
- Thin wrapper around the **kk-judge** fat skill. Fixes the rubric to `tech-accuracy` and forwards
9
+ Thin wrapper around the **judge** fat skill. Fixes the rubric to `tech-accuracy` and forwards
10
10
  evaluation. Do not duplicate evaluation logic here — the skill owns it.
11
11
 
12
12
  ## Behavior
13
13
 
14
14
  1. Receive the content path (and optional verdict output path) from the caller or workflow step.
15
- 2. Invoke the `kk-judge` skill procedure with `rubric: tech-accuracy`.
15
+ 2. Invoke the `judge` skill procedure with `rubric: tech-accuracy`.
16
16
  3. Return the verdict JSON path; the skill writes the verdict file.
17
17
 
18
18
  ## Invocation contract
@@ -20,10 +20,10 @@ evaluation. Do not duplicate evaluation logic here — the skill owns it.
20
20
  When invoked by an `agent.run` workflow step:
21
21
 
22
22
  ```
23
- You are the kk-judge-tech persona.
24
- Load the kk-judge skill from plugins/kk/skills/kk-judge/SKILL.md.
23
+ You are the judge-tech persona.
24
+ Load the judge skill from plugins/kk/skills/judge/SKILL.md.
25
25
  Evaluate the content at {{content_path}} against the tech-accuracy rubric
26
- (plugins/kk/skills/kk-judge/references/rubrics.md, section "tech-accuracy").
26
+ (plugins/kk/skills/judge/references/rubrics.md, section "tech-accuracy").
27
27
  Write the verdict JSON to {{verdict_path}} per the contract in SKILL.md.
28
28
  Print the verdict path as the last line of stdout.
29
29
  ```
@@ -1,19 +1,19 @@
1
1
  ---
2
- name: kk-judge-tone
3
- description: This specialized judge agent should be used when evaluating generated content for brand voice and tone consistency, banned-phrase compliance, and audience fit. Triggers on "check brand tone", "review voice consistency", "tone audit", "style guide check", or when a spur workflow needs a brand-tone evaluation gate before publishing. Delegates all evaluation logic to the kk-judge fat skill with the brand-tone rubric pre-selected.
2
+ name: judge-tone
3
+ description: This specialized judge agent should be used when evaluating generated content for brand voice and tone consistency, banned-phrase compliance, and audience fit. Triggers on "check brand tone", "review voice consistency", "tone audit", "style guide check", or when a spur workflow needs a brand-tone evaluation gate before publishing. Delegates all evaluation logic to the judge fat skill with the brand-tone rubric pre-selected.
4
4
  tools: Read, Grep, Glob
5
5
  ---
6
6
 
7
- # kk-judge-tone — brand-tone judge persona
7
+ # judge-tone — brand-tone judge persona
8
8
 
9
- Thin wrapper around the **kk-judge** fat skill. Fixes the rubric to `brand-tone` and forwards
9
+ Thin wrapper around the **judge** fat skill. Fixes the rubric to `brand-tone` and forwards
10
10
  evaluation. Do not duplicate evaluation logic here — the skill owns it.
11
11
 
12
12
  ## Behavior
13
13
 
14
14
  1. Receive the content path and the brand config (tone profile + banned-phrase list) from the
15
15
  caller or workflow step.
16
- 2. Invoke the `kk-judge` skill procedure with `rubric: brand-tone`.
16
+ 2. Invoke the `judge` skill procedure with `rubric: brand-tone`.
17
17
  3. Return the verdict JSON path; the skill writes the verdict file.
18
18
 
19
19
  ## Invocation contract
@@ -21,10 +21,10 @@ evaluation. Do not duplicate evaluation logic here — the skill owns it.
21
21
  When invoked by an `agent.run` workflow step:
22
22
 
23
23
  ```
24
- You are the kk-judge-tone persona.
25
- Load the kk-judge skill from plugins/kk/skills/kk-judge/SKILL.md.
24
+ You are the judge-tone persona.
25
+ Load the judge skill from plugins/kk/skills/judge/SKILL.md.
26
26
  Evaluate the content at {{content_path}} against the brand-tone rubric
27
- (plugins/kk/skills/kk-judge/references/rubrics.md, section "brand-tone").
27
+ (plugins/kk/skills/judge/references/rubrics.md, section "brand-tone").
28
28
  Brand config (tone profile + banned phrases): {{brand_config}}
29
29
  Write the verdict JSON to {{verdict_path}} per the contract in SKILL.md.
30
30
  Print the verdict path as the last line of stdout.
@@ -0,0 +1,130 @@
1
+ ---
2
+ name: workflow-run
3
+ description: >-
4
+ Run STORM daily research end to end — a topic sentence XOR a markdown file becomes a
5
+ grounded content.md report. Installs the workflow YAML on first run (0056 copy rule),
6
+ creates the config with defaults if missing, computes the topic workspace, and shells
7
+ spur workflow run against the installed workflow.
8
+ argument-hint: "[name] <topic|--in file> [--fixture] [--force]"
9
+ ---
10
+
11
+ Thin wrapper around the **storm-research** skill. Read
12
+ `plugins/kk/skills/storm-research/SKILL.md` first — it is the craft SSOT (query framing,
13
+ evidence verification, better-report practices, fail-loud rows). This command owns the run
14
+ procedure: config create, YAML install, topic-id computation, and the `spur workflow run`.
15
+ No new `kk` CLI noun (ADR-011) — everything here is shell + the workflow.
16
+
17
+ **Args:** `$ARGUMENTS`
18
+
19
+ ## 1. Parse `$ARGUMENTS`
20
+
21
+ Parse per the frozen rule:
22
+
23
+ - First token is the workflow **name** when it matches `^[a-z0-9][a-z0-9-]*$` and is not `--in`;
24
+ otherwise `name=kk-storm-research` and that token is the topic.
25
+ - `--in <file>` → file mode. `--fixture` → force `fixture: "true"`. `--force` → overwrite an
26
+ existing dest YAML.
27
+ - **XOR:** exactly one of `<topic>` / `--in <file>`. Both set or both empty → exit 1, stderr
28
+ states the XOR rule, no writes.
29
+
30
+ Resolve these before step 2: `NAME`, `TOPIC`, `INPUT_FILE` (empty in sentence mode), `FIXTURE`
31
+ (`true`|`false`), `FORCE`.
32
+
33
+ ## 2. Resolve the config
34
+
35
+ `config_path="${KK_CONFIG:-$HOME/.config/kk/config.yaml}"`.
36
+
37
+ - **Missing file → create-on-first-run** with compiled defaults (never overwrite an existing
38
+ file — no merge, no rewrite):
39
+
40
+ ```bash
41
+ install -d "$(dirname "$config_path")"
42
+ cat > "$config_path" <<'EOF'
43
+ works_dir: "$HOME/.config/kk/works"
44
+ workflows_dir: "$HOME/.config/kk/workflows"
45
+ defaults:
46
+ maxResults: 8
47
+ fixture: false
48
+ EOF
49
+ ```
50
+
51
+ - **Unreadable / unparseable / invalid types** (e.g. `maxResults: "eight"`) → exit 1, stderr
52
+ names the key and expected vs actual type; touch nothing.
53
+ - Read the four values with precedence **command flag > env > file > compiled default**:
54
+
55
+ | Value | Env override | Compiled default |
56
+ | --- | --- | --- |
57
+ | `works_dir` | `KK_WORKS_DIR` | `$HOME/.config/kk/works` |
58
+ | `workflows_dir` | `KK_WORKFLOWS_DIR` | `$HOME/.config/kk/workflows` |
59
+ | `defaults.maxResults` | — | `8` |
60
+ | `defaults.fixture` | — | `false` |
61
+
62
+ `--fixture` sets `fixture=true` regardless of the file. `FIRECRAWL_API_KEY` is **never** read
63
+ from config — env-only.
64
+
65
+ ## 3. Install the workflow YAML
66
+
67
+ `dest="$workflows_dir/$NAME.yaml"`. Copy from the **first hit** of, in order:
68
+
69
+ 1. `$KK_WORKFLOWS_SOURCE/$NAME.yaml`
70
+ 2. `plugins/kk/workflows/$NAME.yaml`
71
+ 3. `.spur/workflows/$NAME.yaml`
72
+
73
+ - `dest` missing → copy from the first hit. Source missing from all three → exit 1, stderr
74
+ lists the roots searched.
75
+ - `dest` exists + `--force` → overwrite from source.
76
+ - `dest` exists + identical bytes → no-op.
77
+ - `dest` exists + different bytes → **warn and leave** (run proceeds with the user's copy).
78
+
79
+ ```bash
80
+ src=""
81
+ for d in "$KK_WORKFLOWS_SOURCE" "plugins/kk/workflows" ".spur/workflows"; do
82
+ [ -n "$d" ] && [ -f "$d/$NAME.yaml" ] && { src="$d/$NAME.yaml"; break; }
83
+ done
84
+ [ -z "$src" ] && { echo "no source workflow for $NAME (searched KK_WORKFLOWS_SOURCE, plugins/kk/workflows, .spur/workflows)" >&2; exit 1; }
85
+ if [ ! -f "$dest" ] || [ "$FORCE" = true ]; then
86
+ install -d "$(dirname "$dest")" && cp "$src" "$dest"
87
+ elif ! cmp -s "$src" "$dest"; then
88
+ echo "warning: $dest differs from install source; leaving user copy (pass --force to replace)" >&2
89
+ fi
90
+ ```
91
+
92
+ ## 4. Compute the topic workspace (0056 `topicId`)
93
+
94
+ `raw` = `TOPIC` (sentence mode) or the first ATX H1 (`/^#\s+(.+)$/m`) else basename without
95
+ extension (`--in` mode). Then:
96
+
97
+ ```bash
98
+ normalized=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | tr -s '[:space:]' ' ' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
99
+ slug=$(printf '%s' "$normalized" | tr -cs 'a-z0-9' '-' | sed 's/^-*//; s/-*$//' | cut -c1-48 | sed 's/-*$//')
100
+ [ -z "$slug" ] && slug="topic"
101
+ hash=$(printf '%s' "$normalized" | shasum -a 256 | cut -c1-8)
102
+ topic_id="${slug}-${hash}"
103
+ work_dir="$works_dir/$topic_id"
104
+ ```
105
+
106
+ - `raw` derives empty (no H1 and empty basename) → exit 1 with the derived value.
107
+ - Same `raw` → same workspace; **last write wins**; never fail on an existing works dir.
108
+
109
+ ```bash
110
+ mkdir -p "$work_dir"
111
+ ```
112
+
113
+ ## 5. Run the workflow
114
+
115
+ All values are strings. Bind the vars first: `maxResults` and `fixture` from step 2;
116
+ `plugins_path` defaults to `./plugins` in a checkout, else the installed package `plugins/`
117
+ path or `KK_PLUGIN_PATH`; `render_script` stays `plugins/kk/scripts/render-md.ts`.
118
+
119
+ ```bash
120
+ spur workflow run "$dest" --vars \
121
+ "{\"topic\":\"$TOPIC\",\"input_file\":\"$INPUT_FILE\",\"maxResults\":\"$maxResults\",\"fixture\":\"$FIXTURE\",\"work_dir\":\"$work_dir\",\"plugins_path\":\"$plugins_path\",\"render_script\":\"$render_script\"}"
122
+ ```
123
+
124
+ ## 6. Report
125
+
126
+ Print the resolved `work_dir`, then the two artifacts to hand the operator:
127
+ `$work_dir/content.md` (report sidecar) and `$work_dir/content.json` (machine contract).
128
+ Per the skill's better-report practice (d), skim `content.md` citations against `docs.json`
129
+ ids before presenting. If the run ended `failed`, show the failing state
130
+ (`spur workflow trace <run-id> --json`) and the skill's fail-loud recovery row.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Shipped no-checkout runner for the Content→markdown research-report helper.
3
+ * 0059's `render` state shells: `bun <this script> --in <content.json> --out <content.md>`.
4
+ *
5
+ * Self-contained by design: end users install the published `kk` package and cannot
6
+ * resolve `@gobing-ai/kk-core` (private). The render logic below is a COPY of
7
+ * `packages/kk-core/src/render-md.ts`; keep them byte-identical for the same Content.
8
+ *
9
+ * SYNC: packages/kk-core/src/render-md.ts (renderContentMarkdown + helpers)
10
+ */
11
+
12
+ import { readFile, writeFile } from 'node:fs/promises';
13
+ import { parseArgs } from 'node:util';
14
+
15
+ interface Ref {
16
+ url?: string;
17
+ title?: string;
18
+ cite?: string;
19
+ }
20
+
21
+ interface ContentInput {
22
+ body: string;
23
+ title?: string;
24
+ outline?: string;
25
+ references?: Ref[];
26
+ }
27
+
28
+ /** Mirror of `ContentSchema` structural requirements for the fields this render touches. */
29
+ function parseContent(raw: string): ContentInput {
30
+ const value: unknown = JSON.parse(raw);
31
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
32
+ throw new Error('content must be an object');
33
+ }
34
+ const c = value as Record<string, unknown>;
35
+ if (typeof c.body !== 'string') {
36
+ throw new Error('body is required and must be a string');
37
+ }
38
+ const out: ContentInput = { body: c.body };
39
+ if (c.title !== undefined) {
40
+ if (typeof c.title !== 'string') {
41
+ throw new Error('title must be a string');
42
+ }
43
+ out.title = c.title;
44
+ }
45
+ if (c.outline !== undefined) {
46
+ if (typeof c.outline !== 'string') {
47
+ throw new Error('outline must be a string');
48
+ }
49
+ out.outline = c.outline;
50
+ }
51
+ if (c.references !== undefined) {
52
+ if (!Array.isArray(c.references)) {
53
+ throw new Error('references must be an array');
54
+ }
55
+ out.references = c.references.map((r, i) => {
56
+ if (r === null || typeof r !== 'object' || Array.isArray(r)) {
57
+ throw new Error(`references[${i}] must be an object`);
58
+ }
59
+ const rr = r as Record<string, unknown>;
60
+ for (const key of ['url', 'title', 'cite'] as const) {
61
+ if (rr[key] !== undefined && typeof rr[key] !== 'string') {
62
+ throw new Error(`references[${i}].${key} must be a string`);
63
+ }
64
+ }
65
+ return { url: rr.url, title: rr.title, cite: rr.cite } as Ref;
66
+ });
67
+ }
68
+ return out;
69
+ }
70
+
71
+ export function renderContentMarkdown(content: ContentInput): string {
72
+ const heading = content.title?.trim() || 'Untitled';
73
+ const startsWithHeading = content.body.startsWith(`# ${heading}`);
74
+ const lead = startsWithHeading ? '' : `# ${heading}\n\n`;
75
+ const outlineBlock =
76
+ content.outline && content.outline.trim().length > 0 ? `\n\n## Outline\n\n${content.outline.trim()}` : '';
77
+ const refsBlock = renderReferences(content.references);
78
+ return `${lead}${content.body}${outlineBlock}${refsBlock}\n`;
79
+ }
80
+
81
+ function renderReferences(references: ContentInput['references']): string {
82
+ if (!references || references.length === 0) {
83
+ return '';
84
+ }
85
+ const lines: string[] = [];
86
+ for (const ref of references) {
87
+ const label = renderReferenceLabel(ref);
88
+ if (label === '') {
89
+ continue;
90
+ }
91
+ lines.push(`- ${label}`);
92
+ }
93
+ if (lines.length === 0) {
94
+ return '';
95
+ }
96
+ return `\n\n## References\n\n${lines.join('\n')}`;
97
+ }
98
+
99
+ function renderReferenceLabel(ref: Ref): string {
100
+ const { url, title, cite } = ref;
101
+ const titleT = title?.trim() ?? '';
102
+ const urlT = url?.trim() ?? '';
103
+ const citeT = cite?.trim() ?? '';
104
+ let label: string;
105
+ if (titleT !== '' && urlT !== '') {
106
+ label = `[${titleT}](${urlT})`;
107
+ } else if (titleT !== '') {
108
+ label = titleT;
109
+ } else if (urlT !== '') {
110
+ label = urlT;
111
+ } else if (citeT !== '') {
112
+ label = `\`${citeT}\``;
113
+ } else {
114
+ return '';
115
+ }
116
+ if (citeT !== '' && label !== `\`${citeT}\``) {
117
+ label = `${label} (${citeT})`;
118
+ }
119
+ return label;
120
+ }
121
+
122
+ async function main(): Promise<number> {
123
+ const { values } = parseArgs({
124
+ options: {
125
+ in: { type: 'string' },
126
+ out: { type: 'string' },
127
+ },
128
+ });
129
+ if (!values.in || !values.out) {
130
+ console.error('Error: Missing required arguments --in and --out');
131
+ return 1;
132
+ }
133
+ try {
134
+ const content = parseContent(await readFile(values.in, 'utf-8'));
135
+ await writeFile(values.out, renderContentMarkdown(content), 'utf-8');
136
+ return 0;
137
+ } catch (err: unknown) {
138
+ console.error(`render-md failed: ${err instanceof Error ? err.message : String(err)}`);
139
+ return 1;
140
+ }
141
+ }
142
+
143
+ if (import.meta.main) {
144
+ process.exitCode = await main();
145
+ }
@@ -1,10 +1,10 @@
1
1
  ---
2
- name: kk-judge
3
- description: This skill should be used when an LLM evaluation gate is needed in a knowledge-kit pipeline — "judge this content", "evaluate against rubric", "quality gate before publish", "check technical accuracy", "review brand tone", "compliance check", or when a spur workflow step of type `agent.run` targets the kk-judge skill to emit a PASS/FAIL/NEEDS_REVISION verdict. Centralizes evaluation rubrics, prompt templates, and structured verdict emission for generated Content.
2
+ name: judge
3
+ description: This skill should be used when an LLM evaluation gate is needed in a knowledge-kit pipeline — "judge this content", "evaluate against rubric", "quality gate before publish", "check technical accuracy", "review brand tone", "compliance check", or when a spur workflow step of type `agent.run` targets the judge skill to emit a PASS/FAIL/NEEDS_REVISION verdict. Centralizes evaluation rubrics, prompt templates, and structured verdict emission for generated Content.
4
4
  version: 0.1.0
5
5
  ---
6
6
 
7
- # kk-judge — LLM-as-judge evaluation skill
7
+ # judge — LLM-as-judge evaluation skill
8
8
 
9
9
  ## Purpose
10
10
 
@@ -107,7 +107,7 @@ Content to evaluate: {{content_path}}
107
107
  Rubric: {{rubric}} # general | tech-accuracy | brand-tone | compliance
108
108
  Output verdict to: {{verdict_path}}
109
109
 
110
- Load the rubric definition from plugins/kk/skills/kk-judge/references/rubrics.md (section: {{rubric}}).
110
+ Load the rubric definition from plugins/kk/skills/judge/references/rubrics.md (section: {{rubric}}).
111
111
  Evaluate the content against every criterion in that rubric.
112
112
  For each criterion, quote the specific passage and assign pass | fail | warn.
113
113
  Decide the verdict: any must-pass fail → FAIL; else score < threshold → NEEDS_REVISION; else PASS.
@@ -1,6 +1,6 @@
1
- # Rubrics — kk-judge evaluation criteria
1
+ # Rubrics — judge evaluation criteria
2
2
 
3
- Loaded by the kk-judge skill at evaluation time. Each rubric defines criteria, weights,
3
+ Loaded by the judge skill at evaluation time. Each rubric defines criteria, weights,
4
4
  must-pass flags, and the score thresholds that map to verdicts.
5
5
 
6
6
  ## Scoring model
@@ -1,4 +1,4 @@
1
- # Workflow integration — kk-judge in a spur workflow
1
+ # Workflow integration — judge in a spur workflow
2
2
 
3
3
  Spur's workflow engine executes one node at a time (`type: parallel` is an inert schema field —
4
4
  see `docs/03_ARCHITECTURE.md`). The judge step is therefore a single `agent.run` node followed
@@ -13,10 +13,10 @@ by a `shell` node that guards the transition to `publish`.
13
13
  Evaluate the generated content against the {{rubric}} rubric.
14
14
  Content: {{content_path}}
15
15
  Write the verdict to {{verdict_path}}.
16
- skill: kk-judge
16
+ skill: judge
17
17
  ```
18
18
 
19
- - `agent.run` spawns an agent that loads the `kk-judge` skill, performs the evaluation, and writes
19
+ - `agent.run` spawns an agent that loads the `judge` skill, performs the evaluation, and writes
20
20
  the verdict JSON to `{{verdict_path}}` (typically `.spur/run/verdict-judge.json`).
21
21
  - The verdict file is the only durable artifact. The agent's prose output is not the contract.
22
22
 
@@ -28,22 +28,22 @@ The guard runs as a `shell` step after `judge`. It reads the verdict and gates t
28
28
  #!/usr/bin/env sh
29
29
  verdict_path="{{verdict_path}}"
30
30
  if [ ! -f "$verdict_path" ]; then
31
- echo "kk-judge: verdict file not found at $verdict_path"
31
+ echo "judge: verdict file not found at $verdict_path"
32
32
  exit 1
33
33
  fi
34
34
  verdict=$(jq -r '.verdict' "$verdict_path")
35
35
  case "$verdict" in
36
36
  PASS)
37
- echo "kk-judge: PASS"
37
+ echo "judge: PASS"
38
38
  exit 0
39
39
  ;;
40
40
  FAIL|NEEDS_REVISION)
41
- echo "kk-judge: $verdict — see $verdict_path for findings"
41
+ echo "judge: $verdict — see $verdict_path for findings"
42
42
  jq -r '.findings[]? | select(.status=="fail") | " FAIL: \(.criterion): \(.detail)"' "$verdict_path"
43
43
  exit 1
44
44
  ;;
45
45
  *)
46
- echo "kk-judge: unrecognized verdict '$verdict' in $verdict_path"
46
+ echo "judge: unrecognized verdict '$verdict' in $verdict_path"
47
47
  exit 1
48
48
  ;;
49
49
  esac
@@ -60,7 +60,7 @@ esac
60
60
 
61
61
  For a bounded regenerate-retry loop, wire the workflow so that a `NEEDS_REVISION` verdict routes
62
62
  back to the `generate` step with the verdict's `feedback` as additional input. Cap retries (the
63
- example workflow uses 2). This is workflow-level orchestration — `kk-judge` itself is stateless
63
+ example workflow uses 2). This is workflow-level orchestration — `judge` itself is stateless
64
64
  and re-runnable.
65
65
 
66
66
  ```
@@ -0,0 +1,166 @@
1
+ ---
2
+ name: storm-research
3
+ description: >-
4
+ Use the storm-research skill to run daily STORM research — turn a research topic
5
+ sentence or a markdown source file into a grounded STORM Content report with a content.md
6
+ sidecar ("research X", "produce a research report on X", "run today's STORM research",
7
+ "turn this brief into a researched report"). The machine is the 0059 workflow
8
+ (prepare → ingest → generate → render) over the 0056 works layout. Research is separate from
9
+ authoring: topic owns init→outline→draft and this skill never merges into topic.
10
+ version: 0.1.0
11
+ ---
12
+
13
+ # storm-research — STORM daily research (topic sentence XOR markdown file → content.md)
14
+
15
+ ## Purpose
16
+
17
+ Turn one research topic (a sentence) or one markdown source file into a **grounded STORM
18
+ `Content` report** plus a `content.md` sidecar by composing the `web-search` ingestion plugin,
19
+ the `content-gen` generator, and the `render-md` helper. The machine is the 0059 workflow
20
+ (`prepare → ingest → generate → render → done|failed`); this skill owns the **craft** — framing
21
+ the query, deciding when to re-run ingest, and verifying the report against the evidence.
22
+
23
+ Research is **not** authoring. `topic` owns `init → outline → draft` from an approved
24
+ outline; this skill owns evidence gathering. **Do not merge this into `topic`.**
25
+
26
+ ## When to use / not to use
27
+
28
+ Use when:
29
+
30
+ - A user asks to research a topic: "research X", "produce a research report on X", "run today's
31
+ STORM research", "what does the evidence say about X".
32
+ - A user supplies a markdown source (brief, notes, outline) that should ground the report —
33
+ pass it with `--in <file>`.
34
+
35
+ Do **not** use for:
36
+
37
+ - Authoring an article from a topic with no research need — that is `topic`.
38
+ - Quality gating / judging generated content — that is `judge`.
39
+ - Publishing, platform adaptation, or illustration — out of C4 scope.
40
+ - Anything requiring a raw `kk executor run` chain when the workflow already composes it.
41
+
42
+ ## Invocation contract
43
+
44
+ Same arguments as `/workflow-run` (the thin command delegates here for craft):
45
+
46
+ ```
47
+ [workflow-name] <topic | --in <file>> [--fixture] [--force]
48
+ ```
49
+
50
+ | Token | Meaning | Default |
51
+ | --- | --- | --- |
52
+ | `workflow-name` | installed workflow YAML name; must match `^[a-z0-9][a-z0-9-]*$` and not be `--in` | `kk-storm-research` |
53
+ | `<topic>` | a **specific research question** (better-report a — prefer a question over a noun phrase) | required unless `--in` |
54
+ | `--in <file>` | markdown file mode (XOR with `<topic>`) | — |
55
+ | `--fixture` | force `fixture: "true"` (offline stub ingest) | config default (`false` for real runs) |
56
+ | `--force` | overwrite an existing dest workflow YAML | off |
57
+
58
+ **XOR rule:** exactly one of `<topic>` / `--in <file>` must be set. Both set or both empty →
59
+ fail loud, no writes. `--in` and a bare topic token are mutually exclusive.
60
+
61
+ The operator invocation in practice is `/workflow-run <topic>` or `/workflow-run --in <file>`; the
62
+ skill is also callable directly as `Skill(skill="storm-research", args="<topic|--in file> ...")`.
63
+
64
+ ## How to run
65
+
66
+ 1. **Frame the query** (better-report a): prefer a specific question over a noun phrase.
67
+ "What are the known failure modes of vector databases at 1B rows?" beats "vector databases".
68
+ A question constrains the search and the report; a noun phrase invites scope creep.
69
+ 2. **Choose input**: topic sentence, or a markdown file via `--in` (XOR). File mode derives the
70
+ query from the first ATX H1 (`/^#\s+(.+)$/m`), else the basename without extension.
71
+ 3. **`work_dir` is the resolved topic directory** `$works_dir/<topic-id>`, computed by the
72
+ invoker (the command or the operator) via the 0056 `topicId()` algorithm — never by the
73
+ workflow. `prepare` only `mkdir` + materializes files into `${vars.work_dir}`.
74
+ 4. **Pick fixture vs live** (see below). Default is live (`fixture: "false"`); pass
75
+ `fixture: "true"` for offline/dry-run/CI.
76
+ 5. **Run** the workflow and collect the outputs: `content.md` (report sidecar), `content.json`
77
+ (machine contract), `docs.json` (evidence). Read the report against the evidence before
78
+ presenting it (better-report d).
79
+
80
+ ## topicId algorithm (0056, frozen)
81
+
82
+ `topicId(raw: string)` — computed by the **invoker**, never by the workflow YAML:
83
+
84
+ 1. `normalized` = trim, collapse whitespace, lowercase.
85
+ 2. `slug` = replace `[^a-z0-9]+` with `-`, squeeze dashes, trim dashes, slice to **48 chars**,
86
+ trim dashes again. If empty → `topic`.
87
+ 3. `hash` = `sha256(normalized).digest('hex').slice(0, 8)`.
88
+ 4. Return `slug + '-' + hash`.
89
+
90
+ - Sentence mode: `raw` = the topic string. File mode: `raw` = first ATX H1 else basename
91
+ without extension.
92
+ - Same `raw` → same id → same workspace. **Last write wins**; do not fail on an existing
93
+ works dir (re-running a topic is legal — no `--force` needed).
94
+ - If `raw` derives empty (no H1 and an empty basename, e.g. a file named `.md`) → fail loud.
95
+
96
+ ## Works layout (0056, frozen)
97
+
98
+ Per-topic workspace at `$works_dir/<topic-id>/`:
99
+
100
+ | File | Written by | Contents |
101
+ | --- | --- | --- |
102
+ | `topic.json` | prepare | ingest `--in` (`topic`, `maxResults`, `fixture`) |
103
+ | `input.md` | prepare (file mode only) | verbatim copy of the operator file |
104
+ | `local-doc.json` | prepare (file mode only) | one-element `Doc[]` (id = `sha256('file://'+absPath).hex.slice(0,16)`, rank `-1`, position `0`) |
105
+ | `docs.json` | ingest | `web-search` output `Doc[]` |
106
+ | `content.json` | generate | STORM `Content` (machine contract) |
107
+ | `content.md` | render | markdown sidecar report |
108
+
109
+ Contrast: `topic`'s authoring workspace is `./<kebab-case(topic)>` with `brief.md` /
110
+ `2-outline/` / `3-draft/` and explicitly excludes research. C4 works live under `$works_dir`,
111
+ outside any repo.
112
+
113
+ ## Live vs fixture
114
+
115
+ | Mode | `fixture` | Behavior | Requires |
116
+ | --- | --- | --- | --- |
117
+ | Live | `"false"` (default) | `web-search` calls the Firecrawl search API (`POST https://api.firecrawl.dev/v2/search`, `scrapeOptions.formats: ["markdown"]`), maps `data.web[]` to `Doc[]` | `FIRECRAWL_API_KEY` env var; unset → the ingest exits 1 ("FIRECRAWL_API_KEY is unset") |
118
+ | Fixture | `"true"` | `web-search` emits canned `Doc[]` (stable `sha256(sourceUri)` ids); no network, no key | none — offline/dry-run/CI |
119
+
120
+ - `FIRECRAWL_API_KEY` is **env-only** — never a config key, never a workflow var, never in the
121
+ report.
122
+ - Real daily research defaults to live. **`fixture: true` is not research** (better-report b) —
123
+ the canned docs are for machine checks, not for evidence a reader should trust.
124
+
125
+ ## Fail-loud table (0056 rows the agent must execute)
126
+
127
+ | Condition | Behavior | Recovery |
128
+ | --- | --- | --- |
129
+ | Both `<topic>` and `--in` set, or neither | exit 1, stderr states the XOR rule; no writes | pass exactly one of `topic` / `--in` |
130
+ | `--in` file unreadable (missing / I/O) | exit 1, stderr names the path | fix the path / permissions |
131
+ | Derived query / topic-id empty (no H1 and empty basename) | exit 1, stderr shows the derived value | add an H1 or give the file a real name |
132
+ | Dest `$workflows_dir/<name>.yaml` exists with different bytes | warning, **no overwrite**; run proceeds with the user's copy | re-run with `--force` to replace from the install source |
133
+ | Workflow YAML cannot be resolved from any install source | exit 1, stderr lists the roots searched | reinstall the package / set `KK_WORKFLOWS_SOURCE` |
134
+ | `KK_CONFIG` set but unreadable, or config unparseable / invalid types | exit 1, stderr names the key + expected vs actual type | fix the file, or delete it (recreated with defaults next run) |
135
+ | Missing config at `KK_CONFIG` | **not a failure** — create-on-first-run with compiled defaults; an existing file is never overwritten | — |
136
+
137
+ Every failure names the exact path and the exact next command. Never silently skip, never
138
+ auto-replace, never hash topic-id in the YAML.
139
+
140
+ ## Better-report practices (R1)
141
+
142
+ 1. **(a) Prefer a specific question over a noun phrase.** A question ("How does X behave under
143
+ Y?") pins the search and the report's thesis; a noun ("X") leaves scope open.
144
+ 2. **(b) Do not treat `fixture: true` as research.** Fixture output is canned — fine for
145
+ pipeline checks, never a source of claims to present to a reader.
146
+ 3. **(c) Re-run ingest only when the query or sources must change.** If the question is
147
+ unchanged and the sources are the same, **reuse `docs.json`** — do not re-fetch. Only a new
148
+ query, new sources, or stale evidence justifies a fresh ingest.
149
+ 4. **(d) Read `content.md` citations against `docs.json` ids.** Every citation in the report
150
+ must resolve to a Doc id present in `docs.json`; verify the claim against that Doc's body
151
+ before trusting it. A citation to a missing id is a defect — fix the report, not the id list.
152
+ 5. **Cap `maxResults` before widening.** Start at the default (`8`); only widen when the first
153
+ pass demonstrably misses coverage. More results is not better grounding — it is more noise.
154
+ 6. **Prefer primary sources in the markdown input file.** When a source file is supplied via
155
+ `--in`, favor the primary material in it (first-party specs, owned notes, authoritative
156
+ extracts) as the spine of the report; web results fill gaps, not the reverse.
157
+
158
+ ## Invariants
159
+
160
+ - The invoker computes `topicId` and passes `work_dir` resolved; the workflow never hashes.
161
+ - XOR input always — exactly one of `topic` / `input_file`.
162
+ - Never promise a judge gate or query expansion in this skill (C4 scope: neither exists).
163
+ - No new `kk` first-layer noun (ADR-011) — this skill runs through the workflow, not through a
164
+ `kk` CLI verb.
165
+ - `FIRECRAWL_API_KEY` stays env-only.
166
+ - Works layout file names are frozen (0056) — do not rename or add sidecars in this skill.
@@ -1,7 +1,7 @@
1
1
  ---
2
- name: kk-topic
2
+ name: topic
3
3
  description: >-
4
- Use the kk-topic skill to author technical content from a topic or Markdown
4
+ Use the topic skill to author technical content from a topic or Markdown
5
5
  brief — "create an article from this topic", "turn this brief into a draft",
6
6
  "generate outline options", "initialize a topic workspace", "resume my draft".
7
7
  Runs the topic-core authoring loop (init → outline → draft) through four
@@ -9,7 +9,7 @@ description: >-
9
9
  cohesive topic-authoring core, harvested from the WT 7-stage wrappers.
10
10
  ---
11
11
 
12
- # kk-topic — topic-core authoring (create | init | outline | draft)
12
+ # topic — topic-core authoring (create | init | outline | draft)
13
13
 
14
14
  ## Purpose
15
15
 
@@ -159,7 +159,7 @@ silently skip, never auto-replace, never delete.
159
159
  ## Prompt template (Skill() / agent.run)
160
160
 
161
161
  ```
162
- Skill(skill="kk-topic",
162
+ Skill(skill="topic",
163
163
  args="<operation> <topic-or-brief> [--dir <target>] [--outline <a|b|c>] [--revise <feedback>] [--force]")
164
164
  ```
165
165
 
@@ -0,0 +1,184 @@
1
+ ---
2
+ # STORM topic-research daily workflow (C4):
3
+ # prepare -> ingest -> generate -> render -> done|failed.
4
+ #
5
+ # State-machine extension of the C3 dry-run prototype. Adds the 0056 XOR
6
+ # prepare + works layout, the generate local-doc merge, and the 0058 render
7
+ # sidecar state. No publish, no judge — by design (C4 scope).
8
+ #
9
+ # 0056 contract (docs/tasks/0056 ... Solution blocks 1-8): `work_dir` is the
10
+ # RESOLVED topic dir (`$works_root/<topic-id>`), computed by the invoker
11
+ # (0060/operator) and passed as `--vars`. prepare only `mkdir` + materializes
12
+ # files into `${vars.work_dir}`; it never hashes the topic-id in YAML. XOR:
13
+ # exactly one of `topic`/`input_file` must be non-empty.
14
+ #
15
+ # 0058 contract (docs/tasks/0058 ... Solution): render shells
16
+ # bun plugins/kk/scripts/render-md.ts --in <work_dir>/content.json \
17
+ # --out <work_dir>/content.md
18
+ #
19
+ # Success edges use `guard: { kind: always }` (repo convention — see the C3
20
+ # file and task-pipeline.yaml): a real-run action failure short-circuits at
21
+ # the state under the default onError=fail policy; `always` lets `--dry-run`
22
+ # walk to `done` (actions don't execute in dry-run, so `action-ok` would
23
+ # always deny and fall to `failed`).
24
+ "$schema": "@gobing-ai/spur/schemas/state-machine-workflow.schema.json"
25
+ name: kk-storm-research
26
+ kind: state-machine
27
+ description: >-
28
+ STORM daily topic research — XOR prepare into 0056 works layout, chain
29
+ ingest + generate, render content.md sidecar
30
+ initialState: prepare
31
+ terminalStates:
32
+ - done
33
+ - failed
34
+ vars:
35
+ topic: "" # sentence-mode query; ignored when input_file set (XOR)
36
+ input_file: "" # file-mode markdown path when non-empty (XOR)
37
+ maxResults: "8" # written into topic.json as int (0054 maxResults)
38
+ fixture: "false" # "true" selects the stub; CI/dry-run must pass "true"
39
+ work_dir: "./work" # RESOLVED topic dir ($works_root/<topic-id>)
40
+ plugins_path: "./plugins" # --plugins-path for kk executor run
41
+ ingestion_plugin: "web-search"
42
+ generator_plugin: "content-gen"
43
+ render_script: "plugins/kk/scripts/render-md.ts" # 0058 shipped runner
44
+
45
+ states:
46
+ - id: prepare
47
+ description: >-
48
+ mkdir work_dir; XOR guard; sentence mode writes topic.json; file mode
49
+ copies input.md, writes local-doc.json and topic.json with a derived
50
+ query
51
+ onEnter:
52
+ - kind: shell
53
+ options:
54
+ command: >-
55
+ set -e;
56
+ mkdir -p "${vars.work_dir}";
57
+ if [ -n "${vars.topic}" ] && [ -n "${vars.input_file}" ]; then
58
+ echo "XOR violation: both topic and input_file set" >&2; exit 1;
59
+ fi;
60
+ if [ -z "${vars.topic}" ] && [ -z "${vars.input_file}" ]; then
61
+ echo "XOR violation: neither topic nor input_file set" >&2;
62
+ exit 1;
63
+ fi;
64
+ if [ -n "${vars.topic}" ]; then
65
+ jq -n --arg topic "${vars.topic}" --arg maxResults
66
+ "${vars.maxResults}" --arg fixture "${vars.fixture}"
67
+ '{topic: $topic, maxResults: ($maxResults | tonumber),
68
+ fixture: ($fixture == "true")}' > "${vars.work_dir}/topic.json";
69
+ exit 0;
70
+ fi;
71
+ if [ -f "${vars.input_file}" ]; then
72
+ cp "${vars.input_file}" "${vars.work_dir}/input.md";
73
+ ABS=$(cd "$(dirname "${vars.input_file}")" && pwd)/$(basename
74
+ "${vars.input_file}");
75
+ QUERY=$(grep -m1 '^# ' "${vars.input_file}" | sed 's/^# *//'
76
+ || true);
77
+ if [ -z "$QUERY" ]; then
78
+ QUERY=$(basename "${vars.input_file}" | sed 's/\.[^.]*$//');
79
+ fi;
80
+ if [ -z "$QUERY" ]; then
81
+ echo "no query derivable (no H1 and empty basename)" >&2;
82
+ exit 1;
83
+ fi;
84
+ ID=$(printf 'file://%s' "$ABS" | shasum -a 256 | cut -c1-16);
85
+ jq -n --arg id "$ID" --arg body "$(cat "${vars.input_file}")"
86
+ --arg title "$QUERY" --arg sourceUri "file://$ABS" --arg query
87
+ "$QUERY" '{id: $id, body: $body, title: $title, sourceUri:
88
+ $sourceUri, mediaType: "text/markdown",
89
+ metadata: {query: $query, rank: -1, position: 0}}'
90
+ > "${vars.work_dir}/local-doc.json";
91
+ jq -n --arg topic "$QUERY" --arg maxResults
92
+ "${vars.maxResults}" --arg fixture "${vars.fixture}"
93
+ '{topic: $topic, maxResults: ($maxResults | tonumber),
94
+ fixture: ($fixture == "true")}' > "${vars.work_dir}/topic.json";
95
+ else
96
+ echo "input_file not readable: ${vars.input_file}" >&2; exit 1;
97
+ fi
98
+
99
+ - id: ingest
100
+ description: >-
101
+ Run web-search ingestion plugin to produce document list (docs.json)
102
+ onEnter:
103
+ - kind: shell
104
+ options:
105
+ command: >-
106
+ kk executor run "${vars.ingestion_plugin}" --in
107
+ "${vars.work_dir}/topic.json" --out "${vars.work_dir}/docs.json"
108
+ --plugins-path "${vars.plugins_path}"
109
+
110
+ - id: generate
111
+ description: >-
112
+ Prepend local-doc (when present) onto docs.json, then run content
113
+ generator to produce content.json
114
+ onEnter:
115
+ - kind: shell
116
+ options:
117
+ command: >-
118
+ if [ -f "${vars.work_dir}/local-doc.json" ]; then
119
+ jq -s '[.[0]] + .[1]' "${vars.work_dir}/local-doc.json"
120
+ "${vars.work_dir}/docs.json" > "${vars.work_dir}/docs.json.tmp"
121
+ && mv "${vars.work_dir}/docs.json.tmp" "${vars.work_dir}/docs.json";
122
+ fi
123
+ - kind: shell
124
+ options:
125
+ command: >-
126
+ kk executor run "${vars.generator_plugin}" --in
127
+ "${vars.work_dir}/docs.json" --out "${vars.work_dir}/content.json"
128
+ --plugins-path "${vars.plugins_path}"
129
+
130
+ - id: render
131
+ description: >-
132
+ Render content.json to the markdown sidecar content.md via the 0058
133
+ shipped script
134
+ onEnter:
135
+ - kind: shell
136
+ options:
137
+ command: >-
138
+ bun "${vars.render_script}" --in "${vars.work_dir}/content.json"
139
+ --out "${vars.work_dir}/content.md"
140
+
141
+ - id: done
142
+ description: >-
143
+ Terminal — topic materialized, ingested, generated, and rendered to
144
+ content.md. No publish/judge by design (C4).
145
+ - id: failed
146
+ description: Terminal — one of the steps failed
147
+
148
+ transitions:
149
+ - from: prepare
150
+ to: ingest
151
+ description: Works materialized — proceed to web-search ingestion
152
+ guard:
153
+ kind: always
154
+ - from: prepare
155
+ to: failed
156
+ description: >-
157
+ prepare failed (XOR violation / unreadable input) — terminate workflow
158
+
159
+ - from: ingest
160
+ to: generate
161
+ description: Ingestion succeeded — proceed to content generation
162
+ guard:
163
+ kind: always
164
+ - from: ingest
165
+ to: failed
166
+ description: Ingestion failed — terminate workflow
167
+
168
+ - from: generate
169
+ to: render
170
+ description: Content generated — render the markdown sidecar
171
+ guard:
172
+ kind: always
173
+ - from: generate
174
+ to: failed
175
+ description: Generation failed — terminate workflow
176
+
177
+ - from: render
178
+ to: done
179
+ description: content.md rendered — workflow complete
180
+ guard:
181
+ kind: always
182
+ - from: render
183
+ to: failed
184
+ description: Render failed — terminate workflow