spexcode 0.1.1 → 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.
Files changed (75) hide show
  1. package/package.json +17 -10
  2. package/{bin → spec-cli/bin}/spex.mjs +6 -2
  3. package/spec-cli/hooks/dispatch.sh +74 -0
  4. package/spec-cli/hooks/harness.sh +178 -0
  5. package/{src → spec-cli/src}/cli.ts +14 -0
  6. package/{src → spec-cli/src}/harness.ts +8 -18
  7. package/{src → spec-cli/src}/layout.ts +6 -23
  8. package/{src → spec-cli/src}/materialize.ts +2 -1
  9. package/spec-cli/src/relay.ts +28 -0
  10. package/{src → spec-cli/src}/supervise.ts +2 -1
  11. package/spec-cli/src/tsx-bin.ts +11 -0
  12. package/{dashboard-dist/assets/index-B60MILFg.js → spec-dashboard/dist/assets/index-Bk4E1EQy.js} +29 -29
  13. package/{dashboard-dist → spec-dashboard/dist}/index.html +1 -1
  14. package/spec-forge/src/__fixtures__/github-forge.json +63 -0
  15. package/spec-forge/src/cache.ts +28 -0
  16. package/spec-forge/src/cli.ts +110 -0
  17. package/spec-forge/src/drivers/github.ts +73 -0
  18. package/spec-forge/src/links.ts +76 -0
  19. package/spec-forge/src/needs-yatsu-eval.ts +30 -0
  20. package/spec-forge/src/port.ts +23 -0
  21. package/spec-forge/src/resident.ts +23 -0
  22. package/spec-yatsu/src/cache.ts +68 -0
  23. package/spec-yatsu/src/cli.ts +296 -0
  24. package/spec-yatsu/src/evaltab.ts +112 -0
  25. package/spec-yatsu/src/evaluator.ts +24 -0
  26. package/spec-yatsu/src/freshness.ts +51 -0
  27. package/spec-yatsu/src/proof.ts +491 -0
  28. package/spec-yatsu/src/sidecar.ts +44 -0
  29. package/spec-yatsu/src/yatsu.ts +181 -0
  30. /package/{src → spec-cli/src}/board.ts +0 -0
  31. /package/{src → spec-cli/src}/client.ts +0 -0
  32. /package/{src → spec-cli/src}/gateway.ts +0 -0
  33. /package/{src → spec-cli/src}/git.ts +0 -0
  34. /package/{src → spec-cli/src}/guide.ts +0 -0
  35. /package/{src → spec-cli/src}/hooks.ts +0 -0
  36. /package/{src → spec-cli/src}/index.ts +0 -0
  37. /package/{src → spec-cli/src}/init.ts +0 -0
  38. /package/{src → spec-cli/src}/lint.ts +0 -0
  39. /package/{src → spec-cli/src}/login-page.ts +0 -0
  40. /package/{src → spec-cli/src}/pty-bridge.ts +0 -0
  41. /package/{src → spec-cli/src}/ranker.ts +0 -0
  42. /package/{src → spec-cli/src}/resilience.ts +0 -0
  43. /package/{src → spec-cli/src}/search.bench.mjs +0 -0
  44. /package/{src → spec-cli/src}/search.ts +0 -0
  45. /package/{src → spec-cli/src}/self.ts +0 -0
  46. /package/{src → spec-cli/src}/sessions.ts +0 -0
  47. /package/{src → spec-cli/src}/slash-commands.ts +0 -0
  48. /package/{src → spec-cli/src}/specs.ts +0 -0
  49. /package/{src → spec-cli/src}/uploads.ts +0 -0
  50. /package/{templates → spec-cli/templates}/hooks/pre-commit +0 -0
  51. /package/{templates → spec-cli/templates}/hooks/prepare-commit-msg +0 -0
  52. /package/{templates → spec-cli/templates}/spec/project/.config/core/idle/idle.sh +0 -0
  53. /package/{templates → spec-cli/templates}/spec/project/.config/core/idle/spec.md +0 -0
  54. /package/{templates → spec-cli/templates}/spec/project/.config/core/mark-active/mark-active.sh +0 -0
  55. /package/{templates → spec-cli/templates}/spec/project/.config/core/mark-active/spec.md +0 -0
  56. /package/{templates → spec-cli/templates}/spec/project/.config/core/session-fail/fail.sh +0 -0
  57. /package/{templates → spec-cli/templates}/spec/project/.config/core/session-fail/spec.md +0 -0
  58. /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-first/spec-first.sh +0 -0
  59. /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-first/spec.md +0 -0
  60. /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-of-file/spec-of-file.sh +0 -0
  61. /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-of-file/spec.md +0 -0
  62. /package/{templates → spec-cli/templates}/spec/project/.config/core/spec.md +0 -0
  63. /package/{templates → spec-cli/templates}/spec/project/.config/core/stop-gate/spec.md +0 -0
  64. /package/{templates → spec-cli/templates}/spec/project/.config/core/stop-gate/stop-gate.sh +0 -0
  65. /package/{templates → spec-cli/templates}/spec/project/.config/extract/spec.md +0 -0
  66. /package/{templates → spec-cli/templates}/spec/project/.config/forge-link/spec.md +0 -0
  67. /package/{templates → spec-cli/templates}/spec/project/.config/memory-hygiene/spec.md +0 -0
  68. /package/{templates → spec-cli/templates}/spec/project/.config/regroup/spec.md +0 -0
  69. /package/{templates → spec-cli/templates}/spec/project/.config/scenario/spec.md +0 -0
  70. /package/{templates → spec-cli/templates}/spec/project/.config/spec.md +0 -0
  71. /package/{templates → spec-cli/templates}/spec/project/.config/supervisor/spec.md +0 -0
  72. /package/{templates → spec-cli/templates}/spec/project/.config/tidy/spec.md +0 -0
  73. /package/{templates → spec-cli/templates}/spec/project/spec.md +0 -0
  74. /package/{templates → spec-cli/templates}/spexcode.json +0 -0
  75. /package/{dashboard-dist → spec-dashboard/dist}/assets/index-Cq7hwngj.css +0 -0
package/package.json CHANGED
@@ -1,23 +1,30 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.1.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
- "templates",
12
- "bin",
13
- "dashboard-dist",
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
- "serve": "tsx src/cli.ts serve",
19
- "lint": "tsx src/cli.ts lint",
20
- "test": "tsx --test src/*.test.ts",
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 = `${join(PKG, 'node_modules', '.bin', 'tsx')} ${join(PKG, 'src', 'cli.ts')}`
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
- // TWO launch modes, on ONE tail channel ("$@"). reopen() hands a `--resume <thread-id>` tail (see
262
- // codexHarness.resumeArg) to bring the SAME conversation back: resume that OWNED thread DIRECTLY — no new
263
- // thread, no first-turn prompt. ANY other tail is a NEW launch: BACKEND owns the thread `codex-launch`
264
- // does thread/start { cwd = this worktree } on the shared per-project app-server, stores the new id on the
265
- // governed record (SPEXCODE_SESSION_ID), and fires the tail as the FIRST turn, materializing the rollout.
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 → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
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
- // Three tiers, in order:
129
- // (1) a harness's per-thread env var (`sessionEnvVar`) RESOLVED VIA THE ALIAS when it lands on a governed
130
- // record (directly, or through that record's `harness_session_id`), that record's SpexCode id is the
131
- // answer. This MUST win: codex's design-C runs ONE shared per-project app-server whose env carries the
132
- // FIRST launched session's `SPEXCODE_SESSION_ID`, and the agent's shell tool (its `spex session
133
- // done/park/ask`) runs INSIDE that app-server process, so `SPEXCODE_SESSION_ID` is contaminated with the
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 = `${join(PKG, 'node_modules', '.bin', 'tsx')} ${join(PKG, 'src', 'cli.ts')}`
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, '..', 'node_modules', '.bin', 'tsx') // this package's own tsx (no build step)
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
+ }