@gobing-ai/knowledge-kit 0.0.15 → 0.0.17

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.
@@ -22030,6 +22030,37 @@ function minimumsFromEnv() {
22030
22030
  }
22031
22031
  return out;
22032
22032
  }
22033
+ async function readQcSummary(path2) {
22034
+ if (!path2 || path2.trim() === "")
22035
+ return null;
22036
+ try {
22037
+ const parsed = JSON.parse(await Bun.file(path2).text());
22038
+ const qc = parsed?.metadata?.qc;
22039
+ if (qc === null || typeof qc !== "object")
22040
+ return null;
22041
+ const record2 = qc;
22042
+ const counts = new Map;
22043
+ const audits = Array.isArray(record2.segmentAudits) ? record2.segmentAudits : [];
22044
+ for (const audit of audits) {
22045
+ const issues = audit?.issues;
22046
+ if (!Array.isArray(issues))
22047
+ continue;
22048
+ for (const issue2 of issues) {
22049
+ if (typeof issue2 !== "string")
22050
+ continue;
22051
+ const type = issue2.split(":")[0]?.trim() || issue2;
22052
+ counts.set(type, (counts.get(type) ?? 0) + 1);
22053
+ }
22054
+ }
22055
+ return {
22056
+ passed: record2.passed === true,
22057
+ overallScore: typeof record2.overallScore === "number" ? record2.overallScore : 0,
22058
+ issues: [...counts.entries()].map(([type, count]) => ({ type, count })).sort((a, b) => b.count - a.count || a.type.localeCompare(b.type))
22059
+ };
22060
+ } catch {
22061
+ return null;
22062
+ }
22063
+ }
22033
22064
  async function readTiming(path2) {
22034
22065
  if (!path2 || path2.trim() === "")
22035
22066
  return [];
@@ -22101,6 +22132,16 @@ function renderReportMarkdown(docs, date6, options = {}) {
22101
22132
  }
22102
22133
  lines.push(`| category allowlist | \u2014 | ${counts.category} |`);
22103
22134
  lines.push("");
22135
+ const qc = options.qc ?? null;
22136
+ if (qc !== null) {
22137
+ lines.push("## \u97F3\u9891 QC", "");
22138
+ lines.push("| \u6307\u6807 | \u503C |", "| --- | --- |");
22139
+ lines.push(`| \u7EFC\u5408\u8BC4\u5206 | ${qc.overallScore} |`);
22140
+ lines.push(`| \u901A\u8FC7 | ${qc.passed ? "\u662F" : "\u5426"} |`);
22141
+ for (const issue2 of qc.issues)
22142
+ lines.push(`| ${issue2.type} | ${issue2.count} |`);
22143
+ lines.push("");
22144
+ }
22104
22145
  const timing = options.timing ?? [];
22105
22146
  if (timing.length > 0) {
22106
22147
  lines.push("## \u6B65\u8017\u65F6", "");
@@ -22127,7 +22168,10 @@ async function readRejectedAudit(path2) {
22127
22168
  return [];
22128
22169
  try {
22129
22170
  const parsed = JSON.parse(await Bun.file(path2).text());
22130
- return Array.isArray(parsed) ? parsed : [];
22171
+ if (Array.isArray(parsed))
22172
+ return parsed;
22173
+ const rejected = parsed?.rejected;
22174
+ return Array.isArray(rejected) ? rejected : [];
22131
22175
  } catch {
22132
22176
  return [];
22133
22177
  }
@@ -22148,7 +22192,8 @@ async function processGeneratorIO(inputPath, outputPath) {
22148
22192
  const docs = DocListSchema.parse(await readJsonFile(inputPath));
22149
22193
  const rejected = await readRejectedAudit(process.env.NEWS_REPORT_REJECTED_FILE);
22150
22194
  const timing = await readTiming(process.env.NEWS_REPORT_TIMING_FILE);
22151
- const content = docsToContent(docs, dateFromEnv(), { rejected, timing });
22195
+ const qc = await readQcSummary(process.env.NEWS_REPORT_QC_FILE);
22196
+ const content = docsToContent(docs, dateFromEnv(), { rejected, timing, qc });
22152
22197
  const fs = createNodeFileSystem();
22153
22198
  const outDir = dirname2(outputPath);
22154
22199
  if (outDir && outDir !== ".")
@@ -22186,6 +22231,7 @@ export {
22186
22231
  rejectedCounts,
22187
22232
  readTiming,
22188
22233
  readRejectedAudit,
22234
+ readQcSummary,
22189
22235
  processGeneratorIO,
22190
22236
  minimumsFromEnv,
22191
22237
  main2 as main,
@@ -17,7 +17,9 @@ import { echoError } from '@gobing-ai/ts-utils';
17
17
  * workflow from the filter Content's metadata.docs). Scores live in
18
18
  * `metadata.scores` (quality/importance/urgency/impact 0-5).
19
19
  * - `NEWS_REPORT_REJECTED_FILE` — the filter's rejected audit path
20
- * (`2-plan/candidates.rejected.json`, RejectedDoc[] of {doc, reasons});
20
+ * (`2-plan/candidates.rejected.json`, RejectedDoc[] of {doc, reasons})
21
+ * - `NEWS_REPORT_QC_FILE` — the generate stage's QC audit path
22
+ * (`3-audio/<date>_11_generate_content.json`); absent -> the 音频 QC block is omitted;
21
23
  * absent or unreadable -> reject counts render as 0, never throw.
22
24
  * - `NEWS_REPORT_TIMING_FILE` — `<work_dir>/step-timing.json` JSONL of
23
25
  * {step, startedAt, endedAt} appended by the workflow shell steps; missing
@@ -144,6 +146,51 @@ interface RejectedAudit {
144
146
  reasons: string[];
145
147
  }
146
148
 
149
+ /** Generate-stage audio QC summary rendered into the run report (task 0139 R5b). */
150
+ export interface QcSummary {
151
+ passed: boolean;
152
+ overallScore: number;
153
+ /** Per-issue-type counts, most frequent first; `duration_anomaly: <detail>` counts as its type. */
154
+ issues: { type: string; count: number }[];
155
+ }
156
+
157
+ /**
158
+ * Read the generate stage's QC audit (`<run_date>_11_generate_content.json`) (task 0139 R5b).
159
+ *
160
+ * Best-effort by contract: an absent, unreadable, or malformed file returns `null` and the report
161
+ * omits the section — a run report must never fail a run. Issue types are the text before the first
162
+ * `:` so a detailed `duration_anomaly: expected …` rolls up under one row.
163
+ */
164
+ export async function readQcSummary(path: string | undefined): Promise<QcSummary | null> {
165
+ if (!path || path.trim() === '') return null;
166
+ try {
167
+ const parsed = JSON.parse(await Bun.file(path).text()) as { metadata?: { qc?: unknown } } | null;
168
+ const qc = parsed?.metadata?.qc;
169
+ if (qc === null || typeof qc !== 'object') return null;
170
+ const record = qc as { passed?: unknown; overallScore?: unknown; segmentAudits?: unknown };
171
+ const counts = new Map<string, number>();
172
+ const audits = Array.isArray(record.segmentAudits) ? record.segmentAudits : [];
173
+ for (const audit of audits) {
174
+ const issues = (audit as { issues?: unknown } | null)?.issues;
175
+ if (!Array.isArray(issues)) continue;
176
+ for (const issue of issues) {
177
+ if (typeof issue !== 'string') continue;
178
+ const type = issue.split(':')[0]?.trim() || issue;
179
+ counts.set(type, (counts.get(type) ?? 0) + 1);
180
+ }
181
+ }
182
+ return {
183
+ passed: record.passed === true,
184
+ overallScore: typeof record.overallScore === 'number' ? record.overallScore : 0,
185
+ issues: [...counts.entries()]
186
+ .map(([type, count]) => ({ type, count }))
187
+ .sort((a, b) => b.count - a.count || a.type.localeCompare(b.type)),
188
+ };
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
147
194
  /** Parse the timing JSONL; unreadable/missing -> [] (never throw). Last entry per step wins. */
148
195
  export async function readTiming(path: string | undefined): Promise<TimingEntry[]> {
149
196
  if (!path || path.trim() === '') return [];
@@ -180,7 +227,11 @@ function durationMs(entry: TimingEntry): number | null {
180
227
  export function renderReportMarkdown(
181
228
  docs: Doc[],
182
229
  date: string,
183
- options: { rejected?: { doc: Doc; reasons: string[] }[]; timing?: TimingEntry[] } = {},
230
+ options: {
231
+ rejected?: { doc: Doc; reasons: string[] }[];
232
+ timing?: TimingEntry[];
233
+ qc?: QcSummary | null;
234
+ } = {},
184
235
  ): string {
185
236
  const lines: string[] = [`# 每日 AI 语音运行报告 — ${date}`, ''];
186
237
  lines.push('## 选稿评分(保留候选)', '');
@@ -221,6 +272,18 @@ export function renderReportMarkdown(
221
272
  lines.push(`| category allowlist | — | ${counts.category} |`);
222
273
  lines.push('');
223
274
 
275
+ const qc = options.qc ?? null;
276
+ if (qc !== null) {
277
+ // 0139 R5b: the audio QC verdict is otherwise invisible in the report — this run published
278
+ // an episode whose audit said `passed:false`, and nothing but the artifact recorded it.
279
+ lines.push('## 音频 QC', '');
280
+ lines.push('| 指标 | 值 |', '| --- | --- |');
281
+ lines.push(`| 综合评分 | ${qc.overallScore} |`);
282
+ lines.push(`| 通过 | ${qc.passed ? '是' : '否'} |`);
283
+ for (const issue of qc.issues) lines.push(`| ${issue.type} | ${issue.count} |`);
284
+ lines.push('');
285
+ }
286
+
224
287
  const timing = options.timing ?? [];
225
288
  if (timing.length > 0) {
226
289
  lines.push('## 步耗时', '');
@@ -240,12 +303,20 @@ export function renderReportMarkdown(
240
303
  return lines.join('\n');
241
304
  }
242
305
 
243
- /** Read the rejected audit; absent/unreadable/malformed -> [] (never throw). */
306
+ /**
307
+ * Read the rejected audit; absent/unreadable/malformed -> [] (never throw).
308
+ *
309
+ * Two writer shapes: a bare `RejectedAudit[]` (older producers) and episode-plan-gen's
310
+ * `{ rejected, config, total }` envelope (`processFilterIO` — the live one). Reading only the
311
+ * array shape reported 0 rejections for every real run (task 0138 D1).
312
+ */
244
313
  export async function readRejectedAudit(path: string | undefined): Promise<RejectedAudit[]> {
245
314
  if (!path || path.trim() === '') return [];
246
315
  try {
247
- const parsed = JSON.parse(await Bun.file(path).text());
248
- return Array.isArray(parsed) ? (parsed as RejectedAudit[]) : [];
316
+ const parsed: unknown = JSON.parse(await Bun.file(path).text());
317
+ if (Array.isArray(parsed)) return parsed as RejectedAudit[];
318
+ const rejected = (parsed as { rejected?: unknown } | null)?.rejected;
319
+ return Array.isArray(rejected) ? (rejected as RejectedAudit[]) : [];
249
320
  } catch {
250
321
  return [];
251
322
  }
@@ -255,7 +326,11 @@ export async function readRejectedAudit(path: string | undefined): Promise<Rejec
255
326
  export function docsToContent(
256
327
  docs: Doc[],
257
328
  date: string,
258
- options: { rejected?: { doc: Doc; reasons: string[] }[]; timing?: TimingEntry[] },
329
+ options: {
330
+ rejected?: { doc: Doc; reasons: string[] }[];
331
+ timing?: TimingEntry[];
332
+ qc?: QcSummary | null;
333
+ },
259
334
  ): Content {
260
335
  return ContentSchema.parse({
261
336
  title: `每日 AI 语音运行报告 — ${date}`,
@@ -274,7 +349,8 @@ export async function processGeneratorIO(inputPath: string, outputPath: string):
274
349
  const docs = DocListSchema.parse(await readJsonFile(inputPath));
275
350
  const rejected = await readRejectedAudit(process.env.NEWS_REPORT_REJECTED_FILE);
276
351
  const timing = await readTiming(process.env.NEWS_REPORT_TIMING_FILE);
277
- const content = docsToContent(docs, dateFromEnv(), { rejected, timing });
352
+ const qc = await readQcSummary(process.env.NEWS_REPORT_QC_FILE);
353
+ const content = docsToContent(docs, dateFromEnv(), { rejected, timing, qc });
278
354
  const fs = createNodeFileSystem();
279
355
  const outDir = dirname(outputPath);
280
356
  if (outDir && outDir !== '.') await fs.ensureDir(outDir);
@@ -303,3 +303,19 @@ def run(
303
303
  except BaseException:
304
304
  _delete(out_path, audio_path, mp3_path)
305
305
  raise
306
+
307
+ # R5a (task 0139): repetition is the one QC issue the verify loop cannot fix and the one that
308
+ # damages credibility on air, so it fails the run — but only AFTER content.json is on disk, and
309
+ # outside the delete-on-failure try above, so the operator can inspect the audit that condemned
310
+ # it. The audio and MP3 stay too: this is a review stop, not a corrupt-artifact cleanup. Recovery
311
+ # is HITL YAML inspection plus a re-run; Q&A item 3 ships this with no override knob.
312
+ repeated = [
313
+ audit.get("segmentIndex")
314
+ for audit in content.metadata.get("qc", {}).get("segmentAudits", [])
315
+ if "repetition_detected" in audit.get("issues", [])
316
+ ]
317
+ if repeated:
318
+ indexes = ", ".join(str(index) for index in repeated)
319
+ raise RuntimeError(
320
+ f"segment(s) {indexes} carry repetition_detected — content.json written for inspection"
321
+ )
@@ -24,13 +24,6 @@ MAX_RATE_ZH = 15.0
24
24
  MAX_RATE_EN = 30.0
25
25
  MAX_RATE_OTHER = 25.0
26
26
 
27
- MAX_DURATION_ZH = 15.0
28
- MAX_DURATION_EN = 30.0
29
- MAX_DURATION_OTHER = 20.0
30
-
31
- MAX_DURATION_ZH_DIVISOR = 15.0
32
- MAX_DURATION_EN_DIVISOR = 20.0
33
- MAX_DURATION_OTHER_DIVISOR = 15.0
34
27
 
35
28
  RE_CHAR_RUN = re.compile(r"(.)\1{2,}", re.DOTALL)
36
29
  RE_WORD_RUN = re.compile(r"(.{2,3}?)\1{2,}", re.DOTALL)
@@ -185,21 +178,10 @@ def audit_voice_segments(
185
178
  abs_anomaly = duration > max_sec + MAX_ABS_DIFF_SEC if max_sec > 0 else False
186
179
  max_rate = MAX_RATE_ZH if language.startswith("zh") else MAX_RATE_EN if language.startswith("en") else MAX_RATE_OTHER
187
180
  rate_anomaly = duration > 0 and (len(text) / duration) > max_rate
188
- max_possible = (
189
- MAX_DURATION_ZH
190
- if language.startswith("zh")
191
- else MAX_DURATION_EN
192
- if language.startswith("en")
193
- else MAX_DURATION_OTHER
194
- ) + len(text) / (
195
- MAX_DURATION_ZH_DIVISOR
196
- if language.startswith("zh")
197
- else MAX_DURATION_EN_DIVISOR
198
- if language.startswith("en")
199
- else MAX_DURATION_OTHER_DIVISOR
200
- )
201
- abs_ceiling_anomaly = duration > max_possible
202
- duration_anomaly = ratio_anomaly or abs_anomaly or rate_anomaly or abs_ceiling_anomaly
181
+ # The former abs-ceiling term (duration > MAX_DURATION_<lang> + len(text)/<divisor>)
182
+ # compared against a fastest-speech floor, so any segment over ~39 chars flagged even
183
+ # when inside its expected range; genuinely overlong audio stays covered by ratio/abs/rate.
184
+ duration_anomaly = ratio_anomaly or abs_anomaly or rate_anomaly
203
185
  if duration_anomaly:
204
186
  issues.append(
205
187
  f"duration_anomaly: expected {min_sec:.2f}s-{max_sec:.2f}s, got {duration:.2f}s"
@@ -22715,6 +22715,40 @@ function mergeVoiceScripts(scripts, docs, envDefaultProfile) {
22715
22715
  }
22716
22716
  return result;
22717
22717
  }
22718
+ var CONTENT_ECHO_NOISE_RE = /[\uFF0C\u3002\u3001\uFF1A:\uFF1B;!\uFF01?\uFF1F\u2026\s\u300C\u300D\u300E\u300F\u3010\u3011]/g;
22719
+ var CONTENT_SCAFFOLD_LABEL_RE = /^(?:\*\*)?[\u300C\u300E]?(?:\u80CC\u666F|\u6F5C\u5728\u5F71\u54CD|\u53EF\u505A\u89D2\u5EA6|\u4E3A\u4EC0\u4E48\u73B0\u5728\u503C\u5F97\u6CE8\u610F|\u793E\u533A\u8BA8\u8BBA)[\u300D\u300F]?(?:\*\*)?(?:[\uFF1A:\uFF0C,]|\s|$)/;
22720
+ var CONTENT_TAG_TAIL_RE = /\u6807\u7B7E[\uFF1A:\uFF0C,]/;
22721
+ var CONTENT_URL_MANGLE_RE = /[\uFF0C\u3002]\/\//;
22722
+ function assertSegmentContent(script) {
22723
+ const seen = new Map;
22724
+ for (let idx = 0;idx < script.segments.length; idx++) {
22725
+ const text = (script.segments[idx]?.text ?? "").trim();
22726
+ if (text === "")
22727
+ continue;
22728
+ const previous = seen.get(text);
22729
+ if (previous !== undefined) {
22730
+ throw new Error(`content check failed: segment ${idx} duplicates segment ${previous} verbatim`);
22731
+ }
22732
+ seen.set(text, idx);
22733
+ const lead = text.match(/^[^\u3010]*\u3010([^\u3011]{4,80})\u3011[\uFF0C\u3002\u3001\s]*/);
22734
+ if (lead !== null) {
22735
+ const bracketed = (lead[1] ?? "").replace(CONTENT_ECHO_NOISE_RE, "");
22736
+ const rest = text.slice(lead[0].length).replace(CONTENT_ECHO_NOISE_RE, "");
22737
+ if (bracketed.length >= 4 && rest.startsWith(bracketed)) {
22738
+ throw new Error(`content check failed: segment ${idx} repeats its \u3010${lead[1]}\u3011 title in the body text`);
22739
+ }
22740
+ }
22741
+ if (CONTENT_SCAFFOLD_LABEL_RE.test(text)) {
22742
+ throw new Error(`content check failed: segment ${idx} opens with a research scaffold label`);
22743
+ }
22744
+ if (CONTENT_TAG_TAIL_RE.test(text)) {
22745
+ throw new Error(`content check failed: segment ${idx} carries a \u6807\u7B7E metadata tail`);
22746
+ }
22747
+ if (CONTENT_URL_MANGLE_RE.test(text)) {
22748
+ throw new Error(`content check failed: segment ${idx} has a colon-mangled URL`);
22749
+ }
22750
+ }
22751
+ }
22718
22752
  function validateVoiceScript(script) {
22719
22753
  if (!script || typeof script !== "object") {
22720
22754
  throw new Error("VoiceScript must be an object");
@@ -22791,6 +22825,7 @@ function validateVoiceScript(script) {
22791
22825
  assertEngine(segment.engine, `Segment at index ${idx}`);
22792
22826
  assertLanguage(segment.language, `Segment at index ${idx}`);
22793
22827
  }
22828
+ assertSegmentContent(script);
22794
22829
  }
22795
22830
 
22796
22831
  // ../../plugins/generations/voice-gen/src/index.ts
@@ -367,6 +367,57 @@ export function mergeVoiceScripts(scripts: VoiceScript[], docs: Doc[], envDefaul
367
367
  return result;
368
368
  }
369
369
 
370
+ /**
371
+ * Content checks mirrored from `plugins/kk/scripts/validate-voicescript.ts` (0139 R3 SYNC contract).
372
+ * Keep the messages byte-identical to the kk copy — the daily workflow runs whichever copy the
373
+ * package resolves, and both must reject the same spoken-copy defects with the same reason.
374
+ */
375
+
376
+ /** Punctuation/space the translator drifts between a title and its body restatement. */
377
+ const CONTENT_ECHO_NOISE_RE = /[,。、::;;!!??…\s「」『』【】]/g;
378
+ /** Research scaffold labels the plan-translate stage must not emit as spoken copy. */
379
+ const CONTENT_SCAFFOLD_LABEL_RE =
380
+ /^(?:\*\*)?[「『]?(?:背景|潜在影响|可做角度|为什么现在值得注意|社区讨论)[」』]?(?:\*\*)?(?:[::,,]|\s|$)/;
381
+ /** `标签,` metadata tails — the plan item's tag line spoken aloud. */
382
+ const CONTENT_TAG_TAIL_RE = /标签[::,,]/;
383
+ /** `wss,//` / `https。//` — the translator replacing a URL colon with a Chinese comma. */
384
+ const CONTENT_URL_MANGLE_RE = /[,。]\/\//;
385
+
386
+ function assertSegmentContent(script: VoiceScript): void {
387
+ const seen = new Map<string, number>();
388
+ for (let idx = 0; idx < script.segments.length; idx++) {
389
+ const text = (script.segments[idx]?.text ?? '').trim();
390
+ if (text === '') continue;
391
+
392
+ const previous = seen.get(text);
393
+ if (previous !== undefined) {
394
+ throw new Error(`content check failed: segment ${idx} duplicates segment ${previous} verbatim`);
395
+ }
396
+ seen.set(text, idx);
397
+
398
+ const lead = text.match(/^[^【]*【([^】]{4,80})】[,。、\s]*/);
399
+ if (lead !== null) {
400
+ const bracketed = (lead[1] ?? '').replace(CONTENT_ECHO_NOISE_RE, '');
401
+ const rest = text.slice(lead[0].length).replace(CONTENT_ECHO_NOISE_RE, '');
402
+ if (bracketed.length >= 4 && rest.startsWith(bracketed)) {
403
+ throw new Error(
404
+ `content check failed: segment ${idx} repeats its 【${lead[1]}】 title in the body text`,
405
+ );
406
+ }
407
+ }
408
+
409
+ if (CONTENT_SCAFFOLD_LABEL_RE.test(text)) {
410
+ throw new Error(`content check failed: segment ${idx} opens with a research scaffold label`);
411
+ }
412
+ if (CONTENT_TAG_TAIL_RE.test(text)) {
413
+ throw new Error(`content check failed: segment ${idx} carries a 标签 metadata tail`);
414
+ }
415
+ if (CONTENT_URL_MANGLE_RE.test(text)) {
416
+ throw new Error(`content check failed: segment ${idx} has a colon-mangled URL`);
417
+ }
418
+ }
419
+ }
420
+
370
421
  /**
371
422
  * Validate that a VoiceScript satisfies all Voicebox and plugin constraints.
372
423
  */
@@ -499,4 +550,6 @@ export function validateVoiceScript(script: VoiceScript): void {
499
550
  assertEngine(segment.engine, `Segment at index ${idx}`);
500
551
  assertLanguage(segment.language, `Segment at index ${idx}`);
501
552
  }
553
+
554
+ assertSegmentContent(script);
502
555
  }
@@ -28,8 +28,8 @@ Parse per the frozen rule:
28
28
 
29
29
  - First token is the workflow **name** when it matches `^[a-z0-9][a-z0-9-]*$` and is not `--in`;
30
30
  otherwise `name=kk-storm-research` and that token is the topic. Supported profile names:
31
- `kk-storm-research` (default), `kk-itc`, `kk-solo-podcast`. Any other name after successful copy → exit 1 listing
32
- `kk-storm-research`, `kk-itc`, `kk-solo-podcast`.
31
+ `kk-storm-research` (default), `kk-itc`, `kk-solo-podcast`, `kk-daily-ai-voice`. Any other name after successful copy → exit 1 listing
32
+ `kk-storm-research`, `kk-itc`, `kk-solo-podcast`, `kk-daily-ai-voice`.
33
33
  - `--in <file>` → file mode. `--force` → overwrite an existing dest YAML / existing `brief.md`.
34
34
  - Profile `kk-storm-research`: `--dir <path>` (default `$works_dir/kk-storm-research/<topic-id>`),
35
35
  `--fixture` → force `fixture: "true"`.
@@ -40,6 +40,9 @@ Parse per the frozen rule:
40
40
  - Profile `kk-solo-podcast`: `--dir <path>` (default `$works_dir/kk-solo-podcast/<date>`), `--outline <a|b|c>`
41
41
  (default empty), `--duration <min>` (default `8`), `--language <code>` (default `en`),
42
42
  `--script-approved` (sets `script_approved=true`; default off). `--fixture` is storm-only.
43
+ - Profile `kk-daily-ai-voice`: `--dir <path>` (default `$works_dir/kk-daily-ai-voice/<date>`),
44
+ `--publish <true|false>` (default `false`), `--last30days <true|false>` (default `false`),
45
+ `--skip-review` (sets `skip_review=true`; default off). `--fixture` is storm-only.
43
46
  - **XOR:** exactly one of `<topic>` / `--in <file>`. Both set or both empty → exit 1, stderr
44
47
  states the XOR rule, no writes.
45
48
 
@@ -241,6 +244,19 @@ spur workflow run "$dest" --vars \
241
244
  "{\"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\"}"
242
245
  ```
243
246
 
247
+ ### Profile `kk-daily-ai-voice`
248
+
249
+ Bind vars: `work_dir`, `publish_enabled`, `last30days_enabled`, `skip_review`, `plugins_path`
250
+ (empty → ADR-012 default discovery), `agent`. `$dest` resolves like solo-podcast. The run is
251
+ topicless — no `topic`/`input_file`/`fixture` vars. `run_date` is computed by the `prepare`
252
+ state and written to `$work_dir/run-date.txt`. The `review` state pauses the run for operator
253
+ approval of the VoiceScript; resume with `spur workflow continue [run-id]`.
254
+
255
+ ```bash
256
+ spur workflow run "$dest" --vars \
257
+ "{\"work_dir\":\"$work_dir\",\"publish_enabled\":\"$PUBLISH\",\"last30days_enabled\":\"$L30D\",\"skip_review\":\"$SKIP_REVIEW\",\"plugins_path\":\"\",\"agent\":\"$AGENT\"}"
258
+ ```
259
+
244
260
  ## 6. Report
245
261
 
246
262
  - **`kk-storm-research`**: print resolved `work_dir`, then `$work_dir/content.md` (report sidecar)
@@ -251,6 +267,13 @@ spur workflow run "$dest" --vars \
251
267
  - **`kk-solo-podcast`**: print resolved `work_dir`, then `$work_dir/1-briefing-briefing.md`,
252
268
  `$work_dir/3-script-voicescript.yaml`, `$work_dir/4-audio/content.json`, and
253
269
  `$work_dir/4-audio/content.wav`.
270
+ - **`kk-daily-ai-voice`**: print resolved `work_dir` (read the date from `$work_dir/run-date.txt`),
271
+ then `$work_dir/2-article/<date>_06_article_article.md`,
272
+ `$work_dir/2-script/<date>_08_script_voicescript.yaml`,
273
+ `$work_dir/3-audio/<date>_11_generate_content.json` (carries the audio path and QC metrics),
274
+ and `$work_dir/<date>_19_run-report_report.md`. With `publish_enabled=true` also print
275
+ `$work_dir/3-publish/<date>_17_publish_publish-status.txt` and the
276
+ `<date>_14_publish-surfdash_result.json` / `<date>_16_publish-podcast_result.json` outcomes.
254
277
 
255
278
  If the run ended `failed`, show the failing state (`spur workflow trace <run-id> --json`) and the
256
279
  fail-loud recovery line.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "kk",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "description": "knowledge-kit Claude Code plugin: skills, commands, subagents, hooks, and rules"
5
5
  }