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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +13 -10
- package/spec-cli/hooks/dispatch.sh +28 -40
- package/spec-cli/hooks/harness.sh +43 -7
- package/spec-cli/src/board.ts +2 -1
- package/spec-cli/src/boardCache.ts +27 -1
- package/spec-cli/src/boardStream.ts +5 -4
- package/spec-cli/src/cli.ts +93 -20
- package/spec-cli/src/commit-surgery.ts +81 -0
- package/spec-cli/src/contract-filter.ts +156 -0
- package/spec-cli/src/doctor.ts +11 -5
- package/spec-cli/src/git.ts +18 -4
- package/spec-cli/src/guide.ts +94 -38
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +22 -8
- package/spec-cli/src/help.ts +28 -16
- package/spec-cli/src/index.ts +9 -6
- package/spec-cli/src/init.ts +17 -10
- package/spec-cli/src/issues.ts +7 -5
- package/spec-cli/src/layout.ts +31 -13
- package/spec-cli/src/lint.ts +19 -0
- package/spec-cli/src/localIssues.ts +16 -4
- package/spec-cli/src/materialize.ts +214 -144
- package/spec-cli/src/mentions.ts +5 -3
- package/spec-cli/src/plugin-harness.ts +10 -9
- package/spec-cli/src/ranker.ts +25 -8
- package/spec-cli/src/runtime-guard.ts +44 -0
- package/spec-cli/src/search.bench.mjs +15 -5
- package/spec-cli/src/sessions.ts +96 -22
- package/spec-cli/src/specs.ts +38 -18
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tsx-bin.ts +6 -8
- package/spec-cli/src/uninstall.ts +18 -19
- package/spec-cli/src/worktree-sources.ts +52 -13
- package/spec-cli/templates/hooks/post-checkout +22 -0
- package/spec-cli/templates/hooks/post-merge +15 -9
- package/spec-cli/templates/hooks/pre-commit +10 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +26 -8
- package/spec-cli/templates/spec/project/.config/distill/digest.mjs +136 -0
- package/spec-cli/templates/spec/project/.config/distill/spec.md +74 -0
- package/spec-dashboard/dist/assets/Dashboard-BlRRsxE7.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BzVE38-Z.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-DFuLVOeu.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CzDaazhe.js +1 -0
- package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-CXQrQCNp.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-D1pUBl6q.js} +8 -8
- package/spec-dashboard/dist/assets/SessionWindow-Y25Bwg1e.js +9 -0
- package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-R610Vbzd.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BO0Zuweu.js} +2 -2
- package/spec-dashboard/dist/assets/index-uGs9v_9o.css +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-forge/src/cli.ts +2 -2
- package/spec-forge/src/drivers/gitlab.ts +168 -0
- package/spec-forge/src/drivers.ts +80 -2
- package/spec-forge/src/resident.ts +10 -5
- package/spec-yatsu/src/cli.ts +37 -16
- package/spec-yatsu/src/evaltab.ts +6 -3
- package/spec-yatsu/src/filing.ts +13 -8
- package/spec-yatsu/src/freshness.ts +97 -22
- package/spec-yatsu/src/proof.ts +14 -3
- package/spec-yatsu/src/scenariofresh.ts +38 -11
- package/spec-yatsu/src/yatsu.ts +52 -28
- package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +0 -3
- package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +0 -1
- package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +0 -9
- 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,
|
|
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.
|
|
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",
|
package/spec-cli/bin/spex.mjs
CHANGED
|
@@ -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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
53
|
-
spawn(
|
|
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.
|
|
7
|
-
# in order
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
|
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
|
|
34
|
-
# NOT the worktree — so
|
|
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
|
-
# ---
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
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).
|
|
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
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
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
|
-
|
|
108
|
-
|
|
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).
|
package/spec-cli/src/board.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
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) ----
|
package/spec-cli/src/cli.ts
CHANGED
|
@@ -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 -
|
|
398
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
533
|
-
|
|
534
|
-
|
|
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'))}` :
|
|
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}` :
|
|
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'))}` :
|
|
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' :
|
|
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'))}` :
|
|
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.
|