spexcode 0.1.6 → 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.
Files changed (70) hide show
  1. package/README.md +86 -25
  2. package/package.json +5 -6
  3. package/spec-cli/README.md +86 -0
  4. package/spec-cli/bin/spex.mjs +15 -3
  5. package/spec-cli/hooks/dispatch.sh +20 -8
  6. package/spec-cli/hooks/harness.sh +18 -11
  7. package/spec-cli/src/board.ts +47 -18
  8. package/spec-cli/src/boardCache.ts +70 -0
  9. package/spec-cli/src/boardDelta.ts +90 -0
  10. package/spec-cli/src/boardStream.ts +178 -0
  11. package/spec-cli/src/cli.ts +172 -119
  12. package/spec-cli/src/client.ts +6 -4
  13. package/spec-cli/src/gateway.ts +49 -13
  14. package/spec-cli/src/git.ts +105 -92
  15. package/spec-cli/src/guide.ts +175 -12
  16. package/spec-cli/src/harness-select.ts +63 -0
  17. package/spec-cli/src/harness.ts +506 -100
  18. package/spec-cli/src/help.ts +360 -0
  19. package/spec-cli/src/hooks.ts +0 -14
  20. package/spec-cli/src/index.ts +272 -32
  21. package/spec-cli/src/init.ts +41 -1
  22. package/spec-cli/src/issues.ts +250 -0
  23. package/spec-cli/src/layout.ts +70 -28
  24. package/spec-cli/src/lint.ts +12 -10
  25. package/spec-cli/src/listen.ts +28 -0
  26. package/spec-cli/src/localIssues.ts +683 -0
  27. package/spec-cli/src/materialize.ts +182 -27
  28. package/spec-cli/src/mentions.ts +192 -0
  29. package/spec-cli/src/plugin-harness.ts +145 -0
  30. package/spec-cli/src/pty-bridge.ts +378 -81
  31. package/spec-cli/src/self.ts +123 -20
  32. package/spec-cli/src/sessions.ts +461 -298
  33. package/spec-cli/src/specs.ts +55 -14
  34. package/spec-cli/src/supervise.ts +23 -3
  35. package/spec-cli/src/tsx-bin.ts +14 -5
  36. package/spec-cli/src/uninstall.ts +146 -0
  37. package/spec-cli/templates/hooks/post-merge +27 -0
  38. package/spec-cli/templates/hooks/pre-commit +51 -31
  39. package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
  40. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
  41. package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
  42. package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
  43. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
  44. package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
  46. package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
  47. package/spec-cli/templates/spec/project/.config/spec.md +3 -3
  48. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
  49. package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
  50. package/spec-cli/templates/spec/project/spec.md +2 -2
  51. package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
  52. package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
  53. package/spec-dashboard/dist/index.html +17 -5
  54. package/spec-forge/src/cache.ts +16 -0
  55. package/spec-forge/src/drivers/github.ts +74 -4
  56. package/spec-forge/src/port.ts +25 -0
  57. package/spec-forge/src/resident.ts +40 -6
  58. package/spec-yatsu/src/cli.ts +227 -38
  59. package/spec-yatsu/src/evaltab.ts +169 -19
  60. package/spec-yatsu/src/filing.ts +48 -0
  61. package/spec-yatsu/src/freshness.ts +55 -20
  62. package/spec-yatsu/src/proof.ts +89 -3
  63. package/spec-yatsu/src/scenariofresh.ts +92 -0
  64. package/spec-yatsu/src/sidecar.ts +75 -11
  65. package/spec-yatsu/src/timeline.ts +47 -0
  66. package/spec-yatsu/src/yatsu.ts +47 -3
  67. package/spec-cli/src/relay.ts +0 -28
  68. package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
  69. package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
  70. 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 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.
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
- 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.
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 don't clone this repo to *use* SpexCode. Install the published CLI once, then point it at any project.
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
- Adopt it in your project — `spex init` is **additive**, it never restructures your code:
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
- 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):
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 --port 8788 # the backend (API + sessions) for THIS repo
43
- spex dashboard --port 5174 --api-port 8788 # the board UI, pointed at that backend
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
- Then open <http://localhost:5174>. With no flags, `spex serve` defaults to `:8787` and `spex dashboard`
47
- to `:5173`.
101
+ Open <http://localhost:5173>.
48
102
 
49
- Day to day:
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` file-format manuals |
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
- 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.
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.1.6",
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": "npm --prefix spec-cli run serve",
28
- "web": "npm --prefix spec-dashboard run dev",
29
- "lint": "npm --prefix spec-cli run -s 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
- "prepublishOnly": "node scripts/prepublish.mjs",
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.
@@ -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 at the PUBLISHED package root's
15
- // node_modules (one level up: spec-cli is a subdir of the `spexcode` tarball) when installed. Try both.
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
- const tsx = tsxCandidates.find(existsSync) ?? tsxCandidates[0]
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 event
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
- ( flock 9
42
- if [ "$cur" != "$(cat "$rt/content-hash" 2>/dev/null || true)" ]; then # re-check: a sibling dispatch may have just rendered
43
- ( cd "$proj" && ${SPEX:-spex} materialize >/dev/null 2>&1 )
44
- fi
45
- ) 9>"$rt/.materialize.lock"
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
- # the GOVERNED record id the launcher baked into the session env (SPEXCODE_SESSION_ID) wins over the harness's
58
- # own payload session_id. This is what lets a dashboard-launched CODEX session feed its governed record: codex
59
- # mints its OWN thread id (un-pinnable), so the record can't be keyed by it instead the launcher keys the
60
- # record by a SpexCode id and exports it into the launch, and every hook resolves THAT. Claude's payload id is
61
- # already the record id, so the env (= same value) is a harmless no-op there. A self-launched agent sets no
62
- # env → falls back to the payload session_id (its own non-governed record). One resolver, both harnesses.
63
- hp_session_id() { printf '%s' "${SPEXCODE_SESSION_ID:-$(hp_field "$1" session_id)}"; }
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 carries NO
79
- # SPEXCODE_SESSION_ID, so hp_session_id falls back to the payload session_id = the codex THREAD id NOT the
80
- # SpexCode record id the dir is keyed by. So when no record sits at <id> directly, find the one record that
81
- # captured this id as `harness_session_id` (the backend stored it at thread/start, before the first tool turn).
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`.
@@ -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 { residentForgeView } from '../../spec-forge/src/resident.js'
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
- return { ...n, overlays, status: deriveStatus({ version: n.version, drift: n.drift, hasOverlay: overlays.length > 0, hasCode: (n.code?.length ?? 0) > 0, fmStatus: n.fmStatus ?? undefined }) }
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 spec-forge issues onto each node (full set issues, open subset openIssues, attached only when
85
- // non-empty), non-blocking: residentForgeView never waits on `gh` and returns [] absent a forge, so a
86
- // forge-less board is unchanged. Sorted open-first, newest number first.
87
- const isOpen = (i: any) => (i.state || '').toLowerCase() === 'open'
88
- const issuesByNode: Record<string, any[]> = {}
89
- for (const link of residentForgeView(nodes.map((n) => n.id))) {
90
- issuesByNode[link.node] = link.issues
91
- .map((i) => ({ number: i.number, state: i.state, title: i.title, url: i.url }))
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
- const open = issues.filter(isOpen)
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 timeline onto it, riding this one board poll: `evals` (readings, newest-first)
103
- // and `scenarios` (the declared set) attached only when the node declares scenarios. evalContext reuses the
104
- // specs + driftIndex above; evalTimeline short-circuits non-yatsu nodes so the poll stays fast.
105
- const ectx = evalContext(root, specs, idx, hidx)
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
+ }