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.
- package/README.md +158 -103
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +24 -1
- package/spec-cli/src/attach.ts +50 -0
- package/spec-cli/src/cli.ts +217 -64
- package/spec-cli/src/client.ts +47 -9
- package/spec-cli/src/{self.ts → doctor.ts} +26 -25
- package/spec-cli/src/guide.ts +79 -21
- package/spec-cli/src/harness.ts +53 -29
- package/spec-cli/src/help.ts +137 -49
- package/spec-cli/src/index.ts +31 -11
- package/spec-cli/src/issues.ts +48 -21
- package/spec-cli/src/layout.ts +3 -5
- package/spec-cli/src/lint.ts +34 -5
- package/spec-cli/src/localIssues.ts +44 -60
- package/spec-cli/src/materialize.ts +4 -2
- package/spec-cli/src/mentions.ts +22 -1
- package/spec-cli/src/pty-bridge.ts +39 -4
- package/spec-cli/src/ranker.ts +31 -12
- package/spec-cli/src/search.bench.mjs +30 -7
- package/spec-cli/src/search.ts +39 -0
- package/spec-cli/src/sessions.ts +160 -69
- package/spec-cli/src/specs.ts +16 -4
- package/spec-cli/src/supervise.ts +30 -6
- package/spec-cli/src/tree.ts +118 -0
- package/spec-cli/templates/hooks/post-merge +2 -2
- package/spec-cli/templates/hooks/pre-commit +34 -15
- package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
- package/spec-cli/templates/spexcode.json +7 -0
- package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
- package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +3 -0
- package/spec-dashboard/dist/assets/FoldToggle-B5leylLf.js +1 -0
- package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-RHNECU6x.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-YLD6IOmC.js +71 -0
- package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +9 -0
- package/spec-dashboard/dist/assets/Settings-ZnOwskMZ.js +1 -0
- package/spec-dashboard/dist/assets/index-BdRQfrkR.js +41 -0
- package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-yatsu/src/cli.ts +128 -26
- package/spec-yatsu/src/evaltab.ts +7 -6
- package/spec-yatsu/src/filing.ts +6 -3
- package/spec-yatsu/src/proof.ts +10 -0
- package/spec-yatsu/src/scenariofresh.ts +100 -30
- package/spec-yatsu/src/sidecar.ts +25 -3
- package/spec-yatsu/src/timeline.ts +53 -23
- package/README.zh-CN.md +0 -135
- package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +0 -32
- 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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
48
|
-
// scenario whose block differs from the next-older version (undefined
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
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,
|
|
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
|
|
65
|
-
const
|
|
66
|
-
for (
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
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
|
-
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
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
|
|
4
|
-
// emitter's own job. The
|
|
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 = {
|
|
8
|
-
export type StepTimeline = { v:
|
|
12
|
+
export type TimelineEvent = { at: number; step: string; node?: string }
|
|
13
|
+
export type StepTimeline = { v: 2; axis: string; events: TimelineEvent[] }
|
|
9
14
|
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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 (!
|
|
26
|
-
|
|
27
|
-
|
|
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 (
|
|
30
|
-
prev =
|
|
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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
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.
|
|
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)。
|