spexcode 0.1.0 → 0.1.2
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 -0
- package/package.json +17 -10
- package/{bin → spec-cli/bin}/spex.mjs +6 -2
- package/spec-cli/hooks/dispatch.sh +74 -0
- package/spec-cli/hooks/harness.sh +178 -0
- package/{src → spec-cli/src}/cli.ts +14 -0
- package/{src → spec-cli/src}/harness.ts +8 -18
- package/{src → spec-cli/src}/layout.ts +6 -23
- package/{src → spec-cli/src}/materialize.ts +2 -1
- package/spec-cli/src/relay.ts +28 -0
- package/{src → spec-cli/src}/supervise.ts +2 -1
- package/spec-cli/src/tsx-bin.ts +11 -0
- package/{dashboard-dist/assets/index-B60MILFg.js → spec-dashboard/dist/assets/index-Bk4E1EQy.js} +29 -29
- package/{dashboard-dist → spec-dashboard/dist}/index.html +1 -1
- package/spec-forge/src/__fixtures__/github-forge.json +63 -0
- package/spec-forge/src/cache.ts +28 -0
- package/spec-forge/src/cli.ts +110 -0
- package/spec-forge/src/drivers/github.ts +73 -0
- package/spec-forge/src/links.ts +76 -0
- package/spec-forge/src/needs-yatsu-eval.ts +30 -0
- package/spec-forge/src/port.ts +23 -0
- package/spec-forge/src/resident.ts +23 -0
- package/spec-yatsu/src/cache.ts +68 -0
- package/spec-yatsu/src/cli.ts +296 -0
- package/spec-yatsu/src/evaltab.ts +112 -0
- package/spec-yatsu/src/evaluator.ts +24 -0
- package/spec-yatsu/src/freshness.ts +51 -0
- package/spec-yatsu/src/proof.ts +491 -0
- package/spec-yatsu/src/sidecar.ts +44 -0
- package/spec-yatsu/src/yatsu.ts +181 -0
- /package/{src → spec-cli/src}/board.ts +0 -0
- /package/{src → spec-cli/src}/client.ts +0 -0
- /package/{src → spec-cli/src}/gateway.ts +0 -0
- /package/{src → spec-cli/src}/git.ts +0 -0
- /package/{src → spec-cli/src}/guide.ts +0 -0
- /package/{src → spec-cli/src}/hooks.ts +0 -0
- /package/{src → spec-cli/src}/index.ts +0 -0
- /package/{src → spec-cli/src}/init.ts +0 -0
- /package/{src → spec-cli/src}/lint.ts +0 -0
- /package/{src → spec-cli/src}/login-page.ts +0 -0
- /package/{src → spec-cli/src}/pty-bridge.ts +0 -0
- /package/{src → spec-cli/src}/ranker.ts +0 -0
- /package/{src → spec-cli/src}/resilience.ts +0 -0
- /package/{src → spec-cli/src}/search.bench.mjs +0 -0
- /package/{src → spec-cli/src}/search.ts +0 -0
- /package/{src → spec-cli/src}/self.ts +0 -0
- /package/{src → spec-cli/src}/sessions.ts +0 -0
- /package/{src → spec-cli/src}/slash-commands.ts +0 -0
- /package/{src → spec-cli/src}/specs.ts +0 -0
- /package/{src → spec-cli/src}/uploads.ts +0 -0
- /package/{templates → spec-cli/templates}/hooks/pre-commit +0 -0
- /package/{templates → spec-cli/templates}/hooks/prepare-commit-msg +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/idle/idle.sh +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/idle/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/mark-active/mark-active.sh +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/mark-active/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/session-fail/fail.sh +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/session-fail/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-first/spec-first.sh +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-first/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-of-file/spec-of-file.sh +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-of-file/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/stop-gate/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/core/stop-gate/stop-gate.sh +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/extract/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/forge-link/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/memory-hygiene/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/regroup/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/scenario/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/supervisor/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/.config/tidy/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spec/project/spec.md +0 -0
- /package/{templates → spec-cli/templates}/spexcode.json +0 -0
- /package/{dashboard-dist → spec-dashboard/dist}/assets/index-Cq7hwngj.css +0 -0
package/README.md
ADDED
|
@@ -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/package.json
CHANGED
|
@@ -1,23 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spexcode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
|
7
|
-
"bin": { "spex": "bin/spex.mjs" },
|
|
7
|
+
"bin": { "spex": "spec-cli/bin/spex.mjs" },
|
|
8
8
|
"files": [
|
|
9
|
-
"src",
|
|
10
|
-
"!src/**/*.test.ts",
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
9
|
+
"spec-cli/src",
|
|
10
|
+
"!spec-cli/src/**/*.test.ts",
|
|
11
|
+
"spec-cli/bin",
|
|
12
|
+
"spec-cli/templates",
|
|
13
|
+
"spec-cli/hooks",
|
|
14
|
+
"spec-yatsu/src",
|
|
15
|
+
"!spec-yatsu/src/**/*.test.ts",
|
|
16
|
+
"spec-forge/src",
|
|
17
|
+
"!spec-forge/src/**/*.test.ts",
|
|
18
|
+
"spec-dashboard/dist",
|
|
14
19
|
"README.md"
|
|
15
20
|
],
|
|
16
21
|
"engines": { "node": ">=22" },
|
|
17
22
|
"scripts": {
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
23
|
+
"api": "npm --prefix spec-cli run serve",
|
|
24
|
+
"web": "npm --prefix spec-dashboard run dev",
|
|
25
|
+
"lint": "npm --prefix spec-cli run -s lint",
|
|
26
|
+
"seed": "bash scripts/seed-spec-history.sh",
|
|
27
|
+
"hooks": "bash scripts/install-hooks.sh",
|
|
21
28
|
"prepublishOnly": "node scripts/prepublish.mjs",
|
|
22
29
|
"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{}\""
|
|
23
30
|
},
|
|
@@ -2,14 +2,18 @@
|
|
|
2
2
|
// @@@ spex launcher - this repo has no build step, so the installed `spex` bin shells to tsx to
|
|
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
|
+
import { existsSync } from 'node:fs'
|
|
5
6
|
import { fileURLToPath } from 'node:url'
|
|
6
7
|
import { dirname, join } from 'node:path'
|
|
7
8
|
|
|
8
9
|
// @@@ self-contained - resolve tsx + cli from THIS package, not the cwd. A fresh worktree off main has
|
|
9
10
|
// no node_modules, so `npx tsx` there would try to install; using the package's own tsx makes a global
|
|
10
11
|
// `spex` work from any cwd (agents, git hooks) against this package's code, operating on the cwd.
|
|
11
|
-
const pkg = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
12
|
-
const tsx = join(pkg, 'node_modules', '.bin', 'tsx')
|
|
12
|
+
const pkg = join(dirname(fileURLToPath(import.meta.url)), '..') // spec-cli/
|
|
13
13
|
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.
|
|
16
|
+
const tsxCandidates = [join(pkg, 'node_modules', '.bin', 'tsx'), join(pkg, '..', 'node_modules', '.bin', 'tsx')]
|
|
17
|
+
const tsx = tsxCandidates.find(existsSync) ?? tsxCandidates[0]
|
|
14
18
|
spawn(tsx, [cli, ...process.argv.slice(2)], { stdio: 'inherit' })
|
|
15
19
|
.on('exit', (code) => process.exit(code ?? 0))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# @@@ dispatch - the SINGLE hook entry point for ALL harness lifecycle events. The shim (.claude/settings.json
|
|
3
|
+
# / .codex/hooks.json, written by each [[harness-adapter]]) binds one line per event to
|
|
4
|
+
# `dispatch.sh <harness> <Event>` — the harness id is BAKED IN by the adapter that wrote the shim, so this is
|
|
5
|
+
# the deterministic harness DETECTOR for the shell side: we export SPEXCODE_HARNESS (read by harness.sh, the
|
|
6
|
+
# adapter's shell mirror, which the hook handlers source) without ever sniffing the payload shape. Two jobs,
|
|
7
|
+
# in order:
|
|
8
|
+
# (1) GATE — a cheap pure-shell content hash of the config roots (~10ms, every event). If it moved since the
|
|
9
|
+
# last render, re-run `spex materialize` (the ~0.85s node step) to bring manifest + contract (AGENTS.md/
|
|
10
|
+
# CLAUDE.md block) + shims + Codex trust back in lockstep with the EDITABLE .config. Content-based, so it
|
|
11
|
+
# catches bash/sed/user/other-agent/git edits alike (a tool-payload path would miss them). Serialized by
|
|
12
|
+
# a lock with a re-check inside, so concurrent sessions never race the write (§ atomicity).
|
|
13
|
+
# (2) DISPATCH — run every handler bound to <Event> from the persistent manifest, in order, feeding each the
|
|
14
|
+
# ORIGINAL stdin. Reproduces the native parallel multi-hook contract DETERMINISTICALLY: all handlers run
|
|
15
|
+
# (side effects preserved), their stdout (decision/additionalContext) is concatenated through, and a
|
|
16
|
+
# block:true handler that exits 2 makes the dispatch exit 2 with that handler's stderr — the one signal
|
|
17
|
+
# the harness propagates. Pure bash, no node boot on the hot path (node runs only inside the gate, only
|
|
18
|
+
# on actual change). cwd = the project/worktree. $SPEX (abs tsx+cli) is inherited from the shim env.
|
|
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.
|
|
22
|
+
harness=claude
|
|
23
|
+
case "${1:-}" in claude|codex) harness="$1"; shift ;; esac
|
|
24
|
+
event="${1:?usage: dispatch.sh <harness> <Event>}"
|
|
25
|
+
export SPEXCODE_HARNESS="$harness"
|
|
26
|
+
# the harness.sh path (the adapter's shell mirror) — sibling of this script; hook handlers source it, and we
|
|
27
|
+
# source it here too for hp_runtime_dir (the per-project store dir).
|
|
28
|
+
export SPEXCODE_HARNESS_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/harness.sh"
|
|
29
|
+
. "$SPEXCODE_HARNESS_LIB"
|
|
30
|
+
proj="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
31
|
+
# the manifest + content-hash + gate lock live in the GLOBAL per-project store (mirrors layout.runtimeRoot),
|
|
32
|
+
# NOT the worktree — so the worktree carries zero SpexCode-rendered runtime. Empty if git can't resolve.
|
|
33
|
+
rt="$(cd "$proj" 2>/dev/null && hp_runtime_dir)" || rt=""
|
|
34
|
+
|
|
35
|
+
# --- (1) gate -------------------------------------------------------------------------------------------
|
|
36
|
+
# the config fingerprint is hp_config_hash (harness.sh, already sourced) — the SAME function materialize.ts
|
|
37
|
+
# shells to, so the gate and the renderer never disagree on "changed".
|
|
38
|
+
cur="$( (cd "$proj" 2>/dev/null && hp_config_hash) )"
|
|
39
|
+
if [ -n "$rt" ] && [ -n "$cur" ] && [ "$cur" != "$(cat "$rt/content-hash" 2>/dev/null || true)" ]; then
|
|
40
|
+
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"
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# --- (2) dispatch ---------------------------------------------------------------------------------------
|
|
49
|
+
manifest="${SPEX_HOOK_MANIFEST:-$rt/hooks-manifest}"
|
|
50
|
+
[ -f "$manifest" ] || exit 0 # no manifest yet (materialize never ran) → nothing to dispatch
|
|
51
|
+
input="$(cat 2>/dev/null || true)" # capture stdin ONCE; each handler gets its own copy
|
|
52
|
+
err="/tmp/.spex-hook-$$.err" # per-dispatch (pid-unique) stderr capture; no cross-session race
|
|
53
|
+
trap 'rm -f "$err"' EXIT
|
|
54
|
+
rc=0
|
|
55
|
+
# manifest line: event<TAB>order<TAB>block<TAB>script (pre-sorted by event,order,script)
|
|
56
|
+
while IFS=$'\t' read -r ev order block script; do
|
|
57
|
+
[ "$ev" = "$event" ] || continue
|
|
58
|
+
out="$(printf '%s' "$input" | bash "$proj/$script" 2>"$err")"; code=$?
|
|
59
|
+
[ -n "$out" ] && printf '%s' "$out"
|
|
60
|
+
if [ "$block" = "true" ] && { [ "$code" = "2" ] || printf '%s' "$out" | grep -q '"decision"[[:space:]]*:[[:space:]]*"block"'; }; then
|
|
61
|
+
cat "$err" >&2
|
|
62
|
+
# codex reads a Stop block's continuation prompt from STDERR (+ exit 2), NOT the claude-style
|
|
63
|
+
# decision:block JSON a handler writes to stdout. So when we block on the JSON path under codex and the
|
|
64
|
+
# handler left stderr empty, extract its "reason" and forward it to stderr — else codex sees exit 2 with
|
|
65
|
+
# no stderr ("Stop hook exited with code 2 but did not write a continuation prompt"). Claude is unchanged
|
|
66
|
+
# (it keeps reading the stdout JSON). The reason is the JSON's last field, so capture to the final `"}`.
|
|
67
|
+
if [ "$SPEXCODE_HARNESS" = codex ] && [ ! -s "$err" ]; then
|
|
68
|
+
printf '%s' "$out" | sed -n 's/.*"reason"[[:space:]]*:[[:space:]]*"\(.*\)"[[:space:]]*}[[:space:]]*$/\1/p' \
|
|
69
|
+
| sed 's/\\"/"/g; s/\\\\/\\/g' >&2
|
|
70
|
+
fi
|
|
71
|
+
rc=2
|
|
72
|
+
fi
|
|
73
|
+
done < "$manifest"
|
|
74
|
+
exit "$rc"
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# @@@ harness.sh - the SHELL face of the [[harness-adapter]] (spec-cli/src/harness.ts). The hook scripts run
|
|
3
|
+
# as pure shell, so they cannot import the TS adapter; this sourced library is its mirror. dispatch.sh sources
|
|
4
|
+
# it and exports SPEXCODE_HARNESS (claude|codex) — baked into the shim by each adapter, so a hook learns its
|
|
5
|
+
# harness deterministically, never by sniffing the payload shape. EVERY harness-divergent payload-parse lives
|
|
6
|
+
# HERE; the hook scripts stay harness-agnostic and just call hp_* (the one place Claude's tool names appear in
|
|
7
|
+
# shell, plus codex's Bash-command mapping). The session-id + global-store resolution is harness-agnostic and
|
|
8
|
+
# lives here too, so the six hooks no longer each repeat the git-common-dir → project-key dance.
|
|
9
|
+
|
|
10
|
+
# the string value of a top-level JSON string field (first match). Harness-agnostic — both harnesses' payloads
|
|
11
|
+
# carry session_id / tool_name as plain string fields. $1 = payload, $2 = field name. The value is scanned as a
|
|
12
|
+
# real JSON string: the closing quote is the first UNESCAPED `"`, so a value containing `\"` (a quoted literal
|
|
13
|
+
# inside a codex Bash/apply_patch command — extremely common) is captured WHOLE, not truncated at the inner
|
|
14
|
+
# quote. The standard escapes are decoded (`\"` `\\` `\/` `\n` `\t` `\r` `\b` `\f`), so a patch envelope's `\n`
|
|
15
|
+
# arrives as a real newline here (the downstream codex decode then no-ops). Pure awk, no jq on the hot path.
|
|
16
|
+
hp_field() {
|
|
17
|
+
printf '%s' "$1" | awk -v field="$2" '
|
|
18
|
+
BEGIN { s = ""; while ((getline line) > 0) s = s (s == "" ? "" : "\n") line }
|
|
19
|
+
END {
|
|
20
|
+
key = "\"" field "\""
|
|
21
|
+
n = length(s); i = 1
|
|
22
|
+
while (i <= n) {
|
|
23
|
+
p = index(substr(s, i), key)
|
|
24
|
+
if (p == 0) exit
|
|
25
|
+
i += p + length(key) - 1
|
|
26
|
+
# skip whitespace, require a colon, skip whitespace, require an opening quote
|
|
27
|
+
while (i <= n && substr(s, i, 1) ~ /[ \t\n]/) i++
|
|
28
|
+
if (substr(s, i, 1) != ":") continue
|
|
29
|
+
i++
|
|
30
|
+
while (i <= n && substr(s, i, 1) ~ /[ \t\n]/) i++
|
|
31
|
+
if (substr(s, i, 1) != "\"") continue
|
|
32
|
+
i++
|
|
33
|
+
out = ""
|
|
34
|
+
while (i <= n) {
|
|
35
|
+
c = substr(s, i, 1)
|
|
36
|
+
if (c == "\\") {
|
|
37
|
+
e = substr(s, i + 1, 1)
|
|
38
|
+
if (e == "n") out = out "\n"
|
|
39
|
+
else if (e == "t") out = out "\t"
|
|
40
|
+
else if (e == "r") out = out "\r"
|
|
41
|
+
else if (e == "b") out = out "\b"
|
|
42
|
+
else if (e == "f") out = out "\f"
|
|
43
|
+
else out = out e # \" \\ \/ and any other → the literal char
|
|
44
|
+
i += 2
|
|
45
|
+
} else if (c == "\"") {
|
|
46
|
+
print out; exit
|
|
47
|
+
} else {
|
|
48
|
+
out = out c; i++
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
exit
|
|
52
|
+
}
|
|
53
|
+
}'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# 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)}"; }
|
|
64
|
+
|
|
65
|
+
# the per-PROJECT GLOBAL runtime dir (mirrors spec-cli/src/layout.ts `runtimeRoot`): <store>/projects/<enc>,
|
|
66
|
+
# keyed by the project (dirname of the ABSOLUTE git-common-dir, so the answer is identical from main or any
|
|
67
|
+
# worktree). This is where the materialized hook manifest + content-hash + gate lock live — NOT the worktree.
|
|
68
|
+
# Echoes the dir; returns non-zero (echoing nothing) when git can't resolve, so a caller can `|| exit 0`.
|
|
69
|
+
hp_runtime_dir() {
|
|
70
|
+
local gcd
|
|
71
|
+
gcd=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || gcd=$(realpath "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null)
|
|
72
|
+
[ -n "$gcd" ] || return 1
|
|
73
|
+
printf '%s/projects/%s' "${SPEXCODE_HOME:-$HOME/.spexcode}" "$(printf '%s' "$(dirname "$gcd")" | sed 's#[/.]#-#g')"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# the per-session GLOBAL store dir for a session id — <runtime>/sessions/<id> (sibling of the per-project
|
|
77
|
+
# 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).
|
|
82
|
+
# A grep over the few session.json files — no jq on the hot path; the trailing quote anchors the value so a
|
|
83
|
+
# thread id can't match a longer one as a prefix. Direct hit wins; a miss with no alias echoes the direct path
|
|
84
|
+
# unchanged, so the caller's `[ -e "$rec" ]` still no-ops gracefully. Mirrors layout.ts `readAliasedRawRecord`.
|
|
85
|
+
hp_store_dir() {
|
|
86
|
+
local rd; rd=$(hp_runtime_dir) || return 1
|
|
87
|
+
local direct="$rd/sessions/$1"
|
|
88
|
+
if [ -e "$direct/session.json" ]; then printf '%s' "$direct"; return 0; fi
|
|
89
|
+
local hit
|
|
90
|
+
hit=$(grep -lF "\"harness_session_id\": \"$1\"" "$rd"/sessions/*/session.json 2>/dev/null | head -1)
|
|
91
|
+
[ -n "$hit" ] && { printf '%s' "${hit%/session.json}"; return 0; }
|
|
92
|
+
printf '%s' "$direct"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# the deterministic content fingerprint of the EDITABLE config roots (.config + config md/sh) — the gate's
|
|
96
|
+
# "did the .config move?" signal. Run with cwd = the project. ONE definition: the dispatch.sh gate sources this
|
|
97
|
+
# and materialize.ts shells to it, so the gate and the renderer can NEVER disagree on what "changed" means (the
|
|
98
|
+
# two used to inline this pipeline verbatim, each commenting the other "MUST match").
|
|
99
|
+
hp_config_hash() {
|
|
100
|
+
find .spec/*/.config .spec/*/config \( -name '*.md' -o -name '*.sh' \) -type f -print0 2>/dev/null \
|
|
101
|
+
| sort -z | xargs -0 cat 2>/dev/null | sha256sum | cut -d' ' -f1
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# the tool a payload is about to run / just ran (harness-agnostic field name).
|
|
105
|
+
hp_tool() { hp_field "$1" tool_name; }
|
|
106
|
+
|
|
107
|
+
# is THIS payload the agent pausing to ask the HUMAN a question? Claude: the AskUserQuestion tool. Codex: the
|
|
108
|
+
# experimental request_user_input tool (its only structured ask path). Echoes "1" when yes, else nothing.
|
|
109
|
+
hp_is_ask() {
|
|
110
|
+
case "$SPEXCODE_HARNESS" in
|
|
111
|
+
codex) [ "$(hp_tool "$1")" = request_user_input ] && printf 1 ;;
|
|
112
|
+
*) [ "$(hp_tool "$1")" = AskUserQuestion ] && printf 1 ;;
|
|
113
|
+
esac
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# the question text of an ask payload (best-effort; for the board note). Both harnesses carry it under a
|
|
117
|
+
# "question" field of the tool input — so it is just the first such JSON string value: collapse onto hp_field,
|
|
118
|
+
# which (unlike the old grep `[^"]*`) handles an embedded `\"` and decodes escapes instead of truncating.
|
|
119
|
+
hp_ask_note() { hp_field "$1" question; }
|
|
120
|
+
|
|
121
|
+
# the CODE file(s) a payload touches, mapped to the trigger the spec hooks key on. $2 = mode:
|
|
122
|
+
# access → the file being READ or edited ([[spec-first]] fires on any code touch)
|
|
123
|
+
# mutate → the file being EDITED ([[spec-of-file]] fires only on a mutation)
|
|
124
|
+
# Echoes the path(s), ONE PER LINE — a codex multi-file apply_patch (several `*** Update File:` markers)
|
|
125
|
+
# touches several files in one tool call, so every consuming hook iterates the lines. Echoes nothing when the
|
|
126
|
+
# payload is not a code touch of that mode. The harness divergence:
|
|
127
|
+
# Claude — Read/Edit/Write/NotebookEdit + tool_input.file_path|notebook_path.
|
|
128
|
+
# Codex — NO file_path. An EDIT is its own first-class tool `tool_name:"apply_patch"` whose tool_input.command
|
|
129
|
+
# is the bare patch envelope (`*** Update File: <path>`); a READ/shell is `tool_name:"Bash"` +
|
|
130
|
+
# tool_input.command. Both carry the touched file inside `command`, so we parse that one field.
|
|
131
|
+
hp_code_path() {
|
|
132
|
+
local payload="$1" mode="$2" tool
|
|
133
|
+
tool=$(hp_tool "$payload")
|
|
134
|
+
case "$SPEXCODE_HARNESS" in
|
|
135
|
+
codex)
|
|
136
|
+
case "$tool" in apply_patch|Bash) ;; *) return 0 ;; esac
|
|
137
|
+
_hp_codex_cmd_path "$(hp_field "$payload" command)" "$mode"
|
|
138
|
+
;;
|
|
139
|
+
*)
|
|
140
|
+
case "$mode" in
|
|
141
|
+
mutate) case "$tool" in Edit|Write|NotebookEdit) ;; *) return 0 ;; esac ;;
|
|
142
|
+
*) case "$tool" in Read|Edit|Write|NotebookEdit) ;; *) return 0 ;; esac ;;
|
|
143
|
+
esac
|
|
144
|
+
local p; p=$(hp_field "$payload" file_path); [ -n "$p" ] || p=$(hp_field "$payload" notebook_path)
|
|
145
|
+
printf '%s' "$p"
|
|
146
|
+
;;
|
|
147
|
+
esac
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# @@@ codex command → path - the apply_patch / sed / cat path-extractor. Codex never sends a file_path; the
|
|
151
|
+
# touched file lives inside `command`. Two shapes: a PATCH envelope carries `*** (Add|Update|Delete) File:
|
|
152
|
+
# <path>` lines (always a MUTATION) — this is what the apply_patch tool sends, the bare envelope with NO literal
|
|
153
|
+
# `apply_patch` token, so we detect it by the File: markers themselves (the legacy `apply_patch` token is kept
|
|
154
|
+
# too, for a shell-wrapped invocation); a plain command (sed/cat/head/rg/…) carries the path as a token.
|
|
155
|
+
# A MUTATION is a patch envelope or a write shape (a redirect, tee, `sed -i`, dd of=); in `mutate` mode a pure
|
|
156
|
+
# read yields nothing. A patch envelope can carry SEVERAL `*** Update File:` markers (a multi-file edit) — ALL
|
|
157
|
+
# of them are emitted, one per line, so the consuming hook acts on every touched file (a single-file patch
|
|
158
|
+
# emits one line; a plain command emits its one token). The plain-command path is the last path-like token
|
|
159
|
+
# (has a / or a .ext), ignoring flags — matches the verified-facts example `sed -n 1p f.ts` → `f.ts`.
|
|
160
|
+
# Best-effort: an exotic command may not resolve, which only means a missed nudge, never a wrong block.
|
|
161
|
+
_hp_codex_cmd_path() {
|
|
162
|
+
local mode="$2" cmd
|
|
163
|
+
# the command arrives as a JSON STRING value; hp_field already decodes JSON escapes (so a patch File: line
|
|
164
|
+
# already ends at a real newline). This gsub is a no-op safety net for any caller that bypassed hp_field.
|
|
165
|
+
cmd=$(printf '%s' "$1" | awk '{gsub(/\\n/,"\n"); gsub(/\\t/,"\t")}1')
|
|
166
|
+
case "$cmd" in
|
|
167
|
+
*apply_patch*|*applypatch*|*'*** Add File:'*|*'*** Update File:'*|*'*** Delete File:'*)
|
|
168
|
+
printf '%s\n' "$cmd" | sed -n 's/^\*\*\* \(Add\|Update\|Delete\) File: \(.*\)$/\2/p' | sed 's/[[:space:]]*$//'
|
|
169
|
+
return 0 ;;
|
|
170
|
+
esac
|
|
171
|
+
local is_mutate=0
|
|
172
|
+
case "$cmd" in *' >> '*|*' > '*|*' >>'*|*' >'*|*' tee '*|*'sed -i'*|*' dd '*) is_mutate=1 ;; esac
|
|
173
|
+
[ "$mode" = mutate ] && [ "$is_mutate" = 0 ] && return 0
|
|
174
|
+
printf '%s\n' "$cmd" | tr ' \t' '\n\n' | grep -E '^[^-].*[/.][A-Za-z0-9_]+' | grep -vE '^(apply_patch|applypatch)$' | tail -1
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# the notification type of a Notification payload (Claude only — Codex fires no Notification event).
|
|
178
|
+
hp_notification_type() { hp_field "$1" notification_type; }
|
|
@@ -293,6 +293,20 @@ if (cmd === 'serve') {
|
|
|
293
293
|
if (r.snippet) console.log(` ${r.snippet}`)
|
|
294
294
|
})
|
|
295
295
|
process.exit(0)
|
|
296
|
+
} else if (cmd === 'relay') {
|
|
297
|
+
const { relaySearch } = await import('./relay.js')
|
|
298
|
+
const query = positionals(3).join(' ')
|
|
299
|
+
if (!query.trim()) { console.error('usage: spex relay <query> [--json] [--limit N]'); process.exit(2) }
|
|
300
|
+
const limit = Number(flag('limit')) || 3
|
|
301
|
+
const hits = await relaySearch(query, { limit })
|
|
302
|
+
if (has('json')) { console.log(JSON.stringify(hits)); process.exit(0) }
|
|
303
|
+
if (!hits.length) { console.log(`no spec node matches "${query}"`); process.exit(0) }
|
|
304
|
+
hits.forEach((h, i) => {
|
|
305
|
+
console.log(`${String(i + 1).padStart(2)}. ${h.title} [${h.id}] · score ${h.score}`)
|
|
306
|
+
if (h.code.length) h.code.forEach((c) => console.log(` ${c}`))
|
|
307
|
+
else console.log(` (no governed code: files — a pure-prose node)`)
|
|
308
|
+
})
|
|
309
|
+
process.exit(0)
|
|
296
310
|
} else if (cmd === 'ls' || cmd === 'sessions') {
|
|
297
311
|
// pretty list of living sessions + states. `spex ls [SEL...] [--status a,b] [--json]`
|
|
298
312
|
// the board comes from the backend (so `spex ls` shows the sessions of whatever SPEXCODE_API_URL points at,
|
|
@@ -8,6 +8,7 @@ import { promisify } from 'node:util'
|
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
9
|
import { claudeSlashCommands, codexSlashCommands, type SlashCommand } from './slash-commands.js'
|
|
10
10
|
import { runtimeRoot, mainCheckout } from './layout.js'
|
|
11
|
+
import { tsxBin } from './tsx-bin.js'
|
|
11
12
|
|
|
12
13
|
// @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
|
|
13
14
|
// Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
|
|
@@ -124,7 +125,7 @@ function shQuote(s: string): string {
|
|
|
124
125
|
// launch shell can call back into `spex codex-launch` to own the thread + fire the first turn before it
|
|
125
126
|
// exec's the visible TUI.
|
|
126
127
|
const PKG = fileURLToPath(new URL('..', import.meta.url))
|
|
127
|
-
const SPEX = `${
|
|
128
|
+
const SPEX = `${tsxBin(PKG)} ${join(PKG, 'src', 'cli.ts')}`
|
|
128
129
|
|
|
129
130
|
const ACCEPT_TIMEOUT_MS = 2500
|
|
130
131
|
// @@@ replyViaSocket - inject `text` as a prompt AND confirm the daemon ACCEPTED it (not mere write-success,
|
|
@@ -258,19 +259,11 @@ export function codexLaunchCommand(_id: string, codexCmd = process.env.SPEXCODE_
|
|
|
258
259
|
' for i in $(seq 1 100); do [ -S "$sock" ] && break; sleep 0.05; done',
|
|
259
260
|
' fi',
|
|
260
261
|
') 9>"$lock"',
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
// Either way it ends with a thread id, which the visible TUI then RESUMES (the rollout persists on disk),
|
|
267
|
-
// rendering it natively. A new launch's tail is always ONE single-quoted prompt arg, so it can never be the
|
|
268
|
-
// literal "--resume" marker — the discriminator is unambiguous.
|
|
269
|
-
`if [ "$1" = "--resume" ]; then`,
|
|
270
|
-
` tid=$2`,
|
|
271
|
-
`else`,
|
|
272
|
-
` tid=$(${SPEX} codex-launch "$sock" "$PWD" "$@")`,
|
|
273
|
-
`fi`,
|
|
262
|
+
// BACKEND owns the thread: `codex-launch` does thread/start { cwd = this worktree } on the shared
|
|
263
|
+
// per-project app-server, stores the new thread id on the governed record (SPEXCODE_SESSION_ID), and
|
|
264
|
+
// fires the launch prompt as the first turn — materializing the rollout. It prints the thread id, which
|
|
265
|
+
// the visible TUI then RESUMES (the rollout persists on disk), rendering it natively. "$@" is the prompt.
|
|
266
|
+
`tid=$(${SPEX} codex-launch "$sock" "$PWD" "$@")`,
|
|
274
267
|
`exec ${codexCmd} --remote unix://"$sock" resume "$tid"`,
|
|
275
268
|
].join('\n')
|
|
276
269
|
return `bash -lc ${shQuote(script)} spexcode-codex`
|
|
@@ -653,10 +646,7 @@ export const codexHarness: Harness = {
|
|
|
653
646
|
slashCommands: codexSlashCommands,
|
|
654
647
|
liveness: (rec, tmuxAlive, runtimeDir) => (tmuxAlive && existsSync(codexAppServerSock(runtimeDir)) ? 'online' : 'offline'),
|
|
655
648
|
deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
|
|
656
|
-
// owned thread id →
|
|
657
|
-
// a tail handed to a bare `codex` — the script's final `codex … resume "$tid"` performs codex's own resume on
|
|
658
|
-
// the owned id, the SAME conversation); none → empty tail → relaunch a FRESH thread on the same worktree/record.
|
|
659
|
-
resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
|
|
649
|
+
resumeArg: (rec) => (rec.harnessSessionId ? `resume ${rec.harnessSessionId}` : ''), // owned thread id → codex `resume <id>` (SAME conversation); none → relaunch FRESH
|
|
660
650
|
}
|
|
661
651
|
|
|
662
652
|
// every adapter — materialize iterates this to render each harness's artifacts in one pass.
|
|
@@ -125,30 +125,13 @@ export type RawRecord = {
|
|
|
125
125
|
sortkey: number | null; createdAt: number; harness?: string; harness_session_id?: string
|
|
126
126
|
}
|
|
127
127
|
// the agent's OWN session id from the environment — the only locator now that the record left the worktree.
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
// wrong session. But codex injects the ACTING thread's id into every spawned command's env as
|
|
135
|
-
// CODEX_THREAD_ID (== codex's `sessionEnvVar`), so the per-thread var aliases to the RIGHT record while
|
|
136
|
-
// the shared `SPEXCODE_SESSION_ID` does not.
|
|
137
|
-
// (2) else `SPEXCODE_SESSION_ID` (the GOVERNED record id the launcher bakes in) — the claude path and the
|
|
138
|
-
// non-shared baseline, mirroring the shell hooks' `hp_session_id`.
|
|
139
|
-
// (3) else a harness's env var RAW — a self-launched, non-governed agent's own minted id, which has no
|
|
140
|
-
// governed record to alias to (codex CODEX_THREAD_ID / claude CLAUDE_CODE_SESSION_ID). The RAW form must
|
|
141
|
-
// stay BELOW (2): an un-aliased codex thread id is not a record key, so it must never beat a real
|
|
142
|
-
// `SPEXCODE_SESSION_ID`.
|
|
143
|
-
// Claude is UNCHANGED: its `sessionEnvVar` (CLAUDE_CODE_SESSION_ID) already EQUALS its record id, so tier (1)
|
|
144
|
-
// resolves to that very id — the same value `SPEXCODE_SESSION_ID` would have returned; there is no shared
|
|
145
|
-
// app-server to contaminate it. No worktree fallback. (sessions.ts's `ownSessionId` delegates here; spec-yatsu
|
|
146
|
-
// reads it to resolve the current node.)
|
|
128
|
+
// SPEXCODE_SESSION_ID (the GOVERNED record id the launcher bakes in) wins, EXACTLY mirroring the shell hooks'
|
|
129
|
+
// `hp_session_id` — this matters for codex, whose own CODEX_THREAD_ID is its un-pinnable minted id and does NOT
|
|
130
|
+
// equal the governed record id, so resolving the harness var first would point a governed codex agent's own
|
|
131
|
+
// `spex session …` at the wrong/nonexistent record. Else each adapter's env var (Claude CLAUDE_CODE_SESSION_ID
|
|
132
|
+
// — already == the record id; Codex CODEX_THREAD_ID for a self-launched, non-governed agent). No worktree
|
|
133
|
+
// fallback. (sessions.ts's `ownSessionId` delegates here; spec-yatsu reads it to resolve the current node.)
|
|
147
134
|
export function envSessionId(): string | null {
|
|
148
|
-
for (const h of HARNESSES) {
|
|
149
|
-
const v = process.env[h.sessionEnvVar]
|
|
150
|
-
if (v && v.trim()) { const r = readAliasedRawRecord(v.trim()); if (r) return r.session_id }
|
|
151
|
-
}
|
|
152
135
|
const o = process.env.SPEXCODE_SESSION_ID
|
|
153
136
|
if (o && o.trim()) return o.trim()
|
|
154
137
|
for (const h of HARNESSES) { const v = process.env[h.sessionEnvVar]; if (v && v.trim()) return v.trim() }
|
|
@@ -6,6 +6,7 @@ import { loadSystemConfig, loadSkillConfig } from './specs.js'
|
|
|
6
6
|
import { compileManifest } from './hooks.js'
|
|
7
7
|
import { HARNESSES, writeManagedBlock } from './harness.js'
|
|
8
8
|
import { runtimeRoot } from './layout.js'
|
|
9
|
+
import { tsxBin } from './tsx-bin.js'
|
|
9
10
|
|
|
10
11
|
// @@@ materialize - the "pay-per-change" node step (≈0.85s) the cheap shell gate invokes ONLY when the
|
|
11
12
|
// .config content-hash moved. It renders the spec tree's surface nodes into the flat artifacts each
|
|
@@ -19,7 +20,7 @@ import { runtimeRoot } from './layout.js'
|
|
|
19
20
|
|
|
20
21
|
const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
|
|
21
22
|
const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
|
|
22
|
-
const SPEX = `${
|
|
23
|
+
const SPEX = `${tsxBin(PKG)} ${join(PKG, 'src', 'cli.ts')}`
|
|
23
24
|
// the manifest + content-hash marker render into the GLOBAL per-project store (layout.runtimeRoot), NOT the
|
|
24
25
|
// worktree — the worktree keeps zero SpexCode-rendered runtime; only the harness-discovered contract files +
|
|
25
26
|
// shims (which the harness must find in-tree) are written under proj below.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { searchSpecs } from './search.js'
|
|
2
|
+
import { loadSpecs } from './specs.js'
|
|
3
|
+
|
|
4
|
+
export type RelayHit = { id: string; title: string; path: string; score: number; code: string[] }
|
|
5
|
+
|
|
6
|
+
export async function relaySearch(query: string, opts: { limit?: number } = {}): Promise<RelayHit[]> {
|
|
7
|
+
const limit = opts.limit ?? 3
|
|
8
|
+
const hits = await searchSpecs(query, { limit })
|
|
9
|
+
if (!hits.length) return []
|
|
10
|
+
// one spec-index read; per node: its frontmatter `code:` governed paths (files/dirs/globs) + the tree shape.
|
|
11
|
+
const specs = await loadSpecs()
|
|
12
|
+
const codeById = new Map(specs.map((s) => [s.id, (s.code as string[]) ?? []]))
|
|
13
|
+
const childrenOf = new Map<string, string[]>()
|
|
14
|
+
for (const s of specs) if (s.parent) childrenOf.set(s.parent, [...(childrenOf.get(s.parent) ?? []), s.id])
|
|
15
|
+
const subtreeCode = (id: string): string[] => {
|
|
16
|
+
const own = codeById.get(id) ?? []
|
|
17
|
+
if (own.length) return own
|
|
18
|
+
const acc: string[] = []
|
|
19
|
+
const stack = [...(childrenOf.get(id) ?? [])]
|
|
20
|
+
while (stack.length) {
|
|
21
|
+
const c = stack.pop()!
|
|
22
|
+
acc.push(...(codeById.get(c) ?? []))
|
|
23
|
+
stack.push(...(childrenOf.get(c) ?? []))
|
|
24
|
+
}
|
|
25
|
+
return [...new Set(acc)]
|
|
26
|
+
}
|
|
27
|
+
return hits.map((h) => ({ id: h.id, title: h.title, path: h.path, score: h.score, code: subtreeCode(h.id) }))
|
|
28
|
+
}
|
|
@@ -8,13 +8,14 @@ import { fileURLToPath } from 'node:url'
|
|
|
8
8
|
import { dirname, join } from 'node:path'
|
|
9
9
|
import { installProcessGuards } from './resilience.js'
|
|
10
10
|
import { resolvePublicConfig, startGateway, ensureDashboardBuilt, resolveDistDir } from './gateway.js'
|
|
11
|
+
import { tsxBin } from './tsx-bin.js'
|
|
11
12
|
|
|
12
13
|
// the supervisor OWNS the public port, so it must outlive any transient throw: an uncaught error here is
|
|
13
14
|
// logged and survived, never an exit that closes the port (and the tmux session) and takes the frontend down.
|
|
14
15
|
installProcessGuards()
|
|
15
16
|
|
|
16
17
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
17
|
-
const tsx = join(here, '..'
|
|
18
|
+
const tsx = tsxBin(join(here, '..')) // this package's own tsx — spec-cli/node_modules (dev) or pkg root (published)
|
|
18
19
|
const entry = join(here, 'index.ts') // the real Hono server
|
|
19
20
|
const publicPort = Number(process.env.PORT || 8787)
|
|
20
21
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
// @@@ tsxBin - where the tsx executable lives, dev-or-published. In the dev monorepo it sits in
|
|
5
|
+
// spec-cli/node_modules; in the PUBLISHED `spexcode` tarball spec-cli is a SUBDIR of the package, so tsx
|
|
6
|
+
// installs one level up at the package ROOT's node_modules. Resolve against both so the supervisor's child
|
|
7
|
+
// spawn and the baked launch/hook SPEX commands work in either layout. `pkgDir` is the spec-cli directory.
|
|
8
|
+
export function tsxBin(pkgDir: string): string {
|
|
9
|
+
const candidates = [join(pkgDir, 'node_modules', '.bin', 'tsx'), join(pkgDir, '..', 'node_modules', '.bin', 'tsx')]
|
|
10
|
+
return candidates.find(existsSync) ?? candidates[0]
|
|
11
|
+
}
|