spexcode 0.2.1 → 0.2.3

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 (52) hide show
  1. package/README.md +158 -103
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +24 -1
  4. package/spec-cli/src/attach.ts +50 -0
  5. package/spec-cli/src/cli.ts +217 -64
  6. package/spec-cli/src/client.ts +47 -9
  7. package/spec-cli/src/{self.ts → doctor.ts} +26 -25
  8. package/spec-cli/src/guide.ts +79 -21
  9. package/spec-cli/src/harness.ts +53 -29
  10. package/spec-cli/src/help.ts +137 -49
  11. package/spec-cli/src/index.ts +31 -11
  12. package/spec-cli/src/issues.ts +48 -21
  13. package/spec-cli/src/layout.ts +3 -5
  14. package/spec-cli/src/lint.ts +34 -5
  15. package/spec-cli/src/localIssues.ts +44 -60
  16. package/spec-cli/src/materialize.ts +4 -2
  17. package/spec-cli/src/mentions.ts +22 -1
  18. package/spec-cli/src/pty-bridge.ts +39 -4
  19. package/spec-cli/src/ranker.ts +31 -12
  20. package/spec-cli/src/search.bench.mjs +30 -7
  21. package/spec-cli/src/search.ts +39 -0
  22. package/spec-cli/src/sessions.ts +160 -69
  23. package/spec-cli/src/specs.ts +16 -4
  24. package/spec-cli/src/supervise.ts +30 -6
  25. package/spec-cli/src/tree.ts +118 -0
  26. package/spec-cli/templates/hooks/post-merge +2 -2
  27. package/spec-cli/templates/hooks/pre-commit +34 -15
  28. package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
  29. package/spec-cli/templates/spexcode.json +7 -0
  30. package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
  31. package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +27 -0
  32. package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +3 -0
  33. package/spec-dashboard/dist/assets/FoldToggle-B5leylLf.js +1 -0
  34. package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +1 -0
  35. package/spec-dashboard/dist/assets/MobileApp-RHNECU6x.js +1 -0
  36. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
  37. package/spec-dashboard/dist/assets/SessionInterface-YLD6IOmC.js +71 -0
  38. package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +9 -0
  39. package/spec-dashboard/dist/assets/Settings-ZnOwskMZ.js +1 -0
  40. package/spec-dashboard/dist/assets/index-BdRQfrkR.js +41 -0
  41. package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +1 -0
  42. package/spec-dashboard/dist/index.html +2 -2
  43. package/spec-yatsu/src/cli.ts +128 -26
  44. package/spec-yatsu/src/evaltab.ts +7 -6
  45. package/spec-yatsu/src/filing.ts +6 -3
  46. package/spec-yatsu/src/proof.ts +10 -0
  47. package/spec-yatsu/src/scenariofresh.ts +100 -30
  48. package/spec-yatsu/src/sidecar.ts +25 -3
  49. package/spec-yatsu/src/timeline.ts +53 -23
  50. package/README.zh-CN.md +0 -135
  51. package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +0 -32
  52. package/spec-dashboard/dist/assets/index-DehTZ-h9.js +0 -145
@@ -1,3 +1,4 @@
1
+ import { spawn } from 'node:child_process'
1
2
  import { gitA, headSha } from '../../spec-cli/src/git.js'
2
3
  import { parseScenarios } from './yatsu.js'
3
4
 
@@ -25,49 +26,118 @@ function blockContent(src: string): Map<string, string> {
25
26
  return m
26
27
  }
27
28
 
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) })
29
+ const ZERO = '0'.repeat(40) // the null OID a delete/add carries on its absent side
30
+ const EMPTY: Map<string, string> = new Map()
31
+
32
+ // content-addressed: a blob OID -> its canonical per-scenario block map. Git objects are IMMUTABLE, so this
33
+ // memo never needs invalidation and is shared across every root a per-worktree build reuses the blocks main
34
+ // already parsed, which is what lets a worktree's freshness build stay cheap instead of re-reading everything.
35
+ const blockByOid = new Map<string, Map<string, string>>()
36
+
37
+ // ONE `git log --raw` walk over all yatsu.md per head-path version chain [{hash, oid}], newest-first,
38
+ // rename-followed via the `alias` idiom (git.ts buildIndex): the newest sighting of a path IS its head path;
39
+ // an `R` row remaps the older `from` path onto that head. The raw row carries the new blob OID directly, so
40
+ // no per-version path resolution and no `git show`. --full-history is REQUIRED: default pathspec
41
+ // history-simplification prunes commits off HEAD's first-parent chain, but in this repo every spec edit lands
42
+ // via a --no-ff merge of a node branch, so those pruned commits ARE the scenario edits — dropping them would
43
+ // under-report staleness (a stale reading judged fresh). Merge commits emit no raw diff row, so they add no
44
+ // version (exactly as the old `--follow` did).
45
+ async function fileChains(root: string, wanted: Set<string>): Promise<Map<string, { hash: string; oid: string }[]>> {
46
+ const chains = new Map<string, { hash: string; oid: string }[]>()
47
+ const alias = new Map<string, string>()
48
+ const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log',
49
+ '--raw', '--no-abbrev', '--full-history', '-M', `--format=${RS}%H`, '--', '*yatsu.md'])
50
+ for (const rec of out.split(RS)) {
51
+ const nl = rec.indexOf('\n')
52
+ if (nl < 0) continue
53
+ const hash = rec.slice(0, nl)
54
+ if (!hash) continue
55
+ for (const line of rec.slice(nl + 1).split('\n')) {
56
+ if (line[0] !== ':') continue // `:<oldmode> <newmode> <oldoid> <newoid> <status>\t<path>[\t<path2>]`
57
+ const tab = line.indexOf('\t')
58
+ if (tab < 0) continue
59
+ const meta = line.slice(1, tab).split(' ')
60
+ const oid = meta[3], rename = meta[4][0] === 'R' || meta[4][0] === 'C'
61
+ const paths = line.slice(tab + 1).split('\t')
62
+ const to = rename ? paths[1] : paths[0] // the path on the newer side of this commit
63
+ let head = alias.get(to)
64
+ if (head === undefined) { head = to; alias.set(to, to) }
65
+ let arr = chains.get(head); if (!arr) { arr = []; chains.set(head, arr) }
66
+ arr.push({ hash, oid })
67
+ if (rename && paths[0] !== to) { alias.set(paths[0], head); alias.delete(to) } // older history calls it `from`
68
+ }
43
69
  }
44
- return out
70
+ for (const k of [...chains.keys()]) if (!wanted.has(k)) chains.delete(k) // keep only the head paths asked for
71
+ return chains
45
72
  }
46
73
 
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)
74
+ // per-scenario change-commits for one file's version chain: newest->oldest, attribute a commit to every
75
+ // scenario whose canonical block differs from the next-older version (undefined either side = add/remove = a
76
+ // change). A ZERO-oid (delete) version is dropped — it carries no readable content, matching the old
77
+ // `git show ''` skip; a pure rename (R100, oid == the older version's oid) diffs to no change for free.
78
+ function scenarioCommits(chain: { hash: string; oid: string }[]): Map<string, string[]> {
51
79
  const commits = new Map<string, string[]>()
52
80
  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>()
81
+ const real = chain.filter((v) => v.oid !== ZERO)
82
+ for (let i = 0; i < real.length; i++) {
83
+ const cur = blockByOid.get(real[i].oid) ?? EMPTY
84
+ const older = i + 1 < real.length ? (blockByOid.get(real[i + 1].oid) ?? EMPTY) : EMPTY
56
85
  for (const name of new Set([...cur.keys(), ...older.keys()])) {
57
- if (cur.get(name) !== older.get(name)) push(name, versions[i].hash)
86
+ if (cur.get(name) !== older.get(name)) push(name, real[i].hash)
58
87
  }
59
88
  }
60
89
  return commits
61
90
  }
62
91
 
92
+ // read MANY blobs in ONE `git cat-file --batch` process (vs one `git show` per blob). Feeds the OIDs on
93
+ // stdin, parses the `<oid> <type> <size>\n<payload>\n` records byte-accurately (size is bytes; blobs are
94
+ // UTF-8). A `<oid> missing` line yields no entry. Env-stripped like git.ts's helpers (a stray GIT_DIR would
95
+ // misroute repo discovery); kept here beside its only caller — a general git-seam primitive if a second
96
+ // caller ever wants one.
97
+ function catFileBatch(root: string, oids: string[]): Promise<Map<string, string>> {
98
+ const out = new Map<string, string>()
99
+ if (!oids.length) return Promise.resolve(out)
100
+ return new Promise((resolve, reject) => {
101
+ const env = { ...process.env }
102
+ delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
103
+ const p = spawn('git', ['-C', root, 'cat-file', '--batch'], { env })
104
+ const chunks: Buffer[] = []
105
+ p.stdout.on('data', (c: Buffer) => chunks.push(c))
106
+ p.on('error', reject)
107
+ p.on('close', () => {
108
+ const buf = Buffer.concat(chunks)
109
+ let i = 0
110
+ while (i < buf.length) {
111
+ const nl = buf.indexOf(0x0a, i)
112
+ if (nl < 0) break
113
+ const header = buf.toString('utf8', i, nl)
114
+ i = nl + 1
115
+ if (header.endsWith(' missing')) continue // unknown OID — no payload follows
116
+ const size = Number(header.slice(header.lastIndexOf(' ') + 1))
117
+ if (!Number.isFinite(size)) break
118
+ out.set(header.slice(0, header.indexOf(' ')), buf.toString('utf8', i, i + size))
119
+ i += size + 1 // payload + its trailing newline
120
+ }
121
+ resolve(out)
122
+ })
123
+ p.stdin.on('error', () => { /* EPIPE if git exits early; the close handler reports what arrived */ })
124
+ p.stdin.write(oids.join('\n') + '\n')
125
+ p.stdin.end()
126
+ })
127
+ }
128
+
129
+ // TWO git subprocesses for the whole index (was F logs + V shows): one `--raw` log for the rename-followed
130
+ // chains, one `cat-file --batch` for every distinct blob not already memoized. Then a pure in-memory diff.
63
131
  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]))
132
+ const chains = await fileChains(root, new Set(yatsuPaths))
133
+ const need = new Set<string>()
134
+ for (const chain of chains.values()) for (const v of chain) if (v.oid !== ZERO && !blockByOid.has(v.oid)) need.add(v.oid)
135
+ if (need.size) {
136
+ const blobs = await catFileBatch(root, [...need])
137
+ for (const [oid, src] of blobs) blockByOid.set(oid, blockContent(src))
70
138
  }
139
+ const idx: ScenarioIndex = new Map()
140
+ for (const p of yatsuPaths) idx.set(p, scenarioCommits(chains.get(p) ?? [])) // every asked path gets an entry (empty if untracked)
71
141
  return idx
72
142
  }
73
143
 
@@ -5,9 +5,14 @@ import { readFileSync, appendFileSync, existsSync } from 'node:fs'
5
5
  // on disk with status:'note'; render stays tolerant of them, the CLI no longer mints them.)
6
6
  export type Verdict = { status: 'pass' | 'fail'; note?: string }
7
7
 
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.
8
+ // The evidence-kind taxonomy ([[evidence-kind-taxonomy]]) is a MEDIA/RENDER type how a blob's bytes are
9
+ // shown kept ORTHOGONAL to the step-map AXIS (which is derived from the kind, not welded to it): `image`
10
+ // (a still), `transcript` (free-form text), `video` (a screenshot with a time axis), `data` (a structured
11
+ // machine export — a JSON/metrics dump — rendered as a validatable data block, not flattened into scrolling
12
+ // transcript text). A `data` reading is honest about being structured: it can be parsed and checked, and it
13
+ // is derived from CONTENT (isJsonBlob), never from which filing flag was used.
14
+ export type EvidenceKind = 'image' | 'transcript' | 'video' | 'data'
15
+ // one piece of a reading's evidence: a content-addressed blob (`hash`) tagged by `kind`.
11
16
  export type Evidence = { hash: string; kind: EvidenceKind }
12
17
 
13
18
  // A reading's evidence is a LIST of typed entries — N images and/or a video (with its step-timeline) and/or
@@ -41,6 +46,23 @@ export function evidenceOf(r: { evidence?: Evidence[]; blob?: string | null; blo
41
46
  return []
42
47
  }
43
48
 
49
+ // Is this blob STRUCTURED DATA (a machine export — a JSON object/array) rather than free-form transcript
50
+ // text? The `data` evidence kind ([[evidence-kind-taxonomy]]) is derived from CONTENT, never from which
51
+ // filing flag was used: a hyperfine `--export-json`, an API payload, a metrics dump is data, and flattening
52
+ // it into a scrolling transcript loses that it can be structurally validated and rendered as a data block.
53
+ // Sniffed cheaply and self-contained (no deps): a text blob (no NUL) whose trimmed body brackets as an
54
+ // object/array AND parses to one; anything else (plain logs, terminal text) is not data. This is the ONE
55
+ // predicate both the blob MIME sniff (application/json) and the CLI `--result` kind derive from, so the
56
+ // stored kind and the served Content-Type always agree.
57
+ export function isJsonBlob(b: Buffer): boolean {
58
+ if (!b.length || b.includes(0)) return false // empty or binary → not JSON text
59
+ if (b.length > 4_000_000) return false // don't parse an unbounded blob just to sniff a type
60
+ const s = b.toString('utf8').trim()
61
+ const open = s[0], close = s[s.length - 1]
62
+ if (!((open === '{' && close === '}') || (open === '[' && close === ']'))) return false
63
+ try { const v = JSON.parse(s); return v !== null && typeof v === 'object' } catch { return false }
64
+ }
65
+
44
66
  // a RETRACTION is the sanctioned inverse of a filing — itself an appended event, never a deleted line
45
67
  // (the sidecar stays append-only; git shows who retracted what, when). `retracts` is the target reading's
46
68
  // `ts` within `scenario` (its natural key). Deliberately NO `evaluator` field: an old reader's line filter
@@ -1,33 +1,53 @@
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.
1
+ // step-timeline — the map from a POSITION on a piece of evidence's own axis to a named step. SpexCode owns
2
+ // only 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, a CLI run stamping line numbers); aligning the
4
+ // emitter's positions to the evidence is the emitter's own job. The map rides as a second content-addressed
5
+ // blob on the reading, never a new ndjson column beyond the one hash.
6
+ //
7
+ // The step is anchored to the evidence's OWN axis, tagged by `axis`: `time` (ms, a video), `frame` (a still
8
+ // SEQUENCE by index), `line` (a transcript by line number), `index` (a bare action ordinal). The set is
9
+ // OPEN by convention — an unknown axis is legal and a reader renders its positions as bare numbers. `stepAt`
10
+ // (last step at or before a position) is axis-agnostic and unchanged.
6
11
 
7
- export type TimelineEvent = { tMs: number; step: string; node?: string }
8
- export type StepTimeline = { v: 1; events: TimelineEvent[] }
12
+ export type TimelineEvent = { at: number; step: string; node?: string }
13
+ export type StepTimeline = { v: 2; axis: string; events: TimelineEvent[] }
9
14
 
10
- const EVENT_KEYS = new Set(['tMs', 'step', 'node'])
15
+ // legacy v1 is the TIME axis with `tMs` as the position — read losslessly, normalized to the axis-tagged
16
+ // shape (`axis: 'time'`, `at: tMs`). Kept forever: an emitter that only knew `{ v: 1, events: [{ tMs }] }`
17
+ // still files a valid video step-map.
18
+ export type LegacyTimelineEvent = { tMs: number; step: string; node?: string }
19
+ export type LegacyStepTimeline = { v: 1; events: LegacyTimelineEvent[] }
11
20
 
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.
21
+ const V2_EVENT_KEYS = new Set(['at', 'step', 'node'])
22
+ const V1_EVENT_KEYS = new Set(['tMs', 'step', 'node'])
23
+
24
+ // validate LOUD — every violation named, [] when well-formed. Both schema versions are accepted: v1 (legacy
25
+ // time axis, `tMs`) and v2 (axis-tagged, `at`). The key set is closed per version (like the yatsu.md
26
+ // scenario schema): a malformed timeline is rejected at filing time, never silently reshaped. The `axis`
27
+ // string itself is open — only its ABSENCE is an error, never an unrecognized value.
14
28
  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[] = []
29
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return ['timeline must be a JSON object { v, axis, events }']
17
30
  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')
31
+ if (o.v !== 1 && o.v !== 2) return ['`v` must be 1 (legacy time axis) or 2 (axis-tagged)']
32
+ const errs: string[] = []
33
+ const v2 = o.v === 2
34
+ const posKey = v2 ? 'at' : 'tMs'
35
+ const rootKeys = v2 ? new Set(['v', 'axis', 'events']) : new Set(['v', 'events'])
36
+ const evKeys = v2 ? V2_EVENT_KEYS : V1_EVENT_KEYS
37
+ for (const k of Object.keys(o)) if (!rootKeys.has(k)) errs.push(`unknown field \`${k}\` (allowed: ${[...rootKeys].join(', ')})`)
38
+ if (v2 && (typeof o.axis !== 'string' || !o.axis.trim())) errs.push('`axis` must be a non-empty string (e.g. time, frame, line, index)')
20
39
  if (!Array.isArray(o.events)) { errs.push('`events` must be an array'); return errs }
21
40
  let prev = -Infinity
22
41
  o.events.forEach((e, i) => {
23
42
  if (typeof e !== 'object' || e === null || Array.isArray(e)) { errs.push(`events[${i}] must be an object`); return }
24
43
  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`)
44
+ for (const k of Object.keys(ev)) if (!evKeys.has(k)) errs.push(`events[${i}]: unknown field \`${k}\` (allowed: ${[...evKeys].join(', ')})`)
45
+ const pos = ev[posKey]
46
+ if (typeof pos !== 'number' || !Number.isFinite(pos) || pos < 0) {
47
+ errs.push(`events[${i}].${posKey} must be a finite number ≥ 0`)
28
48
  } 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
49
+ if (pos < prev) errs.push(`events[${i}].${posKey} is out of order (the list is ordered by position)`)
50
+ prev = pos
31
51
  }
32
52
  if (typeof ev.step !== 'string' || !ev.step.trim()) errs.push(`events[${i}].step must be a non-empty string`)
33
53
  if (ev.node !== undefined && (typeof ev.node !== 'string' || !ev.node.trim())) errs.push(`events[${i}].node must be a non-empty string when present`)
@@ -35,12 +55,22 @@ export function validateTimeline(raw: unknown): string[] {
35
55
  return errs
36
56
  }
37
57
 
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 {
58
+ // normalize any VALID timeline (v1 or v2) to the axis-tagged shape every reader uses: v1 IS the time axis
59
+ // with `tMs` as the position. Call only on input `validateTimeline` accepted. This is the whole of the
60
+ // lossless back-compat: an old v1 blob and a new `{ v: 2, axis: 'time' }` render identically.
61
+ export function normalizeTimeline(raw: unknown): { axis: string; events: TimelineEvent[] } {
62
+ const o = (raw ?? {}) as Record<string, any>
63
+ const events: any[] = Array.isArray(o.events) ? o.events : []
64
+ if (o.v === 1) return { axis: 'time', events: events.map((e) => ({ at: e.tMs, step: e.step, ...(e.node ? { node: e.node } : {}) })) }
65
+ return { axis: typeof o.axis === 'string' ? o.axis : 'time', events: events.map((e) => ({ at: e.at, step: e.step, ...(e.node ? { node: e.node } : {}) })) }
66
+ }
67
+
68
+ // the whole of "which step is this position": the last event at or before `pos`; null before the first event
69
+ // (a plain moment, no step to name — graceful, never an error). Axis-agnostic — `pos` is on the events' axis.
70
+ export function stepAt(events: TimelineEvent[], pos: number): TimelineEvent | null {
41
71
  let hit: TimelineEvent | null = null
42
72
  for (const e of events) {
43
- if (e.tMs <= tMs) hit = e
73
+ if (e.at <= pos) hit = e
44
74
  else break
45
75
  }
46
76
  return hit
package/README.zh-CN.md DELETED
@@ -1,135 +0,0 @@
1
- <img src="docs/sdd-tuxedo-pooh.png" alt="写代码 vs. 维护一份活的、可执行的规格文档" width="420">
2
-
3
- [English](./README.md) | 中文
4
-
5
- > Spec 驱动开发通常死于两种方式:spec 和代码脱节,或者膨胀成一堆过时的仪式文档。SpexCode 让每个
6
- > spec 保持简短、保持当前态:原地重写,由 git 记版本,不做累积式的 changelog。
7
-
8
- **SpexCode** 是一个 spec 驱动、用自己开发自己的开发工具。项目的每个部分都是一个带版本的
9
- *spec 节点*,即一个 `.spec/**/spec.md`,正文描述这个部分当前的意图。git 就是数据库:节点的版本号
10
- 是它的内容 commit 数,"drift" 指被管辖的代码走到了 spec 前面。`spex` CLI 和实时看板直接从 git
11
- 读取这一切,没有第二份存储。
12
-
13
- - **[使用 SpexCode](#使用-spexcode)**:安装 `spex` CLI,通过你的 coding agent 治理*你自己的*项目。
14
- - **[参与开发](#参与开发)**:在本仓库里改进工具本身。
15
- - **[Working with agents →](https://spexcode.net/working-with-agents/)**:文档站上更完整的介绍。
16
-
17
- ---
18
-
19
- ## 使用 SpexCode
20
-
21
- SpexCode 只需要设置一次(`npm i -g spexcode`,然后在你的仓库里 `spex init`)。之后的主要用法是和
22
- 你的 coding agent 对话:用自然语言说你要什么(*"给鉴权流程加一个 spec 节点""把这个包的 spec
23
- 提取出来""派一个 worker 去实现 Y"*),agent 替你执行 `spex` CLI,你在看板上盯进度。下面的手动
24
- CLI 是底座,agent 才是日常界面。
25
-
26
- 这套之所以成立,是因为新启动的 agent 已经认识 SpexCode。`spex init` 会把整套约定(spec 节点流程、
27
- 先 commit 再声明完成、merge 方式)生成到仓库 `CLAUDE.md`/`AGENTS.md` 里的 `<!-- spexcode -->`
28
- 托管块中,**[Claude Code](https://www.anthropic.com/claude-code)** 和 **Codex** 启动时自动读到。
29
- 更细的内容 agent 自己按需查内置手册:`spex guide`(工作流)、`spex guide spec` /
30
- `spex guide yatsu`(文件格式)、`spex guide config`(`spexcode.json` 的全部配置项)。你可以直接
31
- 对它说*"跑一下 `spex guide config`,帮我配好 launcher"*。
32
-
33
- 完整的说明在文档站:**[working with agents](https://spexcode.net/working-with-agents/)** 讲
34
- agent 驱动的用法,**[getting started](https://spexcode.net/getting-started/)** 从头到尾走一遍安装。
35
-
36
- > **抛开 agent,它也是一套普通工具。** 核心部分单独用也成立:git 记版本的 spec 文件,`spex lint`
37
- > 检查,只读看板展示。不含 AI,除了 Node 和 git 什么都不用跑。vibe coding 的玩法建立在这层之上,
38
- > 不是替代它。
39
-
40
- > **环境要求。** 核心:**Node ≥ 22** 和 **git**。要通过 agent 驱动(或者往节点上派 worker),
41
- > 还需要 **tmux** 和 PATH 里已登录的 **Claude Code 或 Codex**。这些 agent 会在你的机器上执行
42
- > 命令,对外暴露后端之前请先读 [`SECURITY.md`](./docs/SECURITY.md)。
43
-
44
- ### 安装
45
-
46
- 装一次发布版 CLI,然后在任何项目里接入:
47
-
48
- ```sh
49
- npm i -g spexcode # 安装 spex 命令(需要 Node ≥ 22)
50
- cd ~/my-app
51
- spex init # 纯增量,不会动你的代码结构
52
- ```
53
-
54
- `spex init` 是增量式的:生成一棵起始 **`.spec/`** 树(根 `project` 节点,加上定义开发流程的
55
- `.config` 插件)、一份起始 **`spexcode.json`**、每个 clone 各自的 **git hooks**(`pre-commit`
56
- 跑 **spec-lint**,spec↔code 链接坏了会拦下;**main-guard** 拦截直接向 `main` 的提交;
57
- `prepare-commit-msg` 给每个 commit 盖 session 归属戳)。同时生成 agent 路径需要的产物:
58
- `CLAUDE.md`/`AGENTS.md` 里的 `<!-- spexcode -->` 契约块,以及 `.claude/`、`.codex/` 的
59
- settings hooks。这些产物是生成物,已被 gitignore,每台机器各自重新生成,不进版本库。
60
-
61
- 然后把它变成你的项目,让 agent 改或者自己动手都行:编辑 `.spec/project/spec.md` 描述项目,把
62
- `spexcode.json` 的 `lint.governedRoots` 指向真实源码目录,再检查一遍:
63
-
64
- ```sh
65
- spex lint # coverage 警告就是你的接入 TODO 清单
66
- ```
67
-
68
- ### 配置
69
-
70
- 仓库根目录两个可选的 JSON 文件承载全部设置,按可移植性分开:
71
-
72
- - **`spexcode.json`**:提交进仓库,可移植。布局、看板标识(`title` + `icon`)、lint 预算、
73
- launcher 的**名字**。对整个项目成立的事实。
74
- - **`spexcode.local.json`**:gitignore,单机。launcher 命令的绝对路径、证书和密钥路径、私有覆盖
75
- 开关(`private: true`,让 `spex materialize` 在被跟踪的文件里不留任何痕迹,ignore 写进本地
76
- git exclude 而不是提交的 `.gitignore`,适合你参与但不拥有的仓库)。只对这台机器成立的事实。
77
-
78
- 没有 `spex config set`,你(或你的 agent)直接编辑文件。每个字段的含义、该放哪个文件,
79
- **`spex guide config`** 是权威手册。
80
-
81
- ### 运行
82
-
83
- 启动后端和看板,打开页面:
84
-
85
- ```sh
86
- spex serve # 后端(API + sessions),:8787
87
- spex dashboard # 看板 UI,:5173,/api 代理到后端
88
- ```
89
-
90
- 打开 <http://localhost:5173>。
91
-
92
- 两个端口都是参数(`spex serve --port 8788`、`spex dashboard --port 5174 --api-port 8788`),
93
- 多个项目的看板可以并排跑,工作目录决定各自服务哪个项目。每个标签页的身份在各自项目的
94
- `spexcode.json` 里配:`dashboard.title` 定名字,`dashboard.icon` 定 favicon,emoji(`"🔭"`)、
95
- Iconify 名字(`"mdi:rocket-launch"`)或 URL 都行。
96
-
97
- 日常命令(agent 替你跑,你也可以自己跑):
98
-
99
- | 命令 | 作用 |
100
- | --- | --- |
101
- | `spex lint` | 检查 spec↔code 图:coverage、drift、living 规则 |
102
- | `spex watch` | 实时输出 session / 看板的状态变化 |
103
- | `spex guide` | 打印完整工作流,以及 `spec.md` / `yatsu.md` / `config` 手册 |
104
- | `spex board` | 以 JSON 输出当前看板状态 |
105
-
106
- ---
107
-
108
- ## 参与开发
109
-
110
- 这个仓库就是 SpexCode 的源码,而且它 dogfood 自己:工具的每个改动都以 spec 节点的形式合入
111
- `main`。搭一个开发环境:
112
-
113
- ```sh
114
- git clone https://github.com/shuxueshuxue/spexcode && cd spexcode
115
- npm --prefix spec-cli install
116
- npm --prefix spec-dashboard install
117
- npm run hooks # 安装每个 clone 各自的 git hooks(main-guard + session 戳)
118
- ```
119
-
120
- 开发循环直接跑源码,带热重载(开发用 `npm run web`,区别于安装用户的 `spex dashboard`):
121
-
122
- ```sh
123
- npm run api # 后端 :8787,spec-cli/src 改动即热重载
124
- npm run web # Vite 起看板(HMR),/api 代理到 :8787
125
- ```
126
-
127
- ---
128
-
129
- ## 致谢
130
-
131
- SpexCode 的首次公开介绍发在 [LINUX DO](https://linux.do),感谢佬友们的第一轮讨论和反馈。
132
-
133
- ## License
134
-
135
- [MIT](./LICENSE)。