@gobing-ai/knowledge-kit 0.0.9 → 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.
@@ -0,0 +1,157 @@
1
+ import type { VoiceboxClient } from './voicebox-client';
2
+ import type { VoiceScript, VoiceSegment } from './voicescript';
3
+
4
+ export interface SegmentQualityAudit {
5
+ segmentIndex: number;
6
+ text: string;
7
+ duration: number;
8
+ expectedDurationRange: [number, number];
9
+ speechRateCharsPerSec: number;
10
+ transcription?: string;
11
+ repetitionDetected: boolean;
12
+ durationAnomaly: boolean;
13
+ issues: string[];
14
+ passed: boolean;
15
+ }
16
+
17
+ export interface VoiceQualityReport {
18
+ passed: boolean;
19
+ totalDuration: number;
20
+ overallScore: number; // 0 to 100
21
+ segmentAudits: SegmentQualityAudit[];
22
+ criticalIssues: string[];
23
+ }
24
+
25
+ /**
26
+ * Detects stuttering, repetitive loops, or mode collapse in text.
27
+ * E.g., "酒酒酒酒酒", "这种这种这种这种", "我会帮您收拾到最新的节目我会帮您收拾到最新的节目"
28
+ */
29
+ export function detectRepetitions(text: string): boolean {
30
+ const trimmed = text.trim();
31
+ if (trimmed.length < 4) {
32
+ return false;
33
+ }
34
+
35
+ // 1. Single character repeated 3+ times (e.g. "酒酒酒" or "诶诶诶")
36
+ if (/(.)\1{2,}/u.test(trimmed)) {
37
+ return true;
38
+ }
39
+
40
+ // 2. 2-to-3 character word repeated 3+ times (e.g. "这种这种这种")
41
+ if (/(.{2,3}?)\1{2,}/u.test(trimmed)) {
42
+ return true;
43
+ }
44
+
45
+ // 3. 4-to-30 character phrase repeated 2+ times consecutively
46
+ if (/(.{4,30}?)\1{1,}/u.test(trimmed)) {
47
+ return true;
48
+ }
49
+
50
+ return false;
51
+ }
52
+
53
+ /**
54
+ * Compute expected speech duration range given text and language.
55
+ */
56
+ export function computeExpectedDurationRange(text: string, language = 'zh'): [number, number] {
57
+ const cleanText = text.replace(/\[[^\]]+\]/g, '').trim(); // ignore [laugh] tags
58
+ const isZh = language.toLowerCase().startsWith('zh');
59
+
60
+ if (isZh) {
61
+ const charCount = cleanText.replace(/\s+/g, '').length;
62
+ const minSec = Math.max(1.0, charCount / 7.0);
63
+ const maxSec = Math.max(2.5, charCount / 2.2 + 2.5);
64
+ return [Number(minSec.toFixed(2)), Number(maxSec.toFixed(2))];
65
+ }
66
+
67
+ const wordCount = cleanText.split(/\s+/).filter(Boolean).length;
68
+ const minSec = Math.max(1.0, wordCount / 5.0);
69
+ const maxSec = Math.max(2.5, wordCount / 1.5 + 2.5);
70
+ return [Number(minSec.toFixed(2)), Number(maxSec.toFixed(2))];
71
+ }
72
+
73
+ /**
74
+ * Audit the quality of synthesized voice segments.
75
+ */
76
+ export async function auditVoiceSegments(
77
+ script: VoiceScript,
78
+ segmentWavs: Uint8Array[],
79
+ segmentMetadata: Array<{ generationId: string; profile: string; duration: number }>,
80
+ client?: VoiceboxClient,
81
+ ): Promise<VoiceQualityReport> {
82
+ const audits: SegmentQualityAudit[] = [];
83
+ const criticalIssues: string[] = [];
84
+ let totalDuration = 0;
85
+
86
+ for (let i = 0; i < script.segments.length; i++) {
87
+ const segment = script.segments[i] as VoiceSegment;
88
+ const meta = segmentMetadata[i];
89
+ const wavBytes = segmentWavs[i];
90
+ const duration = meta?.duration ?? 0;
91
+ totalDuration += duration;
92
+
93
+ const lang = segment.language ?? script.language ?? 'zh';
94
+ const [minExpected, maxExpected] = computeExpectedDurationRange(segment.text, lang);
95
+ const cleanLen = segment.text.replace(/\s+/g, '').length;
96
+ const speechRate = duration > 0 ? cleanLen / duration : 0;
97
+
98
+ const issues: string[] = [];
99
+ let durationAnomaly = false;
100
+
101
+ // Check duration anomaly: duration exceeds 1.5x max expected or rate < 1.8 chars/sec on >= 8 chars
102
+ if (cleanLen >= 8 && (duration > maxExpected * 1.5 || speechRate < 1.8)) {
103
+ durationAnomaly = true;
104
+ issues.push(
105
+ `Duration anomaly: ${duration.toFixed(2)}s is unusually long for ${cleanLen} chars (expected ${minExpected}-${maxExpected}s, rate ${speechRate.toFixed(1)} chars/s)`,
106
+ );
107
+ }
108
+
109
+ let transcriptionText: string | undefined;
110
+ let repetitionDetected = false;
111
+
112
+ if (client && wavBytes && typeof client.transcribe === 'function') {
113
+ try {
114
+ const trans = await client.transcribe(wavBytes, lang);
115
+ transcriptionText = trans.text;
116
+ if (detectRepetitions(transcriptionText)) {
117
+ repetitionDetected = true;
118
+ issues.push(
119
+ `Repetition hallucination detected in audio transcription: "${transcriptionText.slice(0, 80)}..."`,
120
+ );
121
+ }
122
+ } catch {
123
+ // Transcription service unavailable or optional
124
+ }
125
+ }
126
+
127
+ const passed = !durationAnomaly && !repetitionDetected;
128
+ if (!passed) {
129
+ criticalIssues.push(`Segment ${i + 1} (${meta?.profile || 'Unknown'}): ${issues.join('; ')}`);
130
+ }
131
+
132
+ audits.push({
133
+ segmentIndex: i,
134
+ text: segment.text,
135
+ duration,
136
+ expectedDurationRange: [minExpected, maxExpected],
137
+ speechRateCharsPerSec: Number(speechRate.toFixed(2)),
138
+ transcription: transcriptionText,
139
+ repetitionDetected,
140
+ durationAnomaly,
141
+ issues,
142
+ passed,
143
+ });
144
+ }
145
+
146
+ const passedCount = audits.filter((a) => a.passed).length;
147
+ const overallScore = audits.length > 0 ? Math.round((passedCount / audits.length) * 100) : 100;
148
+ const allPassed = criticalIssues.length === 0;
149
+
150
+ return {
151
+ passed: allPassed,
152
+ totalDuration: Number(totalDuration.toFixed(2)),
153
+ overallScore,
154
+ segmentAudits: audits,
155
+ criticalIssues,
156
+ };
157
+ }
@@ -1,3 +1,9 @@
1
+ export interface EffectConfig {
2
+ type: string;
3
+ enabled?: boolean;
4
+ params?: Record<string, number>;
5
+ }
6
+
1
7
  export interface VoiceboxGenerateBody {
2
8
  profile_id: string;
3
9
  text: string;
@@ -6,8 +12,10 @@ export interface VoiceboxGenerateBody {
6
12
  instruct?: string;
7
13
  max_chunk_chars: number;
8
14
  crossfade_ms: number;
9
- personality: false;
10
- normalize: true;
15
+ personality?: boolean;
16
+ normalize?: boolean;
17
+ seed?: number | null;
18
+ effects_chain?: EffectConfig[] | null;
11
19
  }
12
20
 
13
21
  export interface VoiceboxProfile {
@@ -36,6 +44,7 @@ export interface VoiceboxClient {
36
44
  pollMs?: number,
37
45
  ): Promise<{ status: 'completed' | 'failed'; duration?: number; error?: string }>;
38
46
  downloadAudio(id: string): Promise<Uint8Array>;
47
+ transcribe(audio: Uint8Array, language?: string): Promise<{ text: string; duration?: number }>;
39
48
  }
40
49
 
41
50
  export interface VoiceboxClientOptions {
@@ -219,5 +228,34 @@ export function createVoiceboxClient(options: VoiceboxClientOptions = {}): Voice
219
228
  throw new Error(`Voicebox /audio/${id} failed to read audio stream at ${url}: ${reason}`);
220
229
  }
221
230
  },
231
+
232
+ async transcribe(audio: Uint8Array, language = 'zh'): Promise<{ text: string; duration?: number }> {
233
+ const formData = new FormData();
234
+ const blob = new Blob([audio], { type: 'audio/wav' });
235
+ formData.append('file', blob, 'audio.wav');
236
+ formData.append('language', language);
237
+
238
+ let res: Response;
239
+ try {
240
+ res = await customFetch(`${url}/transcribe`, {
241
+ method: 'POST',
242
+ body: formData,
243
+ });
244
+ } catch (err: unknown) {
245
+ const reason = err instanceof Error ? err.message : String(err);
246
+ throw new Error(`Voicebox /transcribe request failed at ${url}: ${reason}`);
247
+ }
248
+
249
+ if (!res.ok) {
250
+ throw new Error(`Voicebox /transcribe failed at ${url}: HTTP ${res.status} ${res.statusText}`);
251
+ }
252
+
253
+ try {
254
+ return (await res.json()) as { text: string; duration?: number };
255
+ } catch (err: unknown) {
256
+ const reason = err instanceof Error ? err.message : String(err);
257
+ throw new Error(`Voicebox /transcribe returned invalid JSON at ${url}: ${reason}`);
258
+ }
259
+ },
222
260
  };
223
261
  }
@@ -1,4 +1,5 @@
1
1
  import type { Doc } from '@gobing-ai/kk-core';
2
+ import type { EffectConfig } from './voicebox-client';
2
3
 
3
4
  export const VOICEBOX_TEXT_MAX = 50_000;
4
5
  export const VOICEBOX_INSTRUCT_MAX = 500;
@@ -45,6 +46,43 @@ export const VOICEBOX_LANGUAGES = [
45
46
  'tr',
46
47
  ] as const;
47
48
 
49
+ export const BUILTIN_EFFECT_PRESETS: Record<string, EffectConfig[]> = {
50
+ robotic: [
51
+ {
52
+ type: 'chorus',
53
+ enabled: true,
54
+ params: { rate_hz: 0.2, depth: 1.0, feedback: 0.35, centre_delay_ms: 7.0, mix: 0.5 },
55
+ },
56
+ ],
57
+ radio: [
58
+ { type: 'highpass', enabled: true, params: { cutoff_frequency_hz: 300.0 } },
59
+ { type: 'lowpass', enabled: true, params: { cutoff_frequency_hz: 3500.0 } },
60
+ {
61
+ type: 'compressor',
62
+ enabled: true,
63
+ params: { threshold_db: -15.0, ratio: 6.0, attack_ms: 5.0, release_ms: 50.0 },
64
+ },
65
+ { type: 'gain', enabled: true, params: { gain_db: 6.0 } },
66
+ ],
67
+ 'echo chamber': [
68
+ {
69
+ type: 'reverb',
70
+ enabled: true,
71
+ params: { room_size: 0.85, damping: 0.3, wet_level: 0.45, dry_level: 0.55, width: 1.0 },
72
+ },
73
+ { type: 'delay', enabled: true, params: { delay_seconds: 0.25, feedback: 0.3, mix: 0.2 } },
74
+ ],
75
+ 'deep voice': [
76
+ { type: 'pitch_shift', enabled: true, params: { semitones: -3.0 } },
77
+ { type: 'lowpass', enabled: true, params: { cutoff_frequency_hz: 6000.0 } },
78
+ {
79
+ type: 'compressor',
80
+ enabled: true,
81
+ params: { threshold_db: -18.0, ratio: 3.0, attack_ms: 10.0, release_ms: 150.0 },
82
+ },
83
+ ],
84
+ };
85
+
48
86
  const ENGINE_SET = new Set<string>(VOICEBOX_ENGINES);
49
87
  const LANGUAGE_SET = new Set<string>(VOICEBOX_LANGUAGES);
50
88
 
@@ -60,23 +98,67 @@ function assertLanguage(value: string | undefined, label: string): void {
60
98
  }
61
99
  }
62
100
 
101
+ export function resolveEffectsChain(customChain?: EffectConfig[], presetName?: string): EffectConfig[] | undefined {
102
+ if (customChain && customChain.length > 0) {
103
+ return customChain;
104
+ }
105
+ if (presetName) {
106
+ const normalized = presetName.trim().toLowerCase();
107
+ const preset = BUILTIN_EFFECT_PRESETS[normalized];
108
+ if (preset) {
109
+ return preset;
110
+ }
111
+ }
112
+ return undefined;
113
+ }
114
+
115
+ export function resolveInstruct(instruct?: string, emotion?: string): string | undefined {
116
+ if (instruct && emotion) {
117
+ const lower = instruct.toLowerCase();
118
+ if (lower.includes(emotion.toLowerCase())) {
119
+ return instruct;
120
+ }
121
+ return `${instruct}. Emotion: ${emotion}`;
122
+ }
123
+ if (instruct) {
124
+ return instruct;
125
+ }
126
+ if (emotion) {
127
+ return `Speak in a ${emotion} tone.`;
128
+ }
129
+ return undefined;
130
+ }
131
+
63
132
  export interface VoiceSpeaker {
64
- profile: string;
133
+ profile?: string;
134
+ profile_id?: string;
135
+ name?: string;
136
+ role?: string;
65
137
  engine?: string;
66
138
  language?: string;
139
+ emotion?: string;
67
140
  instruct?: string;
141
+ effects_chain?: EffectConfig[];
142
+ effect_preset?: string;
143
+ personality?: boolean;
68
144
  }
69
145
 
70
146
  export interface VoiceSegment {
71
147
  speaker?: string;
72
148
  profile?: string;
149
+ profile_id?: string;
73
150
  text: string;
151
+ emotion?: string;
74
152
  instruct?: string;
75
153
  engine?: string;
76
154
  language?: string;
77
155
  gap_ms?: number;
78
156
  max_chunk_chars?: number;
79
157
  crossfade_ms?: number;
158
+ personality?: boolean;
159
+ seed?: number;
160
+ effects_chain?: EffectConfig[];
161
+ effect_preset?: string;
80
162
  }
81
163
 
82
164
  export interface VoiceScript {
@@ -84,13 +166,26 @@ export interface VoiceScript {
84
166
  language?: string;
85
167
  default_engine?: string;
86
168
  default_profile?: string;
169
+ default_profile_id?: string;
170
+ default_emotion?: string;
171
+ default_effects_chain?: EffectConfig[];
172
+ default_effect_preset?: string;
87
173
  max_chunk_chars?: number;
88
174
  crossfade_ms?: number;
89
175
  speakers?: Record<string, VoiceSpeaker>;
90
176
  segments: VoiceSegment[];
91
177
  }
92
178
 
93
- const VOICESCRIPT_KEYS = new Set(['segments', 'speakers', 'default_profile', 'default_engine']);
179
+ const VOICESCRIPT_KEYS = new Set([
180
+ 'segments',
181
+ 'speakers',
182
+ 'default_profile',
183
+ 'default_profile_id',
184
+ 'default_engine',
185
+ 'default_emotion',
186
+ 'default_effects_chain',
187
+ 'default_effect_preset',
188
+ ]);
94
189
 
95
190
  /**
96
191
  * Parse a Doc into a VoiceScript structure.
@@ -107,7 +202,11 @@ export function parseDocToVoiceScript(doc: Doc, envDefaultProfile?: string): Voi
107
202
  try {
108
203
  parsed = JSON.parse(trimmed);
109
204
  } catch {
110
- throw new Error('Invalid VoiceScript JSON structure: failed to parse JSON object');
205
+ try {
206
+ parsed = Bun.YAML.parse(trimmed);
207
+ } catch {
208
+ throw new Error('Invalid VoiceScript JSON structure: failed to parse JSON object');
209
+ }
111
210
  }
112
211
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
113
212
  throw new Error('Invalid VoiceScript JSON structure: root must be a JSON object');
@@ -115,13 +214,17 @@ export function parseDocToVoiceScript(doc: Doc, envDefaultProfile?: string): Voi
115
214
  return parsed as VoiceScript;
116
215
  }
117
216
 
118
- if (trimmed.startsWith('[')) {
119
- return {
120
- segments: [{ text: trimmed }],
121
- default_profile:
122
- envDefaultProfile ??
123
- (typeof doc.metadata?.voiceProfile === 'string' ? doc.metadata.voiceProfile : undefined),
124
- };
217
+ if (trimmed.startsWith('[') && !trimmed.startsWith('[-')) {
218
+ // Plain text starting with paralinguistic tag e.g. [laugh] or [sigh]
219
+ const hasYamlStructure = trimmed.includes('segments:') || trimmed.includes('speakers:');
220
+ if (!hasYamlStructure) {
221
+ return {
222
+ segments: [{ text: trimmed }],
223
+ default_profile:
224
+ envDefaultProfile ??
225
+ (typeof doc.metadata?.voiceProfile === 'string' ? doc.metadata.voiceProfile : undefined),
226
+ };
227
+ }
125
228
  }
126
229
 
127
230
  try {
@@ -161,7 +264,11 @@ export function mergeVoiceScripts(scripts: VoiceScript[], docs: Doc[], envDefaul
161
264
 
162
265
  let title: string | undefined;
163
266
  let defaultProfile: string | undefined = envDefaultProfile;
267
+ let defaultProfileId: string | undefined;
164
268
  let defaultEngine: string | undefined;
269
+ let defaultEmotion: string | undefined;
270
+ let defaultEffectsChain: EffectConfig[] | undefined;
271
+ let defaultEffectPreset: string | undefined;
165
272
  let language: string | undefined;
166
273
  let maxChunkChars: number | undefined;
167
274
  let crossfadeMs: number | undefined;
@@ -179,12 +286,24 @@ export function mergeVoiceScripts(scripts: VoiceScript[], docs: Doc[], envDefaul
179
286
  if (script.default_profile && defaultProfile === envDefaultProfile) {
180
287
  defaultProfile = script.default_profile;
181
288
  }
289
+ if (script.default_profile_id && !defaultProfileId) {
290
+ defaultProfileId = script.default_profile_id;
291
+ }
182
292
  if (!defaultProfile && typeof doc?.metadata?.voiceProfile === 'string') {
183
293
  defaultProfile = doc.metadata.voiceProfile;
184
294
  }
185
295
  if (!defaultEngine && script.default_engine) {
186
296
  defaultEngine = script.default_engine;
187
297
  }
298
+ if (!defaultEmotion && script.default_emotion) {
299
+ defaultEmotion = script.default_emotion;
300
+ }
301
+ if (!defaultEffectsChain && script.default_effects_chain) {
302
+ defaultEffectsChain = script.default_effects_chain;
303
+ }
304
+ if (!defaultEffectPreset && script.default_effect_preset) {
305
+ defaultEffectPreset = script.default_effect_preset;
306
+ }
188
307
  if (!language && script.language) {
189
308
  language = script.language;
190
309
  }
@@ -220,9 +339,21 @@ export function mergeVoiceScripts(scripts: VoiceScript[], docs: Doc[], envDefaul
220
339
  if (defaultProfile) {
221
340
  result.default_profile = defaultProfile;
222
341
  }
342
+ if (defaultProfileId) {
343
+ result.default_profile_id = defaultProfileId;
344
+ }
223
345
  if (defaultEngine) {
224
346
  result.default_engine = defaultEngine;
225
347
  }
348
+ if (defaultEmotion) {
349
+ result.default_emotion = defaultEmotion;
350
+ }
351
+ if (defaultEffectsChain) {
352
+ result.default_effects_chain = defaultEffectsChain;
353
+ }
354
+ if (defaultEffectPreset) {
355
+ result.default_effect_preset = defaultEffectPreset;
356
+ }
226
357
  if (language) {
227
358
  result.language = language;
228
359
  }
@@ -282,8 +413,8 @@ export function validateVoiceScript(script: VoiceScript): void {
282
413
  if (!speaker || typeof speaker !== 'object') {
283
414
  throw new Error(`Speaker "${name}" definition must be an object`);
284
415
  }
285
- if (!speaker.profile || typeof speaker.profile !== 'string') {
286
- throw new Error(`Speaker "${name}" must have a profile specified`);
416
+ if (!speaker.profile && !speaker.profile_id) {
417
+ throw new Error(`Speaker "${name}" must have a profile or profile_id specified`);
287
418
  }
288
419
  if (
289
420
  speaker.instruct &&
@@ -359,6 +490,12 @@ export function validateVoiceScript(script: VoiceScript): void {
359
490
  }
360
491
  }
361
492
 
493
+ if (segment.seed !== undefined) {
494
+ if (typeof segment.seed !== 'number' || !Number.isInteger(segment.seed) || segment.seed < 0) {
495
+ throw new Error('seed must be a non-negative integer');
496
+ }
497
+ }
498
+
362
499
  assertEngine(segment.engine, `Segment at index ${idx}`);
363
500
  assertLanguage(segment.language, `Segment at index ${idx}`);
364
501
  }
@@ -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