spexcode 0.2.8 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -44
- package/package.json +3 -3
- package/spec-cli/bin/spex.mjs +2 -2
- package/spec-cli/hooks/dispatch.sh +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +300 -0
- package/spec-cli/src/attach.ts +2 -2
- package/spec-cli/src/cli.ts +691 -536
- package/spec-cli/src/client.ts +31 -30
- package/spec-cli/src/contract-filter.ts +1 -1
- package/spec-cli/src/doctor.ts +40 -13
- package/spec-cli/src/gateway.ts +11 -7
- package/spec-cli/src/git.ts +23 -15
- package/spec-cli/src/{board.ts → graph.ts} +44 -14
- package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
- package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
- package/spec-cli/src/graphStream.ts +288 -0
- package/spec-cli/src/guide.ts +142 -98
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +30 -14
- package/spec-cli/src/help.ts +300 -385
- package/spec-cli/src/hooks.ts +1 -1
- package/spec-cli/src/index.ts +144 -103
- package/spec-cli/src/init.ts +14 -13
- package/spec-cli/src/issues.ts +89 -33
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +128 -30
- package/spec-cli/src/localIssues.ts +61 -60
- package/spec-cli/src/materialize.ts +1 -1
- package/spec-cli/src/mentions.ts +15 -15
- package/spec-cli/src/migrate-table.ts +398 -0
- package/spec-cli/src/migrate.ts +386 -0
- package/spec-cli/src/ranker.ts +30 -4
- package/spec-cli/src/reaper.ts +117 -0
- package/spec-cli/src/search.bench.mjs +12 -12
- package/spec-cli/src/search.ts +1 -1
- package/spec-cli/src/sessions.ts +244 -138
- package/spec-cli/src/specs.ts +40 -25
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tree.ts +5 -5
- package/spec-cli/src/uninstall.ts +4 -4
- package/spec-cli/templates/hooks/post-checkout +1 -1
- package/spec-cli/templates/hooks/post-merge +3 -3
- package/spec-cli/templates/hooks/pre-commit +9 -9
- package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
- package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +5 -5
- package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/forge-link/spec.md +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/reproduce-before-fix/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/spec.md +18 -0
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-CTcH2eW9.js} +9 -9
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-CVFbBpyW.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-kULjonqj.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-B0ZJju8K.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-BRKJqU2U.js} +3 -3
- package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CDhEL7wO.js} +7 -7
- package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BL6FV_8S.js} +1 -1
- package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-DmQsNYKK.css} +1 -1
- package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-DulGPk6A.js} +8 -8
- package/spec-dashboard/dist/index.html +2 -2
- package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
- package/{spec-yatsu → spec-eval}/src/cli.ts +212 -96
- package/{spec-yatsu → spec-eval}/src/evaltab.ts +35 -27
- package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
- package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
- package/spec-eval/src/humanok.ts +43 -0
- package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
- package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
- package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
- package/{spec-yatsu → spec-eval}/src/sidecar.ts +42 -10
- package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
- package/spec-forge/src/__fixtures__/github-forge.json +9 -9
- package/spec-forge/src/cli.ts +14 -13
- package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
- package/spec-cli/src/boardStream.ts +0 -179
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +0 -11
- package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
- package/spec-cli/templates/spec/project/.config/spec.md +0 -15
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
- package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/memory-hygiene/spec.md +0 -0
package/spec-cli/src/cli.ts
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
export {} // make this a module so top-level await is allowed
|
|
2
2
|
// static import is fine here: mentions.ts is dependency-free at module level, and stripRefSigil is needed
|
|
3
|
-
// by several verbs (owner, new,
|
|
3
|
+
// by several verbs (spec owner, session new, graph) — a CLI reference arg tolerates an optional @/[[ ]]
|
|
4
|
+
// sigil ([[mentions]]).
|
|
4
5
|
import { stripRefSigil } from './mentions.js'
|
|
5
6
|
|
|
6
|
-
// @@@
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
const PROMOTED_SESSION_VERBS = new Set(['new', 'ls', 'watch', 'wait', 'review', 'merge'])
|
|
13
|
-
const SESSION_SUBS = new Set(['reopen', 'done', 'park', 'ask', 'exit', 'close', 'send', 'capture', 'attach', 'rename', 'rawkey', 'prompt'])
|
|
14
|
-
if (process.argv[2] === 'session' && PROMOTED_SESSION_VERBS.has(process.argv[3])) process.argv.splice(2, 1)
|
|
15
|
-
else if (SESSION_SUBS.has(process.argv[2])) process.argv.splice(2, 0, 'session')
|
|
7
|
+
// @@@ noun-first dispatch ([[cli-surface]]) - `spex <noun> <verb> [object] [flags]`: the verb is always the
|
|
8
|
+
// second token after its noun, a bare noun prints its drawer's help, and a bare verb exists only where the
|
|
9
|
+
// object is invariably THIS PROJECT (graph · init · materialize · doctor · uninstall · serve). There is no
|
|
10
|
+
// verb mirror and no promoted spelling — one verb, one spelling. Every REMOVED spelling lives in the
|
|
11
|
+
// signpost tables below: it REPORTS the new spelling and exits non-zero, never executes (a signpost is not
|
|
12
|
+
// an alias; the tables die in 0.4.0).
|
|
16
13
|
const cmd = process.argv[2]
|
|
17
14
|
|
|
18
15
|
// Registered before any await so a fatal top-level error lands here. Errors we OWN — BackendError, the
|
|
@@ -37,12 +34,12 @@ function flag(name: string): string | undefined {
|
|
|
37
34
|
}
|
|
38
35
|
|
|
39
36
|
// Exit AFTER stdout has flushed. process.exit() force-quits without draining buffered pipe writes, so a
|
|
40
|
-
// large piped dump (`spex
|
|
41
|
-
// buffer (~64KB). The empty write's callback fires once every prior queued chunk has drained; the
|
|
42
|
-
// promise never resolves (process.exit ends the process inside the callback), so
|
|
43
|
-
// halts execution here exactly like process.exit did — safe to drop in on any
|
|
44
|
-
// EPIPE (a reader that closed early — `| head`, `| jq` exiting) can never drain, so
|
|
45
|
-
// stream error rather than hang: the truncation is the reader's choice then, not ours.
|
|
37
|
+
// large piped dump (`spex issue ls --json | …`, graph --json, review --json) is silently cut off at the
|
|
38
|
+
// pipe buffer (~64KB). The empty write's callback fires once every prior queued chunk has drained; the
|
|
39
|
+
// returned promise never resolves (process.exit ends the process inside the callback), so
|
|
40
|
+
// `await flushExit(code)` halts execution here exactly like process.exit did — safe to drop in on any
|
|
41
|
+
// unbounded-output verb. EPIPE (a reader that closed early — `| head`, `| jq` exiting) can never drain, so
|
|
42
|
+
// we ALSO exit on the stream error rather than hang: the truncation is the reader's choice then, not ours.
|
|
46
43
|
function flushExit(code = 0): Promise<never> {
|
|
47
44
|
return new Promise<never>(() => {
|
|
48
45
|
const done = () => process.exit(code)
|
|
@@ -52,7 +49,7 @@ function flushExit(code = 0): Promise<never> {
|
|
|
52
49
|
}
|
|
53
50
|
const has = (name: string) => process.argv.includes(`--${name}`)
|
|
54
51
|
// 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', '--prompt-file', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth'])
|
|
52
|
+
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', '--focus', '--keys'])
|
|
56
53
|
function positionals(from: number): string[] {
|
|
57
54
|
const out: string[] = []
|
|
58
55
|
for (let i = from; i < process.argv.length; i++) {
|
|
@@ -63,23 +60,77 @@ function positionals(from: number): string[] {
|
|
|
63
60
|
return out
|
|
64
61
|
}
|
|
65
62
|
|
|
63
|
+
// @@@ signposts (one version only — delete in 0.4.0) - every spelling v0.3.0 removed maps to its new home.
|
|
64
|
+
// A signpost REPORTS and exits 2; it never executes (not an alias): a stale hook or a human's muscle memory
|
|
65
|
+
// gets a readable failure that names the migration, and nothing old keeps silently working.
|
|
66
|
+
const SIGNPOSTS: Record<string, string> = {
|
|
67
|
+
search: 'spex spec search <query>',
|
|
68
|
+
owner: 'spex spec owner <path>',
|
|
69
|
+
lint: 'spex spec lint',
|
|
70
|
+
ack: 'spex spec ack <node>… --reason "<why>"',
|
|
71
|
+
tree: 'spex graph',
|
|
72
|
+
board: 'spex graph --json',
|
|
73
|
+
blob: 'spex evidence put|get',
|
|
74
|
+
issues: 'spex issue — ls (was: bare issues) · show · open · reply · close · promote; on|off|status → the `issues.enabled` key in spexcode.json; `issues nudge` → spex internal nudge',
|
|
75
|
+
forge: 'spex issue links [--pending] [--store <host>] (--host is now --store)',
|
|
76
|
+
dashboard: 'spex serve ui',
|
|
77
|
+
new: 'spex session new',
|
|
78
|
+
ls: 'spex session ls',
|
|
79
|
+
watch: 'spex session watch',
|
|
80
|
+
wait: 'spex session wait',
|
|
81
|
+
review: 'spex session review',
|
|
82
|
+
merge: 'spex session merge',
|
|
83
|
+
send: 'spex session send',
|
|
84
|
+
reopen: 'spex session resume',
|
|
85
|
+
done: 'spex session done',
|
|
86
|
+
park: 'spex session park',
|
|
87
|
+
ask: 'spex session ask',
|
|
88
|
+
exit: 'spex session stop',
|
|
89
|
+
close: 'spex session close',
|
|
90
|
+
capture: 'spex session show <SEL> --capture',
|
|
91
|
+
attach: 'spex session attach',
|
|
92
|
+
rename: 'spex session rename',
|
|
93
|
+
prompt: 'spex session show <SEL>',
|
|
94
|
+
rawkey: 'spex session send <SEL> --keys "<keys>"',
|
|
95
|
+
resolve: 'spex remark resolve <ref>',
|
|
96
|
+
retract: 'spex remark retract <ref>',
|
|
97
|
+
}
|
|
98
|
+
// the session drawer's removed sub-spellings: rawkey folded into send; capture/prompt folded into show;
|
|
99
|
+
// exit/reopen respelled stop/resume; the hook-only verbs moved to internal.
|
|
100
|
+
const SESSION_SIGNPOSTS: Record<string, string> = {
|
|
101
|
+
rawkey: 'spex session send <SEL> --keys "<keys>"',
|
|
102
|
+
exit: 'spex session stop <SEL>',
|
|
103
|
+
reopen: 'spex session resume <SEL> [--force]',
|
|
104
|
+
capture: 'spex session show <SEL> --capture',
|
|
105
|
+
prompt: 'spex session show <SEL>',
|
|
106
|
+
state: 'spex internal session-state',
|
|
107
|
+
fail: 'spex internal session-fail',
|
|
108
|
+
idle: 'spex internal session-idle',
|
|
109
|
+
'commit-gate': 'spex internal commit-gate',
|
|
110
|
+
}
|
|
111
|
+
function signpost(oldSpelling: string, newSpelling: string): never {
|
|
112
|
+
console.error(`spex: \`${oldSpelling}\` was removed in v0.3.0 — use: ${newSpelling} (map: spex help)`)
|
|
113
|
+
process.exit(2)
|
|
114
|
+
}
|
|
115
|
+
if (cmd !== undefined && SIGNPOSTS[cmd]) signpost(`spex ${cmd}`, SIGNPOSTS[cmd])
|
|
116
|
+
|
|
66
117
|
// After a successful launch, nudge the caller to actually MONITOR the session — launch-then-forget is a real
|
|
67
118
|
// gap (a supervisor or human launches and then never watches, so a review/failure goes unnoticed). Goes to
|
|
68
119
|
// STDERR so the JSON on stdout (which callers parse) stays clean; keyed to whoever's calling — a supervising
|
|
69
120
|
// 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.
|
|
121
|
+
// (`spex session send`) — field-tested gap: callers who couldn't find it reached for raw tmux keystrokes instead.
|
|
71
122
|
async function launchMonitorReminder(id: string): Promise<void> {
|
|
72
123
|
const { ownSessionId } = await import('./sessions.js')
|
|
73
124
|
const agent = ownSessionId()
|
|
74
125
|
console.error(`\nspex: launched session ${id} — now MONITOR it, or its review/failure goes unnoticed:`)
|
|
75
126
|
if (agent) {
|
|
76
|
-
// a supervising agent: the per-worker monitor is a backgrounded `spex wait`, which
|
|
77
|
-
console.error(` supervising agent → background \`spex wait ${id}\` (
|
|
78
|
-
console.error(` or watch the whole stream: \`spex watch\``)
|
|
127
|
+
// a supervising agent: the per-worker monitor is a backgrounded `spex session wait`, which is edge-triggered.
|
|
128
|
+
console.error(` supervising agent → background \`spex session wait ${id}\` (edge-triggered: exits when it OBSERVES the session transition into an actionable status — also how you await a dispatched merge actually landing; its exit is your wake-up. Already actionable and you just want to read it? \`spex session ls\`)`)
|
|
129
|
+
console.error(` or watch the whole stream: \`spex session watch\``)
|
|
79
130
|
} else {
|
|
80
|
-
console.error(` \`spex watch\` — the live stream of actionable session transitions (or \`spex wait ${id}\` to
|
|
131
|
+
console.error(` \`spex session watch\` — the live stream of actionable session transitions (or \`spex session wait ${id}\` to sleep until this one's next transition into actionable)`)
|
|
81
132
|
}
|
|
82
|
-
console.error(` talk to it: \`spex send ${id} "<msg>"\` —
|
|
133
|
+
console.error(` talk to it: \`spex session send ${id} "<msg>"\` — plain text; \`send --keys\` is a LAST RESORT (unstable raw TUI keys — only when a text send provably can't land)`)
|
|
83
134
|
}
|
|
84
135
|
|
|
85
136
|
const greeted = new Set<string>()
|
|
@@ -99,7 +150,7 @@ async function greetWatchTargets(watcher: string, selectors: string[]): Promise<
|
|
|
99
150
|
const target = r.ok.id
|
|
100
151
|
if (target === watcher || greeted.has(target)) continue
|
|
101
152
|
greeted.add(target)
|
|
102
|
-
const text = `🔭 ${meWho} is now supervising you — they started \`spex watch\` over this session. To reach them directly, run: spex session send ${watcher} "<your message>". (One-time heads-up; reply only if you need to.)`
|
|
153
|
+
const text = `🔭 ${meWho} is now supervising you — they started \`spex session watch\` over this session. To reach them directly, run: spex session send ${watcher} "<your message>". (One-time heads-up; reply only if you need to.)`
|
|
103
154
|
void clientSend(target, text) // no sender id → the connection notice is not double-counted as comms
|
|
104
155
|
}
|
|
105
156
|
} catch { /* greeting is best-effort — it must never disturb the watch */ }
|
|
@@ -133,14 +184,12 @@ async function resolveSelectorOrExit(selector: string): Promise<string> {
|
|
|
133
184
|
process.exit(2)
|
|
134
185
|
}
|
|
135
186
|
|
|
136
|
-
// the [[
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
const r = await clientProof(id, has('json'))
|
|
187
|
+
// the [[session-eval]] EXPORT artifact behind `spex eval ls --session <SEL> --export`: fetch the
|
|
188
|
+
// backend-rendered self-contained HTML, write it (--out, else a tmp file) or open it (--open). Never returns.
|
|
189
|
+
async function evalExport(id: string): Promise<never> {
|
|
190
|
+
const { clientEvalExport } = await import('./client.js')
|
|
191
|
+
const r = await clientEvalExport(id)
|
|
142
192
|
if (!r.ok) { console.error(`no export for ${id} (status ${r.status})`); process.exit(1) }
|
|
143
|
-
if (has('json')) { console.log(r.body); await flushExit(0) }
|
|
144
193
|
const { writeFileSync } = await import('node:fs')
|
|
145
194
|
const { join } = await import('node:path')
|
|
146
195
|
const { tmpdir } = await import('node:os')
|
|
@@ -155,9 +204,70 @@ async function proofExport(id: string): Promise<never> {
|
|
|
155
204
|
process.exit(0)
|
|
156
205
|
}
|
|
157
206
|
|
|
207
|
+
// 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.
|
|
208
|
+
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.'
|
|
209
|
+
// 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.
|
|
210
|
+
const CLOSE_CLEANUP = '\n\nBefore this worktree closes, check whether you left anything running that you started to test this change — a background process, a dev or preview server, a bound port, a scratch session. If nothing depends on it anymore, shut it down, or it keeps running as an orphan. Leave anything meant to keep running: a service you deliberately stood up, a production build, anything other work relies on. What matters is whether it still needs to exist after this task, not whether you started it. If unsure, leave it. This is a reminder to check, not a required step.'
|
|
211
|
+
|
|
212
|
+
// @@@ session-state kit - the shared machinery behind the agent-authored state writers, used by BOTH the
|
|
213
|
+
// typeable worker declarations (`spex session done|park|ask`) and the hook-only writers under
|
|
214
|
+
// `spex internal session-*` — one diagnosis, one truncation-echo, either drawer.
|
|
215
|
+
async function stateKit() {
|
|
216
|
+
const s = await import('./sessions.js')
|
|
217
|
+
const l = await import('./layout.js')
|
|
218
|
+
const { existsSync, writeFileSync } = await import('node:fs')
|
|
219
|
+
// the agent-authored state writers resolve WHICH session by id: a `--session <id>` flag (the lifecycle
|
|
220
|
+
// hooks pass it, parsed from the payload, since they no longer have a cwd `.session`) wins, else the
|
|
221
|
+
// harness env var (ownSessionId — the agent's own `spex session …` carries the harness session id).
|
|
222
|
+
const sess = flag('session')
|
|
223
|
+
// @@@ no-record diagnosis ([[state]]) - the session store resolves from the CURRENT directory (runtimeRoot
|
|
224
|
+
// ← the cwd's git common dir), so the classic declaration failure is a cd OUTSIDE the session's project —
|
|
225
|
+
// and a bare "no session record" told the author none of that (field-reported). Name the actual cause and
|
|
226
|
+
// route the fix; each branch is a distinct situation, distinguished by probing the same store the writer used.
|
|
227
|
+
const noRecord = (): string => {
|
|
228
|
+
// cwd probe FIRST: outside a git repo nothing below can resolve — not the store, and not even the env
|
|
229
|
+
// id (ownSessionId's alias walk reads the store too) — so diagnose the cwd before touching either.
|
|
230
|
+
let ids: string[] | null
|
|
231
|
+
try { ids = l.listSessionIds() } catch { ids = null }
|
|
232
|
+
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.`
|
|
233
|
+
const wid = sess || s.ownSessionId() // safe now: the store just resolved, so the alias walk cannot throw
|
|
234
|
+
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 session ls lists ids).'
|
|
235
|
+
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.`
|
|
236
|
+
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 session ls).`
|
|
237
|
+
}
|
|
238
|
+
// a state writer from a non-repo cwd throws git's not-a-repo before it can return false — map exactly
|
|
239
|
+
// that throw to the no-record path (noRecord re-probes and names the cwd); anything else stays loud.
|
|
240
|
+
const mark = (fn: () => boolean): boolean => {
|
|
241
|
+
try { return fn() }
|
|
242
|
+
catch (e) { if (/not a git repository/i.test(String((e as any)?.stderr ?? e))) return false; throw e }
|
|
243
|
+
}
|
|
244
|
+
// truncation transparency ([[state]]): the session table shows only the first NOTE_BOARD_LIMIT chars of a
|
|
245
|
+
// note. When a declared note overflows that cap, the confirmation says so — length, what the board shows,
|
|
246
|
+
// where the full text is readable — so the cut is visible to the author instead of silently eaten.
|
|
247
|
+
// Taught ONCE per session: the first overflowing note prints the full notice and drops a sentinel beside
|
|
248
|
+
// the record; later overflows stay silent (the rule was taught — a verbatim repeat on every park/ask is
|
|
249
|
+
// noise, field-reported). A nudge riding the echo, never a gate: the declaration has already landed.
|
|
250
|
+
const noteEcho = (note?: string): string => {
|
|
251
|
+
if (!note || note.length <= s.NOTE_BOARD_LIMIT) return ''
|
|
252
|
+
const wid = sess || s.ownSessionId()
|
|
253
|
+
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
|
|
254
|
+
if (rid) {
|
|
255
|
+
const sentinel = l.sessionArtifactPath(rid, 'note-echo-taught')
|
|
256
|
+
try {
|
|
257
|
+
if (existsSync(sentinel)) return ''
|
|
258
|
+
writeFileSync(sentinel, `${new Date().toISOString()}\n`) // only reached on a successful declaration (the echo rides the success branch)
|
|
259
|
+
} catch { /* unreadable/unwritable store dir → fall through and teach again; never block the echo */ }
|
|
260
|
+
}
|
|
261
|
+
return `\nyour note is ${note.length} chars; the session table shows only the first ${s.NOTE_BOARD_LIMIT} — the full text IS recorded, and readable via spex session review ${(wid || '<your-session>').slice(0, 8)} / spex session ls --json. (said once — later long notes won't repeat this.)`
|
|
262
|
+
}
|
|
263
|
+
return { s, l, sess, noRecord, mark, noteEcho }
|
|
264
|
+
}
|
|
265
|
+
|
|
158
266
|
// a trailing --help/-h prints help and exits BEFORE any verb runs, so a help probe never fires a
|
|
159
267
|
// streaming/mutating command. It prints THAT command's usage when an entry exists (the second layer
|
|
160
|
-
// of the help journey — see help.ts)
|
|
268
|
+
// of the help journey — see help.ts): a drawer sub's probe (`spex session send --help`) answers with the
|
|
269
|
+
// drawer's entry. Unknown tokens fall back to the map. (Removed spellings never reach here — the signpost
|
|
270
|
+
// table above already exited.)
|
|
161
271
|
if (cmd && cmd !== 'help' && (has('help') || process.argv.includes('-h'))) {
|
|
162
272
|
const { commandHelp, overviewHelp } = await import('./help.js')
|
|
163
273
|
console.log(commandHelp(cmd) ?? overviewHelp())
|
|
@@ -165,117 +275,191 @@ if (cmd && cmd !== 'help' && (has('help') || process.argv.includes('-h'))) {
|
|
|
165
275
|
}
|
|
166
276
|
|
|
167
277
|
if (cmd === 'serve') {
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
278
|
+
// `spex serve [api]` runs the backend; `spex serve ui` serves the dashboard on top of a running backend —
|
|
279
|
+
// two processes, two verbs in one operator drawer.
|
|
280
|
+
const target = positionals(3)[0]
|
|
281
|
+
if (target === 'ui') {
|
|
282
|
+
// the natural post-install UI: serve the bundled dashboard on its OWN port (loopback by default;
|
|
283
|
+
// --host widens the bind for LAN/tailnet viewing), proxying /api + the terminal socket to a
|
|
284
|
+
// separately-run `spex serve`. Replaces the dogfood-only `npm run web` (vite).
|
|
285
|
+
const { serveDashboardLocal } = await import('./gateway.js')
|
|
286
|
+
const port = Number(flag('port') ?? process.env.SPEXCODE_DASHBOARD_PORT ?? 5173)
|
|
287
|
+
const apiPort = Number(flag('api-port') ?? process.env.PORT ?? 8787)
|
|
288
|
+
const host = flag('host') ?? '127.0.0.1'
|
|
289
|
+
if (!Number.isInteger(port) || !Number.isInteger(apiPort)) { console.error('spex serve ui: --port and --api-port must be integers'); process.exit(2) }
|
|
290
|
+
serveDashboardLocal({ port, apiPort, host })
|
|
291
|
+
} else if (target === undefined || target === 'api') {
|
|
292
|
+
// fail loud, not cryptic ([[platform-support]]): serve IS the entry to the session runtime, which needs a
|
|
293
|
+
// POSIX host (tmux/bash/unix-sockets). On a non-POSIX host (native Windows) point at WSL2 and exit here,
|
|
294
|
+
// before importing the supervisor spawns tsx into a downstream ENOENT.
|
|
295
|
+
const { assertSessionRuntime } = await import('./runtime-guard.js')
|
|
296
|
+
assertSessionRuntime()
|
|
297
|
+
// the supervisor owns the public port and runs index.ts as a child for zero-downtime reloads; it
|
|
298
|
+
// (not `tsx watch`) is what watches spec-cli/src, so the package `serve` script must NOT use --watch.
|
|
299
|
+
// --port is sugar over the PORT env supervise.ts reads — set BEFORE importing so it takes effect. This
|
|
300
|
+
// mirrors `spex serve ui --api-port`, so one host runs many projects (each `serve --port N` paired with
|
|
301
|
+
// a `serve ui --api-port N`), cwd picking which project's .spec is served — no shared default collides.
|
|
302
|
+
const portArg = flag('port')
|
|
303
|
+
if (portArg !== undefined) {
|
|
304
|
+
if (!Number.isInteger(Number(portArg))) { console.error('spex serve: --port must be an integer'); process.exit(2) }
|
|
305
|
+
process.env.PORT = portArg
|
|
306
|
+
}
|
|
307
|
+
await import('./supervise.js')
|
|
308
|
+
} else {
|
|
309
|
+
console.error(`spex serve: unknown target '${target}' — spex serve [api] (the backend) | spex serve ui (the dashboard)`)
|
|
310
|
+
process.exit(2)
|
|
182
311
|
}
|
|
183
|
-
await import('./supervise.js')
|
|
184
|
-
} else if (cmd === 'dashboard') {
|
|
185
|
-
// the natural post-install UI: serve the bundled dashboard on its OWN port (loopback by default;
|
|
186
|
-
// --host widens the bind for LAN/tailnet viewing), proxying /api + the terminal socket to a
|
|
187
|
-
// separately-run `spex serve`. Replaces the dogfood-only `npm run web` (vite).
|
|
188
|
-
const { serveDashboardLocal } = await import('./gateway.js')
|
|
189
|
-
const port = Number(flag('port') ?? process.env.SPEXCODE_DASHBOARD_PORT ?? 5173)
|
|
190
|
-
const apiPort = Number(flag('api-port') ?? process.env.PORT ?? 8787)
|
|
191
|
-
const host = flag('host') ?? '127.0.0.1'
|
|
192
|
-
if (!Number.isInteger(port) || !Number.isInteger(apiPort)) { console.error('spex dashboard: --port and --api-port must be integers'); process.exit(2) }
|
|
193
|
-
serveDashboardLocal({ port, apiPort, host })
|
|
194
312
|
} else if (cmd === undefined || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
195
313
|
// `spex help <cmd>` drills into one command; bare help is the map. Both name the next layer down.
|
|
196
314
|
const { commandHelp, overviewHelp } = await import('./help.js')
|
|
197
315
|
const topic = positionals(3)[0]
|
|
198
316
|
if (cmd === 'help' && topic) {
|
|
317
|
+
if (SIGNPOSTS[topic]) signpost(`spex ${topic}`, SIGNPOSTS[topic])
|
|
199
318
|
const h = commandHelp(topic)
|
|
200
319
|
if (!h) { console.error(`spex help: no command '${topic}' — run \`spex help\` for the map`); process.exit(2) }
|
|
201
320
|
console.log(h)
|
|
202
321
|
} else console.log(overviewHelp())
|
|
203
322
|
} else if (cmd === 'guide') {
|
|
204
323
|
const { guideText } = await import('./guide.js')
|
|
324
|
+
if (process.argv[3] === 'config') signpost('spex guide config', 'spex guide settings')
|
|
205
325
|
const text = guideText(process.argv[3])
|
|
206
326
|
if (text === null) {
|
|
207
|
-
console.error(`spex guide: no topic '${process.argv[3]}'. Topics: spec,
|
|
327
|
+
console.error(`spex guide: no topic '${process.argv[3]}'. Topics: spec, eval, settings, footprint. Run \`spex guide\` (no topic) for the setup workflow, \`spex help\` for the command map.`)
|
|
208
328
|
process.exit(2)
|
|
209
329
|
}
|
|
210
330
|
console.log(text)
|
|
211
|
-
} else if (cmd === '
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
const
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
console.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
// but a human asking gets the honest nuance: nothing tracks this file's drift.
|
|
230
|
-
if (has('actionable')) process.exit(0)
|
|
231
|
-
console.log(`${rel} — not governed (no code: claim), but referenced by ${names(related)} (related: coverage only). Nothing tracks its drift; if your change is substantive, consider giving it a governing home.`)
|
|
232
|
-
} else if (owners.length <= maxOwners) {
|
|
233
|
-
// a sanely-owned file is NOT actionable: --actionable callers (the per-edit spec-of-file hook) stay
|
|
234
|
-
// silent here, so the annotation fires only on an OVER-owned or uncovered file — rare and worth acting on.
|
|
235
|
-
if (has('actionable')) process.exit(0)
|
|
236
|
-
const named = owners.map((o) => `'${o.id}'`).join(', ')
|
|
237
|
-
const lead = owners.length === 1 ? `${rel} is governed by ${named} — ${owners[0].desc}` : `${rel} is governed by ${named} (shared, fine).`
|
|
238
|
-
console.log(`${lead} Read/honor the spec; if your change shifts the intent, update the spec in the SAME commit.${relLine}`)
|
|
239
|
-
} else {
|
|
240
|
-
const ids = owners.map((o) => o.id).join(', ')
|
|
241
|
-
console.log(`${rel} is governed by ${owners.length} specs (${ids}) — more than one file should hold. This file does TOO MUCH: SPLIT it so each governor owns its own module (or merge the nodes if they're one concern, or give it a single foundation owner + relate the rest).${relLine}`)
|
|
242
|
-
}
|
|
243
|
-
} else if (cmd === 'lint') {
|
|
244
|
-
const { specLint, driftGate, DRIFT_GUIDANCE } = await import('./lint.js')
|
|
245
|
-
const findings = await specLint()
|
|
246
|
-
const errors = findings.filter((f) => f.level === 'error')
|
|
247
|
-
for (const f of findings) console.error(` ${f.level === 'error' ? '✗' : '•'} ${f.rule}: ${f.msg}`)
|
|
248
|
-
console.error(`spex lint: ${errors.length} error(s), ${findings.length - errors.length} warning(s)`)
|
|
249
|
-
// drift teaches + gates from the ONE `spex lint` (no flag): print the remediation guidance wherever
|
|
250
|
-
// drift exists, then apply the commit-local gate — which reads the staged index itself, so it only
|
|
251
|
-
// blocks an in-flight commit that touches an already heavily-drifted node. CI/manual (nothing staged)
|
|
252
|
-
// stays advisory, per the ci-gate contract.
|
|
253
|
-
const { blocked, touched, threshold } = await driftGate()
|
|
254
|
-
if (findings.some((f) => f.rule === 'drift') || touched.length) console.error(`\n${DRIFT_GUIDANCE}`)
|
|
255
|
-
for (const t of touched) console.error(` ${t.drift >= threshold ? '✗' : '•'} drift-gate: '${t.id}' is ${t.drift} behind${t.drift >= threshold ? ' — BLOCKS this commit' : ' (advisory)'}`)
|
|
256
|
-
if (blocked.length) console.error(`\n✗ SpexCode: ${blocked.join(', ')} ${blocked.length === 1 ? 'is' : 'are'} ≥ ${threshold} commit(s) behind. Reconcile (above) or bypass with SPEXCODE_SKIP_LINT=1.`)
|
|
257
|
-
process.exit(errors.length || blocked.length ? 1 : 0)
|
|
258
|
-
} else if (cmd === 'ack') {
|
|
259
|
-
// An EMPTY stamp commit on top of HEAD, never an amend: driftFor (git.ts) quiets every drift commit
|
|
260
|
-
// REACHABLE from an ack, so a child stamp covers exactly what amending HEAD would — and it works where
|
|
261
|
-
// amend can't: on a trunk merge commit, re-authoring it after MERGE_HEAD is gone reads to main-guard as
|
|
262
|
-
// a direct trunk commit. The guard passes the stamp through its tree-unchanged gate instead. `--only`
|
|
263
|
-
// with no paths pins the commit to HEAD's tree even when the index is dirty — an ack must never sweep
|
|
264
|
-
// staged files along. git de-dupes adjacent trailers, so re-acking is harmless.
|
|
265
|
-
const { git } = await import('./git.js')
|
|
266
|
-
const nodes = positionals(3)
|
|
267
|
-
const reason = (flag('reason') ?? '').trim()
|
|
268
|
-
if (!nodes.length || !reason) {
|
|
269
|
-
console.error('usage: spex ack <node-id>… --reason "<why this change keeps each spec valid>"')
|
|
270
|
-
console.error(' --reason is required (it forces you to check before acking) and is NOT stored — git keeps only the Spec-OK trailer.')
|
|
271
|
-
process.exit(2)
|
|
331
|
+
} else if (cmd === 'graph') {
|
|
332
|
+
// @@@ graph - the ONE assembled view (tree + worktree overlay + sessions), both faces of it: bare (with
|
|
333
|
+
// --focus/--depth) renders the human-readable status-coloured tree; --json dumps the full payload —
|
|
334
|
+
// identical to GET /api/graph, machine food. Colour degrades cleanly: off unless stdout is a tty, and
|
|
335
|
+
// NO_COLOR always wins.
|
|
336
|
+
if (flag('node') !== undefined) { console.error('spex graph: --node was renamed — use --focus <id>'); process.exit(2) }
|
|
337
|
+
const { buildBoard } = await import('./graph.js')
|
|
338
|
+
const focusRaw = flag('focus')
|
|
339
|
+
const depthRaw = flag('depth')
|
|
340
|
+
const depth = depthRaw === undefined ? undefined : Number(depthRaw)
|
|
341
|
+
if (depth !== undefined && (!Number.isInteger(depth) || depth < 0)) { console.error('spex graph: --depth must be a non-negative integer'); process.exit(2) }
|
|
342
|
+
const board = await buildBoard()
|
|
343
|
+
if (has('json') && focusRaw === undefined && depth === undefined) {
|
|
344
|
+
// interactive-only stderr hint: the JSON dump is machine food; a human at a tty gets pointed at
|
|
345
|
+
// the readable twin. Piped/redirected stdout stays byte-identical (the hint never touches stdout).
|
|
346
|
+
if (process.stdout.isTTY) console.error('(human-readable: spex graph)')
|
|
347
|
+
console.log(JSON.stringify(board, null, 2))
|
|
348
|
+
await flushExit(0)
|
|
272
349
|
}
|
|
350
|
+
const { renderTree, treeJson } = await import('./tree.js')
|
|
351
|
+
const opts = { node: focusRaw && stripRefSigil(focusRaw), depth, color: process.stdout.isTTY && !process.env.NO_COLOR }
|
|
273
352
|
try {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
console.log(
|
|
353
|
+
// --json on a FOCUSED/pruned view is that view's JSON (the filtered subtree, badge counts precomputed);
|
|
354
|
+
// --json bare is the full payload above — the filter chooses which representation you asked for.
|
|
355
|
+
console.log(has('json') ? JSON.stringify(treeJson(board.nodes, opts), null, 2) : renderTree(board.nodes, opts))
|
|
277
356
|
} catch (e: any) {
|
|
278
|
-
console.error(`
|
|
357
|
+
console.error(`spex graph: ${e?.message ?? e}`)
|
|
358
|
+
process.exit(2)
|
|
359
|
+
}
|
|
360
|
+
await flushExit(0)
|
|
361
|
+
} else if (cmd === 'spec') {
|
|
362
|
+
// @@@ spec drawer - the governance graph's own verbs: search (topic → node), owner (file → node, the
|
|
363
|
+
// reverse edge), lint (the spec↔code graph check — errors gate commits), ack (the drift stamp).
|
|
364
|
+
const sub = process.argv[3]
|
|
365
|
+
if (sub === undefined) {
|
|
366
|
+
console.log((await import('./help.js')).commandHelp('spec'))
|
|
367
|
+
} else if (sub === 'search') {
|
|
368
|
+
const { searchSpecs, nearestTitles } = await import('./search.js')
|
|
369
|
+
const query = positionals(4).join(' ')
|
|
370
|
+
if (!query.trim()) { console.error('usage: spex spec search <query> [--json] [--limit N]'); process.exit(2) }
|
|
371
|
+
const limit = Number(flag('limit')) || 10
|
|
372
|
+
const results = await searchSpecs(query, { limit, onStats: (s) => console.error(`[spec-search] compute ${s.ms.toFixed(1)}ms · ${s.nodes} nodes · ${s.tokens} tokens (excludes process start)`) })
|
|
373
|
+
// zero-result fail-loud + route-to-next-step: always the corpus-is-English fact (unconditional — no
|
|
374
|
+
// language sniffing, no score threshold), plus the nearest titles when anything is even near (typo
|
|
375
|
+
// recovery) and the browse-all pointer, so no query dead-ends.
|
|
376
|
+
const NO_MATCH = (q: string) => {
|
|
377
|
+
const near = nearestTitles(q, 3)
|
|
378
|
+
return [
|
|
379
|
+
`no spec node matches "${q}" (the corpus is English — if your query isn't, translate and retry)`,
|
|
380
|
+
...(near.length ? ['nearest titles:', ...near.map((t) => ` ${t.title} [${t.id}]`)] : []),
|
|
381
|
+
'browse all: spex graph',
|
|
382
|
+
].join('\n')
|
|
383
|
+
}
|
|
384
|
+
if (has('json')) {
|
|
385
|
+
if (!results.length) console.error(NO_MATCH(query)) // stderr: the stdout JSON contract stays verbatim
|
|
386
|
+
console.log(JSON.stringify(results)); await flushExit(0)
|
|
387
|
+
}
|
|
388
|
+
if (!results.length) { console.log(NO_MATCH(query)); process.exit(0) }
|
|
389
|
+
results.forEach((r, i) => {
|
|
390
|
+
console.log(`${String(i + 1).padStart(2)}. ${r.title} [${r.id}] · score ${r.score}`)
|
|
391
|
+
console.log(` ${r.path}`)
|
|
392
|
+
if (r.snippet) console.log(` ${r.snippet}`)
|
|
393
|
+
})
|
|
394
|
+
process.exit(0)
|
|
395
|
+
} else if (sub === 'owner') {
|
|
396
|
+
// BOTH [[governed-related]] relations, distinctly: governors (code: — the verdict) and referencers
|
|
397
|
+
// (related: — pointers; coverage only, never drift, never eval freshness).
|
|
398
|
+
const { specOwners, specRelated } = await import('./specs.js')
|
|
399
|
+
const { loadConfig } = await import('./lint.js')
|
|
400
|
+
const p0 = positionals(4)[0]
|
|
401
|
+
if (!p0) { console.error('usage: spex spec owner <path> [--actionable]'); process.exit(2) }
|
|
402
|
+
const p = stripRefSigil(p0)
|
|
403
|
+
const rel = p.startsWith(process.cwd()) ? p.slice(process.cwd().length + 1) : p
|
|
404
|
+
const owners = specOwners(p)
|
|
405
|
+
const related = specRelated(p)
|
|
406
|
+
const maxOwners = loadConfig(process.cwd()).maxOwners
|
|
407
|
+
const names = (xs: { id: string }[]) => xs.map((o) => `'${o.id}'`).join(', ')
|
|
408
|
+
const relLine = related.length ? `\n also referenced by ${names(related)} (related: coverage only — no drift, no eval freshness)` : ''
|
|
409
|
+
if (owners.length === 0 && related.length === 0) {
|
|
410
|
+
console.log(`${rel} — no spec claims this yet (uncovered). If your change is substantive, give it a home before it drifts.`)
|
|
411
|
+
} else if (owners.length === 0) {
|
|
412
|
+
// related-only: lint's coverage is satisfied, so the per-edit hook stays silent (lint-consistent) —
|
|
413
|
+
// but a human asking gets the honest nuance: nothing tracks this file's drift.
|
|
414
|
+
if (has('actionable')) process.exit(0)
|
|
415
|
+
console.log(`${rel} — not governed (no code: claim), but referenced by ${names(related)} (related: coverage only). Nothing tracks its drift; if your change is substantive, consider giving it a governing home.`)
|
|
416
|
+
} else if (owners.length <= maxOwners) {
|
|
417
|
+
// a sanely-owned file is NOT actionable: --actionable callers (the per-edit spec-of-file hook) stay
|
|
418
|
+
// silent here, so the annotation fires only on an OVER-owned or uncovered file — rare and worth acting on.
|
|
419
|
+
if (has('actionable')) process.exit(0)
|
|
420
|
+
const named = owners.map((o) => `'${o.id}'`).join(', ')
|
|
421
|
+
const lead = owners.length === 1 ? `${rel} is governed by ${named} — ${owners[0].desc}` : `${rel} is governed by ${named} (shared, fine).`
|
|
422
|
+
console.log(`${lead} Read/honor the spec; if your change shifts the intent, update the spec in the SAME commit.${relLine}`)
|
|
423
|
+
} else {
|
|
424
|
+
const ids = owners.map((o) => o.id).join(', ')
|
|
425
|
+
console.log(`${rel} is governed by ${owners.length} specs (${ids}) — more than one file should hold. This file does TOO MUCH: SPLIT it so each governor owns its own module (or merge the nodes if they're one concern, or give it a single foundation owner + relate the rest).${relLine}`)
|
|
426
|
+
}
|
|
427
|
+
} else if (sub === 'lint') {
|
|
428
|
+
const { specLint, DRIFT_GUIDANCE } = await import('./lint.js')
|
|
429
|
+
const findings = await specLint()
|
|
430
|
+
const errors = findings.filter((f) => f.level === 'error')
|
|
431
|
+
for (const f of findings) console.error(` ${f.level === 'error' ? '✗' : '•'} ${f.rule}: ${f.msg}`)
|
|
432
|
+
console.error(`spex spec lint: ${errors.length} error(s), ${findings.length - errors.length} warning(s)`)
|
|
433
|
+
// drift teaches from the ONE `spex spec lint` (no flag). Unanchored drift stays advisory forever; the
|
|
434
|
+
// blocking tier is anchor-drift ([[code-anchor]]) — an ERROR like any other, so the pre-commit shim
|
|
435
|
+
// (and CI) gates on it with no separate staged-index machinery.
|
|
436
|
+
if (findings.some((f) => f.rule === 'drift' || f.rule === 'anchor-drift')) console.error(`\n${DRIFT_GUIDANCE}`)
|
|
437
|
+
process.exit(errors.length ? 1 : 0)
|
|
438
|
+
} else if (sub === 'ack') {
|
|
439
|
+
// An EMPTY stamp commit on top of HEAD, never an amend: driftFor (git.ts) quiets every drift commit
|
|
440
|
+
// REACHABLE from an ack, so a child stamp covers exactly what amending HEAD would — and it works where
|
|
441
|
+
// amend can't: on a trunk merge commit, re-authoring it after MERGE_HEAD is gone reads to main-guard as
|
|
442
|
+
// a direct trunk commit. The guard passes the stamp through its tree-unchanged gate instead. `--only`
|
|
443
|
+
// with no paths pins the commit to HEAD's tree even when the index is dirty — an ack must never sweep
|
|
444
|
+
// staged files along. git de-dupes adjacent trailers, so re-acking is harmless.
|
|
445
|
+
const { git } = await import('./git.js')
|
|
446
|
+
const nodes = positionals(4)
|
|
447
|
+
const reason = (flag('reason') ?? '').trim()
|
|
448
|
+
if (!nodes.length || !reason) {
|
|
449
|
+
console.error('usage: spex spec ack <node-id>… --reason "<why this change keeps each spec valid>"')
|
|
450
|
+
console.error(' --reason is required (it forces you to check before acking) and is recorded in the ack commit\'s message body — an ack that quiets an anchor hit is a strong claim, so its why must be durable.')
|
|
451
|
+
process.exit(2)
|
|
452
|
+
}
|
|
453
|
+
try {
|
|
454
|
+
git(['commit', '--only', '--allow-empty', '-m', `ack: Spec-OK ${nodes.join(', ')}`, '-m', reason,
|
|
455
|
+
...nodes.flatMap((n) => ['--trailer', `Spec-OK: ${n}`])])
|
|
456
|
+
console.log(`Spec-OK: ${nodes.join(', ')} → ${git(['rev-parse', '--short', 'HEAD']).trim()} (empty stamp commit; reason recorded in the commit body)`)
|
|
457
|
+
} catch (e: any) {
|
|
458
|
+
console.error(`ack failed: ${e?.message ?? e}`); process.exit(1)
|
|
459
|
+
}
|
|
460
|
+
} else {
|
|
461
|
+
console.error(`spex spec: unknown verb '${sub}' — search | owner | lint | ack (spex help spec)`)
|
|
462
|
+
process.exit(2)
|
|
279
463
|
}
|
|
280
464
|
} else if (cmd === 'init') {
|
|
281
465
|
// scaffold a repo to adopt SpexCode: copy the shipped DATA templates (seed spec tree + git hooks)
|
|
@@ -284,122 +468,112 @@ if (cmd === 'serve') {
|
|
|
284
468
|
await specInit(positionals(3)[0], flag('preset'))
|
|
285
469
|
} else if (cmd === 'uninstall') {
|
|
286
470
|
// the surgical inverse of init: remove every SpexCode-generated artifact (harness shims/contract/trust, the
|
|
287
|
-
// .gitignore block, the global store, any plugin bundle) — NEVER the user's .spec/.
|
|
471
|
+
// .gitignore block, the global store, any plugin bundle) — NEVER the user's .spec/.plugins data or their own
|
|
288
472
|
// prose. Git hooks preserved unless --hooks. spex uninstall [targetDir] [--hooks]
|
|
289
473
|
const { uninstall } = await import('./uninstall.js')
|
|
290
474
|
uninstall(positionals(3)[0], { hooks: has('hooks') })
|
|
291
475
|
} else if (cmd === 'eval') {
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
console.log(
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
console.log(`
|
|
476
|
+
// @@@ eval drawer - the measurement system's verbs: add (file a reading) · ls (read a node's timeline, or
|
|
477
|
+
// — with an explicit --session, never type-sniffed — a session's aggregate) · lint (the measurement-layer
|
|
478
|
+
// lint, pure advisory) · retract · clean. Node-scoped verbs live in spec-eval; the session read lives
|
|
479
|
+
// here (it talks to the backend).
|
|
480
|
+
const sub = process.argv[3]
|
|
481
|
+
if (sub === undefined) {
|
|
482
|
+
console.log((await import('./help.js')).commandHelp('eval'))
|
|
483
|
+
} else if (sub === 'ls' && flag('session') !== undefined) {
|
|
484
|
+
// the session EVAL read ([[session-eval]]'s interactive face as a CLI verb): the dashboard Eval tab's
|
|
485
|
+
// text twin. Renders the session's changed nodes with each DECLARED scenario at its CURRENT score
|
|
486
|
+
// (latest reading per scenario, worktree-rooted) — blind spots lead, the session's OWN measurements
|
|
487
|
+
// ✦-marked ahead of the inherited baseline under its divider. --export writes the self-contained HTML
|
|
488
|
+
// artifact instead.
|
|
489
|
+
const id = await resolveSelectorOrExit(flag('session')!)
|
|
490
|
+
if (has('export')) await evalExport(id)
|
|
491
|
+
const { clientEvals } = await import('./client.js')
|
|
492
|
+
const r = await clientEvals(id)
|
|
493
|
+
if (!r.ok) { console.error(`no evals for ${id} (status ${r.status})`); process.exit(1) }
|
|
494
|
+
if (has('json')) { console.log(JSON.stringify(r.model, null, 2)); await flushExit(0) }
|
|
495
|
+
const m = r.model
|
|
496
|
+
// mirror the tab's scenarioStates per node: latest reading per DECLARED scenario (evals arrive
|
|
497
|
+
// newest-first), so a retired scenario's residual reading contributes no row; the ✦ count is over
|
|
498
|
+
// these rows — the same number the tab's chip shows.
|
|
499
|
+
const groups = m.nodes.map((n) => {
|
|
500
|
+
const latest = new Map<string, (typeof n.evals)[number]>()
|
|
501
|
+
for (const e of n.evals) if (!latest.has(e.scenario)) latest.set(e.scenario, e)
|
|
502
|
+
const blind = n.scenarios.filter((s) => !latest.has(s.name))
|
|
503
|
+
const rows = n.scenarios.filter((s) => latest.has(s.name)).map((s) => latest.get(s.name)!)
|
|
504
|
+
.sort((a, b) => (Number(b.inSession) - Number(a.inSession)) || (a.ts < b.ts ? 1 : -1))
|
|
505
|
+
return { n, blind, rows }
|
|
506
|
+
})
|
|
507
|
+
const own = groups.reduce((a, g) => a + g.rows.filter((e) => e.inSession).length, 0)
|
|
508
|
+
console.log(`eval ${m.title} [${m.id}]`)
|
|
509
|
+
console.log(` branch : ${m.branch ?? '—'} · ${m.ahead} commit(s) ahead · ${m.dirtyNonRuntime} uncommitted`)
|
|
510
|
+
console.log(` gates : ${m.gates.map((g) => `${g.ok ? '✓' : '✗'} ${g.label} — ${g.detail}`).join(' · ')}`)
|
|
511
|
+
if (own) console.log(` ✦ : ${own} scenario(s) measured by THIS session (unmarked rows = inherited baseline)`)
|
|
512
|
+
if (!m.nodes.length) console.log('\n no changed spec nodes — nothing to evaluate yet (empty diff)')
|
|
513
|
+
for (const { n, blind, rows } of groups) {
|
|
514
|
+
console.log(`\n${n.title} [${n.id}]${n.uncoveredFrontend ? ' ⚠ frontend change with NO eval.md — a blind spot: give it a scenario' : ''}`)
|
|
515
|
+
for (const s of blind) console.log(` ∅ unmeasured ${s.name} — declared, never measured (blind spot)`)
|
|
516
|
+
let divided = false
|
|
517
|
+
for (const e of rows) {
|
|
518
|
+
if (!e.inSession && !divided && rows.some((x) => x.inSession)) { console.log(` ── inherited baseline (other sessions' latest evals) ──`); divided = true }
|
|
519
|
+
const verdict = e.verdict?.status === 'pass' ? '✓ pass' : e.verdict?.status === 'fail' ? '✗ fail' : '· unscored'
|
|
520
|
+
const stale = e.fresh ? '' : ` (stale: ${e.staleAxes.join(',')})`
|
|
521
|
+
console.log(` ${e.inSession ? '✦' : ' '} ${verdict}${stale} ${e.scenario} — ${e.ts}${e.evaluator ? ` · ${e.evaluator}` : ''}`)
|
|
522
|
+
}
|
|
523
|
+
if (!n.hasEvalFile && !n.uncoveredFrontend) console.log(' (no eval.md — nothing declared to measure)')
|
|
524
|
+
else if (n.hasEvalFile && !n.scenarios.length) console.log(' (eval.md declares no scenarios)')
|
|
332
525
|
}
|
|
333
|
-
|
|
334
|
-
|
|
526
|
+
} else if (['add', 'ls', 'scenario', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
|
|
527
|
+
// node-scoped verbs — thin route; the logic lives in spec-eval.
|
|
528
|
+
const { runEval } = await import('../../spec-eval/src/cli.js')
|
|
529
|
+
await flushExit(await runEval(process.argv.slice(3)))
|
|
530
|
+
} else {
|
|
531
|
+
console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls | lint | ok | retract | clean (spex help eval)`)
|
|
532
|
+
if (!sub.startsWith('--')) console.error(` (the old \`spex eval <SEL>\` session read is now \`spex eval ls --session <SEL>\` [--export])`) // dead-words-ok: signpost — one-version tombstone teaching the renamed spelling (0.4.0 removes it)
|
|
533
|
+
process.exit(2)
|
|
534
|
+
}
|
|
535
|
+
} else if (cmd === 'evidence') {
|
|
536
|
+
// @@@ evidence drawer - the bare content-addressed transport pair ([[evidence-put]], [[evidence-get]]): put bytes
|
|
537
|
+
// in the shared evidence cache / read them back by hash, decoupled from filing a reading. Thin route — the
|
|
538
|
+
// cache lives in spec-eval. flushExit matters here: `get` pipes raw blob bytes to stdout.
|
|
539
|
+
if (process.argv[3] === undefined) {
|
|
540
|
+
console.log((await import('./help.js')).commandHelp('evidence'))
|
|
541
|
+
} else {
|
|
542
|
+
const { runEvidence } = await import('../../spec-eval/src/cli.js')
|
|
543
|
+
await flushExit(await runEvidence(process.argv.slice(3)))
|
|
544
|
+
}
|
|
545
|
+
} else if (cmd === 'issue') {
|
|
546
|
+
// @@@ issue drawer - the ONE issue surface ([[issues]]): `ls` is THE read — local + forge issues as ONE
|
|
547
|
+
// store-tagged list, the supervisor's/human's drain view; `show <id>` the single-thread detail (the same
|
|
548
|
+
// read GET /api/issues/:id serves); open/reply/close are store-routed (the SAME
|
|
549
|
+
// createIssue/replyIssue/closeIssue the dashboard's API calls); `promote` moves a thread cross-store;
|
|
550
|
+
// `links` traces forge issues/PRs onto spec nodes (read-only, spec-forge).
|
|
551
|
+
if (process.argv[3] === undefined) {
|
|
552
|
+
console.log((await import('./help.js')).commandHelp('issue'))
|
|
553
|
+
} else {
|
|
554
|
+
const { runIssues } = await import('./issues.js')
|
|
555
|
+
await flushExit(await runIssues(process.argv.slice(3)))
|
|
335
556
|
}
|
|
336
|
-
} else if (cmd === '
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
else {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
console.log(` uncommitted : ${r.dirtyNonRuntime} non-runtime file(s)`)
|
|
356
|
-
console.log(` proposal : ${r.proposal.kind ?? '—'}${r.proposal.note ? ` — ${r.proposal.note}` : ''}`)
|
|
357
|
-
console.log(' gates:')
|
|
358
|
-
console.log(` conflicts w/ main : ${g.conflictsWithMain ? 'YES' : 'no'}`)
|
|
359
|
-
console.log(` lint : ${g.lint.errorCount} error(s), ${g.lint.warningCount} warning(s)`)
|
|
360
|
-
console.log(` diff (merge-base, ${r.diff.length} file(s)):`)
|
|
361
|
-
for (const f of r.diff) console.log(` ${f.status.padEnd(12)} +${f.additions} -${f.deletions} ${f.path}`)
|
|
557
|
+
} else if (cmd === 'remark') {
|
|
558
|
+
// @@@ remark drawer - the resolvable interaction primitive ([[remark-substrate]]): `add` pins a concern to
|
|
559
|
+
// a HOST (a local issue, or a scenario `<node> --scenario <name>`), a second agent `resolve`s it, the
|
|
560
|
+
// author `retract`s it. CLI-first — the whole loop is these thin store-write wrappers, so the dashboard
|
|
561
|
+
// adds no capability.
|
|
562
|
+
const sub = process.argv[3]
|
|
563
|
+
const m = sub === 'add' || sub === 'resolve' || sub === 'retract' ? await import('./localIssues.js') : null
|
|
564
|
+
if (sub === undefined) {
|
|
565
|
+
console.log((await import('./help.js')).commandHelp('remark'))
|
|
566
|
+
} else if (sub === 'add') {
|
|
567
|
+
await flushExit(await m!.runRemark(process.argv.slice(4)))
|
|
568
|
+
} else if (sub === 'resolve') {
|
|
569
|
+
await flushExit(await m!.runResolve(process.argv.slice(4)))
|
|
570
|
+
} else if (sub === 'retract') {
|
|
571
|
+
await flushExit(await m!.runRetract(process.argv.slice(4)))
|
|
572
|
+
} else {
|
|
573
|
+
console.error(`spex remark: unknown verb '${sub}' — add | resolve | retract (spex help remark)`)
|
|
574
|
+
if (!sub.startsWith('--')) console.error(' (the old bare `spex remark <host> --body …` write is now `spex remark add <host> --body …`)')
|
|
575
|
+
process.exit(2)
|
|
362
576
|
}
|
|
363
|
-
} else if (cmd === 'merge') {
|
|
364
|
-
const { clientMerge } = await import('./client.js')
|
|
365
|
-
const sel = positionals(3)[0]
|
|
366
|
-
if (!sel) { console.error('usage: spex merge <selector> (id | id-prefix | node | branch)'); process.exit(2) }
|
|
367
|
-
const id = await resolveSelectorOrExit(sel)
|
|
368
|
-
const r = await clientMerge(id)
|
|
369
|
-
if (r.dispatched) console.log(`merge dispatched to ${id} — its agent is landing the merge`)
|
|
370
|
-
else console.error(`merge dispatch failed: ${r.reason}`)
|
|
371
|
-
process.exit(r.dispatched ? 0 : 1)
|
|
372
|
-
} else if (cmd === 'forge') {
|
|
373
|
-
// thin route — all logic lives in spec-forge.
|
|
374
|
-
const { runForge } = await import('../../spec-forge/src/cli.js')
|
|
375
|
-
await flushExit(await runForge(process.argv.slice(3)))
|
|
376
|
-
} else if (cmd === 'yatsu') {
|
|
377
|
-
// thin route — all logic lives in spec-yatsu.
|
|
378
|
-
const { runYatsu } = await import('../../spec-yatsu/src/cli.js')
|
|
379
|
-
await flushExit(await runYatsu(process.argv.slice(3)))
|
|
380
|
-
} else if (cmd === 'blob') {
|
|
381
|
-
// @@@ blob - the bare evidence-transport pair ([[blob-put]], [[blob-get]]): put bytes in the shared
|
|
382
|
-
// content-addressed cache / read them back by hash, decoupled from filing a reading. Thin route — the
|
|
383
|
-
// cache lives in spec-yatsu. flushExit matters here: `get` pipes raw blob bytes to stdout.
|
|
384
|
-
const { runBlob } = await import('../../spec-yatsu/src/cli.js')
|
|
385
|
-
await flushExit(await runBlob(process.argv.slice(3)))
|
|
386
|
-
} else if (cmd === 'issues') {
|
|
387
|
-
// @@@ issues - the ONE issues surface ([[issues]]): bare it is THE read — local + forge issues as ONE
|
|
388
|
-
// store-tagged list, the supervisor's/human's drain view; a write first-positional (open|reply|
|
|
389
|
-
// on|off|status, [[local-issues]]) routes to the write verbs (open/reply/close are store-routed —
|
|
390
|
-
// the SAME createIssue/replyIssue/closeIssue the dashboard's API calls), `promote` moves a
|
|
391
|
-
// thread cross-store. (The pre-rename `spex propose` alias is gone — a deployed post-merge hook still
|
|
392
|
-
// calling it prints an unknown-command line, advisory-only, until `npm run hooks` reinstalls it.)
|
|
393
|
-
const { runIssues } = await import('./issues.js')
|
|
394
|
-
await flushExit(await runIssues(process.argv.slice(3)))
|
|
395
|
-
} else if (cmd === 'remark' || cmd === 'resolve' || cmd === 'retract') {
|
|
396
|
-
// @@@ remark - the resolvable interaction primitive ([[remark-substrate]]): pin a concern to a HOST (a
|
|
397
|
-
// local issue, or a scenario `<node> --scenario <name>`) that a second agent can `resolve` and the author
|
|
398
|
-
// can `retract`. CLI-first — the whole author→resolve→retract loop is these thin store-write wrappers, so
|
|
399
|
-
// the dashboard adds no capability. `spex remark <host> --body -|<text> [--code-sha <sha>]`.
|
|
400
|
-
const m = await import('./localIssues.js')
|
|
401
|
-
const run = cmd === 'remark' ? m.runRemark : cmd === 'resolve' ? m.runResolve : m.runRetract
|
|
402
|
-
await flushExit(await run(process.argv.slice(3)))
|
|
403
577
|
} else if (cmd === 'materialize') {
|
|
404
578
|
// @@@ materialize - surface nodes → manifest + AGENTS.md/CLAUDE.md block + shims + Codex
|
|
405
579
|
// trust, for cwd's project. Anchored on git-native events only ([[commit-surgery]]): this verb, init,
|
|
@@ -411,316 +585,260 @@ if (cmd === 'serve') {
|
|
|
411
585
|
// the global install, while the report is about THIS agent's wiring): does the materialized workflow
|
|
412
586
|
// actually reach this agent? Bare `doctor` reports per-layer coverage (preconditions · git-hook floor ·
|
|
413
587
|
// contract · hooks+handler-existence · backend) over the same HARNESSES materialize delivers through;
|
|
414
|
-
//
|
|
588
|
+
// `--contract` prints the surface:system text; `--conflicts` just the double-delivery check. Thin route.
|
|
415
589
|
const { runDoctor } = await import('./doctor.js')
|
|
416
590
|
await flushExit(await runDoctor(process.argv.slice(3)))
|
|
417
|
-
} else if (cmd === 'board') {
|
|
418
|
-
const { buildBoard } = await import('./board.js')
|
|
419
|
-
// interactive-only stderr hint: the JSON dump is machine food; a human at a tty gets pointed at
|
|
420
|
-
// the readable twin. Piped/redirected stdout stays byte-identical (the hint never touches stdout).
|
|
421
|
-
if (process.stdout.isTTY) console.error('(human-readable tree: spex tree)')
|
|
422
|
-
console.log(JSON.stringify(await buildBoard(), null, 2))
|
|
423
|
-
await flushExit(0)
|
|
424
|
-
} else if (cmd === 'tree') {
|
|
425
|
-
// @@@ tree - the human-readable graph ([[spex-tree]]): the same buildBoard() the dashboard renders,
|
|
426
|
-
// as an indented status-coloured terminal tree. Colour degrades cleanly: off unless stdout is a tty,
|
|
427
|
-
// and NO_COLOR always wins.
|
|
428
|
-
const { buildBoard } = await import('./board.js')
|
|
429
|
-
const { renderTree, treeJson } = await import('./tree.js')
|
|
430
|
-
const depthRaw = flag('depth')
|
|
431
|
-
const depth = depthRaw === undefined ? undefined : Number(depthRaw)
|
|
432
|
-
if (depth !== undefined && (!Number.isInteger(depth) || depth < 0)) { console.error('spex tree: --depth must be a non-negative integer'); process.exit(2) }
|
|
433
|
-
const opts = { node: flag('node') && stripRefSigil(flag('node')!), depth, color: process.stdout.isTTY && !process.env.NO_COLOR }
|
|
434
|
-
const { nodes } = await buildBoard()
|
|
435
|
-
try {
|
|
436
|
-
console.log(has('json') ? JSON.stringify(treeJson(nodes, opts), null, 2) : renderTree(nodes, opts))
|
|
437
|
-
} catch (e: any) {
|
|
438
|
-
console.error(`spex tree: ${e?.message ?? e}`)
|
|
439
|
-
process.exit(2)
|
|
440
|
-
}
|
|
441
|
-
await flushExit(0)
|
|
442
|
-
} else if (cmd === 'search') {
|
|
443
|
-
const { searchSpecs, nearestTitles } = await import('./search.js')
|
|
444
|
-
const query = positionals(3).join(' ')
|
|
445
|
-
if (!query.trim()) { console.error('usage: spex search <query> [--json] [--limit N]'); process.exit(2) }
|
|
446
|
-
const limit = Number(flag('limit')) || 10
|
|
447
|
-
const results = await searchSpecs(query, { limit, onStats: (s) => console.error(`[spec-search] compute ${s.ms.toFixed(1)}ms · ${s.nodes} nodes · ${s.tokens} tokens (excludes process start)`) })
|
|
448
|
-
// zero-result fail-loud + route-to-next-step: always the corpus-is-English fact (unconditional — no
|
|
449
|
-
// language sniffing, no score threshold), plus the nearest titles when anything is even near (typo
|
|
450
|
-
// recovery) and the browse-all pointer, so no query dead-ends.
|
|
451
|
-
const NO_MATCH = (q: string) => {
|
|
452
|
-
const near = nearestTitles(q, 3)
|
|
453
|
-
return [
|
|
454
|
-
`no spec node matches "${q}" (the corpus is English — if your query isn't, translate and retry)`,
|
|
455
|
-
...(near.length ? ['nearest titles:', ...near.map((t) => ` ${t.title} [${t.id}]`)] : []),
|
|
456
|
-
'browse all: spex tree',
|
|
457
|
-
].join('\n')
|
|
458
|
-
}
|
|
459
|
-
if (has('json')) {
|
|
460
|
-
if (!results.length) console.error(NO_MATCH(query)) // stderr: the stdout JSON contract stays verbatim
|
|
461
|
-
console.log(JSON.stringify(results)); await flushExit(0)
|
|
462
|
-
}
|
|
463
|
-
if (!results.length) { console.log(NO_MATCH(query)); process.exit(0) }
|
|
464
|
-
results.forEach((r, i) => {
|
|
465
|
-
console.log(`${String(i + 1).padStart(2)}. ${r.title} [${r.id}] · score ${r.score}`)
|
|
466
|
-
console.log(` ${r.path}`)
|
|
467
|
-
if (r.snippet) console.log(` ${r.snippet}`)
|
|
468
|
-
})
|
|
469
|
-
process.exit(0)
|
|
470
|
-
} else if (cmd === 'ls') {
|
|
471
|
-
// pretty list of living sessions + states. `spex ls [SEL...] [--status a,b] [--json]`
|
|
472
|
-
// the board comes from the backend (so `spex ls` shows the sessions of whatever SPEXCODE_API_URL points at,
|
|
473
|
-
// incl. a remote machine); selectSessions/formatTable are pure presentation, applied client-side.
|
|
474
|
-
const { selectSessions, formatTable } = await import('./sessions.js')
|
|
475
|
-
const { clientListSessions } = await import('./client.js')
|
|
476
|
-
const picked = selectSessions(await clientListSessions(), positionals(3), flag('status')?.split(','))
|
|
477
|
-
console.log(has('json') ? JSON.stringify(picked, null, 2) : formatTable(picked))
|
|
478
|
-
} else if (cmd === 'watch') {
|
|
479
|
-
const { watchSessions } = await import('./sessions.js')
|
|
480
|
-
const { clientListSessions } = await import('./client.js')
|
|
481
|
-
const selectors = positionals(3)
|
|
482
|
-
const intervalMs = (Number(flag('interval')) || 5) * 1000
|
|
483
|
-
await withWatchEdge(selectors, intervalMs, () => watchSessions((line) => console.log(line), {
|
|
484
|
-
source: clientListSessions, // poll the backend, so watch streams the (possibly remote) backend's board
|
|
485
|
-
selectors,
|
|
486
|
-
statuses: flag('status')?.split(','),
|
|
487
|
-
includeIdle: has('idle'),
|
|
488
|
-
as: flag('as'),
|
|
489
|
-
intervalMs,
|
|
490
|
-
}), true) // greet=true: a stream watch greets its specific targets once; `wait` (one-shot) does not
|
|
491
|
-
} else if (cmd === 'wait') {
|
|
492
|
-
const { watchSessions, ownSessionId } = await import('./sessions.js')
|
|
493
|
-
const { clientListSessions } = await import('./client.js')
|
|
494
|
-
const [id] = positionals(3)
|
|
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.`)
|
|
502
|
-
const intervalMs = (Number(flag('interval')) || 2) * 1000
|
|
503
|
-
const timeoutSec = Number(flag('timeout')) || 1200
|
|
504
|
-
const r = await withWatchEdge([id], intervalMs, () => watchSessions(() => {}, {
|
|
505
|
-
source: clientListSessions,
|
|
506
|
-
selectors: [id],
|
|
507
|
-
includeIdle: has('idle'),
|
|
508
|
-
intervalMs,
|
|
509
|
-
until: { timeoutMs: timeoutSec * 1000 },
|
|
510
|
-
}))
|
|
511
|
-
if ('reached' in r) { console.log(r.reached); process.exit(0) }
|
|
512
|
-
if ('gone' in r) { console.error(`spex wait: no such (living) session ${id}`); process.exit(2) }
|
|
513
|
-
// a backend failure is a verdict about the TRANSPORT, never the session ([[graph]], issue #40): it prints
|
|
514
|
-
// its own outcome token on stdout — a word OUTSIDE the session-status vocabulary, so a supervisor reading
|
|
515
|
-
// the one status line can never mistake "I could not reach the board" for "the session is offline" — and
|
|
516
|
-
// exits 3, distinct from the plain no-actionable-status timeout (1) and the vanished target (2).
|
|
517
|
-
if ('backendDown' in r) {
|
|
518
|
-
console.error(`spex wait: ${r.backendDown}`)
|
|
519
|
-
console.log(r.kind === 'unreachable' ? 'backend-unreachable' : 'backend-error')
|
|
520
|
-
process.exit(3)
|
|
521
|
-
}
|
|
522
|
-
console.error(`spex wait: timeout — ${id} did not reach an actionable status within ${timeoutSec}s`)
|
|
523
|
-
process.exit(1)
|
|
524
|
-
} else if (cmd === 'new') {
|
|
525
|
-
// shorthand for `spex session new`: spex new "<prompt>" [--node X] (prompt = first positional or --prompt,
|
|
526
|
-
// or --prompt-file <path>|- so a long multi-paragraph prompt never fights shell quoting — [[prompt-file]]).
|
|
527
|
-
// createSession POSTs to the running backend so the launch runs in the backend's process (auth env + cap);
|
|
528
|
-
// it falls back to an in-process launch only when no backend answers.
|
|
529
|
-
const { createSession } = await import('./sessions.js')
|
|
530
|
-
if (has('harness')) { console.error('spex new: --harness was removed; use --launcher <name> (for example --launcher codex)'); process.exit(2) }
|
|
531
|
-
const promptFile = flag('prompt-file')
|
|
532
|
-
const inline = flag('prompt') ?? positionals(3)[0]
|
|
533
|
-
let prompt = inline ?? ''
|
|
534
|
-
if (promptFile !== undefined) {
|
|
535
|
-
// fail-loud exclusive: never silently pick one of two prompt sources.
|
|
536
|
-
if (inline !== undefined) { console.error('spex new: give the prompt either inline or via --prompt-file, not both'); process.exit(2) }
|
|
537
|
-
const { readFileSync } = await import('node:fs')
|
|
538
|
-
try { prompt = promptFile === '-' ? readFileSync(0, 'utf8') : readFileSync(promptFile, 'utf8') }
|
|
539
|
-
catch (e) { console.error(`spex new: --prompt-file ${promptFile}: ${e instanceof Error ? e.message : e}`); process.exit(2) }
|
|
540
|
-
if (!prompt.trim()) { console.error(`spex new: --prompt-file ${promptFile === '-' ? 'stdin' : promptFile} is empty — refusing a promptless launch`); process.exit(2) }
|
|
541
|
-
}
|
|
542
|
-
const nodeArg = flag('node')
|
|
543
|
-
const created = await createSession(nodeArg ? stripRefSigil(nodeArg) : null, prompt, flag('launcher') ?? undefined)
|
|
544
|
-
console.log(JSON.stringify(created, null, 2))
|
|
545
|
-
await launchMonitorReminder(created.id)
|
|
546
591
|
} else if (cmd === 'session') {
|
|
547
592
|
const sub = process.argv[3]
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
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.`
|
|
570
|
-
const wid = sess || s.ownSessionId() // safe now: the store just resolved, so the alias walk cannot throw
|
|
571
|
-
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).'
|
|
572
|
-
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.`
|
|
573
|
-
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).`
|
|
574
|
-
}
|
|
575
|
-
// a state writer from a non-repo cwd throws git's not-a-repo before it can return false — map exactly
|
|
576
|
-
// that throw to the no-record path (noRecord re-probes and names the cwd); anything else stays loud.
|
|
577
|
-
const mark = (fn: () => boolean): boolean => {
|
|
578
|
-
try { return fn() }
|
|
579
|
-
catch (e) { if (/not a git repository/i.test(String((e as any)?.stderr ?? e))) return false; throw e }
|
|
580
|
-
}
|
|
581
|
-
// 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.
|
|
582
|
-
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.'
|
|
583
|
-
// 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.
|
|
584
|
-
const CLOSE_CLEANUP = '\n\nBefore this worktree closes, check whether you left anything running that you started to test this change — a background process, a dev or preview server, a bound port, a scratch session. If nothing depends on it anymore, shut it down, or it keeps running as an orphan. Leave anything meant to keep running: a service you deliberately stood up, a production build, anything other work relies on. What matters is whether it still needs to exist after this task, not whether you started it. If unsure, leave it. This is a reminder to check, not a required step.'
|
|
585
|
-
// truncation transparency ([[state]]): the board table shows only the first NOTE_BOARD_LIMIT chars of a
|
|
586
|
-
// note. When a declared note overflows that cap, the confirmation says so — length, what the board shows,
|
|
587
|
-
// where the full text is readable — so the cut is visible to the author instead of silently eaten.
|
|
588
|
-
// Taught ONCE per session: the first overflowing note prints the full notice and drops a sentinel beside
|
|
589
|
-
// the record; later overflows stay silent (the rule was taught — a verbatim repeat on every park/ask is
|
|
590
|
-
// noise, field-reported). A nudge riding the echo, never a gate: the declaration has already landed.
|
|
591
|
-
const noteEcho = (note?: string): string => {
|
|
592
|
-
if (!note || note.length <= s.NOTE_BOARD_LIMIT) return ''
|
|
593
|
-
const wid = sess || s.ownSessionId()
|
|
594
|
-
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
|
|
595
|
-
if (rid) {
|
|
596
|
-
const sentinel = l.sessionArtifactPath(rid, 'note-echo-taught')
|
|
597
|
-
try {
|
|
598
|
-
if (existsSync(sentinel)) return ''
|
|
599
|
-
writeFileSync(sentinel, `${new Date().toISOString()}\n`) // only reached on a successful declaration (the echo rides the success branch)
|
|
600
|
-
} catch { /* unreadable/unwritable store dir → fall through and teach again; never block the echo */ }
|
|
593
|
+
if (sub === undefined) {
|
|
594
|
+
console.log((await import('./help.js')).commandHelp('session'))
|
|
595
|
+
} else if (SESSION_SIGNPOSTS[sub]) {
|
|
596
|
+
signpost(`spex session ${sub}`, SESSION_SIGNPOSTS[sub])
|
|
597
|
+
} else if (sub === 'new') {
|
|
598
|
+
// spex session new "<prompt>" [--node X] (prompt = first positional or --prompt, or --prompt-file
|
|
599
|
+
// <path>|- so a long multi-paragraph prompt never fights shell quoting — [[prompt-file]]).
|
|
600
|
+
// createSession POSTs to the running backend so the launch runs in the backend's process (auth env + cap);
|
|
601
|
+
// it falls back to an in-process launch only when no backend answers.
|
|
602
|
+
const { createSession } = await import('./sessions.js')
|
|
603
|
+
if (has('harness')) { console.error('spex session new: --harness was removed; use --launcher <name> (for example --launcher codex)'); process.exit(2) }
|
|
604
|
+
const promptFile = flag('prompt-file')
|
|
605
|
+
const inline = flag('prompt') ?? positionals(4)[0]
|
|
606
|
+
let prompt = inline ?? ''
|
|
607
|
+
if (promptFile !== undefined) {
|
|
608
|
+
// fail-loud exclusive: never silently pick one of two prompt sources.
|
|
609
|
+
if (inline !== undefined) { console.error('spex session new: give the prompt either inline or via --prompt-file, not both'); process.exit(2) }
|
|
610
|
+
const { readFileSync } = await import('node:fs')
|
|
611
|
+
try { prompt = promptFile === '-' ? readFileSync(0, 'utf8') : readFileSync(promptFile, 'utf8') }
|
|
612
|
+
catch (e) { console.error(`spex session new: --prompt-file ${promptFile}: ${e instanceof Error ? e.message : e}`); process.exit(2) }
|
|
613
|
+
if (!prompt.trim()) { console.error(`spex session new: --prompt-file ${promptFile === '-' ? 'stdin' : promptFile} is empty — refusing a promptless launch`); process.exit(2) }
|
|
601
614
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
const
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
615
|
+
const nodeArg = flag('node')
|
|
616
|
+
const created = await createSession(nodeArg ? stripRefSigil(nodeArg) : null, prompt, flag('launcher') ?? undefined)
|
|
617
|
+
console.log(JSON.stringify(created, null, 2))
|
|
618
|
+
await launchMonitorReminder(created.id)
|
|
619
|
+
} else if (sub === 'ls') {
|
|
620
|
+
// pretty list of living sessions + states. `spex session ls [SEL...] [--status a,b] [--json]`
|
|
621
|
+
// the board comes from the backend (so it shows the sessions of whatever SPEXCODE_API_URL points at,
|
|
622
|
+
// incl. a remote machine); selectSessions/formatTable are pure presentation, applied client-side.
|
|
623
|
+
const { selectSessions, formatTable } = await import('./sessions.js')
|
|
624
|
+
const { clientListSessions } = await import('./client.js')
|
|
625
|
+
const picked = selectSessions(await clientListSessions(), positionals(4), flag('status')?.split(','))
|
|
626
|
+
console.log(has('json') ? JSON.stringify(picked, null, 2) : formatTable(picked))
|
|
627
|
+
} else if (sub === 'watch') {
|
|
628
|
+
const { watchSessions } = await import('./sessions.js')
|
|
629
|
+
const { clientListSessions } = await import('./client.js')
|
|
630
|
+
const selectors = positionals(4)
|
|
631
|
+
const intervalMs = (Number(flag('interval')) || 5) * 1000
|
|
632
|
+
await withWatchEdge(selectors, intervalMs, () => watchSessions((line) => console.log(line), {
|
|
633
|
+
source: clientListSessions, // poll the backend, so watch streams the (possibly remote) backend's board
|
|
634
|
+
selectors,
|
|
635
|
+
statuses: flag('status')?.split(','),
|
|
636
|
+
includeIdle: has('idle'),
|
|
637
|
+
as: flag('as'),
|
|
638
|
+
intervalMs,
|
|
639
|
+
}), true) // greet=true: a stream watch greets its specific targets once; `wait` (one-shot) does not
|
|
640
|
+
} else if (sub === 'wait') {
|
|
641
|
+
const { watchSessions, ownSessionId } = await import('./sessions.js')
|
|
642
|
+
const { clientListSessions } = await import('./client.js')
|
|
643
|
+
const [id] = positionals(4)
|
|
644
|
+
if (!id) { console.error('usage: spex session wait <id> [--timeout SECONDS] [--interval SECONDS] [--idle]'); process.exit(2) }
|
|
645
|
+
// point-of-use turn-freeze warning ([[session-edges]]): a managed agent that runs this wait in the FOREGROUND
|
|
646
|
+
// freezes its whole turn until the target produces an edge — a warning that used to live only in help
|
|
647
|
+
// prose, now said where it matters. Foreground vs background is invisible from here, so the hint prints
|
|
648
|
+
// for ANY managed-agent shell (harmless in a background transcript), on stderr, and changes nothing else.
|
|
649
|
+
const own = ownSessionId()
|
|
650
|
+
if (own) console.error(`spex session wait: heads-up (managed agent ${own.slice(0, 8)}) — this command BLOCKS until it OBSERVES ${id} transition from non-actionable into an actionable status (edge-triggered: an already-actionable current state does NOT return it — to just read the state now, use \`spex session ls\`/\`review\`); run it in the BACKGROUND or it freezes your whole turn (its exit is your wake-up). Proceeding.`)
|
|
651
|
+
const intervalMs = (Number(flag('interval')) || 2) * 1000
|
|
652
|
+
const timeoutSec = Number(flag('timeout')) || 1200
|
|
653
|
+
const r = await withWatchEdge([id], intervalMs, () => watchSessions(() => {}, {
|
|
654
|
+
source: clientListSessions,
|
|
655
|
+
selectors: [id],
|
|
656
|
+
includeIdle: has('idle'),
|
|
657
|
+
intervalMs,
|
|
658
|
+
until: {
|
|
659
|
+
timeoutMs: timeoutSec * 1000,
|
|
660
|
+
// the arrival state and each observed transition narrate on stderr AS THEY HAPPEN, so a backgrounded
|
|
661
|
+
// wait's transcript is the state sequence itself; stdout stays the one machine verdict (the observed
|
|
662
|
+
// path on an edge, or a transport token).
|
|
663
|
+
onObserved: (st, was) => console.error(was
|
|
664
|
+
? `spex session wait: observed ${was} → ${st}`
|
|
665
|
+
: `spex session wait: current status ${st} — recorded as the path start; returns on the next non-actionable→actionable transition`),
|
|
666
|
+
},
|
|
667
|
+
}))
|
|
668
|
+
// the observed status path is the stdout verdict: read the LAST token as the status reached. Printing the
|
|
669
|
+
// whole path (not just the final status) is the point — a manager sees what the wait lived through
|
|
670
|
+
// (e.g. review→working→close-pending across a merge dispatch), not a bare word out of context.
|
|
671
|
+
if ('reached' in r) { console.log(r.path.join('→')); process.exit(0) }
|
|
672
|
+
if ('gone' in r) { console.error(`spex session wait: no such (living) session ${id}`); process.exit(2) }
|
|
673
|
+
// a backend failure is a verdict about the TRANSPORT, never the session ([[session-edges]], issue #40): it prints
|
|
674
|
+
// its own outcome token on stdout — a word OUTSIDE the session-status vocabulary, so a supervisor reading
|
|
675
|
+
// the one status line can never mistake "I could not reach the board" for "the session is offline" — and
|
|
676
|
+
// exits 3, distinct from the plain no-edge timeout (1) and the vanished target (2).
|
|
677
|
+
if ('backendDown' in r) {
|
|
678
|
+
console.error(`spex session wait: ${r.backendDown}`)
|
|
679
|
+
console.log(r.kind === 'unreachable' ? 'backend-unreachable' : 'backend-error')
|
|
680
|
+
process.exit(3)
|
|
628
681
|
}
|
|
629
|
-
console.
|
|
630
|
-
} else if (sub === 'park') {
|
|
631
|
-
// sugar: the agent is waiting on a background task; it will self-resume (NOT idle/awaiting)
|
|
632
|
-
console.log(mark(() => s.markState('parked', { note: flag('note'), sessionId: sess })) ? `parked${DECLARED}${noteEcho(flag('note'))}` : noRecord())
|
|
633
|
-
} else if (sub === 'fail') {
|
|
634
|
-
// the StopFailure hook marks its session (--session from the payload) as error (turn died on an API error)
|
|
635
|
-
console.log(mark(() => s.markError(sess)) ? 'marked error' : noRecord())
|
|
636
|
-
} else if (sub === 'ask') {
|
|
637
|
-
// the agent DELIBERATELY declares it is pausing to ask the human a question (like `done`/`park`, an
|
|
638
|
-
// authored state — NOT guarded active-only). The --note carries the question. Distinct from `park`
|
|
639
|
-
// (waiting on a background task, self-resumes): an asking agent resumes only when the human replies.
|
|
640
|
-
console.log(mark(() => s.markState('asking', { note: flag('note'), sessionId: sess })) ? `asking${DECLARED}${noteEcho(flag('note'))}` : noRecord())
|
|
641
|
-
} else if (sub === 'commit-gate') {
|
|
642
|
-
// the Stop gate's deterministic commit check (from cwd = the worktree): exit 0 if the node branch is
|
|
643
|
-
// ready to declare done/merge (work committed + ahead of main), else print the reason and exit 1. Uses
|
|
644
|
-
// git() so the hook's exported GIT_DIR/GIT_INDEX_FILE don't misdirect repo discovery (see git.ts).
|
|
645
|
-
const r = s.mergeReadiness()
|
|
646
|
-
if (r.ready) { console.log('ready'); process.exit(0) }
|
|
647
|
-
console.log(r.reason)
|
|
682
|
+
console.error(`spex session wait: timeout — observed no non-actionable→actionable transition on ${id} within ${timeoutSec}s (status path: ${r.path.join('→') || 'never sighted'})`)
|
|
648
683
|
process.exit(1)
|
|
649
|
-
} else if (sub === '
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
// message, so the recipient can reply over the SAME send. The sender's row (hence its display label) is
|
|
670
|
-
// resolved through the shared resolver; a human in a plain shell has no session id → bare message, no
|
|
671
|
-
// hint (see [[agent-reply-channel]]).
|
|
672
|
-
const senderId = s.ownSessionId()
|
|
673
|
-
let sender = null
|
|
674
|
-
if (senderId) {
|
|
675
|
-
const sr = await c.resolveClientSession(senderId)
|
|
676
|
-
// name the sender by the board HEADLINE (sessionHeadline — the live self-summary the recipient sees on
|
|
677
|
-
// the board), NOT the stable sessionLabel that stops at the bare prompt-truncation title.
|
|
678
|
-
sender = 'ok' in sr ? { id: sr.ok.id, label: s.sessionHeadline(sr.ok) } : { id: senderId, label: null }
|
|
684
|
+
} else if (sub === 'review') {
|
|
685
|
+
const first = positionals(4)[0]
|
|
686
|
+
if (first === 'proof') signpost('spex review proof', 'spex eval ls --session <SEL> --export') // dead-words-ok: signpost — one-version tombstone teaching the renamed spelling (0.4.0 removes it)
|
|
687
|
+
const { clientReview } = await import('./client.js')
|
|
688
|
+
if (!first) { console.error('usage: spex session review <SEL> (id | id-prefix | node | branch)'); process.exit(2) }
|
|
689
|
+
const id = await resolveSelectorOrExit(first)
|
|
690
|
+
const r = await clientReview(id)
|
|
691
|
+
if (!r) { console.error(`no such session ${id}`); process.exit(1) }
|
|
692
|
+
if (has('json')) { console.log(JSON.stringify(r, null, 2)) }
|
|
693
|
+
else {
|
|
694
|
+
const g = r.gates
|
|
695
|
+
console.log(`review ${r.label} [${r.id}]`)
|
|
696
|
+
console.log(` ahead of main : ${r.ahead} commit(s)`)
|
|
697
|
+
console.log(` uncommitted : ${r.dirtyNonRuntime} non-runtime file(s)`)
|
|
698
|
+
console.log(` proposal : ${r.proposal.kind ?? '—'}${r.proposal.note ? ` — ${r.proposal.note}` : ''}`)
|
|
699
|
+
console.log(' gates:')
|
|
700
|
+
console.log(` conflicts w/ main : ${g.conflictsWithMain ? 'YES' : 'no'}`)
|
|
701
|
+
console.log(` lint : ${g.lint.errorCount} error(s), ${g.lint.warningCount} warning(s)`)
|
|
702
|
+
console.log(` diff (merge-base, ${r.diff.length} file(s)):`)
|
|
703
|
+
for (const f of r.diff) console.log(` ${f.status.padEnd(12)} +${f.additions} -${f.deletions} ${f.path}`)
|
|
679
704
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
else { console.error(`spex capture: ${r.reason}`); process.exit(r.status === 404 ? 2 : 1) }
|
|
690
|
-
} else if (sub === 'rename') {
|
|
691
|
-
// set the session's display-name override — the right-click rename ([[session-rename]]) as a verb, so an
|
|
692
|
-
// agent manager can fix a label without the GUI. An EXPLICIT "" clears back to the derived label; a
|
|
693
|
-
// MISSING argument is a usage error, never a silent clear. Unknown session → the endpoint's 404, loud.
|
|
694
|
-
const full = await resolveSelectorOrExit(id)
|
|
695
|
-
const name = process.argv[5]
|
|
696
|
-
if (name === undefined) { console.error('usage: spex session rename <SEL> "<name>" (an explicit "" clears the override)'); process.exit(2) }
|
|
697
|
-
if (await c.clientRename(full, name)) console.log(name.trim() ? `${full} -> renamed "${name.trim()}"` : `${full} -> name cleared (derived label restored)`)
|
|
698
|
-
else { console.error(`spex session rename: no such session ${full}`); process.exit(2) }
|
|
699
|
-
} else if (sub === 'rawkey') {
|
|
700
|
-
// forward raw nav-mode keystrokes (tmux send-keys, NEVER the prompt socket) — how a manager drives a
|
|
701
|
-
// worker wedged in an interactive TUI dialog the prompt channel can't reach (a select menu wanting one
|
|
702
|
-
// Enter/arrow). Tokens = named keys, single chars, C-/M-/S- combos; whitespace-separated, delivered as
|
|
703
|
-
// ONE ordered batch ([[nav-mode-key-ordering]]). Fail-loud: nothing delivered exits non-zero.
|
|
704
|
-
const full = await resolveSelectorOrExit(id)
|
|
705
|
-
const keys = process.argv.slice(5).flatMap((s) => s.split(/\s+/)).filter(Boolean)
|
|
706
|
-
if (keys.length === 0) { console.error('usage: spex session rawkey <SEL> "<keys>" (e.g. "Up Up Enter", "C-r", single chars)'); process.exit(2) }
|
|
707
|
-
if (await c.clientRawkey(full, keys)) console.log(`sent ${keys.length} key${keys.length === 1 ? '' : 's'} -> ${full}`)
|
|
708
|
-
else { console.error(`spex session rawkey: nothing delivered to ${full} (offline, unknown session, or no valid key token)`); process.exit(1) }
|
|
709
|
-
} else if (sub === 'attach') {
|
|
710
|
-
// the HUMAN escape hatch (attach.ts, [[session-attach]]): foreground `tmux attach` into the worker's
|
|
711
|
-
// real session. Guards fail loud BEFORE resolving: local-only (the tmux server is the backend
|
|
712
|
-
// machine's) and terminal-only (an agent must never block its turn on it — capture/send/rawkey).
|
|
713
|
-
const { assertLocalBackend, attachSession } = await import('./attach.js')
|
|
714
|
-
await assertLocalBackend()
|
|
715
|
-
await attachSession(await resolveSelectorOrExit(id))
|
|
716
|
-
} else if (sub === 'prompt') {
|
|
717
|
-
// print the session's full ORIGINATING prompt (what it was asked to do), captured at launch.
|
|
718
|
-
const full = await resolveSelectorOrExit(id)
|
|
719
|
-
const r = await c.clientPrompt(full)
|
|
720
|
-
if (!r.ok) { console.error(`no prompt recorded for ${full}`); process.exit(1) }
|
|
721
|
-
process.stdout.write(r.prompt.endsWith('\n') ? r.prompt : r.prompt + '\n')
|
|
705
|
+
} else if (sub === 'merge') {
|
|
706
|
+
const { clientMerge } = await import('./client.js')
|
|
707
|
+
const sel = positionals(4)[0]
|
|
708
|
+
if (!sel) { console.error('usage: spex session merge <SEL> (id | id-prefix | node | branch)'); process.exit(2) }
|
|
709
|
+
const id = await resolveSelectorOrExit(sel)
|
|
710
|
+
const r = await clientMerge(id)
|
|
711
|
+
if (r.dispatched) console.log(`merge dispatched to ${id} — its agent is landing the merge`)
|
|
712
|
+
else console.error(`merge dispatch failed: ${r.reason}`)
|
|
713
|
+
process.exit(r.dispatched ? 0 : 1)
|
|
722
714
|
} else {
|
|
723
|
-
|
|
715
|
+
// `s` (sessions.ts) backs the state PRODUCERS that stay local (done/park/ask write the global record by
|
|
716
|
+
// session_id) and the stateKit shared with `spex internal session-*`. `c` (client.ts) backs the
|
|
717
|
+
// read/control subs that route through the backend. Lazily imported.
|
|
718
|
+
const c = await import('./client.js')
|
|
719
|
+
const id = process.argv[4]
|
|
720
|
+
if (sub === 'resume') {
|
|
721
|
+
// bring the agent back up (relaunch ONLY if confirmed offline, the backend owns it); demotes a working
|
|
722
|
+
// `active` to idle but leaves a standing declaration/proposal untouched (see sessions.ts resumeSession()).
|
|
723
|
+
// The RESUME GUARD refuses a relaunch on a LIVE/unproven agent (that would kill a live worker) — `--force`
|
|
724
|
+
// overrides for a genuinely wedged process. A following prompt is what actually re-drives it.
|
|
725
|
+
const full = await resolveSelectorOrExit(id)
|
|
726
|
+
const r = await c.clientResume(full, process.argv.includes('--force'))
|
|
727
|
+
if (r.ok) console.log(`${full} -> resumed`)
|
|
728
|
+
else { console.error(`spex session resume: ${r.error || `no such session ${full}`}`); process.exit(2) }
|
|
729
|
+
} else if (sub === 'done') {
|
|
730
|
+
// sugar for awaiting; --propose merge|nothing|close, optional --note
|
|
731
|
+
const { s, sess, mark, noRecord, noteEcho } = await stateKit()
|
|
732
|
+
const p = (flag('propose') as any) || 'nothing'
|
|
733
|
+
let closeNote = p === 'close' ? CLOSE_CLEANUP : ''
|
|
734
|
+
if (p === 'close') {
|
|
735
|
+
// the DATA half of the close nudge ([[local-issues]] closeoutNudge): the still-open local issues this
|
|
736
|
+
// session touched, listed by id — empty/OFF/no-identity prints nothing. Loud on failure but never
|
|
737
|
+
// gating: the declaration must land whatever the issue store is doing.
|
|
738
|
+
try { closeNote += (await import('./localIssues.js')).closeoutNudge(sess ?? s.ownSessionId()) }
|
|
739
|
+
catch (e) { console.error(`issue closeout check failed (declaration unaffected): ${e instanceof Error ? e.message : e}`) }
|
|
740
|
+
}
|
|
741
|
+
console.log(mark(() => s.markDone(p, sess, flag('note'))) ? `done (${p})${DECLARED}${noteEcho(flag('note'))}${closeNote}` : noRecord())
|
|
742
|
+
} else if (sub === 'park') {
|
|
743
|
+
// sugar: the agent is waiting on a background task; it will self-resume (NOT idle/awaiting)
|
|
744
|
+
const { s, sess, mark, noRecord, noteEcho } = await stateKit()
|
|
745
|
+
console.log(mark(() => s.markState('parked', { note: flag('note'), sessionId: sess })) ? `parked${DECLARED}${noteEcho(flag('note'))}` : noRecord())
|
|
746
|
+
} else if (sub === 'ask') {
|
|
747
|
+
// the agent DELIBERATELY declares it is pausing to ask the human a question (like `done`/`park`, an
|
|
748
|
+
// authored state — NOT guarded active-only). The --note carries the question. Distinct from `park`
|
|
749
|
+
// (waiting on a background task, self-resumes): an asking agent resumes only when the human replies.
|
|
750
|
+
const { s, sess, mark, noRecord, noteEcho } = await stateKit()
|
|
751
|
+
console.log(mark(() => s.markState('asking', { note: flag('note'), sessionId: sess })) ? `asking${DECLARED}${noteEcho(flag('note'))}` : noRecord())
|
|
752
|
+
} else if (sub === 'stop') {
|
|
753
|
+
// the SOFT stop: kill the agent's tmux + socket but KEEP the worktree, so the session goes offline and
|
|
754
|
+
// can be resumed (`session resume`). Distinct from `close`, which removes the worktree.
|
|
755
|
+
const full = await resolveSelectorOrExit(id)
|
|
756
|
+
console.log(await c.clientStop(full) ? `stopped ${full} (worktree kept — resumable)` : `no such session ${full}`)
|
|
757
|
+
} else if (sub === 'close') {
|
|
758
|
+
const full = await resolveSelectorOrExit(id)
|
|
759
|
+
console.log(await c.clientClose(full) ? `closed ${full}` : `no such session ${full}`)
|
|
760
|
+
} else if (sub === 'send') {
|
|
761
|
+
const full = await resolveSelectorOrExit(id)
|
|
762
|
+
if (has('keys')) {
|
|
763
|
+
// the LAST-RESORT face of send: forward raw nav-mode keystrokes (tmux send-keys, NEVER the prompt
|
|
764
|
+
// socket) — how a manager drives a worker wedged in an interactive TUI dialog the prompt channel
|
|
765
|
+
// can't reach (a select menu wanting one Enter/arrow). UNSTABLE, and able to confirm dangerous
|
|
766
|
+
// dialogs — try plain `session send` text FIRST; reach for --keys only when text provably can't
|
|
767
|
+
// land. Tokens = named keys, single chars, C-/M-/S- combos; whitespace-separated, delivered as ONE
|
|
768
|
+
// ordered batch ([[nav-mode-key-ordering]]). Fail-loud: nothing delivered exits non-zero.
|
|
769
|
+
const keys = (flag('keys') ?? '').split(/\s+/).filter(Boolean)
|
|
770
|
+
if (keys.length === 0) { console.error('usage: spex session send <SEL> --keys "<keys>" (e.g. "Up Up Enter", "C-r", single chars — last resort; try a plain send first)'); process.exit(2) }
|
|
771
|
+
if (await c.clientSendRawKeys(full, keys)) { console.log(`sent ${keys.length} key${keys.length === 1 ? '' : 's'} -> ${full}`); process.exit(0) }
|
|
772
|
+
console.error(`spex session send --keys: nothing delivered to ${full} (offline, unknown session, or no valid key token)`)
|
|
773
|
+
process.exit(1)
|
|
774
|
+
}
|
|
775
|
+
// prompt dispatch is socket-only + fail-loud (the backend enforces it): a non-accepted prompt prints the
|
|
776
|
+
// reason AND exits non-zero, so a manager/script never mistakes a dead dispatch for success.
|
|
777
|
+
// BIDIRECTIONAL: stamp the SENDER (this send process's OWN session — the only process that knows it, via
|
|
778
|
+
// ownSessionId from CLAUDE_CODE_SESSION_ID) + a one-line reply hint into the delivered
|
|
779
|
+
// message, so the recipient can reply over the SAME send. The sender's row (hence its display label) is
|
|
780
|
+
// resolved through the shared resolver; a human in a plain shell has no session id → bare message, no
|
|
781
|
+
// hint (see [[agent-reply-channel]]).
|
|
782
|
+
const s = await import('./sessions.js')
|
|
783
|
+
const senderId = s.ownSessionId()
|
|
784
|
+
let sender = null
|
|
785
|
+
if (senderId) {
|
|
786
|
+
const sr = await c.resolveClientSession(senderId)
|
|
787
|
+
// name the sender by the board HEADLINE (sessionHeadline — the live self-summary the recipient sees on
|
|
788
|
+
// the board), NOT the stable sessionLabel that stops at the bare prompt-truncation title.
|
|
789
|
+
sender = 'ok' in sr ? { id: sr.ok.id, label: s.sessionHeadline(sr.ok) } : { id: senderId, label: null }
|
|
790
|
+
}
|
|
791
|
+
const r = await c.clientSend(full, s.withSenderHint(process.argv[5] ?? '', sender), senderId ?? undefined)
|
|
792
|
+
console.log(r.ok ? 'sent' : `dispatch failed: ${r.error}`)
|
|
793
|
+
process.exit(r.ok ? 0 : 1)
|
|
794
|
+
} else if (sub === 'show') {
|
|
795
|
+
// the session RECORD as one per-id read (status · node · branch · launcher · the full originating
|
|
796
|
+
// prompt); --capture swaps in the LIVE PANE face of the same read. The pane contract is unchanged from
|
|
797
|
+
// the verb it absorbed — fail and empty stay DISTINCT: a real empty pane prints nothing and exits 0;
|
|
798
|
+
// unknown id exits 2, offline / capture-error exit 1, each with a named reason.
|
|
799
|
+
const full = await resolveSelectorOrExit(id)
|
|
800
|
+
if (has('capture')) {
|
|
801
|
+
const r = await c.clientCapture(full)
|
|
802
|
+
if (r.ok) { process.stdout.write(r.pane) }
|
|
803
|
+
else { console.error(`spex session show --capture: ${r.reason}`); process.exit(r.status === 404 ? 2 : 1) }
|
|
804
|
+
} else {
|
|
805
|
+
const r = await c.clientShow(full)
|
|
806
|
+
if (!r.ok) { console.error(`spex session show: no such session ${full}`); process.exit(2) }
|
|
807
|
+
if (has('json')) { console.log(JSON.stringify(r.session, null, 2)) }
|
|
808
|
+
else {
|
|
809
|
+
const x = r.session
|
|
810
|
+
console.log(`${x.label} [${x.id}]`)
|
|
811
|
+
console.log(` status : ${x.status} (lifecycle ${x.lifecycle} · liveness ${x.liveness})`)
|
|
812
|
+
console.log(` node : ${x.node ?? '—'}`)
|
|
813
|
+
console.log(` branch : ${x.branch ?? '—'}`)
|
|
814
|
+
console.log(` launcher : ${x.launcher ?? '—'} (harness ${x.harness})`)
|
|
815
|
+
console.log(` worktree : ${x.path}`)
|
|
816
|
+
console.log(` created : ${new Date(x.created).toISOString()}`)
|
|
817
|
+
if (x.note) console.log(` note : ${x.note}`)
|
|
818
|
+
if (x.proposal) console.log(` proposal : ${x.proposal}`)
|
|
819
|
+
console.log(x.prompt ? ` prompt |\n${x.prompt.replace(/\n$/, '').split('\n').map((l) => ` ${l}`).join('\n')}` : ' prompt : (none recorded)')
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
} else if (sub === 'rename') {
|
|
823
|
+
// set the session's display-name override — the right-click rename ([[session-rename]]) as a verb, so an
|
|
824
|
+
// agent manager can fix a label without the GUI. An EXPLICIT "" clears back to the derived label; a
|
|
825
|
+
// MISSING argument is a usage error, never a silent clear. Unknown session → the endpoint's 404, loud.
|
|
826
|
+
const full = await resolveSelectorOrExit(id)
|
|
827
|
+
const name = process.argv[5]
|
|
828
|
+
if (name === undefined) { console.error('usage: spex session rename <SEL> "<name>" (an explicit "" clears the override)'); process.exit(2) }
|
|
829
|
+
if (await c.clientRename(full, name)) console.log(name.trim() ? `${full} -> renamed "${name.trim()}"` : `${full} -> name cleared (derived label restored)`)
|
|
830
|
+
else { console.error(`spex session rename: no such session ${full}`); process.exit(2) }
|
|
831
|
+
} else if (sub === 'attach') {
|
|
832
|
+
// the HUMAN escape hatch (attach.ts, [[session-attach]]): foreground `tmux attach` into the worker's
|
|
833
|
+
// real session. Guards fail loud BEFORE resolving: local-only (the tmux server is the backend
|
|
834
|
+
// machine's) and terminal-only (an agent must never block its turn on it — capture/send).
|
|
835
|
+
const { assertLocalBackend, attachSession } = await import('./attach.js')
|
|
836
|
+
await assertLocalBackend()
|
|
837
|
+
await attachSession(await resolveSelectorOrExit(id))
|
|
838
|
+
} else {
|
|
839
|
+
console.error(`spex session: unknown verb '${sub}' — new | ls | show | watch | wait | review | merge | send | rename | resume | stop | close | attach | done | park | ask (spex help session)`)
|
|
840
|
+
process.exit(2)
|
|
841
|
+
}
|
|
724
842
|
}
|
|
725
843
|
} else if (cmd === 'internal') {
|
|
726
844
|
// @@@ internal - the machine-plumbing namespace: verbs only generated hooks and launch scripts call,
|
|
@@ -730,7 +848,7 @@ if (cmd === 'serve') {
|
|
|
730
848
|
// print the resolved source-of-truth branch (layout.ts mainBranch(): config override → the main
|
|
731
849
|
// checkout's current branch → 'main'). The pre-commit main-guard captures this so it blocks direct
|
|
732
850
|
// commits on whatever the repo's trunk is actually named, never a hardcoded 'main'. One value, one
|
|
733
|
-
// line; GET /api/
|
|
851
|
+
// line; GET /api/settings exposes the same resolution (`.layout`).
|
|
734
852
|
const { mainBranch } = await import('./layout.js')
|
|
735
853
|
console.log(mainBranch())
|
|
736
854
|
} else if (sub === 'codex-launch') {
|
|
@@ -774,7 +892,7 @@ if (cmd === 'serve') {
|
|
|
774
892
|
commitSurgery()
|
|
775
893
|
} else if (sub === 'refresh-footprint') {
|
|
776
894
|
// the post-checkout / post-merge freshness anchor ([[commit-surgery]]): a quiet materialize after a git
|
|
777
|
-
// state transition (the only events that can move the materialize's inputs — .spec/.
|
|
895
|
+
// state transition (the only events that can move the materialize's inputs — .spec/.plugins arrive by commit,
|
|
778
896
|
// merge, or checkout). Best-effort and silent on success; hooks call it fire-and-forget.
|
|
779
897
|
const { materialize } = await import('./materialize.js')
|
|
780
898
|
try { materialize() } catch (e) { console.error(`spexcode: footprint refresh failed (${(e as Error).message})`); process.exit(1) }
|
|
@@ -786,6 +904,43 @@ if (cmd === 'serve') {
|
|
|
786
904
|
if (!sock || !tid || !text) { console.error('usage: spex internal codex-turn <sock> <threadId> <text...>'); process.exit(2) }
|
|
787
905
|
const r = await codexTurn(sock, tid, text)
|
|
788
906
|
if (r.ok) { console.log('ok') } else { console.error(r.error); process.exit(1) }
|
|
907
|
+
} else if (sub === 'check-staged') {
|
|
908
|
+
// the pre-commit hook's eval backstop: a staged stray evidence blob or malformed eval.md rejects the
|
|
909
|
+
// commit. Logic lives in spec-eval; the hook shims here.
|
|
910
|
+
const { checkStaged } = await import('../../spec-eval/src/cli.js')
|
|
911
|
+
process.exit(checkStaged())
|
|
912
|
+
} else if (sub === 'session-state') {
|
|
913
|
+
// a lifecycle hook authors the session's state: active|awaiting|parked|error|asking
|
|
914
|
+
// [--propose] [--note] [--session] — the machine face of the typeable done/park/ask declarations.
|
|
915
|
+
const { s, sess, mark, noRecord, noteEcho } = await stateKit()
|
|
916
|
+
const st = process.argv[4] as any
|
|
917
|
+
const ok = mark(() => s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess }))
|
|
918
|
+
console.log(ok ? `state -> ${st}${noteEcho(flag('note'))}` : noRecord())
|
|
919
|
+
} else if (sub === 'session-fail') {
|
|
920
|
+
// the StopFailure hook marks its session (--session from the payload) as error (turn died on an API error)
|
|
921
|
+
const { s, sess, mark, noRecord } = await stateKit()
|
|
922
|
+
console.log(mark(() => s.markError(sess)) ? 'marked error' : noRecord())
|
|
923
|
+
} else if (sub === 'session-idle') {
|
|
924
|
+
// the Notification(idle_prompt) hook marks its session (--session from the payload) idle when claude waits
|
|
925
|
+
// at its prompt. INFERRED, so guarded active-only: it no-ops unless the current status is exactly `active`,
|
|
926
|
+
// never clobbering a deliberate awaiting/asking/parked/error declaration. Distinct from `session ask`
|
|
927
|
+
// (the agent deliberately asking the human) — idle is the undeclared stop the Stop gate missed.
|
|
928
|
+
const { s, sess } = await stateKit()
|
|
929
|
+
console.log(s.markIdle(sess) ? 'idle' : 'noop (no session record, or not active)')
|
|
930
|
+
} else if (sub === 'commit-gate') {
|
|
931
|
+
// the Stop gate's deterministic commit check (from cwd = the worktree): exit 0 if the node branch is
|
|
932
|
+
// ready to declare done/merge (work committed + ahead of main), else print the reason and exit 1. Uses
|
|
933
|
+
// git() so the hook's exported GIT_DIR/GIT_INDEX_FILE don't misdirect repo discovery (see git.ts).
|
|
934
|
+
const { s } = await stateKit()
|
|
935
|
+
const r = s.mergeReadiness()
|
|
936
|
+
if (r.ready) { console.log('ready'); process.exit(0) }
|
|
937
|
+
console.log(r.reason)
|
|
938
|
+
process.exit(1)
|
|
939
|
+
} else if (sub === 'nudge') {
|
|
940
|
+
// the post-merge hook prints the (toggle-aware) issue nudge for a merged node — never typed.
|
|
941
|
+
const { nudge } = await import('./localIssues.js')
|
|
942
|
+
const text = nudge(positionals(4)[0] || '')
|
|
943
|
+
if (text) console.log(text)
|
|
789
944
|
} else {
|
|
790
945
|
const { commandHelp } = await import('./help.js')
|
|
791
946
|
console.error(commandHelp('internal'))
|