spexcode 0.1.6 → 0.2.1

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 (73) hide show
  1. package/README.md +99 -35
  2. package/README.zh-CN.md +135 -0
  3. package/package.json +5 -6
  4. package/spec-cli/README.md +86 -0
  5. package/spec-cli/bin/spex.mjs +15 -3
  6. package/spec-cli/hooks/dispatch.sh +20 -8
  7. package/spec-cli/hooks/harness.sh +18 -11
  8. package/spec-cli/src/board.ts +47 -18
  9. package/spec-cli/src/boardCache.ts +70 -0
  10. package/spec-cli/src/boardDelta.ts +90 -0
  11. package/spec-cli/src/boardStream.ts +178 -0
  12. package/spec-cli/src/cli.ts +184 -122
  13. package/spec-cli/src/client.ts +6 -4
  14. package/spec-cli/src/gateway.ts +64 -24
  15. package/spec-cli/src/git.ts +105 -92
  16. package/spec-cli/src/guide.ts +186 -19
  17. package/spec-cli/src/harness-select.ts +63 -0
  18. package/spec-cli/src/harness.ts +506 -100
  19. package/spec-cli/src/help.ts +362 -0
  20. package/spec-cli/src/hooks.ts +0 -14
  21. package/spec-cli/src/index.ts +279 -32
  22. package/spec-cli/src/init.ts +41 -1
  23. package/spec-cli/src/issues.ts +301 -0
  24. package/spec-cli/src/layout.ts +70 -28
  25. package/spec-cli/src/lint.ts +12 -10
  26. package/spec-cli/src/listen.ts +28 -0
  27. package/spec-cli/src/localIssues.ts +700 -0
  28. package/spec-cli/src/materialize.ts +182 -27
  29. package/spec-cli/src/mentions.ts +192 -0
  30. package/spec-cli/src/plugin-harness.ts +145 -0
  31. package/spec-cli/src/pty-bridge.ts +378 -81
  32. package/spec-cli/src/self.ts +123 -20
  33. package/spec-cli/src/sessions.ts +461 -298
  34. package/spec-cli/src/specs.ts +55 -14
  35. package/spec-cli/src/supervise.ts +23 -3
  36. package/spec-cli/src/tsx-bin.ts +14 -5
  37. package/spec-cli/src/uninstall.ts +146 -0
  38. package/spec-cli/templates/hooks/post-merge +27 -0
  39. package/spec-cli/templates/hooks/pre-commit +51 -31
  40. package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
  41. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
  42. package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
  43. package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
  44. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
  45. package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
  46. package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
  47. package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
  48. package/spec-cli/templates/spec/project/.config/spec.md +3 -3
  49. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
  50. package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
  51. package/spec-cli/templates/spec/project/spec.md +2 -2
  52. package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +32 -0
  53. package/spec-dashboard/dist/assets/index-DehTZ-h9.js +145 -0
  54. package/spec-dashboard/dist/index.html +17 -5
  55. package/spec-forge/src/cache.ts +16 -0
  56. package/spec-forge/src/cli.ts +4 -10
  57. package/spec-forge/src/drivers/github.ts +74 -4
  58. package/spec-forge/src/drivers.ts +13 -0
  59. package/spec-forge/src/port.ts +25 -0
  60. package/spec-forge/src/resident.ts +40 -6
  61. package/spec-yatsu/src/cli.ts +227 -38
  62. package/spec-yatsu/src/evaltab.ts +169 -19
  63. package/spec-yatsu/src/filing.ts +48 -0
  64. package/spec-yatsu/src/freshness.ts +55 -20
  65. package/spec-yatsu/src/proof.ts +89 -3
  66. package/spec-yatsu/src/scenariofresh.ts +92 -0
  67. package/spec-yatsu/src/sidecar.ts +75 -11
  68. package/spec-yatsu/src/timeline.ts +47 -0
  69. package/spec-yatsu/src/yatsu.ts +47 -3
  70. package/spec-cli/src/relay.ts +0 -28
  71. package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
  72. package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
  73. package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
@@ -0,0 +1,92 @@
1
+ import { gitA, headSha } from '../../spec-cli/src/git.js'
2
+ import { parseScenarios } from './yatsu.js'
3
+
4
+ // @@@ per-scenario content freshness — the SCENARIO axis, sub-file
5
+ // A yatsu.md holds many scenarios, but a reading measures ONE. yatsu-core's contract says "a scenario is the
6
+ // unit of measurement, so its freshness is its OWN — two scenarios stale independently." Git has no sub-file
7
+ // history, so we build it: for each scenario NAME in a yatsu.md, the commits where THAT scenario's block
8
+ // content changed (added / removed / edited), rename-followed. `scenarioMoved` then reads exactly like the
9
+ // code axis's `changedSince` — a pure ancestry lookup over this per-scenario commit list — so editing one
10
+ // scenario never re-stales its siblings (the file-granular bug this replaces).
11
+
12
+ const RS = '\x1e'
13
+
14
+ // yatsuPath (head path) -> scenario name -> commit hashes that changed that scenario's block (newest-first)
15
+ export type ScenarioIndex = Map<string, Map<string, string[]>>
16
+
17
+ // the block content that stales a reading: everything a measurement is taken AGAINST, minus the name (the
18
+ // join key — a renamed scenario is a remove+add, surfaced as a change-commit on each name). parseScenarios
19
+ // already folds YAML block scalars, so a pure prose re-wrap yields the same string and does NOT stale.
20
+ function blockContent(src: string): Map<string, string> {
21
+ const m = new Map<string, string>()
22
+ for (const s of parseScenarios(src)) {
23
+ m.set(s.name, JSON.stringify({ d: s.description, e: s.expected, t: s.tags ?? [], c: s.code ?? [], r: s.related ?? [], x: s.test ?? '' }))
24
+ }
25
+ return m
26
+ }
27
+
28
+ // the file's content-versions newest-first, each { hash, blocks }. Skips a version we can't read (git show
29
+ // '') so an unreadable blob never fabricates a remove+re-add diff.
30
+ async function fileVersions(root: string, headPath: string): Promise<{ hash: string; blocks: Map<string, string> }[]> {
31
+ // --follow + --name-only pairs each commit with the path the file had THERE, so `git show <hash>:<pathAtCommit>`
32
+ // works across reparents (yatsu.md moves when its node does). `-M` keeps a rename+edit's content.
33
+ const log = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log', '-M', '--follow', `--format=${RS}%H`, '--name-only', '--', headPath])
34
+ const out: { hash: string; blocks: Map<string, string> }[] = []
35
+ for (const rec of log.split(RS)) {
36
+ const lines = rec.split('\n').map((l) => l.trim()).filter(Boolean)
37
+ if (!lines.length) continue
38
+ const hash = lines[0]
39
+ const pathAt = lines.slice(1).find((l) => l.endsWith('/yatsu.md') || l === 'yatsu.md') ?? headPath
40
+ const content = await gitA(['-C', root, 'show', `${hash}:${pathAt}`])
41
+ if (!content) continue
42
+ out.push({ hash, blocks: blockContent(content) })
43
+ }
44
+ return out
45
+ }
46
+
47
+ // per-scenario change-commits for one file: walk versions newest->oldest, attribute a commit to every
48
+ // scenario whose block differs from the next-older version (undefined on either side = add/remove = a change).
49
+ async function scenarioCommits(root: string, headPath: string): Promise<Map<string, string[]>> {
50
+ const versions = await fileVersions(root, headPath)
51
+ const commits = new Map<string, string[]>()
52
+ const push = (name: string, hash: string) => { const a = commits.get(name); if (a) a.push(hash); else commits.set(name, [hash]) }
53
+ for (let i = 0; i < versions.length; i++) {
54
+ const cur = versions[i].blocks
55
+ const older = versions[i + 1]?.blocks ?? new Map<string, string>()
56
+ for (const name of new Set([...cur.keys(), ...older.keys()])) {
57
+ if (cur.get(name) !== older.get(name)) push(name, versions[i].hash)
58
+ }
59
+ }
60
+ return commits
61
+ }
62
+
63
+ async function build(root: string, yatsuPaths: string[]): Promise<ScenarioIndex> {
64
+ const idx: ScenarioIndex = new Map()
65
+ const CONC = 8 // bound the git-show fan-out; the whole index is built once per HEAD then cached
66
+ for (let i = 0; i < yatsuPaths.length; i += CONC) {
67
+ const batch = yatsuPaths.slice(i, i + CONC)
68
+ const done = await Promise.all(batch.map((p) => scenarioCommits(root, p)))
69
+ batch.forEach((p, j) => idx.set(p, done[j]))
70
+ }
71
+ return idx
72
+ }
73
+
74
+ // HEAD-keyed LRU, mirroring historyIndex/driftIndex in git.ts: same head ⇒ same per-scenario history,
75
+ // whatever the caller. Holds the in-flight promise so concurrent board builds share one build.
76
+ const SLOTS = 16
77
+ const cache = new Map<string, Promise<ScenarioIndex>>()
78
+ export function scenarioIndex(root: string, yatsuPaths: string[]): Promise<ScenarioIndex> {
79
+ let head: string
80
+ try { head = headSha(root) } catch { return build(root, yatsuPaths) }
81
+ const hit = cache.get(head)
82
+ if (hit) { cache.delete(head); cache.set(head, hit); return hit }
83
+ const p = build(root, yatsuPaths)
84
+ p.catch(() => cache.delete(head))
85
+ cache.set(head, p)
86
+ while (cache.size > SLOTS) cache.delete(cache.keys().next().value!)
87
+ return p
88
+ }
89
+
90
+ export function scenarioChangeCommits(idx: ScenarioIndex, yatsuPath: string, scenario: string): string[] {
91
+ return idx.get(yatsuPath)?.get(scenario) ?? []
92
+ }
@@ -1,32 +1,90 @@
1
1
  import { readFileSync, appendFileSync, existsSync } from 'node:fs'
2
2
 
3
- export type Verdict = { status: 'pass' | 'fail' | 'note'; note?: string }
3
+ // the verdict is pass | fail; `note` is an OPTIONAL one-line annotation on either (why it failed, how far
4
+ // a pass is from ideal) — not a third status. (Legacy readings filed when `note` was its own status survive
5
+ // on disk with status:'note'; render stays tolerant of them, the CLI no longer mints them.)
6
+ export type Verdict = { status: 'pass' | 'fail'; note?: string }
4
7
 
5
- // blobKind absent on a legacy reading → rendered as an image (every legacy capture was one)
8
+ export type EvidenceKind = 'image' | 'transcript' | 'video'
9
+ // one piece of a reading's evidence: a content-addressed blob (`hash`) tagged by `kind`. `video` is a
10
+ // screenshot with a time axis — the same content-addressed blob, distinguished only by this tag.
11
+ export type Evidence = { hash: string; kind: EvidenceKind }
12
+
13
+ // A reading's evidence is a LIST of typed entries — N images and/or a video (with its step-timeline) and/or
14
+ // a transcript, in filing order. New filings write `evidence`; the legacy scalar shape (`blob` + `blobKind`,
15
+ // absent kind → image) is still READ and normalized to a one-entry list by `evidenceOf`, so old readings
16
+ // still render. A video reading may carry `timelineBlob`: the content hash of its step-timeline sidecar
17
+ // (timeline.ts) mapping clip moments to named steps — it anchors the reading's VIDEO evidence entry.
18
+ // `by` is the SESSION that filed this reading (the filer, from envSessionId) — the ORIGINATOR an eval-comment
19
+ // thread loops in on a reply ([[mentions]] implicit loop-in). Pure additive: a legacy reading without it simply
20
+ // has no originator → silent. `evaluator` is WHO/WHAT measured (a tag like `manual@1`); `by` is the reachable
21
+ // session behind the filing — two different axes.
6
22
  export type Reading = {
7
23
  scenario: string
8
24
  codeSha: string
9
- blob: string | null
10
- blobKind?: 'image' | 'transcript'
25
+ evidence?: Evidence[]
26
+ // legacy scalar evidence — read for old readings, never written by new filings.
27
+ blob?: string | null
28
+ blobKind?: EvidenceKind
29
+ timelineBlob?: string
11
30
  evaluator: string
31
+ by?: string
12
32
  verdict?: Verdict
13
33
  ts: string
14
34
  }
15
35
 
16
- // parse the sidecar: one Reading per non-blank line. A malformed line is skipped (the file is append-only
17
- // and git-tracked, so a partial write or a hand-edit shouldn't sink the whole read) fail soft per line.
18
- export function readReadings(sidecarPath: string): Reading[] {
19
- if (!existsSync(sidecarPath)) return []
20
- const out: Reading[] = []
36
+ // the one scalar→list bridge every evidence consumer passes through: the `evidence` list when present, else
37
+ // the legacy scalar (blob + blobKind, absent kind image) as a one-entry list, else empty.
38
+ export function evidenceOf(r: { evidence?: Evidence[]; blob?: string | null; blobKind?: EvidenceKind }): Evidence[] {
39
+ if (r.evidence?.length) return r.evidence
40
+ if (r.blob) return [{ hash: r.blob, kind: r.blobKind ?? 'image' }]
41
+ return []
42
+ }
43
+
44
+ // a RETRACTION is the sanctioned inverse of a filing — itself an appended event, never a deleted line
45
+ // (the sidecar stays append-only; git shows who retracted what, when). `retracts` is the target reading's
46
+ // `ts` within `scenario` (its natural key). Deliberately NO `evaluator` field: an old reader's line filter
47
+ // (which requires an evaluator string) skips a retraction entirely, so version skew degrades to "the
48
+ // retraction isn't applied yet", never to a mis-rendered reading. `by` is the retracting session; `note`
49
+ // says why (a botched e2e filing, a wrong verdict).
50
+ export type Retraction = { retracts: string; scenario: string; note?: string; by?: string; ts: string }
51
+
52
+ // parse the sidecar RAW: one event per non-blank line — a Reading, or a Retraction (a line carrying a
53
+ // string `retracts`). A malformed line is skipped (the file is append-only and git-tracked, so a partial
54
+ // write or a hand-edit shouldn't sink the whole read) — fail soft per line.
55
+ export function readSidecar(sidecarPath: string): { readings: Reading[]; retractions: Retraction[] } {
56
+ const readings: Reading[] = []
57
+ const retractions: Retraction[] = []
58
+ if (!existsSync(sidecarPath)) return { readings, retractions }
21
59
  for (const line of readFileSync(sidecarPath, 'utf8').split('\n')) {
22
60
  const t = line.trim()
23
61
  if (!t) continue
24
62
  try {
25
63
  const r = JSON.parse(t)
26
- if (r && typeof r.scenario === 'string' && typeof r.evaluator === 'string') out.push(r as Reading)
64
+ if (!r || typeof r.scenario !== 'string') continue
65
+ if (typeof r.retracts === 'string') retractions.push(r as Retraction)
66
+ else if (typeof r.evaluator === 'string') readings.push(r as Reading)
27
67
  } catch { /* skip a malformed line */ }
28
68
  }
29
- return out
69
+ return { readings, retractions }
70
+ }
71
+
72
+ // the retraction join, shared by every effective-view reader: drop each reading a retraction targets by
73
+ // (scenario, ts) — NUL-joined, since a scenario name may contain spaces. A retraction matching nothing is
74
+ // inert: it excludes no reading and harms no read.
75
+ export function applyRetractions(readings: Reading[], retractions: Retraction[]): Reading[] {
76
+ if (!retractions.length) return readings
77
+ const gone = new Set(retractions.map((x) => `${x.scenario}\0${x.retracts}`))
78
+ return readings.filter((r) => !gone.has(`${r.scenario}\0${r.ts}`))
79
+ }
80
+
81
+ // the EFFECTIVE readings — what the scoreboard sees: every reading minus the retracted. Every score
82
+ // consumer (freshness, scan, clean's referenced set, the eval tab, the proof) reads through here, so a
83
+ // retract undoes a botched filing on ALL of them at once — the previous reading becomes the latest again,
84
+ // or the scenario honestly returns to yatsu-missing.
85
+ export function readReadings(sidecarPath: string): Reading[] {
86
+ const { readings, retractions } = readSidecar(sidecarPath)
87
+ return applyRetractions(readings, retractions)
30
88
  }
31
89
 
32
90
  // append ONE reading as a JSON line — the only mutation eval performs (a reading is an event, never an
@@ -35,6 +93,12 @@ export function appendReading(sidecarPath: string, r: Reading): void {
35
93
  appendFileSync(sidecarPath, JSON.stringify(r) + '\n')
36
94
  }
37
95
 
96
+ // append ONE retraction as a JSON line — the sanctioned undo writes through the same append-only surface
97
+ // that filed the reading; the target line stays in place as history.
98
+ export function appendRetraction(sidecarPath: string, r: Retraction): void {
99
+ appendFileSync(sidecarPath, JSON.stringify(r) + '\n')
100
+ }
101
+
38
102
  // the latest reading per scenario (the file is chronological, so the LAST line for a name wins). clean's
39
103
  // --keep-latest uses it to decide which blob to keep.
40
104
  export function latestPerScenario(readings: Reading[]): Map<string, Reading> {
@@ -0,0 +1,47 @@
1
+ // step-timeline — the map from a moment in a video reading's clip to a named step. SpexCode owns only
2
+ // this FORMAT (a tiny data contract any userland emitter satisfies — a Playwright reporter, a WebDriver
3
+ // listener, a computer-use hand narrating as it drives); aligning the emitter's clock to the clip is the
4
+ // emitter's own job. The timeline rides as a second content-addressed blob on the reading, never a new
5
+ // ndjson column beyond the one hash.
6
+
7
+ export type TimelineEvent = { tMs: number; step: string; node?: string }
8
+ export type StepTimeline = { v: 1; events: TimelineEvent[] }
9
+
10
+ const EVENT_KEYS = new Set(['tMs', 'step', 'node'])
11
+
12
+ // validate LOUD — every violation named, [] when well-formed. The key set is closed (like the yatsu.md
13
+ // scenario schema): a malformed timeline is rejected at filing time, never silently reshaped.
14
+ export function validateTimeline(raw: unknown): string[] {
15
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return ['timeline must be a JSON object { v, events }']
16
+ const errs: string[] = []
17
+ const o = raw as Record<string, unknown>
18
+ for (const k of Object.keys(o)) if (k !== 'v' && k !== 'events') errs.push(`unknown field \`${k}\` (allowed: v, events)`)
19
+ if (o.v !== 1) errs.push('`v` must be 1')
20
+ if (!Array.isArray(o.events)) { errs.push('`events` must be an array'); return errs }
21
+ let prev = -Infinity
22
+ o.events.forEach((e, i) => {
23
+ if (typeof e !== 'object' || e === null || Array.isArray(e)) { errs.push(`events[${i}] must be an object`); return }
24
+ const ev = e as Record<string, unknown>
25
+ for (const k of Object.keys(ev)) if (!EVENT_KEYS.has(k)) errs.push(`events[${i}]: unknown field \`${k}\` (allowed: tMs, step, node)`)
26
+ if (typeof ev.tMs !== 'number' || !Number.isFinite(ev.tMs) || ev.tMs < 0) {
27
+ errs.push(`events[${i}].tMs must be a finite number ≥ 0`)
28
+ } else {
29
+ if (ev.tMs < prev) errs.push(`events[${i}].tMs is out of order (the list is ordered by time)`)
30
+ prev = ev.tMs
31
+ }
32
+ if (typeof ev.step !== 'string' || !ev.step.trim()) errs.push(`events[${i}].step must be a non-empty string`)
33
+ if (ev.node !== undefined && (typeof ev.node !== 'string' || !ev.node.trim())) errs.push(`events[${i}].node must be a non-empty string when present`)
34
+ })
35
+ return errs
36
+ }
37
+
38
+ // the whole of "which step is this moment": the last event at or before T; null before the first event
39
+ // (a plain player moment, no step to name — graceful, never an error).
40
+ export function stepAt(events: TimelineEvent[], tMs: number): TimelineEvent | null {
41
+ let hit: TimelineEvent | null = null
42
+ for (const e of events) {
43
+ if (e.tMs <= tMs) hit = e
44
+ else break
45
+ }
46
+ return hit
47
+ }
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, readdirSync, existsSync } from 'node:fs'
2
+ import { readFile, readdir } from 'node:fs/promises'
2
3
  import { join, relative, basename } from 'node:path'
3
4
 
4
5
  export const YATSU_FILE = 'yatsu.md'
@@ -8,6 +9,7 @@ export type Scenario = {
8
9
  name: string
9
10
  description: string
10
11
  expected: string
12
+ tags?: string[]
11
13
  test?: string
12
14
  code?: string[]
13
15
  related?: string[]
@@ -21,9 +23,9 @@ export type YatsuNode = {
21
23
  scenarios: Scenario[]
22
24
  }
23
25
 
24
- const SCENARIO_KEYS = ['name', 'description', 'expected', 'test', 'code', 'related'] as const
26
+ const SCENARIO_KEYS = ['name', 'description', 'expected', 'tags', 'test', 'code', 'related'] as const
25
27
  type ScenarioKey = (typeof SCENARIO_KEYS)[number]
26
- const LIST_KEYS: readonly ScenarioKey[] = ['code', 'related']
28
+ const LIST_KEYS: readonly ScenarioKey[] = ['tags', 'code', 'related']
27
29
 
28
30
  // a raw scenario item straight off the frontmatter walk: the known fields it set, plus any UNKNOWN keys it
29
31
  // carried — kept (not dropped) so the validator can name a typo'd field instead of silently swallowing it.
@@ -123,12 +125,14 @@ function parseCodeList(raw: string): string[] {
123
125
  export function parseScenarios(src: string): Scenario[] {
124
126
  return walkScenarios(src).items
125
127
  .map((it): Scenario => {
128
+ const tags = it.fields.tags ? parseCodeList(it.fields.tags) : []
126
129
  const code = it.fields.code ? parseCodeList(it.fields.code) : []
127
130
  const related = it.fields.related ? parseCodeList(it.fields.related) : []
128
131
  return {
129
132
  name: it.fields.name ?? '',
130
133
  description: it.fields.description ?? '',
131
134
  expected: it.fields.expected ?? '',
135
+ ...(tags.length ? { tags } : {}),
132
136
  ...(it.fields.test ? { test: it.fields.test } : {}),
133
137
  ...(code.length ? { code } : {}),
134
138
  ...(related.length ? { related } : {}),
@@ -137,18 +141,31 @@ export function parseScenarios(src: string): Scenario[] {
137
141
  .filter((s) => s.name) // a scenario with no name is malformed — drop it (validateScenarios reports it)
138
142
  }
139
143
 
140
- export function validateScenarios(src: string): string[] {
144
+ // `tagLibrary` is the closed vocabulary a scenario's `tags:` must draw from (config's `lint.scenarioTags`).
145
+ // Every scenario needs ≥1 tag; each tag must be IN the library — an out-of-library tag is rejected LOUD with
146
+ // the repair the user owns: pick an existing tag, or extend the library. An empty library (none configured)
147
+ // disables only the membership check, never the ≥1-tag requirement.
148
+ export function validateScenarios(src: string, tagLibrary: string[] = []): string[] {
141
149
  const { hasFrontmatter, hasKey, items } = walkScenarios(src)
142
150
  if (!hasFrontmatter) return ['no frontmatter block — a yatsu.md must declare a `scenarios:` list']
143
151
  if (!hasKey) return ['frontmatter has no `scenarios:` key — declare at least one scenario']
144
152
  if (!items.length) return ['`scenarios:` declares no scenarios — add one (name + description + expected)']
145
153
  const errs: string[] = []
146
154
  const counts = new Map<string, number>()
155
+ const lib = tagLibrary.length ? ` (library: ${tagLibrary.join(', ')})` : ''
147
156
  items.forEach((it, idx) => {
148
157
  const label = it.fields.name ? `scenario '${it.fields.name}'` : `scenario #${idx + 1}`
149
158
  for (const k of ['name', 'description', 'expected'] as const) {
150
159
  if (!it.fields[k]?.trim()) errs.push(`${label}: missing required field \`${k}\``)
151
160
  }
161
+ const tags = it.fields.tags ? parseCodeList(it.fields.tags) : []
162
+ if (!tags.length) {
163
+ errs.push(`${label}: missing required field \`tags\` — every scenario needs ≥1 tag from the library${lib}; pick one, or add a new tag to lint.scenarioTags in spexcode.json to create it`)
164
+ } else if (tagLibrary.length) {
165
+ for (const t of tags) if (!tagLibrary.includes(t)) {
166
+ errs.push(`${label}: tag \`${t}\` is not in the configured tag library${lib} — use an existing tag, or add \`${t}\` to lint.scenarioTags in spexcode.json to create it`)
167
+ }
168
+ }
152
169
  for (const u of it.unknownKeys) errs.push(`${label}: unknown field \`${u}\` (allowed: ${SCENARIO_KEYS.join(', ')})`)
153
170
  if (it.fields.name) counts.set(it.fields.name, (counts.get(it.fields.name) ?? 0) + 1)
154
171
  })
@@ -179,3 +196,30 @@ export function yatsuNodes(root: string): YatsuNode[] {
179
196
  }
180
197
  return out.sort((a, b) => a.id.localeCompare(b.id))
181
198
  }
199
+
200
+ // async twin of yatsuNodes for the HOT board build ([[board-cache]]): reading each yatsu.md through
201
+ // fs/promises YIELDS the event loop between files, so the walk no longer stalls a `/health` probe in one
202
+ // ~600ms uninterrupted stretch. Same output (id-sorted) as yatsuNodes; only buildBoard uses it, other
203
+ // callers keep the sync form.
204
+ export async function yatsuNodesAsync(root: string): Promise<YatsuNode[]> {
205
+ const specDir = join(root, '.spec')
206
+ const out: YatsuNode[] = []
207
+ const stack = existsSync(specDir) ? [specDir] : []
208
+ while (stack.length) {
209
+ const dir = stack.pop()!
210
+ let ents
211
+ try { ents = await readdir(dir, { withFileTypes: true }) } catch { continue }
212
+ if (existsSync(join(dir, YATSU_FILE))) {
213
+ const yatsuPath = relative(root, join(dir, YATSU_FILE))
214
+ out.push({
215
+ id: basename(dir),
216
+ dir,
217
+ yatsuPath,
218
+ sidecarPath: join(dir, SIDECAR_FILE),
219
+ scenarios: parseScenarios(await readFile(join(dir, YATSU_FILE), 'utf8')),
220
+ })
221
+ }
222
+ for (const e of ents) if (e.isDirectory()) stack.push(join(dir, e.name))
223
+ }
224
+ return out.sort((a, b) => a.id.localeCompare(b.id))
225
+ }
@@ -1,28 +0,0 @@
1
- import { searchSpecs } from './search.js'
2
- import { loadSpecs } from './specs.js'
3
-
4
- export type RelayHit = { id: string; title: string; path: string; score: number; code: string[] }
5
-
6
- export async function relaySearch(query: string, opts: { limit?: number } = {}): Promise<RelayHit[]> {
7
- const limit = opts.limit ?? 3
8
- const hits = await searchSpecs(query, { limit })
9
- if (!hits.length) return []
10
- // one spec-index read; per node: its frontmatter `code:` governed paths (files/dirs/globs) + the tree shape.
11
- const specs = await loadSpecs()
12
- const codeById = new Map(specs.map((s) => [s.id, (s.code as string[]) ?? []]))
13
- const childrenOf = new Map<string, string[]>()
14
- for (const s of specs) if (s.parent) childrenOf.set(s.parent, [...(childrenOf.get(s.parent) ?? []), s.id])
15
- const subtreeCode = (id: string): string[] => {
16
- const own = codeById.get(id) ?? []
17
- if (own.length) return own
18
- const acc: string[] = []
19
- const stack = [...(childrenOf.get(id) ?? [])]
20
- while (stack.length) {
21
- const c = stack.pop()!
22
- acc.push(...(codeById.get(c) ?? []))
23
- stack.push(...(childrenOf.get(c) ?? []))
24
- }
25
- return [...new Set(acc)]
26
- }
27
- return hits.map((h) => ({ id: h.id, title: h.title, path: h.path, score: h.score, code: subtreeCode(h.id) }))
28
- }
@@ -1,32 +0,0 @@
1
- ---
2
- title: scenario
3
- surface: slash
4
- status: active
5
- hue: 160
6
- kind: mutating
7
- desc: Enter Scenario mode — author, refine, and re-measure a node's yatsu.md loss scenarios as first-class, file-scoped units.
8
- ---
9
- You are in **Scenario mode** — a focused flow for managing a node's yatsu.md **scenarios** as first-class
10
- units of loss. A scenario is one declared way to measure a node's loss: a `name`, a `description` of what to
11
- check, the `expected` result that is zero loss, and optionally a `test` (a co-located runnable file) and
12
- `code` (the concrete repo files THIS scenario depends on — its own freshness axis). Your job is to **edit,
13
- create, and manage** these scenarios for the target node(s) — nothing else; do not write feature code.
14
-
15
- Work through the real `spex` surface, never by reverse-engineering files:
16
-
17
- 1. **Read the schema and the node.** Run `spex guide yatsu` for the exact yatsu.md format. Read the target
18
- node's `spec.md` (its present intent) and its governed `code:` files, plus its existing `yatsu.md` if any.
19
- A scenario must measure what the SPEC promises, through the real product surface (YATU — You As The User),
20
- not an internal helper chosen to make the proof easy.
21
- 2. **Author or refine scenarios.** For a node with no yatsu.md, create one with at least one scenario. For an
22
- existing one, sharpen weak scenarios and add missing coverage. Give a scenario its own `code:` list when it
23
- depends on a specific subset of the node's files, so it goes stale independently of its siblings. Keep
24
- names unique and the schema valid — `spex yatsu scan <node>` reports violations (`yatsu-schema`), and the
25
- pre-commit gate rejects a malformed file, so fix every finding before committing.
26
- 3. **Re-measure when the change warrants it.** If you changed a scenario or the code it measures, file a fresh
27
- reading: measure through the product, then `spex yatsu eval <node> --scenario <name> (--pass|--fail|--note)
28
- [--image <png>|--result <txt>]`. Use `spex yatsu show <node>` to read the timeline.
29
- 4. **Commit spec + yatsu.md together** on the node's branch, then report what scenarios now exist and their
30
- satisfaction status. Do not bundle unrelated edits.
31
-
32
- The node(s) to manage scenarios for: {{targets}}