spexcode 0.2.4 → 0.2.6

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 (69) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +13 -10
  4. package/spec-cli/hooks/dispatch.sh +28 -40
  5. package/spec-cli/hooks/harness.sh +43 -7
  6. package/spec-cli/src/board.ts +2 -1
  7. package/spec-cli/src/boardCache.ts +27 -1
  8. package/spec-cli/src/boardStream.ts +5 -4
  9. package/spec-cli/src/cli.ts +93 -20
  10. package/spec-cli/src/commit-surgery.ts +81 -0
  11. package/spec-cli/src/contract-filter.ts +156 -0
  12. package/spec-cli/src/doctor.ts +11 -5
  13. package/spec-cli/src/git.ts +18 -4
  14. package/spec-cli/src/guide.ts +94 -38
  15. package/spec-cli/src/harness-select.ts +2 -2
  16. package/spec-cli/src/harness.ts +22 -8
  17. package/spec-cli/src/help.ts +28 -16
  18. package/spec-cli/src/index.ts +9 -6
  19. package/spec-cli/src/init.ts +17 -10
  20. package/spec-cli/src/issues.ts +7 -5
  21. package/spec-cli/src/layout.ts +31 -13
  22. package/spec-cli/src/lint.ts +19 -0
  23. package/spec-cli/src/localIssues.ts +16 -4
  24. package/spec-cli/src/materialize.ts +214 -144
  25. package/spec-cli/src/mentions.ts +5 -3
  26. package/spec-cli/src/plugin-harness.ts +10 -9
  27. package/spec-cli/src/ranker.ts +25 -8
  28. package/spec-cli/src/runtime-guard.ts +44 -0
  29. package/spec-cli/src/search.bench.mjs +15 -5
  30. package/spec-cli/src/sessions.ts +96 -22
  31. package/spec-cli/src/specs.ts +38 -18
  32. package/spec-cli/src/supervise.ts +2 -2
  33. package/spec-cli/src/tsx-bin.ts +6 -8
  34. package/spec-cli/src/uninstall.ts +18 -19
  35. package/spec-cli/src/worktree-sources.ts +52 -13
  36. package/spec-cli/templates/hooks/post-checkout +22 -0
  37. package/spec-cli/templates/hooks/post-merge +15 -9
  38. package/spec-cli/templates/hooks/pre-commit +10 -0
  39. package/spec-cli/templates/spec/project/.config/core/stop-gate/spec.md +1 -1
  40. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +26 -8
  41. package/spec-cli/templates/spec/project/.config/distill/digest.mjs +136 -0
  42. package/spec-cli/templates/spec/project/.config/distill/spec.md +74 -0
  43. package/spec-dashboard/dist/assets/Dashboard-BlRRsxE7.js +27 -0
  44. package/spec-dashboard/dist/assets/EvalsPage-BzVE38-Z.js +2 -0
  45. package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-DFuLVOeu.js} +1 -1
  46. package/spec-dashboard/dist/assets/IssuesPage-CzDaazhe.js +1 -0
  47. package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-CXQrQCNp.js} +1 -1
  48. package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-D1pUBl6q.js} +8 -8
  49. package/spec-dashboard/dist/assets/SessionWindow-Y25Bwg1e.js +9 -0
  50. package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-R610Vbzd.js} +1 -1
  51. package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BO0Zuweu.js} +2 -2
  52. package/spec-dashboard/dist/assets/index-uGs9v_9o.css +1 -0
  53. package/spec-dashboard/dist/index.html +2 -2
  54. package/spec-forge/src/cli.ts +2 -2
  55. package/spec-forge/src/drivers/gitlab.ts +168 -0
  56. package/spec-forge/src/drivers.ts +80 -2
  57. package/spec-forge/src/resident.ts +10 -5
  58. package/spec-yatsu/src/cli.ts +37 -16
  59. package/spec-yatsu/src/evaltab.ts +6 -3
  60. package/spec-yatsu/src/filing.ts +13 -8
  61. package/spec-yatsu/src/freshness.ts +97 -22
  62. package/spec-yatsu/src/proof.ts +14 -3
  63. package/spec-yatsu/src/scenariofresh.ts +38 -11
  64. package/spec-yatsu/src/yatsu.ts +52 -28
  65. package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +0 -27
  66. package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +0 -3
  67. package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +0 -1
  68. package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +0 -9
  69. package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +0 -1
package/README.md CHANGED
@@ -74,7 +74,7 @@ Requires Node ≥ 22 and git. This part is plain tooling — no AI involved yet.
74
74
  ```sh
75
75
  npm i -g spexcode # installs the `spex` command
76
76
  cd your-repo
77
- spex init # seeds .spec/, installs git hooks, renders the agent contract
77
+ spex init # seeds .spec/, installs git hooks, materializes the agent contract
78
78
  spex serve # API backend on :8787
79
79
  spex dashboard # board UI on :5173, proxying to the backend
80
80
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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",
@@ -35,20 +35,23 @@ if (conflicted.length) {
35
35
  console.error('spex executes this TypeScript directly (no build step); resolve the merge, then retry. (exit 75)')
36
36
  process.exit(75)
37
37
  }
38
- // tsx lives in spec-cli/node_modules in the dev monorepo, but npm may hoist it above the installed
39
- // `spexcode` package in a real consumer project. Try local candidates first, then let Node resolve upward
40
- // from spec-cli so project-local and global installs work the same way.
41
- const tsxCandidates = [join(pkg, 'node_modules', '.bin', 'tsx'), join(pkg, '..', 'node_modules', '.bin', 'tsx')]
42
- function resolveTsx() {
43
- const local = tsxCandidates.find(existsSync)
44
- if (local) return local
38
+ // @@@ cross-platform tsx resolution ([[platform-support]]) - resolve tsx's JS ENTRY (dist/cli.mjs) with
39
+ // Node's own resolver from spec-cli, then run it through THIS node binary (process.execPath). tsx may live
40
+ // in spec-cli/node_modules (dev) or be hoisted above the installed `spexcode` package (a real consumer
41
+ // project) one resolver covers both without hardcoded consumer paths. We deliberately never spawn the
42
+ // `.bin/tsx` shim, nor a `.mjs` directly: on Windows the shim is an extensionless sh script and the `.mjs`
43
+ // leans on a shebang, neither of which `child_process.spawn` can execute — that is the #37 crash
44
+ // (`spawn …\node_modules\.bin\tsx ENOENT`) of `spex init`. `node dist/cli.mjs …` is shell-free and identical
45
+ // on every OS.
46
+ function resolveTsxCli() {
45
47
  try {
46
48
  const req = createRequire(join(pkg, 'package.json'))
47
49
  return join(dirname(req.resolve('tsx/package.json')), 'dist', 'cli.mjs')
48
50
  } catch {
49
- return tsxCandidates[0]
51
+ console.error('spex: cannot find the `tsx` runtime this package needs — run `npm install` in the SpexCode package, then retry.')
52
+ process.exit(69)
50
53
  }
51
54
  }
52
- const tsx = resolveTsx()
53
- spawn(tsx, [cli, ...process.argv.slice(2)], { stdio: 'inherit' })
55
+ const tsxCli = resolveTsxCli()
56
+ spawn(process.execPath, [tsxCli, cli, ...process.argv.slice(2)], { stdio: 'inherit' })
54
57
  .on('exit', (code) => process.exit(code ?? 0))
@@ -3,22 +3,21 @@
3
3
  # / .codex/hooks.json, written by each [[harness-adapter]]) binds one line per event to
4
4
  # `dispatch.sh <harness> <Event>` — the harness id is BAKED IN by the adapter that wrote the shim, so this is
5
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.
6
+ # adapter's shell mirror, which the hook handlers source) without ever sniffing the payload shape. ONE job:
7
+ # DISPATCH — run every handler bound to <Event> from the persistent manifest, in order, feeding each the
8
+ # ORIGINAL stdin. Reproduces the native parallel multi-hook contract DETERMINISTICALLY: all handlers run
9
+ # (side effects preserved), their stdout (decision/additionalContext) is concatenated through, and a
10
+ # block:true handler that exits 2 makes the dispatch exit 2 with that handler's stderr the one signal
11
+ # the harness propagates. Pure bash, no node boot on the hot path. cwd = the project/worktree. $SPEX (abs
12
+ # tsx+cli) is inherited from the shim env.
13
+ #
14
+ # The old (1) GATE an auto-materialize when the config content-hash moved — is RETIRED ([[commit-surgery]]):
15
+ # a harness event is never a materialize trigger; the materialize anchors are git-native only (spex verbs,
16
+ # session-worktree creation, and the pre-commit/post-checkout/post-merge hooks). .config edits are
17
+ # git-transactional: they take effect at the commit/checkout/merge that carries them, like any other source.
19
18
  set -u
20
19
  # 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.
20
+ # event and default the harness to claude — so a stale shim still written as `dispatch.sh <Event>` keeps working.
22
21
  # `plugin` is the bundle form ([[plugin-harness]]): it parses payloads as the claude family in harness.sh (z-code/
23
22
  # Claude share Claude's tool names + file_path), so it joins the claude branch there via the default case.
24
23
  harness=claude
@@ -30,35 +29,24 @@ export SPEXCODE_HARNESS="$harness"
30
29
  export SPEXCODE_HARNESS_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/harness.sh"
31
30
  . "$SPEXCODE_HARNESS_LIB"
32
31
  proj="${CLAUDE_PROJECT_DIR:-$PWD}"
33
- # the manifest + content-hash + gate lock live in the GLOBAL per-project store (mirrors layout.runtimeRoot),
34
- # NOT the worktree — so the worktree carries zero SpexCode-rendered runtime. Empty if git can't resolve.
32
+ # the manifest lives in THIS tree's materialize slot of the GLOBAL per-project store (mirrors layout.treeSlotDir),
33
+ # NOT the worktree — and per tree, so a dispatch can only read the manifest of the tree it fires in
34
+ # ([[hook-dispatch]]). Slot key = this cwd's rev-parse --show-toplevel through hp_tree_dir. Empty if git
35
+ # can't resolve.
35
36
  rt="$(cd "$proj" 2>/dev/null && hp_runtime_dir)" || rt=""
37
+ slot="$(cd "$proj" 2>/dev/null && hp_tree_dir)" || slot=""
36
38
 
37
- # --- (1) gate -------------------------------------------------------------------------------------------
38
- # the config fingerprint is hp_config_hash (harness.sh, already sourced) — the SAME function materialize.ts
39
- # shells to, so the gate and the renderer never disagree on "changed".
40
- cur="$( (cd "$proj" 2>/dev/null && hp_config_hash) )"
41
- if [ -n "$rt" ] && [ -n "$cur" ] && [ "$cur" != "$(cat "$rt/content-hash" 2>/dev/null || true)" ]; then
42
- mkdir -p "$rt" 2>/dev/null
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
39
+ # --- dispatch ---------------------------------------------------------------------------------------------
40
+ if [ -n "${SPEX_HOOK_MANIFEST:-}" ]; then
41
+ manifest="$SPEX_HOOK_MANIFEST"
42
+ else
43
+ # migration window: a tree last materialized by a pre-slot toolchain has no slot until its next git-native
44
+ # anchor — fall back to the legacy global manifest (its exact pre-migration behavior) so no hook (the
45
+ # Stop gate included) silently no-ops. The legacy file is never written again; the next anchor plants the
46
+ # slot and this branch goes dead.
47
+ manifest="$slot/hooks-manifest"
48
+ [ -f "$manifest" ] || manifest="$rt/hooks-manifest"
58
49
  fi
59
-
60
- # --- (2) dispatch ---------------------------------------------------------------------------------------
61
- manifest="${SPEX_HOOK_MANIFEST:-$rt/hooks-manifest}"
62
50
  [ -f "$manifest" ] || exit 0 # no manifest yet (materialize never ran) → nothing to dispatch
63
51
  input="$(cat 2>/dev/null || true)" # capture stdin ONCE; each handler gets its own copy
64
52
  err="/tmp/.spex-hook-$$.err" # per-dispatch (pid-unique) stderr capture; no cross-session race
@@ -70,7 +70,7 @@ hp_session_id() {
70
70
 
71
71
  # the per-PROJECT GLOBAL runtime dir (mirrors spec-cli/src/layout.ts `runtimeRoot`): <store>/projects/<enc>,
72
72
  # keyed by the project (dirname of the ABSOLUTE git-common-dir, so the answer is identical from main or any
73
- # worktree). This is where the materialized hook manifest + content-hash + gate lock live NOT the worktree.
73
+ # worktree). The per-session dirs and the per-tree materialize slots (hp_tree_dir) live under it.
74
74
  # Echoes the dir; returns non-zero (echoing nothing) when git can't resolve, so a caller can `|| exit 0`.
75
75
  hp_runtime_dir() {
76
76
  local gcd
@@ -79,6 +79,19 @@ hp_runtime_dir() {
79
79
  printf '%s/projects/%s' "${SPEXCODE_HOME:-$HOME/.spexcode}" "$(printf '%s' "$(dirname "$gcd")" | sed 's#[/.]#-#g')"
80
80
  }
81
81
 
82
+ # the per-WORKTREE materialize slot (mirrors layout.ts `treeSlotDir`): <runtime>/trees/<enc(worktree-toplevel)> —
83
+ # where THIS tree's materialized hook manifest + content-hash marker live. Keyed by the cwd's own
84
+ # `rev-parse --show-toplevel` through the same enc transform, so a dispatch can only ever read the manifest
85
+ # of the tree it fires in ([[hook-dispatch]] — the old single global file let the last-materialized tree's
86
+ # hook set reach every other tree's sessions). Echoes the dir; returns non-zero when git can't resolve.
87
+ hp_tree_dir() {
88
+ local rd top
89
+ rd=$(hp_runtime_dir) || return 1
90
+ top=$(git rev-parse --show-toplevel 2>/dev/null)
91
+ [ -n "$top" ] || return 1
92
+ printf '%s/trees/%s' "$rd" "$(printf '%s' "$top" | sed 's#[/.]#-#g')"
93
+ }
94
+
82
95
  # the per-session GLOBAL store dir for a session id — <runtime>/sessions/<id> (sibling of the per-project
83
96
  # runtime above). Echoes the dir; returns non-zero (echoing nothing) when git can't resolve.
84
97
  # ALIAS resolution: a codex hook fires from the shared per-PROJECT app-server process, whose env may carry a
@@ -99,13 +112,36 @@ hp_store_dir() {
99
112
  printf '%s' "$direct"
100
113
  }
101
114
 
102
- # the deterministic content fingerprint of the EDITABLE config roots (.config + config md/sh) the gate's
103
- # "did the .config move?" signal. Run with cwd = the project. ONE definition: the dispatch.sh gate sources this
104
- # and materialize.ts shells to it, so the gate and the renderer can NEVER disagree on what "changed" means (the
105
- # two used to inline this pipeline verbatim, each commenting the other "MUST match").
115
+ # the TOOLCHAIN's own version fingerprint the toolchain side of the content key. The materialized artifacts
116
+ # are a function of (config content, toolchain), so a TOOLCHAIN update must move the key too, or an updated
117
+ # deploy never self-heals its stale contract/shims/manifest until someone happens to edit .config (the field
118
+ # lesson: a toolchain update does NOT self-heal). A source checkout answers with the git TREE hash of the
119
+ # package dir (moves exactly when the toolchain's content moves, not on every repo commit); an npm install
120
+ # (no .git) answers with the package.json hash (npm bumps the version). env-stripped git — a git hook's
121
+ # exported GIT_DIR must not misdirect repo discovery (same rule as git.ts's git()).
122
+ SPEXCODE_HP_PKG="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)"
123
+ hp_toolchain_version() {
124
+ ( cd "$SPEXCODE_HP_PKG" 2>/dev/null && env -u GIT_DIR -u GIT_INDEX_FILE git rev-parse 'HEAD:./' 2>/dev/null ) \
125
+ || sha256sum "$SPEXCODE_HP_PKG/package.json" 2>/dev/null | cut -d' ' -f1 \
126
+ || echo unversioned
127
+ }
128
+
129
+ # the deterministic content fingerprint of EVERYTHING the materialize is a function of: the EDITABLE config
130
+ # roots (.config + config md/sh), the PERSISTED POLICY files (the MAIN checkout's spexcode.json +
131
+ # spexcode.local.json — the `harnesses` set materialize reads via readConfig(mainCheckout)), and the
132
+ # toolchain version above. Since the dispatch-gate retired ([[commit-surgery]] — materialize anchors on
133
+ # git-native events only), this is a FRESHNESS STAMP materialize records after each pass, a diagnostic
134
+ # (is the last materialize current?) rather than a trigger. Run with cwd = the project. ONE definition:
135
+ # materialize.ts shells to it. env-stripped git, same rule as hp_toolchain_version.
106
136
  hp_config_hash() {
107
- find .spec/*/.config .spec/*/config \( -name '*.md' -o -name '*.sh' \) -type f -print0 2>/dev/null \
108
- | sort -z | xargs -0 cat 2>/dev/null | sha256sum | cut -d' ' -f1
137
+ local gcd
138
+ gcd=$(env -u GIT_DIR -u GIT_INDEX_FILE git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \
139
+ || gcd=$(realpath "$(env -u GIT_DIR -u GIT_INDEX_FILE git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null)
140
+ { hp_toolchain_version
141
+ find .spec/*/.config .spec/*/config \( -name '*.md' -o -name '*.sh' \) -type f -print0 2>/dev/null \
142
+ | sort -z | xargs -0 cat 2>/dev/null
143
+ [ -n "$gcd" ] && cat "$(dirname "$gcd")/spexcode.json" "$(dirname "$gcd")/spexcode.local.json" 2>/dev/null
144
+ } | sha256sum | cut -d' ' -f1
109
145
  }
110
146
 
111
147
  # the tool a payload is about to run / just ran (harness-agnostic field name).
@@ -4,6 +4,7 @@ import { resolveLayout, readConfig } from './layout.js'
4
4
  import { listSessions } from './sessions.js'
5
5
  import { repoRoot, driftIndex, historyIndex } from './git.js'
6
6
  import { residentForgeState } from '../../spec-forge/src/resident.js'
7
+ import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
7
8
  import { mergedIssues } from './issues.js'
8
9
  import { evalContext, evalTimeline } from '../../spec-yatsu/src/evaltab.js'
9
10
  import { yatsuNodesAsync } from '../../spec-yatsu/src/yatsu.js'
@@ -109,7 +110,7 @@ export async function buildBoard() {
109
110
  // empty absent a forge, so the fold then carries the local slice alone. Sorted open-first, newest first.
110
111
  const isOpen = (i: { status: string }) => i.status === 'open'
111
112
  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 issue of mergedIssues({ host: resolveForgeHost(), state: residentForgeState() }, nodes.map((n) => n.id)))
113
114
  for (const nid of issue.nodes) (issuesByNode[nid] ??= []).push(issue)
114
115
  for (const n of nodes) {
115
116
  const issues = issuesByNode[n.id]
@@ -18,6 +18,18 @@ export type Board = Awaited<ReturnType<typeof buildBoard>>
18
18
  // genuinely-degraded hot path shouts while an ordinary cold start stays quiet-ish.
19
19
  const BUDGET_MS = Number(process.env.SPEXCODE_BOARD_BUDGET_MS || 1500)
20
20
 
21
+ // a build that NEVER settles is a different animal from a slow one: `inflight` clears only in the finally
22
+ // below, so a never-settling buildBoard() would pin the single-flight forever — every later read (even of a
23
+ // perfectly good cached board) short-circuits into the pinned promise before `valid` is consulted,
24
+ // invalidation can't help, no log ever fires, and only a restart cures it (the live wedge: hung git
25
+ // children → /api/board 503 forever, silently). So the build races a generous watchdog that REJECTS loudly;
26
+ // the rejection flows through the SAME finally → inflight clears → the next read retries fresh. Sitting at
27
+ // the single-flight boundary, this one wall bounds every never-settle cause — including ones with no child
28
+ // process at all (fs/promises under libuv threadpool starvation); git.ts's per-child timeouts merely make
29
+ // the common cause die sooner. Generous: well above the slowest legitimate cold build, so it only ever
30
+ // fires on a genuine wedge.
31
+ const BUILD_TIMEOUT_MS = Number(process.env.SPEXCODE_BOARD_BUILD_TIMEOUT_MS || 120000)
32
+
21
33
  let cached: Board | null = null // last completed build; served while `valid`
22
34
  let cachedJson: string | null = null // JSON.stringify(cached), serialized ONCE per build (see getBoardJson)
23
35
  let valid = false
@@ -42,13 +54,27 @@ export function getBoard(): Promise<Board> {
42
54
  const startGen = gen
43
55
  const p = (async () => {
44
56
  const t0 = Date.now()
57
+ let watchdog: ReturnType<typeof setTimeout> | undefined
45
58
  try {
46
- const board = await buildBoard()
59
+ const board = await Promise.race([
60
+ buildBoard(),
61
+ // the race consumes the loser's eventual settlement, so an abandoned build that fails later
62
+ // can't surface as an unhandled rejection; unref'd so a pending watchdog never holds a one-shot
63
+ // CLI process open.
64
+ new Promise<never>((_, reject) => {
65
+ watchdog = setTimeout(() => {
66
+ console.warn(`spec-cli: /api/board build did not settle within ${BUILD_TIMEOUT_MS}ms — wedged build abandoned so the next read can retry`)
67
+ reject(new Error(`board build did not settle within ${BUILD_TIMEOUT_MS}ms`))
68
+ }, BUILD_TIMEOUT_MS)
69
+ watchdog.unref?.()
70
+ }),
71
+ ])
47
72
  cached = board
48
73
  cachedJson = null // invalidate the memoized serialization; re-serialized lazily on first read
49
74
  valid = gen === startGen
50
75
  return board
51
76
  } finally {
77
+ clearTimeout(watchdog)
52
78
  const ms = Date.now() - t0
53
79
  if (ms > BUDGET_MS) console.warn(`spec-cli: /api/board build took ${ms}ms (budget ${BUDGET_MS}ms) — hot path is slow`)
54
80
  inflight = null
@@ -89,10 +89,11 @@ function fireChanged(): void {
89
89
  }
90
90
 
91
91
  // ---- event source 0: an EXPLICIT server-side nudge ----
92
- // for the one mutation the watchers structurally cannot see: /rename writes the worktree's `.session`
93
- // (the name's ONE home, outside the watched store — [[session-rename]]). The route calls this instead of
94
- // double-writing the name into the store: a notification problem gets a notification solution, never a
95
- // second data home. Same debounced funnel as every other source.
92
+ // for a server-side mutation that must show instantly regardless of watcher health: /rename writes the
93
+ // session's global record (`session.json` — [[session-rename]]), which lives INSIDE the watched store, so
94
+ // source 1 normally sees the write too. The explicit route call stays because that fs watch is best-effort
95
+ // (it can fail to attach), and the nudge makes the sub-second rename guarantee deterministic. Same
96
+ // debounced funnel as every other source.
96
97
  export const notifyBoardChanged = (): void => fireChanged()
97
98
 
98
99
  // ---- event source 1: the session store (lifecycle status writes) ----
@@ -52,7 +52,7 @@ function flushExit(code = 0): Promise<never> {
52
52
  }
53
53
  const has = (name: string) => process.argv.includes(`--${name}`)
54
54
  // bare positionals after argv index `from`, skipping flags and their values (selectors for ls/watch).
55
- const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth'])
55
+ const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--prompt-file', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth'])
56
56
  function positionals(from: number): string[] {
57
57
  const out: string[] = []
58
58
  for (let i = from; i < process.argv.length; i++) {
@@ -66,7 +66,8 @@ function positionals(from: number): string[] {
66
66
  // After a successful launch, nudge the caller to actually MONITOR the session — launch-then-forget is a real
67
67
  // gap (a supervisor or human launches and then never watches, so a review/failure goes unnoticed). Goes to
68
68
  // STDERR so the JSON on stdout (which callers parse) stays clean; keyed to whoever's calling — a supervising
69
- // agent has an own-session id, a human at a terminal does not.
69
+ // agent has an own-session id, a human at a terminal does not. The hint also names the COMM channel
70
+ // (`spex send`) — field-tested gap: callers who couldn't find it reached for raw tmux keystrokes instead.
70
71
  async function launchMonitorReminder(id: string): Promise<void> {
71
72
  const { ownSessionId } = await import('./sessions.js')
72
73
  const agent = ownSessionId()
@@ -78,6 +79,7 @@ async function launchMonitorReminder(id: string): Promise<void> {
78
79
  } else {
79
80
  console.error(` \`spex watch\` — the live stream of actionable session transitions (or \`spex wait ${id}\` to block on this one)`)
80
81
  }
82
+ console.error(` talk to it: \`spex send ${id} "<msg>"\` — never raw tmux keystrokes`)
81
83
  }
82
84
 
83
85
  const greeted = new Set<string>()
@@ -163,6 +165,11 @@ if (cmd && cmd !== 'help' && (has('help') || process.argv.includes('-h'))) {
163
165
  }
164
166
 
165
167
  if (cmd === 'serve') {
168
+ // fail loud, not cryptic ([[platform-support]]): serve IS the entry to the session runtime, which needs a
169
+ // POSIX host (tmux/bash/unix-sockets). On a non-POSIX host (native Windows) point at WSL2 and exit here,
170
+ // before importing the supervisor spawns tsx into a downstream ENOENT.
171
+ const { assertSessionRuntime } = await import('./runtime-guard.js')
172
+ assertSessionRuntime()
166
173
  // the supervisor owns the public port and runs index.ts as a child for zero-downtime reloads; it
167
174
  // (not `tsx watch`) is what watches spec-cli/src, so the package `serve` script must NOT use --watch.
168
175
  // --port is sugar over the PORT env supervise.ts reads — set BEFORE importing so it takes effect. This
@@ -197,7 +204,7 @@ if (cmd === 'serve') {
197
204
  const { guideText } = await import('./guide.js')
198
205
  const text = guideText(process.argv[3])
199
206
  if (text === null) {
200
- console.error(`spex guide: no topic '${process.argv[3]}'. Topics: spec, yatsu, config. Run \`spex guide\` (no topic) for the setup workflow, \`spex help\` for the command map.`)
207
+ console.error(`spex guide: no topic '${process.argv[3]}'. Topics: spec, yatsu, config, footprint. Run \`spex guide\` (no topic) for the setup workflow, \`spex help\` for the command map.`)
201
208
  process.exit(2)
202
209
  }
203
210
  console.log(text)
@@ -272,7 +279,7 @@ if (cmd === 'serve') {
272
279
  }
273
280
  } else if (cmd === 'init') {
274
281
  // scaffold a repo to adopt SpexCode: copy the shipped DATA templates (seed spec tree + git hooks)
275
- // into <targetDir> (default cwd). spex init [targetDir]
282
+ // into <targetDir> (default cwd). spex init [targetDir] [--preset <tier>]
276
283
  const { specInit } = await import('./init.js')
277
284
  await specInit(positionals(3)[0], flag('preset'))
278
285
  } else if (cmd === 'uninstall') {
@@ -394,15 +401,16 @@ if (cmd === 'serve') {
394
401
  const run = cmd === 'remark' ? m.runRemark : cmd === 'resolve' ? m.runResolve : m.runRetract
395
402
  await flushExit(await run(process.argv.slice(3)))
396
403
  } else if (cmd === 'materialize') {
397
- // @@@ materialize - the pay-per-change render: surface nodes → manifest + AGENTS.md/CLAUDE.md block +
398
- // shims + Codex trust, for cwd's project. The cheap shell gate (dispatch.sh) invokes it only on change.
404
+ // @@@ materialize - surface nodes → manifest + AGENTS.md/CLAUDE.md block + shims + Codex
405
+ // trust, for cwd's project. Anchored on git-native events only ([[commit-surgery]]): this verb, init,
406
+ // session-worktree creation, and the planted pre-commit/post-checkout/post-merge hooks.
399
407
  const { materialize } = await import('./materialize.js')
400
408
  console.log(`materialized — content-hash ${materialize()}`)
401
409
  } else if (cmd === 'doctor') {
402
410
  // @@@ doctor - the diagnosis surface ([[doctor]], né `self` — renamed: "self" read as the tool itself /
403
411
  // the global install, while the report is about THIS agent's wiring): does the materialized workflow
404
412
  // actually reach this agent? Bare `doctor` reports per-layer coverage (preconditions · git-hook floor ·
405
- // contract · hooks+handler-existence · backend) over the same HARNESSES materialize renders through;
413
+ // contract · hooks+handler-existence · backend) over the same HARNESSES materialize delivers through;
406
414
  // `contract` prints the surface:system text; `conflicts` just the double-delivery check. Thin route.
407
415
  const { runDoctor } = await import('./doctor.js')
408
416
  await flushExit(await runDoctor(process.argv.slice(3)))
@@ -481,10 +489,16 @@ if (cmd === 'serve') {
481
489
  intervalMs,
482
490
  }), true) // greet=true: a stream watch greets its specific targets once; `wait` (one-shot) does not
483
491
  } else if (cmd === 'wait') {
484
- const { watchSessions } = await import('./sessions.js')
492
+ const { watchSessions, ownSessionId } = await import('./sessions.js')
485
493
  const { clientListSessions } = await import('./client.js')
486
494
  const [id] = positionals(3)
487
495
  if (!id) { console.error('usage: spex wait <id> [--timeout SECONDS] [--interval SECONDS] [--idle]'); process.exit(2) }
496
+ // point-of-use turn-freeze warning ([[graph]]): a managed agent that runs this wait in the FOREGROUND
497
+ // freezes its whole turn until the target turns actionable — a warning that used to live only in help
498
+ // prose, now said where it matters. Foreground vs background is invisible from here, so the hint prints
499
+ // for ANY managed-agent shell (harmless in a background transcript), on stderr, and changes nothing else.
500
+ const own = ownSessionId()
501
+ if (own) console.error(`spex wait: heads-up (managed agent ${own.slice(0, 8)}) — this command BLOCKS until ${id} turns actionable; run it in the BACKGROUND or it freezes your whole turn (its exit is your wake-up). Proceeding.`)
488
502
  const intervalMs = (Number(flag('interval')) || 2) * 1000
489
503
  const timeoutSec = Number(flag('timeout')) || 1200
490
504
  const r = await withWatchEdge([id], intervalMs, () => watchSessions(() => {}, {
@@ -500,12 +514,23 @@ if (cmd === 'serve') {
500
514
  console.error(`spex wait: timeout — ${id} did not reach an actionable status within ${timeoutSec}s`)
501
515
  process.exit(1)
502
516
  } else if (cmd === 'new') {
503
- // shorthand for `spex session new`: spex new "<prompt>" [--node X] (prompt = first positional or --prompt)
517
+ // shorthand for `spex session new`: spex new "<prompt>" [--node X] (prompt = first positional or --prompt,
518
+ // or --prompt-file <path>|- so a long multi-paragraph prompt never fights shell quoting — [[prompt-file]]).
504
519
  // createSession POSTs to the running backend so the launch runs in the backend's process (auth env + cap);
505
520
  // it falls back to an in-process launch only when no backend answers.
506
521
  const { createSession } = await import('./sessions.js')
507
522
  if (has('harness')) { console.error('spex new: --harness was removed; use --launcher <name> (for example --launcher codex)'); process.exit(2) }
508
- const prompt = flag('prompt') ?? positionals(3)[0] ?? ''
523
+ const promptFile = flag('prompt-file')
524
+ const inline = flag('prompt') ?? positionals(3)[0]
525
+ let prompt = inline ?? ''
526
+ if (promptFile !== undefined) {
527
+ // fail-loud exclusive: never silently pick one of two prompt sources.
528
+ if (inline !== undefined) { console.error('spex new: give the prompt either inline or via --prompt-file, not both'); process.exit(2) }
529
+ const { readFileSync } = await import('node:fs')
530
+ try { prompt = promptFile === '-' ? readFileSync(0, 'utf8') : readFileSync(promptFile, 'utf8') }
531
+ catch (e) { console.error(`spex new: --prompt-file ${promptFile}: ${e instanceof Error ? e.message : e}`); process.exit(2) }
532
+ if (!prompt.trim()) { console.error(`spex new: --prompt-file ${promptFile === '-' ? 'stdin' : promptFile} is empty — refusing a promptless launch`); process.exit(2) }
533
+ }
509
534
  const nodeArg = flag('node')
510
535
  const created = await createSession(nodeArg ? stripRefSigil(nodeArg) : null, prompt, flag('launcher') ?? undefined)
511
536
  console.log(JSON.stringify(created, null, 2))
@@ -517,11 +542,34 @@ if (cmd === 'serve') {
517
542
  // launch path). `c` (client.ts) backs the read/control subs that route through the backend. Lazily imported.
518
543
  const s = await import('./sessions.js')
519
544
  const c = await import('./client.js')
545
+ const l = await import('./layout.js')
546
+ const { existsSync, writeFileSync } = await import('node:fs')
520
547
  const id = process.argv[4]
521
548
  // the agent-authored state writers resolve WHICH session by id: a `--session <id>` flag (the lifecycle
522
549
  // hooks pass it, parsed from the payload, since they no longer have a cwd `.session`) wins, else the
523
550
  // harness env var (ownSessionId — the agent's own `spex session …` carries the harness session id).
524
551
  const sess = flag('session')
552
+ // @@@ no-record diagnosis ([[state]]) - the session store resolves from the CURRENT directory (runtimeRoot
553
+ // ← the cwd's git common dir), so the classic declaration failure is a cd OUTSIDE the session's project —
554
+ // and a bare "no session record" told the author none of that (field-reported). Name the actual cause and
555
+ // route the fix; each branch is a distinct situation, distinguished by probing the same store the writer used.
556
+ const noRecord = (): string => {
557
+ // cwd probe FIRST: outside a git repo nothing below can resolve — not the store, and not even the env
558
+ // id (ownSessionId's alias walk reads the store too) — so diagnose the cwd before touching either.
559
+ let ids: string[] | null
560
+ try { ids = l.listSessionIds() } catch { ids = null }
561
+ if (ids === null) return `no session record — declarations resolve the session store from the CURRENT directory, and ${process.cwd()} is not inside a git repository. cd back into the session's worktree and re-declare.`
562
+ const wid = sess || s.ownSessionId() // safe now: the store just resolved, so the alias walk cannot throw
563
+ if (!wid) return 'no session record — no session id to write: this shell carries no harness session env (SPEXCODE_SESSION_ID / CLAUDE_CODE_SESSION_ID) and no --session was given. Pass --session <id> (spex ls lists ids).'
564
+ if (ids.length === 0) return `no session record for ${wid.slice(0, 8)} — declarations resolve the session store from the CURRENT directory, and the project at ${process.cwd()} has no sessions at all: you are in a different project's checkout. cd back into the session's worktree and re-declare.`
565
+ return `no session record for ${wid.slice(0, 8)} — this project's store (resolved from ${process.cwd()}) holds ${ids.length} session(s) but not this one: a wrong --session id, or you are declaring from a different project's checkout. cd back into the session's worktree and re-declare, or pass a valid --session <id> (spex ls).`
566
+ }
567
+ // a state writer from a non-repo cwd throws git's not-a-repo before it can return false — map exactly
568
+ // that throw to the no-record path (noRecord re-probes and names the cwd); anything else stays loud.
569
+ const mark = (fn: () => boolean): boolean => {
570
+ try { return fn() }
571
+ catch (e) { if (/not a git repository/i.test(String((e as any)?.stderr ?? e))) return false; throw e }
572
+ }
525
573
  // appended to a done/ask/block declaration: states (not commands) that the next tool call's mark-active hook re-flips the global record to active, so a re-read won't show this.
526
574
  const DECLARED = ' — recorded; the human sees it in the dashboard. This state lives in your session\'s global record; your next tool call flips that record back to active (the mark-active hook, by design), so it is normal for this declaration not to persist.'
527
575
  // appended ONLY to a propose-close declaration: a worktree about to be discarded may still own ephemeral things the agent started to test this change; nudge (not gate) it to reclaim them before the worktree goes, keyed on whether the thing should outlive the task — never on who started it (a deliberately long-running service / a production build is started-by-you yet must be left alone). Project-agnostic on purpose.
@@ -529,10 +577,22 @@ if (cmd === 'serve') {
529
577
  // truncation transparency ([[state]]): the board table shows only the first NOTE_BOARD_LIMIT chars of a
530
578
  // note. When a declared note overflows that cap, the confirmation says so — length, what the board shows,
531
579
  // where the full text is readable — so the cut is visible to the author instead of silently eaten.
532
- // A nudge riding the echo, never a gate: the declaration has already landed.
533
- const noteEcho = (note?: string) => (note && note.length > s.NOTE_BOARD_LIMIT)
534
- ? `\nyour note is ${note.length} chars; the board table shows only the first ${s.NOTE_BOARD_LIMIT} — the full text IS recorded, and readable via spex review ${(sess || s.ownSessionId() || '<your-session>').slice(0, 8)} / spex ls --json.`
535
- : ''
580
+ // Taught ONCE per session: the first overflowing note prints the full notice and drops a sentinel beside
581
+ // the record; later overflows stay silent (the rule was taught — a verbatim repeat on every park/ask is
582
+ // noise, field-reported). A nudge riding the echo, never a gate: the declaration has already landed.
583
+ const noteEcho = (note?: string): string => {
584
+ if (!note || note.length <= s.NOTE_BOARD_LIMIT) return ''
585
+ const wid = sess || s.ownSessionId()
586
+ const rid = wid ? (l.readAliasedRawRecord(wid)?.session_id ?? wid) : null // sentinel lives in the RECORD's dir, so an aliased codex id lands on the same file
587
+ if (rid) {
588
+ const sentinel = l.sessionArtifactPath(rid, 'note-echo-taught')
589
+ try {
590
+ if (existsSync(sentinel)) return ''
591
+ writeFileSync(sentinel, `${new Date().toISOString()}\n`) // only reached on a successful declaration (the echo rides the success branch)
592
+ } catch { /* unreadable/unwritable store dir → fall through and teach again; never block the echo */ }
593
+ }
594
+ return `\nyour note is ${note.length} chars; the board table shows only the first ${s.NOTE_BOARD_LIMIT} — the full text IS recorded, and readable via spex review ${(wid || '<your-session>').slice(0, 8)} / spex ls --json. (said once — later long notes won't repeat this.)`
595
+ }
536
596
  if (sub === 'reopen') {
537
597
  // bring the agent back up (relaunch ONLY if confirmed offline, the backend owns it); demotes a working
538
598
  // `active` to idle but leaves a standing declaration/proposal untouched (see sessions.ts reopen()). The
@@ -545,8 +605,8 @@ if (cmd === 'serve') {
545
605
  } else if (sub === 'state') {
546
606
  // the agent authors ITS OWN state: active|awaiting|parked|error [--propose] [--note] [--session]
547
607
  const st = process.argv[4] as any
548
- const ok = s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess })
549
- console.log(ok ? `state -> ${st}${noteEcho(flag('note'))}` : 'no session record (unknown --session / no CLAUDE_CODE_SESSION_ID, or bad status)')
608
+ const ok = mark(() => s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess }))
609
+ console.log(ok ? `state -> ${st}${noteEcho(flag('note'))}` : noRecord())
550
610
  } else if (sub === 'done') {
551
611
  // sugar for awaiting; --propose merge|nothing|close, optional --note
552
612
  const p = (flag('propose') as any) || 'nothing'
@@ -558,18 +618,18 @@ if (cmd === 'serve') {
558
618
  try { closeNote += (await import('./localIssues.js')).closeoutNudge(sess ?? s.ownSessionId()) }
559
619
  catch (e) { console.error(`issue closeout check failed (declaration unaffected): ${e instanceof Error ? e.message : e}`) }
560
620
  }
561
- console.log(s.markDone(p, sess, flag('note')) ? `done (${p})${DECLARED}${noteEcho(flag('note'))}${closeNote}` : 'no session record')
621
+ console.log(mark(() => s.markDone(p, sess, flag('note'))) ? `done (${p})${DECLARED}${noteEcho(flag('note'))}${closeNote}` : noRecord())
562
622
  } else if (sub === 'park') {
563
623
  // sugar: the agent is waiting on a background task; it will self-resume (NOT idle/awaiting)
564
- console.log(s.markState('parked', { note: flag('note'), sessionId: sess }) ? `parked${DECLARED}${noteEcho(flag('note'))}` : 'no session record')
624
+ console.log(mark(() => s.markState('parked', { note: flag('note'), sessionId: sess })) ? `parked${DECLARED}${noteEcho(flag('note'))}` : noRecord())
565
625
  } else if (sub === 'fail') {
566
626
  // the StopFailure hook marks its session (--session from the payload) as error (turn died on an API error)
567
- console.log(s.markError(sess) ? 'marked error' : 'no session record')
627
+ console.log(mark(() => s.markError(sess)) ? 'marked error' : noRecord())
568
628
  } else if (sub === 'ask') {
569
629
  // the agent DELIBERATELY declares it is pausing to ask the human a question (like `done`/`park`, an
570
630
  // authored state — NOT guarded active-only). The --note carries the question. Distinct from `park`
571
631
  // (waiting on a background task, self-resumes): an asking agent resumes only when the human replies.
572
- console.log(s.markState('asking', { note: flag('note'), sessionId: sess }) ? `asking${DECLARED}${noteEcho(flag('note'))}` : 'no session record')
632
+ console.log(mark(() => s.markState('asking', { note: flag('note'), sessionId: sess })) ? `asking${DECLARED}${noteEcho(flag('note'))}` : noRecord())
573
633
  } else if (sub === 'commit-gate') {
574
634
  // the Stop gate's deterministic commit check (from cwd = the worktree): exit 0 if the node branch is
575
635
  // ready to declare done/merge (work committed + ahead of main), else print the reason and exit 1. Uses
@@ -697,6 +757,19 @@ if (cmd === 'serve') {
697
757
  const sid = process.env.SPEXCODE_SESSION_ID
698
758
  if (sid) markHarnessSessionId(sid, r.threadId)
699
759
  console.log(r.threadId)
760
+ } else if (sub === 'commit-surgery') {
761
+ // the pre-commit footprint anchor ([[commit-surgery]]): unconditional materialize + staged-index repair
762
+ // (strip our sentinel block from staged blobs, unstage HEAD-untracked generated artifacts). Called only
763
+ // by the planted pre-commit hook; repairs and proceeds, never rejects. Exit non-zero only on an internal
764
+ // error — the hook treats that as advisory (warn + continue), CI lint remains the real gate.
765
+ const { commitSurgery } = await import('./commit-surgery.js')
766
+ commitSurgery()
767
+ } else if (sub === 'refresh-footprint') {
768
+ // the post-checkout / post-merge freshness anchor ([[commit-surgery]]): a quiet materialize after a git
769
+ // state transition (the only events that can move the materialize's inputs — .spec/.config arrive by commit,
770
+ // merge, or checkout). Best-effort and silent on success; hooks call it fire-and-forget.
771
+ const { materialize } = await import('./materialize.js')
772
+ try { materialize() } catch (e) { console.error(`spexcode: footprint refresh failed (${(e as Error).message})`); process.exit(1) }
700
773
  } else if (sub === 'codex-turn') {
701
774
  // fire a follow-up turn on an OWNED thread over the per-project socket (the delivery channel, exposed for
702
775
  // tests / scripts). steer-vs-start is chosen live from the thread read.