@gobing-ai/knowledge-kit 0.1.5 → 0.1.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.
@@ -74,6 +74,7 @@ import {
74
74
  coverBrief,
75
75
  coverNormalizeBody,
76
76
  dedupPlanByTitle,
77
+ expandHome,
77
78
  fail,
78
79
  failWith,
79
80
  firstArtifact,
@@ -1317,7 +1318,10 @@ export function dailyIntro(args: string[]): void {
1317
1318
  if (rawFile === undefined) {
1318
1319
  failWith(1, 'intro-music: voice content carries no mp3Path/audioPath');
1319
1320
  }
1320
- const introPath = resolve(introSrc);
1321
+ // The sting is an operator asset under the config root (ADR-042), not a works artifact:
1322
+ // workflow vars carry it as `~/.config/kk/voices/…`, and a shell does not expand `~` inside
1323
+ // the quoted "${vars.intro_music}" argument, so expansion happens here.
1324
+ const introPath = resolve(expandHome(introSrc));
1321
1325
  if (!existsSync(introPath)) failWith(1, `intro-music: intro file not found: ${introPath}`);
1322
1326
  const mainPath = resolve(rawFile);
1323
1327
  if (!existsSync(mainPath)) failWith(1, `intro-music: generated audio not found: ${mainPath}`);
@@ -1981,6 +1985,52 @@ export function dailySafetyGate(args: string[]): void {
1981
1985
  });
1982
1986
  }
1983
1987
 
1988
+ /**
1989
+ * `daily-preflight <workflow.yaml>…` — assert the stage runner that `kk stage` just loaded
1990
+ * advertises every `kk stage <name>` these workflow definitions invoke.
1991
+ *
1992
+ * WHY (2026-10-20): the scheduler runs the CHECKOUT's workflow YAML while `kk stage` resolves the
1993
+ * runner from the INSTALLED package (`apps/cli/src/stage.ts`). A commit that adds a stage therefore
1994
+ * outruns the global install silently, and the mismatch only surfaces where the new stage is first
1995
+ * reached. That day's 04:30 run died 29 minutes in at `safety` with `unknown stage:
1996
+ * daily-safety-verdict` (present in the checkout, absent from the installed 0.1.4) and published
1997
+ * nothing. This runs as the first command of the run's `prepare` node, so the same skew costs one
1998
+ * cheap step and names both the missing stages and the refresh command.
1999
+ *
2000
+ * The known-name set is the dispatch switch of the sibling `kk-workflow-stages.ts` — the file beside
2001
+ * this one, i.e. the copy actually in force, never a checkout-vs-install guess.
2002
+ */
2003
+ export function dailyPreflight(args: string[]): void {
2004
+ const files = args.filter((arg) => arg !== '');
2005
+ timed('', 'preflight', () => {
2006
+ if (files.length === 0) failWith(2, 'daily-preflight: pass at least one workflow YAML path');
2007
+ const runner = join(import.meta.dir, 'kk-workflow-stages.ts');
2008
+ if (!isFile(runner)) failWith(1, `daily-preflight.runner-missing: ${runner} is not beside this stage body`);
2009
+ const known = new Set(
2010
+ [...readFileSync(runner, 'utf-8').matchAll(/^\s*case '([a-z][a-z0-9-]*)':/gm)].map((match) =>
2011
+ String(match[1]),
2012
+ ),
2013
+ );
2014
+ const missing: string[] = [];
2015
+ for (const file of files) {
2016
+ if (!isFile(file)) failWith(1, `daily-preflight.workflow-missing: ${file}`);
2017
+ for (const match of readFileSync(file, 'utf-8').matchAll(/\bkk stage ([a-z][a-z0-9-]*)/g)) {
2018
+ const name = String(match[1]);
2019
+ if (!known.has(name)) missing.push(`${name} (needed by ${basename(file)})`);
2020
+ }
2021
+ }
2022
+ if (missing.length > 0) {
2023
+ failWith(
2024
+ 1,
2025
+ `daily-preflight.runner-stale: the installed stage runner ${runner} does not know ${missing.join(', ')} — refresh it (install the released kk package, or rsync plugins/kk/scripts/ per AGENTS.md) before the scheduled run`,
2026
+ );
2027
+ }
2028
+ console.log(
2029
+ `preflight: stage runner advertises every stage ${files.map((file) => basename(file)).join(' + ')} invokes`,
2030
+ );
2031
+ });
2032
+ }
2033
+
1984
2034
  /**
1985
2035
  * Task 0317 R6 — fold the gate's own classification, its stamp and the `kk-safety-verdict` catalog
1986
2036
  * decision into ONE accept/refuse token the `judge -> clean` guard reads as a single predicate
@@ -159,6 +159,20 @@ async function loadDailyStages(): Promise<typeof import('./daily-stages.ts')> {
159
159
  );
160
160
  }
161
161
  }
162
+ /**
163
+ * Load the audio-quality diagnostic lazily: it shells out to ffprobe/ffmpeg and is only needed when an
164
+ * operator asks for it, so the runner must not pay for it on every stage (and an install without
165
+ * ffmpeg on PATH still runs every other stage).
166
+ */
167
+ async function loadAudioQuality(): Promise<typeof import('./audio-quality.ts')> {
168
+ try {
169
+ return await import('./audio-quality.ts');
170
+ } catch {
171
+ return fail(
172
+ 'audio-quality.ts not found next to kk-workflow-stages.ts — install or refresh the kk package (bun link in a checkout)',
173
+ );
174
+ }
175
+ }
162
176
  export const ERRNO_REASONS: Record<string, string> = {
163
177
  EACCES: 'Permission denied',
164
178
  EROFS: 'Read-only file system',
@@ -737,7 +751,7 @@ function formatRunDate(): string {
737
751
  }
738
752
 
739
753
  /** Expand the `~/` and `$HOME/` prefixes the config examples use. */
740
- function expandHome(p: string): string {
754
+ export function expandHome(p: string): string {
741
755
  if (p.startsWith('~/')) return join(homedir(), p.slice(2));
742
756
  if (p.startsWith('$HOME/')) return join(homedir(), p.slice('$HOME/'.length));
743
757
  return p;
@@ -1638,6 +1652,16 @@ if (import.meta.main)
1638
1652
  dailyScript(rest);
1639
1653
  break;
1640
1654
  }
1655
+ case 'audio-quality': {
1656
+ const { runAudioQuality } = await loadAudioQuality();
1657
+ await runAudioQuality(rest);
1658
+ break;
1659
+ }
1660
+ case 'daily-preflight': {
1661
+ const { dailyPreflight } = await loadDailyStages();
1662
+ dailyPreflight(rest);
1663
+ break;
1664
+ }
1641
1665
  case 'daily-safety-prep': {
1642
1666
  const { dailySafetyPrep } = await loadDailyStages();
1643
1667
  dailySafetyPrep(rest);
@@ -30,7 +30,9 @@ vars:
30
30
  # env block; smoke render 6.8s). Flip back with vdriver="voicebox".
31
31
  vdriver: "gemini"
32
32
  # 20260917 review #7: 8s intro sting prepended to every daily output; empty = skip.
33
- intro_music: ".works/kk-daily-ai-voice/AI每日资讯_8秒播客片头.mp3"
33
+ # The sting is an operator asset: it lives under the config root (ADR-042), never in the works
34
+ # tree. `~` is expanded by the daily-intro stage, so it stays machine-portable.
35
+ intro_music: "~/.config/kk/voices/AI每日资讯_8秒播客片头.mp3"
34
36
  # 20260917 review #4/#6: surfdash checkout the patch stage commits to.
35
37
  surfdash_root: "/Users/robin/xprojects/surfdash"
36
38
  title: ""
@@ -25,11 +25,17 @@ vars:
25
25
  # Voice driver switch — voxcpm (voxcpm-tts) | gemini (gemini-tts) | voicebox (voice-gen)
26
26
  # | ominivoice (omni-voice-gen). The generate tier below mirrors the fallback order, and the
27
27
  # fit/duration gates take their spoken-char rate from this var — switching drivers here is enough.
28
- # 2026-10-15 production test (operator decision): voxcpm first, then gemini, then omni. voxcpm
29
- # rendered that episode in 21.8 min at a measured 5.19 字/秒 (fitted at 5.2); if the audio verdict
30
- # is good this is the standing order, otherwise revert this default to "gemini".
28
+ # 2026-10-20 (operator decision): voxcpm STAYS the primary. It is audibly echoey - a 40 ms periodic
29
+ # reflection on renders longer than ~30 s plus a pause floor only 16-22 dB below speech, measured by
30
+ # `kk stage audio-quality` on every episode's raw model output since 10-15 (cepstrum 40 ms @ 26-31 dB
31
+ # / pauseFloor -16..-22 dB, versus gemini's 20-25 ms @ 19-20 dB / -34..-37 dB) - and no exposed knob
32
+ # (cfg_value, inference_timesteps, normalize, denoise) or usable checkpoint fixes it (VoxCPM1.5:
33
+ # 80 ms @ 31 dB; VoxCPM-0.5B loses the lattice but measures -15.5 dB at 16 kHz). The operator keeps
34
+ # the local driver for its speed and cost and will monitor it episode by episode until a proper fix
35
+ # exists; flipping this one var to "gemini" is the escape hatch (measured dry: 20-25 ms / -34..-37 dB)
36
+ # at the price of a 1h47m-2h21m remote render.
31
37
  vdriver: "voxcpm"
32
- intro_music: ".works/kk-daily-ai-voice/AI每日资讯_8秒播客片头.mp3"
38
+ intro_music: "~/.config/kk/voices/AI每日资讯_8秒播客片头.mp3"
33
39
  surfdash_root: "/Users/robin/xprojects/surfdash"
34
40
  title: ""
35
41
  skip_review: "false"
@@ -113,11 +119,18 @@ nodes:
113
119
  the artifact directories and the safety revise budget. `kk ledger sync` records runs that
114
120
  already finished without a ledger row (failed ones included) — a fail-fast DAG never reaches
115
121
  its own `ledger` node, so the next run's prepare is the backfill (design §7).
122
+ The `daily-preflight` stage runs FIRST: the scheduler executes this checkout's YAML while
123
+ `kk stage` resolves its stage runner from the installed package, so a run whose YAML names a
124
+ stage the installed runner lacks must fail here, naming the stage, instead of 29 minutes later
125
+ inside the nested safety machine (2026-10-20: `unknown stage: daily-safety-verdict`, nothing
126
+ published).
116
127
  action:
117
128
  kind: shell
118
129
  options:
119
130
  command: >-
120
- kk daily prepare --workflow kk-daily-ai-voice2 --run-date "${vars.run_date}" --ledger-sync
131
+ kk stage daily-preflight plugins/kk/workflows/kk-daily-ai-voice2.yaml
132
+ plugins/kk/workflows/kk-daily-safety.yaml
133
+ && kk daily prepare --workflow kk-daily-ai-voice2 --run-date "${vars.run_date}" --ledger-sync
121
134
  --run "${vars.__runId}" --definition-digest "${vars.__definitionDigest}"
122
135
  --setting window="${vars.window}" --setting language="${vars.language}" --setting vdriver="${vars.vdriver}"
123
136
  --setting skip_review="${vars.skip_review}" --setting transcode_mp3="${vars.transcode_mp3}"
@@ -548,7 +561,7 @@ nodes:
548
561
  kk stage daily-wrap-docs "${vars.work_dir}" "${vars.run_date}" "${vars.voice_profile}"
549
562
 
550
563
  - id: generate
551
- description: "Voice tier, primary driver (vars.vdriver — voxcpm while the 2026-10-15 production test runs). onError: continue — the fallback siblings below are the loud chain (same pattern as 0238 R2)."
564
+ description: "Voice tier, primary driver (vars.vdriver — voxcpm; its 40 ms lattice is known and monitored, see the vdriver comment). onError: continue — the fallback siblings below are the loud chain (same pattern as 0238 R2)."
552
565
  # wrap-docs is a data dependency, not a sibling: daily-generate reads its _10_wrap-docs_docs.json
553
566
  # as the TTS input. Without this edge the two nodes race in the same wave (0212 dogfood finding).
554
567
  dependsOn: [script, safety, review, wrap-docs]
@@ -570,7 +583,7 @@ nodes:
570
583
  # its plugin id and the cache ignores foreign-driver sections, so a failed attempt can neither be
571
584
  # relabeled nor mix voices). Non-final siblings continue on error; the last one is loud.
572
585
  - id: generate-fallback
573
- description: "Voice tier fallback 1: gemini (gemini-tts) when the primary driver left no valid Content"
586
+ description: "Voice tier fallback 1: gemini (gemini-tts) when the primary driver left no valid Content — the measured-dry escape hatch from voxcpm's lattice"
574
587
  dependsOn: [generate]
575
588
  condition:
576
589
  kind: shell
@@ -12423,6 +12423,10 @@ ${crossLinkFor(locale, request.dateIso, request.showNotesUrl)}
12423
12423
  if (md !== before) {
12424
12424
  await fs.writeFile(indexMd, md);
12425
12425
  written.push(indexMd);
12426
+ const assetsDir = join(dir, "assets");
12427
+ if (await fs.exists(assetsDir) && !written.includes(join(assetsDir, "cover.png"))) {
12428
+ written.push(assetsDir);
12429
+ }
12426
12430
  changed += 1;
12427
12431
  }
12428
12432
  }
@@ -12437,6 +12441,14 @@ var COVER_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"]);
12437
12441
  function slugify2(str) {
12438
12442
  return str.toLowerCase().replace(/[^\w\s-]/g, "").trim().replace(/[\s_]+/g, "-");
12439
12443
  }
12444
+ function normalizeTag(raw) {
12445
+ const match = raw.match(/^\s*sub-category\s*:(.*)$/i);
12446
+ if (match) {
12447
+ const subSlug = slugify2(match[1] ?? "");
12448
+ return subSlug ? `sub-category:${subSlug}` : "";
12449
+ }
12450
+ return slugify2(raw);
12451
+ }
12440
12452
  function mapContentToSurfingMarkdown(content, options = {}) {
12441
12453
  let title = content.title;
12442
12454
  if (!title) {
@@ -12452,9 +12464,13 @@ function mapContentToSurfingMarkdown(content, options = {}) {
12452
12464
  description = paragraphs[0];
12453
12465
  }
12454
12466
  }
12455
- const rawTags = Array.isArray(content.metadata?.tags) ? content.metadata.tags : Array.isArray(content.metadata?.keywords) ? content.metadata.keywords : [];
12467
+ const rawTags = Array.isArray(content.metadata?.tags) ? [...content.metadata.tags] : Array.isArray(content.metadata?.keywords) ? [...content.metadata.keywords] : [];
12468
+ if (typeof content.metadata?.subCategory === "string" && content.metadata.subCategory.trim().length > 0) {
12469
+ const subRaw = content.metadata.subCategory.trim();
12470
+ rawTags.push(/^sub-category\s*:/i.test(subRaw) ? subRaw : `sub-category:${subRaw}`);
12471
+ }
12456
12472
  const tags = [
12457
- ...new Set(rawTags.filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => slugify2(t)))
12473
+ ...new Set(rawTags.filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => normalizeTag(t)))
12458
12474
  ].filter((tag) => tag.length > 0);
12459
12475
  const isoDate = options.publishDate ?? new Date().toISOString().slice(0, 10);
12460
12476
  const firstRefUrl = content.references?.[0]?.url;
@@ -12743,7 +12759,17 @@ async function patchPost(options) {
12743
12759
  if (outcome.changed === 0)
12744
12760
  echoError(`patch: no post bytes changed for ${outcome.slug}`);
12745
12761
  const synced = await syncPost({ postPath: postPath.postPath });
12746
- const commit = await commitPostPaths(surfdashRoot, outcome.written.map((path) => relative(surfdashRoot, path)), `feat: cover + podcast cross-link for ${outcome.slug} (kk daily patch)`, process.env.SURFDASH_AUTO_PUSH !== "0");
12762
+ const commitPaths = new Set(outcome.written.map((path) => relative(surfdashRoot, path)));
12763
+ for (const locale of PATCH_LOCALES) {
12764
+ const indexMd = localeIndexPath(postPath.postPath, locale);
12765
+ if (!indexMd)
12766
+ continue;
12767
+ const assetsDir = join2(dirname3(indexMd), "assets");
12768
+ if (existsSync2(assetsDir)) {
12769
+ commitPaths.add(relative(surfdashRoot, assetsDir));
12770
+ }
12771
+ }
12772
+ const commit = await commitPostPaths(surfdashRoot, [...commitPaths], `feat: cover + podcast cross-link for ${outcome.slug} (kk daily patch)`, process.env.SURFDASH_AUTO_PUSH !== "0");
12747
12773
  if (!commit.ok)
12748
12774
  return fail(`patch: ${commit.error}`);
12749
12775
  const metadata = {
@@ -12922,6 +12948,7 @@ export {
12922
12948
  main2 as main,
12923
12949
  mapContentToSurfingMarkdown,
12924
12950
  normalizeLocaleFrontmatter,
12951
+ normalizeTag,
12925
12952
  processPublishIO,
12926
12953
  pushPostGit,
12927
12954
  slugify2 as slugify,
@@ -6,7 +6,7 @@ import { type Content, ContentSchema, type Result, ResultSchema } from '@gobing-
6
6
  import type { PublishTransport, PublishTransportPayload } from '@gobing-ai/publish-harness';
7
7
  import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
8
8
  import { echoError } from '@gobing-ai/ts-utils';
9
- import { type PatchPostOutcome, patchPostFiles } from './patch';
9
+ import { localeIndexPath, PATCH_LOCALES, type PatchPostOutcome, patchPostFiles } from './patch';
10
10
 
11
11
  /** Result target name for this plugin. */
12
12
  const TARGET = 'surfdash';
@@ -62,6 +62,19 @@ export function slugify(str: string): string {
62
62
  .replace(/[\s_]+/g, '-');
63
63
  }
64
64
 
65
+ /**
66
+ * Normalizes a raw tag string for frontmatter: preserves `sub-category:<slug>` prefixes
67
+ * while slugifying the value portion (or ordinary tags).
68
+ */
69
+ export function normalizeTag(raw: string): string {
70
+ const match = raw.match(/^\s*sub-category\s*:(.*)$/i);
71
+ if (match) {
72
+ const subSlug = slugify(match[1] ?? '');
73
+ return subSlug ? `sub-category:${subSlug}` : '';
74
+ }
75
+ return slugify(raw);
76
+ }
77
+
65
78
  /**
66
79
  * Maps Content object into Surfing frontmatter and body per the publish spec.
67
80
  */
@@ -86,18 +99,23 @@ export function mapContentToSurfingMarkdown(content: Content, options: MapConten
86
99
  }
87
100
 
88
101
  const rawTags: unknown[] = Array.isArray(content.metadata?.tags)
89
- ? content.metadata.tags
102
+ ? [...content.metadata.tags]
90
103
  : Array.isArray(content.metadata?.keywords)
91
- ? content.metadata.keywords
104
+ ? [...content.metadata.keywords]
92
105
  : [];
106
+ if (typeof content.metadata?.subCategory === 'string' && content.metadata.subCategory.trim().length > 0) {
107
+ const subRaw = content.metadata.subCategory.trim();
108
+ rawTags.push(/^sub-category\s*:/i.test(subRaw) ? subRaw : `sub-category:${subRaw}`);
109
+ }
93
110
  // `slugify` drops non-ASCII, so a Chinese tag (`硬件发布`) collapsed to '' and shipped as an empty
94
111
  // YAML tag — the site renders it as a blank chip (seen on 20261011-ai-2026-10-11). Case variants
95
- // also collapsed to the same slug twice (`AI` + `ai`), so the set dedupes.
112
+ // also collapsed to the same slug twice (`AI` + `ai`), so the set dedupes. `normalizeTag` preserves
113
+ // `sub-category:<slug>` colon prefixes so sub-category series tags survive into frontmatter.
96
114
  const tags = [
97
115
  ...new Set(
98
116
  rawTags
99
117
  .filter((t): t is string => typeof t === 'string' && t.trim().length > 0)
100
- .map((t: string) => slugify(t)),
118
+ .map((t: string) => normalizeTag(t)),
101
119
  ),
102
120
  ].filter((tag) => tag.length > 0);
103
121
 
@@ -519,9 +537,18 @@ async function patchPost(options: SurfdashPubOptions): Promise<Result> {
519
537
  }
520
538
  if (outcome.changed === 0) echoError(`patch: no post bytes changed for ${outcome.slug}`);
521
539
  const synced = await syncPost({ postPath: postPath.postPath });
540
+ const commitPaths = new Set(outcome.written.map((path) => relative(surfdashRoot, path)));
541
+ for (const locale of PATCH_LOCALES) {
542
+ const indexMd = localeIndexPath(postPath.postPath, locale);
543
+ if (!indexMd) continue;
544
+ const assetsDir = join(dirname(indexMd), 'assets');
545
+ if (existsSync(assetsDir)) {
546
+ commitPaths.add(relative(surfdashRoot, assetsDir));
547
+ }
548
+ }
522
549
  const commit = await commitPostPaths(
523
550
  surfdashRoot,
524
- outcome.written.map((path) => relative(surfdashRoot, path)),
551
+ [...commitPaths],
525
552
  `feat: cover + podcast cross-link for ${outcome.slug} (kk daily patch)`,
526
553
  process.env.SURFDASH_AUTO_PUSH !== '0',
527
554
  );
@@ -137,6 +137,10 @@ export async function patchPostFiles(request: PatchPostRequest): Promise<PatchPo
137
137
  if (md !== before) {
138
138
  await fs.writeFile(indexMd, md);
139
139
  written.push(indexMd);
140
+ const assetsDir = join(dir, 'assets');
141
+ if ((await fs.exists(assetsDir)) && !written.includes(join(assetsDir, 'cover.png'))) {
142
+ written.push(assetsDir);
143
+ }
140
144
  changed += 1;
141
145
  }
142
146
  }
@@ -1,89 +0,0 @@
1
- # Feature-scoped verification pass (ADR-119, feature D62, task 0872; D63 task
2
- # 0915 remediation folds the evidence contract into a workflow-owned script).
3
- #
4
- # Owns a lifecycle boundary no per-task graph owns: the repo-wide checks whose
5
- # invariant spans the repository rather than a single task's diff. They run
6
- # once per feature, deliberately the slow pass; `feature-lifecycle`'s verifying
7
- # entry invokes this workflow (task 0880 caller wiring), and its verifying→done
8
- # guard refuses to complete the feature until this pass records PASS (ADR-119
9
- # consequence: a feature is not done until it passes).
10
- #
11
- # Run: spur workflow run feature-verification.yaml --vars '{"featureId":"D63"}'
12
- #
13
- # No project-side override is required (task 1185, A33). Since the pass moved into the
14
- # application (1176 / efd78f058) the bundled definition owns it end to end, so a
15
- # project-layer copy of this file (plus a rooted port under `.spur/scripts/`) is a
16
- # leftover — the port was written against an entry export contract that has since
17
- # changed. Remove the override so this definition applies, or migrate the port to the
18
- # current app-owned operation `runFeatureVerification` (the generated inline bundle's
19
- # export). If a stale consumer is still reached, feature-lifecycle's `verifying` entry
20
- # names it: the override path, the consumer script, the export it requires that the
21
- # shipped entry no longer provides, and the entry path. Reinstalling or upgrading the
22
- # plugin cannot supply a retired export — only the two remedies above can.
23
- #
24
- # Shape: verify -> done | failed. The onEnter step runs `spur feature verify`
25
- # (D63 task 0915; script-absorption PR-5 retired the feature-verification-steps
26
- # plugin script): it
27
- # resolves this very definition for the verifier identity (name, source path,
28
- # layer, definition digest), records the RUNNING receipt (run-scoped +
29
- # feature-latest copies), registers the run-scoped receipt as a run artifact,
30
- # runs the configured verification command via the safe launch splitter, and
31
- # completes the receipt PASS/FAIL with the after-pass proof-input digest. A
32
- # changed tree during the pass records FAIL.
33
- #
34
- # Reliability (aligned with task-pipeline / ADR-043): no agent.run nodes; the
35
- # verification command comes from `vars.verificationCmd` below and is never
36
- # interpolated through a shell. The always-exit-0 onEnter means missing,
37
- # corrupt or FAIL evidence routes to `failed` via the guard (fail-closed),
38
- # never a silent PASS; infrastructure errors exit nonzero and surface.
39
- "$schema": "@gobing-ai/spur/schemas/state-machine-workflow.schema.json"
40
- kind: state-machine
41
- name: feature-verification
42
- version: "1"
43
- description: "Feature-scoped verification pass (ADR-119): runs the repo-wide check set once per feature and records a bound PASS/FAIL evidence receipt. Invoked by feature-lifecycle's verifying entry."
44
- iterationBound: 2
45
- initialState: verify
46
- terminalStates:
47
- - done
48
- - failed
49
- failureStates:
50
- - failed
51
- vars:
52
- featureId: "X"
53
- spurBin: "spur"
54
- verificationCmd: "bun run spur-check"
55
- __runId: ""
56
-
57
- states:
58
- - id: verify
59
- description: >
60
- Run the repo-wide check set via `spur feature verify`, which
61
- records the bound evidence receipt (verifier identity, checked-input
62
- digest, run identity) and the coarse status file, always exit 0 so the
63
- transition guard routes missing/corrupt/FAIL to `failed`.
64
- onEnter:
65
- - kind: shell
66
- options:
67
- command: >-
68
- mkdir -p .spur/run; $spurBin feature verify "$featureId" --run-id "$__runId" --no-logo || printf 'FAIL feature-verification failed closed — spur feature verify exited nonzero\n' > ".spur/run/$__runId-feature-verification.status"; exit 0
69
-
70
- - id: done
71
- description: Terminal — repo-wide checks passed and the receipt is bound.
72
-
73
- - id: failed
74
- description: Terminal — repo-wide checks failed or the evidence is missing/corrupt.
75
-
76
- transitions:
77
- - from: verify
78
- to: done
79
- description: Repo-wide checks passed — `spur feature verify` completed the receipt with PASS.
80
- guard:
81
- kind: shell
82
- options:
83
- command: 'test "$(cat .spur/run/$__runId-feature-verification.status 2>/dev/null)" = PASS'
84
- - from: verify
85
- to: failed
86
- terminalReason: failed-check
87
- description: Repo-wide checks failed (or the evidence is missing/corrupt) — stop at failed.
88
- guard:
89
- kind: always