@gobing-ai/knowledge-kit 0.0.8 → 0.0.10

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 (31) hide show
  1. package/dist/index.js +102 -50
  2. package/package.json +4 -4
  3. package/plugins/generations/dailynews-gen/package.json +17 -0
  4. package/plugins/generations/dailynews-gen/plugin.json +7 -0
  5. package/plugins/generations/dailynews-gen/src/index.ts +111 -0
  6. package/plugins/generations/dailynews-gen/src/script-builder.ts +430 -0
  7. package/plugins/generations/dailynews-gen/tsconfig.json +4 -0
  8. package/plugins/generations/voice-gen/src/concat.ts +9 -7
  9. package/plugins/generations/voice-gen/src/index.ts +65 -11
  10. package/plugins/generations/voice-gen/src/mp3.ts +65 -0
  11. package/plugins/generations/voice-gen/src/qc.ts +157 -0
  12. package/plugins/generations/voice-gen/src/voicebox-client.ts +40 -2
  13. package/plugins/generations/voice-gen/src/voicescript.ts +149 -12
  14. package/plugins/ingestions/aihot-ingest/package.json +17 -0
  15. package/plugins/ingestions/aihot-ingest/plugin.json +7 -0
  16. package/plugins/ingestions/aihot-ingest/src/client.ts +185 -0
  17. package/plugins/ingestions/aihot-ingest/src/index.ts +137 -0
  18. package/plugins/ingestions/aihot-ingest/src/mapper.ts +42 -0
  19. package/plugins/ingestions/aihot-ingest/tsconfig.json +4 -0
  20. package/plugins/ingestions/web-search/src/index.ts +2 -1
  21. package/plugins/kk/commands/workflow-run.md +70 -48
  22. package/plugins/kk/skills/audio-authoring/SKILL.md +185 -0
  23. package/plugins/kk/skills/audio-authoring/templates/voicescript.solo.yaml +25 -0
  24. package/plugins/kk/workflows/judge-gated-publish-example.yaml +6 -7
  25. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +158 -0
  26. package/plugins/kk/workflows/kk-ingest-generate-publish.yaml +13 -4
  27. package/plugins/kk/workflows/kk-itc.yaml +14 -18
  28. package/plugins/kk/workflows/kk-solo-podcast.yaml +135 -104
  29. package/plugins/kk/workflows/kk-storm-research.yaml +26 -5
  30. package/plugins/kk/workflows/wrap-voicescript-doc.ts +40 -0
  31. package/plugins/publishings/surfdash-pub/src/index.ts +1 -1
@@ -0,0 +1,185 @@
1
+ import { echoError } from '@gobing-ai/ts-utils';
2
+ import { z } from 'zod';
3
+
4
+ export const AIHOT_BASE_URL = 'https://aihot.virxact.com';
5
+ export const DEFAULT_AIHOT_USER_AGENT = 'aihot-ingest/1.0.0';
6
+
7
+ export const AihotItemSchema = z.object({
8
+ id: z.string(),
9
+ title: z.string(),
10
+ originalTitle: z.string().nullable(),
11
+ summary: z.string().nullable(),
12
+ source: z.object({ name: z.string() }),
13
+ links: z.object({
14
+ aihot: z.url(),
15
+ original: z.url(),
16
+ }),
17
+ publishedAt: z.iso.datetime().nullable(),
18
+ discoveredAt: z.iso.datetime(),
19
+ category: z.string().nullable(),
20
+ score: z.number().min(0).max(100).nullable(),
21
+ selected: z.boolean(),
22
+ reason: z.string().nullable().optional(),
23
+ attribution: z
24
+ .object({
25
+ name: z.string(),
26
+ url: z.url(),
27
+ })
28
+ .optional(),
29
+ });
30
+
31
+ export type AihotItem = z.infer<typeof AihotItemSchema>;
32
+
33
+ export const AihotSnapshotResponseSchema = z.object({
34
+ schemaVersion: z.literal(1),
35
+ asOf: z.iso.datetime(),
36
+ fields: z.literal('default'),
37
+ cursor: z.string(),
38
+ count: z.number().int().nonnegative(),
39
+ hasMore: z.boolean(),
40
+ nextPage: z.string().nullable(),
41
+ items: z.array(AihotItemSchema),
42
+ });
43
+
44
+ export type AihotSnapshotResponse = z.infer<typeof AihotSnapshotResponseSchema>;
45
+
46
+ const AihotSnapshotEnvelopeSchema = AihotSnapshotResponseSchema.extend({
47
+ items: z.array(z.unknown()),
48
+ });
49
+
50
+ export const AihotChangeSchema = z.discriminatedUnion('op', [
51
+ z.object({
52
+ op: z.literal('upsert'),
53
+ item: AihotItemSchema,
54
+ changedAt: z.iso.datetime(),
55
+ }),
56
+ z.object({
57
+ op: z.literal('remove'),
58
+ id: z.string(),
59
+ changedAt: z.iso.datetime(),
60
+ }),
61
+ ]);
62
+
63
+ export const AihotChangesResponseSchema = z.object({
64
+ schemaVersion: z.literal(1),
65
+ fields: z.literal('default'),
66
+ cursor: z.string(),
67
+ count: z.number().int().nonnegative(),
68
+ hasMore: z.boolean(),
69
+ changes: z.array(AihotChangeSchema),
70
+ });
71
+
72
+ export type AihotChangesResponse = z.infer<typeof AihotChangesResponseSchema>;
73
+
74
+ const AihotChangesEnvelopeSchema = AihotChangesResponseSchema.extend({
75
+ changes: z.array(z.unknown()),
76
+ });
77
+
78
+ export type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
79
+
80
+ export interface AihotDeps {
81
+ fetch: FetchLike;
82
+ baseUrl?: string;
83
+ userAgent?: string;
84
+ warn?: (message: string) => void;
85
+ }
86
+
87
+ export interface FetchSnapshotOptions {
88
+ limit?: number;
89
+ page?: string;
90
+ }
91
+
92
+ export interface FetchChangesOptions {
93
+ cursor: string;
94
+ limit?: number;
95
+ }
96
+
97
+ export class AihotClient {
98
+ private readonly fetch: FetchLike;
99
+ private readonly baseUrl: string;
100
+ private readonly userAgent: string;
101
+ private readonly warn: (message: string) => void;
102
+
103
+ constructor(deps?: Partial<AihotDeps>) {
104
+ this.fetch = deps?.fetch ?? globalThis.fetch;
105
+ this.baseUrl = (deps?.baseUrl ?? AIHOT_BASE_URL).replace(/\/+$/, '');
106
+ this.userAgent = deps?.userAgent ?? DEFAULT_AIHOT_USER_AGENT;
107
+ this.warn = deps?.warn ?? echoError;
108
+ }
109
+
110
+ public async fetchSnapshot(options?: FetchSnapshotOptions): Promise<AihotSnapshotResponse> {
111
+ const url = new URL(`${this.baseUrl}/api/v1/selected/snapshot`);
112
+ if (options?.limit !== undefined) {
113
+ url.searchParams.set('limit', String(options.limit));
114
+ }
115
+ if (options?.page) {
116
+ url.searchParams.set('page', options.page);
117
+ }
118
+ url.searchParams.set('fields', 'default');
119
+
120
+ const res = await this.fetch(url.toString(), {
121
+ method: 'GET',
122
+ headers: {
123
+ Accept: 'application/json',
124
+ 'User-Agent': this.userAgent,
125
+ },
126
+ });
127
+
128
+ if (!res.ok) {
129
+ const errBody = (await res.text()).slice(0, 1000);
130
+ throw new Error(
131
+ `aihot snapshot request failed (HTTP ${res.status} ${res.statusText}): ${errBody || 'Unknown error'}`,
132
+ );
133
+ }
134
+
135
+ const response = AihotSnapshotEnvelopeSchema.parse(await res.json());
136
+ return {
137
+ ...response,
138
+ items: this.parseRecords(response.items, AihotItemSchema, 'snapshot item'),
139
+ };
140
+ }
141
+
142
+ public async fetchChanges(options: FetchChangesOptions): Promise<AihotChangesResponse> {
143
+ const url = new URL(`${this.baseUrl}/api/v1/selected/changes`);
144
+ url.searchParams.set('cursor', options.cursor);
145
+ if (options.limit !== undefined) {
146
+ url.searchParams.set('limit', String(options.limit));
147
+ }
148
+
149
+ const res = await this.fetch(url.toString(), {
150
+ method: 'GET',
151
+ headers: {
152
+ Accept: 'application/json',
153
+ 'User-Agent': this.userAgent,
154
+ },
155
+ });
156
+
157
+ if (!res.ok) {
158
+ const errBody = (await res.text()).slice(0, 1000);
159
+ throw new Error(
160
+ `aihot changes request failed (HTTP ${res.status} ${res.statusText}): ${errBody || 'Unknown error'}`,
161
+ );
162
+ }
163
+
164
+ const response = AihotChangesEnvelopeSchema.parse(await res.json());
165
+ return {
166
+ ...response,
167
+ changes: this.parseRecords(response.changes, AihotChangeSchema, 'change record'),
168
+ };
169
+ }
170
+
171
+ private parseRecords<T>(records: unknown[], schema: z.ZodType<T>, label: string): T[] {
172
+ return records.flatMap((record, index) => {
173
+ const result = schema.safeParse(record);
174
+ if (result.success) return [result.data];
175
+
176
+ const details = result.error.issues.map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`);
177
+ this.warn(`aihot: skipping invalid ${label} at index ${index} (${details.join('; ')})`);
178
+ return [];
179
+ });
180
+ }
181
+ }
182
+
183
+ export function createAihotClient(deps?: Partial<AihotDeps>): AihotClient {
184
+ return new AihotClient(deps);
185
+ }
@@ -0,0 +1,137 @@
1
+ import { dirname } from 'node:path';
2
+ import { parseArgs } from 'node:util';
3
+ import { type Doc, DocListSchema } from '@gobing-ai/kk-core';
4
+ import { atomicWriteJson, createNodeFileSystem, readJsonFile } from '@gobing-ai/ts-runtime';
5
+ import { echoError } from '@gobing-ai/ts-utils';
6
+ import { z } from 'zod';
7
+ import { type AihotDeps, createAihotClient } from './client';
8
+ import { mapAihotItemsToDocs } from './mapper';
9
+
10
+ export * from './client';
11
+ export * from './mapper';
12
+
13
+ export interface IngestionOptions {
14
+ out: string;
15
+ limit?: number;
16
+ cursor?: string;
17
+ stateFile?: string;
18
+ }
19
+
20
+ export interface IngestionResult {
21
+ docs: Doc[];
22
+ cursor: string;
23
+ asOf?: string;
24
+ }
25
+
26
+ const IngestionInputSchema = z.object({
27
+ limit: z.number().int().min(1).max(100).optional(),
28
+ cursor: z.string().trim().min(1).optional(),
29
+ stateFile: z.string().trim().min(1).optional(),
30
+ });
31
+
32
+ const CursorStateSchema = z.object({ cursor: z.string().trim().min(1) });
33
+
34
+ function parseLimit(value: string): number {
35
+ return z.coerce.number().int().min(1).max(100).parse(value);
36
+ }
37
+
38
+ export async function processIngestionIO(
39
+ options: IngestionOptions,
40
+ depsOverride?: Partial<AihotDeps>,
41
+ ): Promise<IngestionResult> {
42
+ const fs = createNodeFileSystem();
43
+ const client = createAihotClient(depsOverride);
44
+
45
+ let effectiveCursor = options.cursor?.trim();
46
+ if (!effectiveCursor && options.stateFile && (await fs.exists(options.stateFile))) {
47
+ effectiveCursor = CursorStateSchema.parse(await readJsonFile(options.stateFile, fs)).cursor;
48
+ }
49
+
50
+ let items: import('./client').AihotItem[];
51
+ let cursor: string;
52
+ let asOf: string | undefined;
53
+
54
+ if (effectiveCursor) {
55
+ const changes = await client.fetchChanges({
56
+ cursor: effectiveCursor,
57
+ limit: options.limit,
58
+ });
59
+ items = changes.changes.flatMap((change) => (change.op === 'upsert' ? [change.item] : []));
60
+ cursor = changes.cursor;
61
+ } else {
62
+ const snapshot = await client.fetchSnapshot({
63
+ limit: options.limit,
64
+ });
65
+ items = snapshot.items;
66
+ cursor = snapshot.cursor;
67
+ asOf = snapshot.asOf;
68
+ }
69
+
70
+ const docs = DocListSchema.parse(mapAihotItemsToDocs(items));
71
+
72
+ const outDir = dirname(options.out);
73
+ if (outDir && outDir !== '.') {
74
+ await fs.ensureDir(outDir);
75
+ }
76
+ await atomicWriteJson(options.out, docs, fs);
77
+
78
+ if (options.stateFile) {
79
+ const stateDir = dirname(options.stateFile);
80
+ if (stateDir && stateDir !== '.') {
81
+ await fs.ensureDir(stateDir);
82
+ }
83
+ const statePayload = {
84
+ cursor,
85
+ asOf,
86
+ lastSyncAt: new Date().toISOString(),
87
+ itemCount: docs.length,
88
+ };
89
+ await atomicWriteJson(options.stateFile, statePayload, fs);
90
+ }
91
+
92
+ return { docs, cursor, asOf };
93
+ }
94
+
95
+ export async function main(depsOverride?: Partial<AihotDeps>): Promise<number> {
96
+ let values: { in?: string; out?: string; limit?: string; cursor?: string; 'state-file'?: string };
97
+ try {
98
+ ({ values } = parseArgs({
99
+ options: {
100
+ in: { type: 'string' },
101
+ out: { type: 'string' },
102
+ limit: { type: 'string' },
103
+ cursor: { type: 'string' },
104
+ 'state-file': { type: 'string' },
105
+ },
106
+ }));
107
+ } catch (err: unknown) {
108
+ echoError(`aihot-ingest failed: ${err instanceof Error ? err.message : String(err)}`);
109
+ return 1;
110
+ }
111
+
112
+ if (!values.out) {
113
+ echoError('aihot-ingest failed: Missing required argument: --out');
114
+ return 1;
115
+ }
116
+
117
+ try {
118
+ const input = values.in ? IngestionInputSchema.parse(await readJsonFile(values.in)) : {};
119
+ await processIngestionIO(
120
+ {
121
+ out: values.out,
122
+ limit: values.limit ? parseLimit(values.limit) : (input.limit ?? 10),
123
+ cursor: values.cursor ?? input.cursor,
124
+ stateFile: values['state-file'] ?? input.stateFile,
125
+ },
126
+ depsOverride,
127
+ );
128
+ return 0;
129
+ } catch (err: unknown) {
130
+ echoError(`aihot-ingest failed: ${err instanceof Error ? err.message : String(err)}`);
131
+ return 1;
132
+ }
133
+ }
134
+
135
+ if (import.meta.main) {
136
+ process.exit(await main());
137
+ }
@@ -0,0 +1,42 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { Doc } from '@gobing-ai/kk-core';
3
+ import type { AihotItem } from './client';
4
+
5
+ export function computeDocId(seed: string): string {
6
+ return createHash('sha256').update(seed).digest('hex').slice(0, 16);
7
+ }
8
+
9
+ export function mapAihotItemToDoc(item: AihotItem): Doc {
10
+ const rawId = item.id.trim();
11
+ const canonicalSource = item.links.original || item.links.aihot || item.title || 'untitled';
12
+ const id = rawId.length > 0 ? rawId : computeDocId(canonicalSource);
13
+
14
+ const sourceUri = item.links?.original || item.links?.aihot || `https://aihot.virxact.com/items/${id}`;
15
+ const title = item.title.trim() || 'Untitled AI News';
16
+ const body = `${item.summary?.trim() || item.title.trim()}\n`;
17
+
18
+ const metadata: Record<string, unknown> = {
19
+ sourceName: item.source.name,
20
+ publishedAt: item.publishedAt,
21
+ discoveredAt: item.discoveredAt,
22
+ category: item.category ?? undefined,
23
+ score: item.score ?? undefined,
24
+ aihotUrl: item.links.aihot,
25
+ originalUrl: item.links.original,
26
+ attribution: item.attribution,
27
+ reason: item.reason ?? undefined,
28
+ };
29
+
30
+ return {
31
+ id,
32
+ title,
33
+ sourceUri,
34
+ body,
35
+ mediaType: 'text/markdown',
36
+ metadata,
37
+ };
38
+ }
39
+
40
+ export function mapAihotItemsToDocs(items: AihotItem[]): Doc[] {
41
+ return items.map(mapAihotItemToDoc);
42
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../../tooling/typescript/base.json",
3
+ "include": ["src", "tests"]
4
+ }
@@ -356,7 +356,7 @@ export function parseInput(raw: string): InInput {
356
356
  return { topic: input.topic, maxResults, fixture: input.fixture === true };
357
357
  }
358
358
 
359
- export async function main(): Promise<number> {
359
+ export async function main(depsOverride?: Partial<SearchDeps>): Promise<number> {
360
360
  const fs = createNodeFileSystem();
361
361
  const { values } = parseArgs({
362
362
  options: {
@@ -385,6 +385,7 @@ export async function main(): Promise<number> {
385
385
  fetch: globalThis.fetch,
386
386
  env: process.env,
387
387
  now: () => new Date(),
388
+ ...depsOverride,
388
389
  };
389
390
  // Bun.which with an explicit PATH — without the option it can ignore runtime
390
391
  // process.env.PATH changes (observed on Bun 1.3.14).
@@ -1,18 +1,21 @@
1
1
  ---
2
2
  name: workflow-run
3
3
  description: >-
4
- Run STORM daily research or IT content authoring end to end. Installs the workflow
5
- YAML on first run (two-root copy rule), creates the config with defaults if missing,
6
- computes the workspace, and shells spur workflow run against the installed workflow.
4
+ Run STORM daily research, IT content authoring, or solo-podcast end to end.
5
+ Resolves the workflow YAML from the user override, KK_WORKFLOWS_SOURCE, the
6
+ installed kk package root, or a repo checkout — runs in place from any cwd;
7
+ creates the config with defaults if missing; computes the workspace; shells
8
+ spur workflow run against the resolved YAML.
7
9
  argument-hint: "[name] <topic|--in file> [--dir <path>] [--playbook generic|english|wechat] [--research] [--judge] [--outline <a|b|c>] [--writer itc-generating|topic] [--duration <min>] [--language <code>] [--script-approved] [--fixture] [--force]"
8
10
  ---
9
11
 
10
12
  Thin runner around the **storm-research**, **itc-generating** / **topic**, and **kk-solo-podcast**
11
13
  capabilities. Read `plugins/kk/skills/storm-research/SKILL.md` or
12
- `plugins/kk/skills/itc-generating/SKILL.md` for craft; solo-podcast craft is inlined in the
13
- workflow `agent.run` prompt (no fat skill). This command owns the run procedure: config create,
14
- YAML install (two-root copy rule) into `$HOME/.config/kk/workflows`, workspace computation, and
15
- the `spur workflow run` of that **runtime** dest. Design-time SSOT remains
14
+ `plugins/kk/skills/itc-generating/SKILL.md` for craft; solo-podcast craft lives in the
15
+ `audio-authoring` skill. This command owns the run procedure: config create,
16
+ workflow YAML resolution (user override → `$KK_WORKFLOWS_SOURCE` → **installed package
17
+ root** → repo checkout, runs in place from any cwd), workspace computation, and the
18
+ `spur workflow run` of the resolved YAML. Design-time SSOT remains
16
19
  `plugins/kk/workflows/` in the package/repo. No new `kk` CLI noun (ADR-011) — everything here
17
20
  is shell + the workflow.
18
21
 
@@ -75,46 +78,58 @@ EOF
75
78
  `--fixture` sets `fixture=true` regardless of the file. `FIRECRAWL_API_KEY` is **never** read
76
79
  from config — env-only.
77
80
 
78
- ## 3. Install the workflow YAML
81
+ ## 3. Resolve the workflow YAML (run anywhere)
79
82
 
80
- `dest="$workflows_dir/$NAME.yaml"`. Copy from the **first hit** of, in order:
83
+ The workflow YAML runs **in place** — no copy on the happy path. Resolve the `kk`
84
+ package root from the binary itself (works under bun/npm/nvm/homebrew prefixes):
81
85
 
82
- 1. `$KK_WORKFLOWS_SOURCE/$NAME.yaml`
83
- 2. `plugins/kk/workflows/$NAME.yaml`
86
+ ```bash
87
+ kbin="$(command -v kk 2>/dev/null || true)"
88
+ while [ -L "$kbin" ]; do
89
+ link="$(readlink "$kbin")"
90
+ case "$link" in /*) kbin="$link";; *) kbin="$(dirname "$kbin")/$link";; esac
91
+ done
92
+ pkg=""; [ -n "$kbin" ] && pkg="$(cd "$(dirname "$kbin")/.." 2>/dev/null && pwd || true)"
93
+ ```
94
+
95
+ Resolution order — first hit wins:
84
96
 
85
- - `dest` missing → copy from the first hit. Source missing from both roots → exit 1, stderr
86
- lists the roots searched: `no source workflow for $NAME (searched KK_WORKFLOWS_SOURCE, plugins/kk/workflows)`.
87
- - `dest` exists + `--force` → overwrite from source.
88
- - `dest` exists + identical bytes → no-op.
89
- - `dest` exists + different bytes → **warn and leave** (run proceeds with the user's copy).
97
+ 1. **User override** `$workflows_dir/$NAME.yaml` (default `~/.config/kk/workflows/`) —
98
+ when it exists it always wins, and nothing is copied.
99
+ 2. `$KK_WORKFLOWS_SOURCE/$NAME.yaml`
100
+ 3. `$pkg/plugins/kk/workflows/$NAME.yaml` — the installed package copy
101
+ 4. `plugins/kk/workflows/$NAME.yaml` — repo checkout (development)
90
102
 
91
103
  ```bash
92
- src=""
93
- for d in "$KK_WORKFLOWS_SOURCE" "plugins/kk/workflows"; do
94
- [ -n "$d" ] && [ -f "$d/$NAME.yaml" ] && { src="$d/$NAME.yaml"; break; }
95
- done
96
- [ -z "$src" ] && { echo "no source workflow for $NAME (searched KK_WORKFLOWS_SOURCE, plugins/kk/workflows)" >&2; exit 1; }
97
- if [ ! -f "$dest" ] || [ "$FORCE" = true ]; then
98
- install -d "$(dirname "$dest")" && cp "$src" "$dest"
99
- elif ! cmp -s "$src" "$dest"; then
100
- echo "warning: $dest differs from install source; leaving user copy (pass --force to replace)" >&2
104
+ dest=""
105
+ if [ -f "$workflows_dir/$NAME.yaml" ]; then
106
+ dest="$workflows_dir/$NAME.yaml"
107
+ else
108
+ for d in "$KK_WORKFLOWS_SOURCE" ${pkg:+"$pkg/plugins/kk/workflows"} "plugins/kk/workflows"; do
109
+ [ -n "$d" ] && [ -f "$d/$NAME.yaml" ] && { dest="$d/$NAME.yaml"; break; }
110
+ done
111
+ [ -z "$dest" ] && { echo "no source workflow for $NAME (searched $workflows_dir, KK_WORKFLOWS_SOURCE, kk package root, plugins/kk/workflows)" >&2; exit 1; }
101
112
  fi
102
- # Runtime dest is $workflows_dir (default ~/.config/kk/workflows). Copy YAML sidecars
103
- # that the machine shells (kk-solo-podcast: validate-voicescript.ts).
104
- if [ "$NAME" = "kk-solo-podcast" ] && [ -n "$src" ]; then
105
- side="validate-voicescript.ts"
106
- sdir=$(dirname "$src")
107
- ddir=$(dirname "$dest")
108
- if [ -f "$sdir/$side" ]; then
109
- if [ ! -f "$ddir/$side" ] || [ "$FORCE" = true ]; then
110
- cp "$sdir/$side" "$ddir/$side"
111
- elif ! cmp -s "$sdir/$side" "$ddir/$side"; then
112
- echo "warning: $ddir/$side differs from install source; leaving user copy (pass --force to replace)" >&2
113
- fi
114
- fi
113
+ # --force: refresh the user override from the best available source, then run the override.
114
+ if [ "$FORCE" = true ]; then
115
+ src=""
116
+ for d in "$KK_WORKFLOWS_SOURCE" ${pkg:+"$pkg/plugins/kk/workflows"} "plugins/kk/workflows"; do
117
+ [ -n "$d" ] && [ -f "$d/$NAME.yaml" ] && { src="$d/$NAME.yaml"; break; }
118
+ done
119
+ [ -z "$src" ] && { echo "no source workflow for $NAME to force-install (searched KK_WORKFLOWS_SOURCE, kk package root, plugins/kk/workflows)" >&2; exit 1; }
120
+ install -d "$workflows_dir" && cp "$src" "$workflows_dir/$NAME.yaml"
121
+ dest="$workflows_dir/$NAME.yaml"
115
122
  fi
116
123
  ```
117
124
 
125
+ - To customize a workflow, copy it to `$workflows_dir/$NAME.yaml` yourself (or use
126
+ `--force` to place a fresh copy there) — that override wins on every later run.
127
+ - No auto-copy, no drift warnings: the package copy is versioned with the CLI and
128
+ never goes stale; overrides are explicit.
129
+ - Sidecars (`validate-voicescript.ts`, `wrap-voicescript-doc.ts`) are **not copied** —
130
+ the workflow's own fallback chain resolves them via `$KK_WORKFLOWS_DIR`, the package
131
+ root, then `$HOME/.config/kk/workflows`.
132
+
118
133
  ## 4. Compute workspace
119
134
 
120
135
  ### Profile `kk-storm-research` (0056 `topicId`)
@@ -144,11 +159,12 @@ mkdir -p "$work_dir"
144
159
  `kebab` = lower-case, `[^a-z0-9]+` → `-`, trimmed dashes (no length cap, no sha256 hash).
145
160
  `work_dir="${DIR:-./$kebab}"`. Empty derived `kebab` → exit 1.
146
161
 
147
- ### Profile `kk-solo-podcast`
162
+ ### Profile `kk-solo-podcast` / `kk-daily-ai-voice`
148
163
 
164
+ `date` = today's date in `YYYY-MM-DD` (`$(date +%Y-%m-%d)`).
149
165
  `raw` = `TOPIC` or file-mode first ATX H1 else basename without extension.
150
166
  `kebab` = lower-case, `[^a-z0-9]+` → `-`, trimmed dashes (no length cap, no sha256 hash).
151
- `work_dir="${DIR:-$works_dir/$kebab}"`. Empty derived `kebab` → exit 1.
167
+ `work_dir="${DIR:-$works_dir/kk-solo-podcast/$date}"`.
152
168
 
153
169
  ## 5. Run the workflow
154
170
 
@@ -157,8 +173,9 @@ All values are strings.
157
173
  ### Profile `kk-storm-research`
158
174
 
159
175
  Bind vars: `maxResults` and `fixture` from step 2; `plugins_path` defaults to `./plugins`
160
- in a checkout, else installed package `plugins/` or `KK_PLUGIN_PATH`; `render_script` is
161
- `plugins/kk/scripts/render-md.ts`.
176
+ in a checkout, else `$pkg/plugins` (package root from step 3) or `KK_PLUGIN_PATH`;
177
+ `render_script` is `plugins/kk/scripts/render-md.ts` in a checkout, else
178
+ `$pkg/plugins/kk/scripts/render-md.ts`.
162
179
 
163
180
  ```bash
164
181
  spur workflow run "$dest" --vars \
@@ -168,24 +185,29 @@ spur workflow run "$dest" --vars \
168
185
  ### Profile `kk-itc`
169
186
 
170
187
  Bind vars: `topic`, `input_file`, `work_dir`, `writer`, `playbook`, `research`, `judge`, `outline`,
171
- `force`, `rubric` (`tech-accuracy`), `verdictFile` (`.spur/run/${vars.__runId}-itc-verdict.json`), `agent`.
188
+ `force`, `rubric` (`tech-accuracy`), `agent`. The judge verdict lands at `$work_dir/.itc-verdict.json`
189
+ (fixed path inside the workflow).
172
190
 
173
191
  ```bash
174
192
  spur workflow run "$dest" --vars \
175
- "{\"topic\":\"$TOPIC\",\"input_file\":\"$INPUT_FILE\",\"work_dir\":\"$work_dir\",\"writer\":\"$WRITER\",\"playbook\":\"$PLAYBOOK\",\"research\":\"$RESEARCH\",\"judge\":\"$JUDGE\",\"outline\":\"$OUTLINE\",\"force\":\"$FORCE\",\"rubric\":\"tech-accuracy\",\"verdictFile\":\".spur/run/\${vars.__runId}-itc-verdict.json\",\"agent\":\"$AGENT\"}"
193
+ "{\"topic\":\"$TOPIC\",\"input_file\":\"$INPUT_FILE\",\"work_dir\":\"$work_dir\",\"writer\":\"$WRITER\",\"playbook\":\"$PLAYBOOK\",\"research\":\"$RESEARCH\",\"judge\":\"$JUDGE\",\"outline\":\"$OUTLINE\",\"force\":\"$FORCE\",\"rubric\":\"tech-accuracy\",\"agent\":\"$AGENT\"}"
176
194
  ```
177
195
 
178
196
  ### Profile `kk-solo-podcast`
179
197
 
180
198
  Bind vars: `topic`, `input_file`, `work_dir`, `outline`, `script_approved`, `force`,
181
199
  `target_duration_min`, `language`, `voice_profile` (from `VOICEBOX_DEFAULT_PROFILE` or empty),
182
- `validate_script` (`$workflows_dir/validate-voicescript.ts`), `plugins_path` (empty → ADR-012
183
- default discovery), `agent`. **Always** `spur workflow run "$dest"` — `$dest` is
184
- `$workflows_dir/kk-solo-podcast.yaml`, never the design-time `plugins/kk/workflows/` path.
200
+ `validate_script` (`$workflows_dir/validate-voicescript.ts` when that override exists, else
201
+ empty), `wrap_script` (`$workflows_dir/wrap-voicescript-doc.ts` when that override exists,
202
+ else empty) — empty binds let the workflow's fallback chain resolve via the package root.
203
+ `plugins_path` (empty → ADR-012 default discovery), `agent`. `$dest` is the user override
204
+ when present, else the package/checkout YAML resolved in step 3 — never a hardcoded path.
185
205
 
186
206
  ```bash
207
+ vs_arg=""; [ -f "$workflows_dir/validate-voicescript.ts" ] && vs_arg="$workflows_dir/validate-voicescript.ts"
208
+ ws_arg=""; [ -f "$workflows_dir/wrap-voicescript-doc.ts" ] && ws_arg="$workflows_dir/wrap-voicescript-doc.ts"
187
209
  spur workflow run "$dest" --vars \
188
- "{\"topic\":\"$TOPIC\",\"input_file\":\"$INPUT_FILE\",\"work_dir\":\"$work_dir\",\"outline\":\"$OUTLINE\",\"script_approved\":\"$SCRIPT_APPROVED\",\"force\":\"$FORCE\",\"target_duration_min\":\"$DURATION\",\"language\":\"$LANGUAGE\",\"voice_profile\":\"$VOICE_PROFILE\",\"validate_script\":\"$workflows_dir/validate-voicescript.ts\",\"plugins_path\":\"\",\"agent\":\"$AGENT\"}"
210
+ "{\"topic\":\"$TOPIC\",\"input_file\":\"$INPUT_FILE\",\"work_dir\":\"$work_dir\",\"outline\":\"$OUTLINE\",\"script_approved\":\"$SCRIPT_APPROVED\",\"force\":\"$FORCE\",\"target_duration_min\":\"$DURATION\",\"language\":\"$LANGUAGE\",\"voice_profile\":\"$VOICE_PROFILE\",\"validate_script\":\"$vs_arg\",\"wrap_script\":\"$ws_arg\",\"plugins_path\":\"\",\"agent\":\"$AGENT\"}"
189
211
  ```
190
212
 
191
213
  ## 6. Report