spexcode 0.1.5 → 0.2.0
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 +86 -25
- package/package.json +5 -6
- package/spec-cli/README.md +86 -0
- package/spec-cli/bin/spex.mjs +15 -3
- package/spec-cli/hooks/dispatch.sh +20 -8
- package/spec-cli/hooks/harness.sh +18 -11
- package/spec-cli/src/board.ts +47 -18
- package/spec-cli/src/boardCache.ts +70 -0
- package/spec-cli/src/boardDelta.ts +90 -0
- package/spec-cli/src/boardStream.ts +178 -0
- package/spec-cli/src/cli.ts +172 -119
- package/spec-cli/src/client.ts +6 -4
- package/spec-cli/src/gateway.ts +60 -19
- package/spec-cli/src/git.ts +105 -92
- package/spec-cli/src/guide.ts +175 -12
- package/spec-cli/src/harness-select.ts +63 -0
- package/spec-cli/src/harness.ts +506 -100
- package/spec-cli/src/help.ts +360 -0
- package/spec-cli/src/hooks.ts +0 -14
- package/spec-cli/src/index.ts +272 -32
- package/spec-cli/src/init.ts +41 -1
- package/spec-cli/src/issues.ts +250 -0
- package/spec-cli/src/layout.ts +70 -28
- package/spec-cli/src/lint.ts +12 -10
- package/spec-cli/src/listen.ts +28 -0
- package/spec-cli/src/localIssues.ts +683 -0
- package/spec-cli/src/materialize.ts +182 -27
- package/spec-cli/src/mentions.ts +192 -0
- package/spec-cli/src/plugin-harness.ts +145 -0
- package/spec-cli/src/pty-bridge.ts +378 -81
- package/spec-cli/src/self.ts +123 -20
- package/spec-cli/src/sessions.ts +461 -298
- package/spec-cli/src/specs.ts +55 -14
- package/spec-cli/src/supervise.ts +23 -3
- package/spec-cli/src/tsx-bin.ts +14 -5
- package/spec-cli/src/uninstall.ts +146 -0
- package/spec-cli/templates/hooks/post-merge +27 -0
- package/spec-cli/templates/hooks/pre-commit +51 -31
- package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
- package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
- package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
- package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
- package/spec-cli/templates/spec/project/.config/spec.md +3 -3
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
- package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +2 -2
- package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
- package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
- package/spec-dashboard/dist/index.html +17 -5
- package/spec-forge/src/cache.ts +16 -0
- package/spec-forge/src/drivers/github.ts +74 -4
- package/spec-forge/src/port.ts +25 -0
- package/spec-forge/src/resident.ts +40 -6
- package/spec-yatsu/src/cli.ts +227 -38
- package/spec-yatsu/src/evaltab.ts +169 -19
- package/spec-yatsu/src/filing.ts +48 -0
- package/spec-yatsu/src/freshness.ts +55 -20
- package/spec-yatsu/src/proof.ts +89 -3
- package/spec-yatsu/src/scenariofresh.ts +92 -0
- package/spec-yatsu/src/sidecar.ts +75 -11
- package/spec-yatsu/src/timeline.ts +47 -0
- package/spec-yatsu/src/yatsu.ts +47 -3
- package/spec-cli/src/relay.ts +0 -28
- package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
- package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
- package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<img src="docs/sdd-tuxedo-pooh.png" alt="Writing code vs. authoring a living, executable specification artifact" width="420">
|
|
2
2
|
|
|
3
|
-
> Spec-driven development
|
|
4
|
-
>
|
|
5
|
-
>
|
|
3
|
+
> Spec-driven development fails two ways: the spec drifts out of sync with the code, or it
|
|
4
|
+
> bloats into stale ceremony. SpexCode keeps each spec short and current — rewritten in place,
|
|
5
|
+
> versioned by git, never an accumulating changelog.
|
|
6
6
|
|
|
7
7
|
**SpexCode** is a spec-driven, self-developing dev tool. Every part of a project becomes a versioned
|
|
8
8
|
*spec node* — a `.spec/**/spec.md` whose body states the part's *present* intent — and **git is the
|
|
@@ -10,49 +10,109 @@ database**: a node's version is its count of content commits, and "drift" is gov
|
|
|
10
10
|
ahead of its spec. A `spex` CLI plus a live dashboard read all of it straight from git; there is no
|
|
11
11
|
separate store.
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
- **[
|
|
16
|
-
- **[Contributing to SpexCode](#contributing-to-spexcode)** — develop the tool itself, in this repo.
|
|
13
|
+
- **[Using SpexCode](#using-spexcode)** — adopt the `spex` CLI and drive it through your coding agent to govern *your own* project.
|
|
14
|
+
- **[Contributing to SpexCode](#contributing-to-spexcode)** — hack on the tool itself, in this repo.
|
|
15
|
+
- **[Working with agents →](https://spexcode.net/working-with-agents/)** — the deeper narrative on the docs site, behind this landing.
|
|
17
16
|
|
|
18
17
|
---
|
|
19
18
|
|
|
20
19
|
## Using SpexCode
|
|
21
20
|
|
|
22
|
-
You
|
|
21
|
+
You set SpexCode up once — `npm i -g spexcode` then `spex init` in your repo — and after that the primary
|
|
22
|
+
way you use it is **by talking to your coding agent**. You describe what you want in plain language —
|
|
23
|
+
*"add a spec node for the auth flow", "extract specs for this package", "dispatch a worker to implement
|
|
24
|
+
Y"* — and the agent runs the `spex` CLI for you while you supervise on the board. The manual CLI below is
|
|
25
|
+
the substrate; your agent is the daily interface.
|
|
26
|
+
|
|
27
|
+
That works because a freshly-launched agent **already knows SpexCode**. `spex init` materializes the whole
|
|
28
|
+
contract — the spec-node ritual, the commit-before-declare gate, the merge style — into a
|
|
29
|
+
`<!-- spexcode -->` managed block in your repo's `CLAUDE.md`/`AGENTS.md`, which
|
|
30
|
+
**[Claude Code](https://www.anthropic.com/claude-code)** and **Codex** **auto-discover** as always-on
|
|
31
|
+
context (no `--append-system-prompt`, nothing to wire). From there the agent self-serves detail on demand
|
|
32
|
+
from the built-in manual — `spex guide` (the workflow), `spex guide spec` / `spex guide yatsu` (the file
|
|
33
|
+
formats), and `spex guide config` (every `spexcode.json` setting). You can literally say *"run `spex guide
|
|
34
|
+
config` and set me up a launcher"* and it will.
|
|
35
|
+
|
|
36
|
+
For the full narrative — the three pillars of driving SpexCode by talking to your agent (agent
|
|
37
|
+
self-learning via the materialized block + `spex guide`, config, and the init artifacts) — see
|
|
38
|
+
**[working with agents](https://spexcode.net/working-with-agents/)** on the docs site;
|
|
39
|
+
**[getting started](https://spexcode.net/getting-started/)** walks the setup end to end. This README is
|
|
40
|
+
the concise landing; the site goes deep.
|
|
41
|
+
|
|
42
|
+
> **It's also just plain tooling.** Strip the agent away and the core is still useful on its own: **spec
|
|
43
|
+
> files versioned by git**, checked by `spex lint` and shown on a read-only dashboard — no AI, and nothing
|
|
44
|
+
> to run but Node and git. The vibe-coding path sits *on top* of that; it doesn't replace it.
|
|
45
|
+
|
|
46
|
+
> **Requirements.** Core: **Node ≥ 22** and **git**. Driving SpexCode through an agent (or dispatching
|
|
47
|
+
> workers onto your nodes) also needs **tmux** and an authenticated **Claude Code or Codex** on your PATH
|
|
48
|
+
> — and those agents run commands on your machine, so read [`SECURITY.md`](./docs/SECURITY.md) before
|
|
49
|
+
> exposing the backend.
|
|
50
|
+
|
|
51
|
+
### Set it up
|
|
52
|
+
|
|
53
|
+
Install the published CLI once, then adopt it in any project:
|
|
23
54
|
|
|
24
55
|
```sh
|
|
25
56
|
npm i -g spexcode # installs the `spex` command (needs Node ≥ 22)
|
|
57
|
+
cd ~/my-app
|
|
58
|
+
spex init # additive — never restructures your code
|
|
26
59
|
```
|
|
27
60
|
|
|
28
|
-
|
|
61
|
+
`spex init` is additive: it seeds a starter **`.spec/`** tree (a root `project` node plus the `.config`
|
|
62
|
+
plugins that define the dev flow), a starter **`spexcode.json`**, and the per-clone **git hooks** (a
|
|
63
|
+
`pre-commit` hook that runs **spec-lint** — blocking on broken spec↔code links — and **main-guard**,
|
|
64
|
+
which blocks direct commits to `main`, plus a `prepare-commit-msg` hook that stamps each commit's
|
|
65
|
+
session attribution). It also **materializes** the harness artifacts that make the agent path
|
|
66
|
+
work: the `<!-- spexcode -->` contract block in `CLAUDE.md`/`AGENTS.md`, and the `.claude/` / `.codex/`
|
|
67
|
+
shims (the `settings.json` hooks) a self-launched agent discovers. Those artifacts are generated and
|
|
68
|
+
gitignored — regenerated on each machine, never committed.
|
|
69
|
+
|
|
70
|
+
Then make it yours — either ask your agent to, or do it by hand: edit `.spec/project/spec.md` to describe
|
|
71
|
+
the project, point `spexcode.json`'s `lint.governedRoots` at your real source dir(s), and check the graph:
|
|
29
72
|
|
|
30
73
|
```sh
|
|
31
|
-
cd ~/my-app
|
|
32
|
-
spex init # seed .spec/, a starter spexcode.json, and git hooks — nothing destructive
|
|
33
|
-
# 1. edit .spec/project/spec.md to describe your project
|
|
34
|
-
# 2. point spexcode.json's lint.governedRoots at your real source dir(s)
|
|
35
74
|
spex lint # the "coverage" warnings are your adoption TODO list
|
|
36
75
|
```
|
|
37
76
|
|
|
38
|
-
|
|
39
|
-
|
|
77
|
+
### Configure it
|
|
78
|
+
|
|
79
|
+
Two optional JSON files at the repo root hold every setting, split by portability — pick the right one and
|
|
80
|
+
that's the whole discipline:
|
|
81
|
+
|
|
82
|
+
- **`spexcode.json`** — *committed, portable*: layout, dashboard identity (`title` + `icon`), lint budgets,
|
|
83
|
+
and launcher **names**. Facts that are true for the project.
|
|
84
|
+
- **`spexcode.local.json`** — *gitignored, host-specific*: absolute launcher command paths, cert/secret
|
|
85
|
+
paths, and the private-overlay switch (`private: true`, which makes `spex materialize` leave zero trace
|
|
86
|
+
in the tracked tree — ignores go to the local git exclude, not the committed `.gitignore` — so you can
|
|
87
|
+
run SpexCode on a repo you share but don't own). Facts that are true for one machine.
|
|
88
|
+
|
|
89
|
+
There is no `spex config set` — you (or your agent) edit the files directly. **`spex guide config`** is the
|
|
90
|
+
authoritative manual for every field and which of the two files it belongs in.
|
|
91
|
+
|
|
92
|
+
### Run it
|
|
93
|
+
|
|
94
|
+
Start the backend and the dashboard, then open the board:
|
|
40
95
|
|
|
41
96
|
```sh
|
|
42
|
-
spex serve
|
|
43
|
-
spex dashboard
|
|
97
|
+
spex serve # the backend (API + sessions), on :8787
|
|
98
|
+
spex dashboard # the board UI on :5173, proxying /api to the backend
|
|
44
99
|
```
|
|
45
100
|
|
|
46
|
-
|
|
47
|
-
to `:5173`.
|
|
101
|
+
Open <http://localhost:5173>.
|
|
48
102
|
|
|
49
|
-
|
|
103
|
+
Both ports are flags (`spex serve --port 8788`, `spex dashboard --port 5174 --api-port 8788`), so you can
|
|
104
|
+
run several projects' boards side by side — the working directory picks which project each serves. Give
|
|
105
|
+
each tab its own identity in that project's `spexcode.json`: `dashboard.title` names it and
|
|
106
|
+
`dashboard.icon` sets the favicon — an emoji (`"🔭"`), an Iconify name (`"mdi:rocket-launch"`), or a URL,
|
|
107
|
+
nothing to download.
|
|
108
|
+
|
|
109
|
+
Day to day (the commands your agent runs for you — and that you can run yourself):
|
|
50
110
|
|
|
51
111
|
| command | what it does |
|
|
52
112
|
| --- | --- |
|
|
53
113
|
| `spex lint` | check the spec↔code graph — coverage, drift, and the living-body rules |
|
|
54
114
|
| `spex watch` | stream session / board transitions as they happen |
|
|
55
|
-
| `spex guide` | print the full workflow, plus the `spec.md` / `yatsu.md`
|
|
115
|
+
| `spex guide` | print the full workflow, plus the `spec.md` / `yatsu.md` / `config` manuals |
|
|
56
116
|
| `spex board` | dump the current board state as JSON |
|
|
57
117
|
|
|
58
118
|
The spec tree is ground truth and git is its database: every change is a `spec.md` node, **rewritten in
|
|
@@ -80,7 +140,8 @@ npm run api # backend on :8787, hot-reloads on spec-cli/src changes
|
|
|
80
140
|
npm run web # the dashboard via Vite (HMR), proxying /api → :8787
|
|
81
141
|
```
|
|
82
142
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
[MIT](./LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spexcode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,12 +24,11 @@
|
|
|
24
24
|
"node": ">=22"
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
|
-
"api": "
|
|
28
|
-
"web": "
|
|
29
|
-
"lint": "
|
|
30
|
-
"seed": "bash scripts/seed-spec-history.sh",
|
|
27
|
+
"api": "cd spec-cli && npm run serve",
|
|
28
|
+
"web": "cd spec-dashboard && npm run dev",
|
|
29
|
+
"lint": "cd spec-cli && npm run -s lint",
|
|
31
30
|
"hooks": "bash scripts/install-hooks.sh",
|
|
32
|
-
"
|
|
31
|
+
"prepack": "node scripts/prepack.mjs",
|
|
33
32
|
"postinstall": "node -e \"try{require('fs').readdirSync('node_modules/node-pty/prebuilds').forEach(d=>{try{require('fs').chmodSync('node_modules/node-pty/prebuilds/'+d+'/spawn-helper',0o755)}catch{}})}catch{}\""
|
|
34
33
|
},
|
|
35
34
|
"dependencies": {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<img src="docs/sdd-tuxedo-pooh.png" alt="Writing code vs. authoring a living, executable specification artifact" width="420">
|
|
2
|
+
|
|
3
|
+
> Spec-driven development gets wrecked by spec drift and spec bloat. SpexCode's bet
|
|
4
|
+
> is to keep the spec the cheap, honest twin of the code — rewritten in place, never
|
|
5
|
+
> a tuxedo of stale ceremony.
|
|
6
|
+
|
|
7
|
+
**SpexCode** is a spec-driven, self-developing dev tool. Every part of a project becomes a versioned
|
|
8
|
+
*spec node* — a `.spec/**/spec.md` whose body states the part's *present* intent — and **git is the
|
|
9
|
+
database**: a node's version is its count of content commits, and "drift" is governed code that moved
|
|
10
|
+
ahead of its spec. A `spex` CLI plus a live dashboard read all of it straight from git; there is no
|
|
11
|
+
separate store.
|
|
12
|
+
|
|
13
|
+
There are two ways to meet SpexCode, and they are kept separate on purpose:
|
|
14
|
+
|
|
15
|
+
- **[Using SpexCode](#using-spexcode)** — install the `spex` CLI from npm and govern *your own* project.
|
|
16
|
+
- **[Contributing to SpexCode](#contributing-to-spexcode)** — develop the tool itself, in this repo.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Using SpexCode
|
|
21
|
+
|
|
22
|
+
You don't clone this repo to *use* SpexCode. Install the published CLI once, then point it at any project.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm i -g spexcode # installs the `spex` command (needs Node ≥ 22)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Adopt it in your project — `spex init` is **additive**, it never restructures your code:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
cd ~/my-app
|
|
32
|
+
spex init # seed .spec/, a starter spexcode.json, and git hooks — nothing destructive
|
|
33
|
+
# 1. edit .spec/project/spec.md to describe your project
|
|
34
|
+
# 2. point spexcode.json's lint.governedRoots at your real source dir(s)
|
|
35
|
+
spex lint # the "coverage" warnings are your adoption TODO list
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Run it. The backend and the dashboard are **two commands on two ports**, so several projects can run
|
|
39
|
+
side by side on one host (the cwd picks which project is served):
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
spex serve --port 8788 # the backend (API + sessions) for THIS repo
|
|
43
|
+
spex dashboard --port 5174 --api-port 8788 # the board UI, pointed at that backend
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then open <http://localhost:5174>. With no flags, `spex serve` defaults to `:8787` and `spex dashboard`
|
|
47
|
+
to `:5173`.
|
|
48
|
+
|
|
49
|
+
Day to day:
|
|
50
|
+
|
|
51
|
+
| command | what it does |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `spex lint` | check the spec↔code graph — coverage, drift, and the living-body rules |
|
|
54
|
+
| `spex watch` | stream session / board transitions as they happen |
|
|
55
|
+
| `spex guide` | print the full workflow, plus the `spec.md` / `yatsu.md` file-format manuals |
|
|
56
|
+
| `spex board` | dump the current board state as JSON |
|
|
57
|
+
|
|
58
|
+
The spec tree is ground truth and git is its database: every change is a `spec.md` node, **rewritten in
|
|
59
|
+
place** (never a `## vN` changelog) and versioned by its commits.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Contributing to SpexCode
|
|
64
|
+
|
|
65
|
+
This repository *is* the SpexCode source, and it **dogfoods itself**: every change to the tool lands as a
|
|
66
|
+
spec node merged into `main`. Set up a checkout:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
git clone https://github.com/shuxueshuxue/spexcode && cd spexcode
|
|
70
|
+
npm --prefix spec-cli install
|
|
71
|
+
npm --prefix spec-dashboard install
|
|
72
|
+
npm run hooks # install the per-clone git hooks (main-guard + the session-stamp hook)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The development loop runs from source, with hot-reload — this is what `npm run web` is for, as opposed
|
|
76
|
+
to an installed user's `spex dashboard`:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
npm run api # backend on :8787, hot-reloads on spec-cli/src changes
|
|
80
|
+
npm run web # the dashboard via Vite (HMR), proxying /api → :8787
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The contribution ritual in one breath: branch `node/<id>` off `main`, make the code change **and** its
|
|
84
|
+
`spec.md` *together*, commit, then `spex session done --propose merge` — a human performs the `--no-ff`
|
|
85
|
+
merge. That ritual, the spec-node model, the lint rules, and the reflexive config system are all spelled
|
|
86
|
+
out in **[`CLAUDE.md`](./CLAUDE.md)** — read it before your first change.
|
package/spec-cli/bin/spex.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// run the TypeScript CLI directly. After `npm link` (or a global install) `spex lint` works anywhere.
|
|
4
4
|
import { spawn } from 'node:child_process'
|
|
5
5
|
import { existsSync } from 'node:fs'
|
|
6
|
+
import { createRequire } from 'node:module'
|
|
6
7
|
import { fileURLToPath } from 'node:url'
|
|
7
8
|
import { dirname, join } from 'node:path'
|
|
8
9
|
|
|
@@ -11,9 +12,20 @@ import { dirname, join } from 'node:path'
|
|
|
11
12
|
// `spex` work from any cwd (agents, git hooks) against this package's code, operating on the cwd.
|
|
12
13
|
const pkg = join(dirname(fileURLToPath(import.meta.url)), '..') // spec-cli/
|
|
13
14
|
const cli = join(pkg, 'src', 'cli.ts')
|
|
14
|
-
// tsx lives in spec-cli/node_modules in the dev monorepo, but
|
|
15
|
-
//
|
|
15
|
+
// tsx lives in spec-cli/node_modules in the dev monorepo, but npm may hoist it above the installed
|
|
16
|
+
// `spexcode` package in a real consumer project. Try local candidates first, then let Node resolve upward
|
|
17
|
+
// from spec-cli so project-local and global installs work the same way.
|
|
16
18
|
const tsxCandidates = [join(pkg, 'node_modules', '.bin', 'tsx'), join(pkg, '..', 'node_modules', '.bin', 'tsx')]
|
|
17
|
-
|
|
19
|
+
function resolveTsx() {
|
|
20
|
+
const local = tsxCandidates.find(existsSync)
|
|
21
|
+
if (local) return local
|
|
22
|
+
try {
|
|
23
|
+
const req = createRequire(join(pkg, 'package.json'))
|
|
24
|
+
return join(dirname(req.resolve('tsx/package.json')), 'dist', 'cli.mjs')
|
|
25
|
+
} catch {
|
|
26
|
+
return tsxCandidates[0]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const tsx = resolveTsx()
|
|
18
30
|
spawn(tsx, [cli, ...process.argv.slice(2)], { stdio: 'inherit' })
|
|
19
31
|
.on('exit', (code) => process.exit(code ?? 0))
|
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
# the harness propagates. Pure bash, no node boot on the hot path (node runs only inside the gate, only
|
|
18
18
|
# on actual change). cwd = the project/worktree. $SPEX (abs tsx+cli) is inherited from the shim env.
|
|
19
19
|
set -u
|
|
20
|
-
# args: `<harness> <Event>`. A harness id as $1 (claude|codex) is consumed; otherwise we keep $1 as the
|
|
21
|
-
# and default the harness to claude — so a stale shim still rendered as `dispatch.sh <Event>` keeps working.
|
|
20
|
+
# args: `<harness> <Event>`. A harness id as $1 (claude|codex|plugin) is consumed; otherwise we keep $1 as the
|
|
21
|
+
# event and default the harness to claude — so a stale shim still rendered as `dispatch.sh <Event>` keeps working.
|
|
22
|
+
# `plugin` is the bundle form ([[plugin-harness]]): it parses payloads as the claude family in harness.sh (z-code/
|
|
23
|
+
# Claude share Claude's tool names + file_path), so it joins the claude branch there via the default case.
|
|
22
24
|
harness=claude
|
|
23
|
-
case "${1:-}" in claude|codex) harness="$1"; shift ;; esac
|
|
25
|
+
case "${1:-}" in claude|codex|plugin) harness="$1"; shift ;; esac
|
|
24
26
|
event="${1:?usage: dispatch.sh <harness> <Event>}"
|
|
25
27
|
export SPEXCODE_HARNESS="$harness"
|
|
26
28
|
# the harness.sh path (the adapter's shell mirror) — sibling of this script; hook handlers source it, and we
|
|
@@ -38,11 +40,21 @@ rt="$(cd "$proj" 2>/dev/null && hp_runtime_dir)" || rt=""
|
|
|
38
40
|
cur="$( (cd "$proj" 2>/dev/null && hp_config_hash) )"
|
|
39
41
|
if [ -n "$rt" ] && [ -n "$cur" ] && [ "$cur" != "$(cat "$rt/content-hash" 2>/dev/null || true)" ]; then
|
|
40
42
|
mkdir -p "$rt" 2>/dev/null
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
# POSIX-portable mutex: mkdir is atomic on every POSIX fs, so it serializes the render with NO dependency on
|
|
44
|
+
# util-linux `flock` (absent on macOS — where it silently no-op'd, letting concurrent sessions race the write).
|
|
45
|
+
# Spin on `mkdir` with a bounded wait; after ~10s (200 * 0.05s, well above the ~0.85s materialize) treat the
|
|
46
|
+
# dir as orphaned by a dead entrant and clear it, so a stale lock can never deadlock a launch. Held only across
|
|
47
|
+
# the re-check + render, released right after.
|
|
48
|
+
lockd="$rt/.materialize.lock.d"
|
|
49
|
+
_lk=0
|
|
50
|
+
until mkdir "$lockd" 2>/dev/null; do
|
|
51
|
+
_lk=$((_lk+1)); [ "$_lk" -ge 200 ] && { rm -rf "$lockd" 2>/dev/null; _lk=0; }
|
|
52
|
+
sleep 0.05
|
|
53
|
+
done
|
|
54
|
+
if [ "$cur" != "$(cat "$rt/content-hash" 2>/dev/null || true)" ]; then # re-check: a sibling dispatch may have just rendered
|
|
55
|
+
( cd "$proj" && ${SPEX:-spex} materialize >/dev/null 2>&1 )
|
|
56
|
+
fi
|
|
57
|
+
rmdir "$lockd" 2>/dev/null
|
|
46
58
|
fi
|
|
47
59
|
|
|
48
60
|
# --- (2) dispatch ---------------------------------------------------------------------------------------
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
# HERE; the hook scripts stay harness-agnostic and just call hp_* (the one place Claude's tool names appear in
|
|
7
7
|
# shell, plus codex's Bash-command mapping). The session-id + global-store resolution is harness-agnostic and
|
|
8
8
|
# lives here too, so the six hooks no longer each repeat the git-common-dir → project-key dance.
|
|
9
|
+
# SPEXCODE_HARNESS is claude|codex|plugin: `plugin` is the bundle form ([[plugin-harness]]) and its host (z-code/
|
|
10
|
+
# Claude) shares Claude's payload shape, so every `case "$SPEXCODE_HARNESS"` below routes it through the claude
|
|
11
|
+
# branch via the default case — there is no separate `plugin)` arm to maintain.
|
|
9
12
|
|
|
10
13
|
# the string value of a top-level JSON string field (first match). Harness-agnostic — both harnesses' payloads
|
|
11
14
|
# carry session_id / tool_name as plain string fields. $1 = payload, $2 = field name. The value is scanned as a
|
|
@@ -54,13 +57,16 @@ hp_field() {
|
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
# the session id from a payload (both harnesses use session_id).
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
# Codex hooks run inside ONE shared per-project app-server. That process can inherit the FIRST launched
|
|
61
|
+
# session's SPEXCODE_SESSION_ID, so on codex the payload session_id (the acting thread id) must win and then
|
|
62
|
+
# hp_store_dir aliases it to the governed record. Claude's payload id already equals its governed record id,
|
|
63
|
+
# so preferring SPEXCODE_SESSION_ID there preserves the launcher's pinned id path.
|
|
64
|
+
hp_session_id() {
|
|
65
|
+
case "$SPEXCODE_HARNESS" in
|
|
66
|
+
codex) hp_field "$1" session_id ;;
|
|
67
|
+
*) printf '%s' "${SPEXCODE_SESSION_ID:-$(hp_field "$1" session_id)}" ;;
|
|
68
|
+
esac
|
|
69
|
+
}
|
|
64
70
|
|
|
65
71
|
# the per-PROJECT GLOBAL runtime dir (mirrors spec-cli/src/layout.ts `runtimeRoot`): <store>/projects/<enc>,
|
|
66
72
|
# keyed by the project (dirname of the ABSOLUTE git-common-dir, so the answer is identical from main or any
|
|
@@ -75,10 +81,11 @@ hp_runtime_dir() {
|
|
|
75
81
|
|
|
76
82
|
# the per-session GLOBAL store dir for a session id — <runtime>/sessions/<id> (sibling of the per-project
|
|
77
83
|
# runtime above). Echoes the dir; returns non-zero (echoing nothing) when git can't resolve.
|
|
78
|
-
# ALIAS resolution: a codex hook fires from the shared per-PROJECT app-server process, whose env
|
|
79
|
-
# SPEXCODE_SESSION_ID
|
|
80
|
-
# SpexCode record id the dir is keyed by. So when no
|
|
81
|
-
#
|
|
84
|
+
# ALIAS resolution: a codex hook fires from the shared per-PROJECT app-server process, whose env may carry a
|
|
85
|
+
# stale SPEXCODE_SESSION_ID from the first launched codex session. hp_session_id therefore returns the acting
|
|
86
|
+
# payload session_id on codex: the codex THREAD id, NOT the SpexCode record id the dir is keyed by. So when no
|
|
87
|
+
# record sits at <id> directly, find the one record that captured this id as `harness_session_id` (the backend
|
|
88
|
+
# stored it at thread/start, before the first tool turn).
|
|
82
89
|
# A grep over the few session.json files — no jq on the hot path; the trailing quote anchors the value so a
|
|
83
90
|
# thread id can't match a longer one as a prefix. Direct hit wins; a miss with no alias echoes the direct path
|
|
84
91
|
# unchanged, so the caller's `[ -e "$rec" ]` still no-ops gracefully. Mirrors layout.ts `readAliasedRawRecord`.
|
package/spec-cli/src/board.ts
CHANGED
|
@@ -3,8 +3,10 @@ import { loadSpecs, deriveStatus } from './specs.js'
|
|
|
3
3
|
import { resolveLayout, readConfig } from './layout.js'
|
|
4
4
|
import { listSessions } from './sessions.js'
|
|
5
5
|
import { repoRoot, driftIndex, historyIndex } from './git.js'
|
|
6
|
-
import {
|
|
6
|
+
import { residentForgeState } from '../../spec-forge/src/resident.js'
|
|
7
|
+
import { mergedIssues } from './issues.js'
|
|
7
8
|
import { evalContext, evalTimeline } from '../../spec-yatsu/src/evaltab.js'
|
|
9
|
+
import { yatsuNodesAsync } from '../../spec-yatsu/src/yatsu.js'
|
|
8
10
|
|
|
9
11
|
// a ghost (added) node's parent: the existing node whose directory is the longest prefix of the new one.
|
|
10
12
|
function resolveParent(path: string, byDir: Record<string, string>): string | null {
|
|
@@ -17,6 +19,22 @@ function resolveParent(path: string, byDir: Record<string, string>): string | nu
|
|
|
17
19
|
return null
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
// the board's eval summary ([[board-lean]]): the LATEST reading per scenario, each kept as the VERBATIM
|
|
23
|
+
// reading object — a filter, never a projection. Consumers hang optional fields off a reading (the
|
|
24
|
+
// annotator's timelineBlob rides only video readings), so dropping a field here is a SILENT downstream
|
|
25
|
+
// degradation no error would surface; the field-preservation unit test pins this contract.
|
|
26
|
+
export function latestPerScenario<T extends { scenario: string }>(readings: T[]): T[] {
|
|
27
|
+
const seen = new Set<string>()
|
|
28
|
+
return readings.filter((r) => !seen.has(r.scenario) && (seen.add(r.scenario), true))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// the board's scenario fold ([[board-lean]]): the declared set rides SLIM — {name, tags} is everything an
|
|
32
|
+
// overview surface joins state onto (badge, stats, focus rows, search rows); the prose (description/
|
|
33
|
+
// expected) and per-scenario code stay off the hot poll, carried by `/api/specs/lite` and `/api/specs/:id/evals`.
|
|
34
|
+
export function slimScenarios(scenarios: { name: string; tags?: string[] }[]): { name: string; tags?: string[] }[] {
|
|
35
|
+
return scenarios.map((s) => ({ name: s.name, ...(s.tags?.length ? { tags: s.tags } : {}) }))
|
|
36
|
+
}
|
|
37
|
+
|
|
20
38
|
export async function buildBoard() {
|
|
21
39
|
// all three sources are warm-cheap and independent, so the board inherits their speed for free: loadSpecs
|
|
22
40
|
// REUSES the HEAD-keyed spec-history cache (the git-derived node data — see specs.ts/git.ts), resolveLayout
|
|
@@ -77,35 +95,46 @@ export async function buildBoard() {
|
|
|
77
95
|
const nodes = [
|
|
78
96
|
...specs.map((n: any) => {
|
|
79
97
|
const overlays = overlaysByNode[n.id] || []
|
|
80
|
-
|
|
98
|
+
// `body` and its derivation `parts` are DROPPED from the board payload ([[board-lean]]): together ~56% of
|
|
99
|
+
// the bytes, and detail the graph overview never renders. The detail view fetches them per node from
|
|
100
|
+
// `/api/specs/:id/content` on open, and the search palette fetches the body corpus from `/api/specs/lite`
|
|
101
|
+
// once on open — both off this hot poll. `undefined` makes JSON.stringify omit the keys.
|
|
102
|
+
return { ...n, body: undefined, parts: undefined, overlays, status: deriveStatus({ version: n.version, drift: n.drift, hasOverlay: overlays.length > 0, hasCode: (n.code?.length ?? 0) > 0, fmStatus: n.fmStatus ?? undefined }) }
|
|
81
103
|
}),
|
|
82
104
|
...Object.values(ghostById),
|
|
83
105
|
]
|
|
84
|
-
// fold
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
.sort((a, b) => Number(isOpen(b)) - Number(isOpen(a)) || b.number - a.number)
|
|
93
|
-
}
|
|
106
|
+
// fold each node's issues onto it through the unified Issue port ([[issues]]): the resident forge slice
|
|
107
|
+
// AND the local store's threads, one merged store-tagged list (full set → issues, open subset →
|
|
108
|
+
// openIssues, attached only when non-empty). Non-blocking: residentForgeState never waits on `gh` and is
|
|
109
|
+
// empty absent a forge, so the fold then carries the local slice alone. Sorted open-first, newest first.
|
|
110
|
+
const isOpen = (i: { status: string }) => i.status === 'open'
|
|
111
|
+
const issuesByNode: Record<string, ReturnType<typeof mergedIssues>> = {}
|
|
112
|
+
for (const issue of mergedIssues({ host: 'github', state: residentForgeState() }, nodes.map((n) => n.id)))
|
|
113
|
+
for (const nid of issue.nodes) (issuesByNode[nid] ??= []).push(issue)
|
|
94
114
|
for (const n of nodes) {
|
|
95
115
|
const issues = issuesByNode[n.id]
|
|
96
116
|
if (!issues || !issues.length) continue
|
|
97
117
|
n.issues = issues
|
|
98
|
-
|
|
118
|
+
.sort((a, b) => Number(isOpen(b)) - Number(isOpen(a)) || b.created.localeCompare(a.created))
|
|
119
|
+
.map((i) => ({ id: i.id, store: i.store, status: i.status, concern: i.concern, url: i.url }))
|
|
120
|
+
const open = n.issues.filter(isOpen)
|
|
99
121
|
if (open.length) n.openIssues = open
|
|
100
122
|
}
|
|
101
123
|
|
|
102
|
-
// fold each yatsu node's eval
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
124
|
+
// fold each yatsu node's eval state onto it — as the LEAN summary ([[board-lean]]): `evals` carries only
|
|
125
|
+
// the LATEST reading per scenario (newest-first), which is all any overview surface consumes (the score
|
|
126
|
+
// badge, stats, search all reduce to latest-per-scenario anyway); the full timeline stays off the board
|
|
127
|
+
// and is lazy-loaded by the eval tab from `/api/specs/:id/evals`. `scenarios` (the declared set) rides
|
|
128
|
+
// SLIM — {name, tags} only, the fields every overview surface joins state onto — with its prose
|
|
129
|
+
// (description/expected) and per-scenario code off the hot poll: they ride the `/api/specs/lite` corpus
|
|
130
|
+
// (search palette, focus-panel preview) and the `/api/specs/:id/evals` timeline (eval tab).
|
|
131
|
+
// evalContext reuses the specs + driftIndex above; evalTimeline short-circuits non-yatsu nodes. The
|
|
132
|
+
// yatsu walk rides fs/promises ([[board-cache]]) so it yields the event loop instead of stalling /health.
|
|
133
|
+
const ynodes = await yatsuNodesAsync(root)
|
|
134
|
+
const ectx = await evalContext(root, specs, idx, hidx, undefined, ynodes)
|
|
106
135
|
await Promise.all(nodes.map(async (n) => {
|
|
107
136
|
const tl = await evalTimeline(n.id, ectx)
|
|
108
|
-
if (tl.hasYatsu) { n.evals = tl.readings; n.scenarios = tl.scenarios }
|
|
137
|
+
if (tl.hasYatsu) { n.evals = latestPerScenario(tl.readings); n.scenarios = slimScenarios(tl.scenarios) }
|
|
109
138
|
}))
|
|
110
139
|
|
|
111
140
|
const opsByPath: Record<string, any[]> = {}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { buildBoard } from './board.js'
|
|
2
|
+
|
|
3
|
+
// @@@ board-cache — single-flight + cache for the hot /api/board build ([[board-lean]]). Assembling the
|
|
4
|
+
// board is expensive (two full-history git-log walks cold, a full `.spec` fs walk every build), so the
|
|
5
|
+
// route MUST NOT rebuild per request: index.ts once ran `buildBoard()` inline on EVERY poll, so a normal
|
|
6
|
+
// dashboard's overlapping polls (+ SSE-triggered refetches) multiplied into N simultaneous builds and
|
|
7
|
+
// starved the event loop — one real user could wedge the backend. Here ONE build is shared by all
|
|
8
|
+
// concurrent callers (a promise memo — this IS the max-concurrent-builds cap: at most one runs) and its
|
|
9
|
+
// result is cached until a REAL change invalidates it. The cache is invalidated by the SAME freshness
|
|
10
|
+
// signals [[board-stream]] already watches (session-store writes, git-ref moves, the cold tick), via
|
|
11
|
+
// invalidateBoard(). So a poll storm costs ONE build, a quiet stretch costs ZERO, and the SSE rebuild and
|
|
12
|
+
// the route share the very same in-flight build.
|
|
13
|
+
|
|
14
|
+
export type Board = Awaited<ReturnType<typeof buildBoard>>
|
|
15
|
+
|
|
16
|
+
// a build slower than this is LOGGED, never silently tolerated — the fail-loud regression alarm. Sized
|
|
17
|
+
// above a warm build (~sub-second once the fs walks yield) but below the cold two-walk first build, so a
|
|
18
|
+
// genuinely-degraded hot path shouts while an ordinary cold start stays quiet-ish.
|
|
19
|
+
const BUDGET_MS = Number(process.env.SPEXCODE_BOARD_BUDGET_MS || 1500)
|
|
20
|
+
|
|
21
|
+
let cached: Board | null = null // last completed build; served while `valid`
|
|
22
|
+
let cachedJson: string | null = null // JSON.stringify(cached), serialized ONCE per build (see getBoardJson)
|
|
23
|
+
let valid = false
|
|
24
|
+
let inflight: Promise<Board> | null = null
|
|
25
|
+
let gen = 0 // bumped on every invalidation — detects a change that landed MID-build
|
|
26
|
+
|
|
27
|
+
// mark the cache stale. Called by every board-stream freshness source (see boardStream.fireChanged), so a
|
|
28
|
+
// real change forces the next getBoard() to rebuild while a quiet poll storm keeps hitting the cache.
|
|
29
|
+
export function invalidateBoard(): void {
|
|
30
|
+
gen++
|
|
31
|
+
valid = false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// the coalesced board read the route and the SSE rebuild both go through. A concurrent caller during a
|
|
35
|
+
// build shares the in-flight promise; a caller after a completed build gets the cached value until the
|
|
36
|
+
// next invalidation. A change that lands WHILE a build runs (gen moved) leaves the cache invalid so the
|
|
37
|
+
// NEXT read rebuilds — the just-finished build still returns to its waiters (freshest available when they
|
|
38
|
+
// asked), never cached as current. Mirrors [[board-stream]]'s building/dirty loop.
|
|
39
|
+
export function getBoard(): Promise<Board> {
|
|
40
|
+
if (inflight) return inflight
|
|
41
|
+
if (valid && cached) return Promise.resolve(cached)
|
|
42
|
+
const startGen = gen
|
|
43
|
+
const p = (async () => {
|
|
44
|
+
const t0 = Date.now()
|
|
45
|
+
try {
|
|
46
|
+
const board = await buildBoard()
|
|
47
|
+
cached = board
|
|
48
|
+
cachedJson = null // invalidate the memoized serialization; re-serialized lazily on first read
|
|
49
|
+
valid = gen === startGen
|
|
50
|
+
return board
|
|
51
|
+
} finally {
|
|
52
|
+
const ms = Date.now() - t0
|
|
53
|
+
if (ms > BUDGET_MS) console.warn(`spec-cli: /api/board build took ${ms}ms (budget ${BUDGET_MS}ms) — hot path is slow`)
|
|
54
|
+
inflight = null
|
|
55
|
+
}
|
|
56
|
+
})()
|
|
57
|
+
inflight = p
|
|
58
|
+
return p
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// the SERIALIZED board for the /api/board route — JSON.stringify runs ONCE per build, not once per poll,
|
|
62
|
+
// so a poll storm of cache hits costs zero serialization CPU (only the etag hash for the 304 path). The SSE
|
|
63
|
+
// path still takes the object (getBoard) because it decomposes it into delta units ([[board-delta]]).
|
|
64
|
+
export async function getBoardJson(): Promise<string> {
|
|
65
|
+
const board = await getBoard()
|
|
66
|
+
if (board === cached && cachedJson !== null) return cachedJson
|
|
67
|
+
const json = JSON.stringify(board)
|
|
68
|
+
if (board === cached) cachedJson = json // memoize only the CURRENT build's serialization
|
|
69
|
+
return json
|
|
70
|
+
}
|