spexcode 0.1.6 → 0.2.1
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 +99 -35
- package/README.zh-CN.md +135 -0
- package/package.json +5 -6
- package/spec-cli/README.md +86 -0
- package/spec-cli/bin/spex.mjs +15 -3
- package/spec-cli/hooks/dispatch.sh +20 -8
- package/spec-cli/hooks/harness.sh +18 -11
- package/spec-cli/src/board.ts +47 -18
- package/spec-cli/src/boardCache.ts +70 -0
- package/spec-cli/src/boardDelta.ts +90 -0
- package/spec-cli/src/boardStream.ts +178 -0
- package/spec-cli/src/cli.ts +184 -122
- package/spec-cli/src/client.ts +6 -4
- package/spec-cli/src/gateway.ts +64 -24
- package/spec-cli/src/git.ts +105 -92
- package/spec-cli/src/guide.ts +186 -19
- package/spec-cli/src/harness-select.ts +63 -0
- package/spec-cli/src/harness.ts +506 -100
- package/spec-cli/src/help.ts +362 -0
- package/spec-cli/src/hooks.ts +0 -14
- package/spec-cli/src/index.ts +279 -32
- package/spec-cli/src/init.ts +41 -1
- package/spec-cli/src/issues.ts +301 -0
- package/spec-cli/src/layout.ts +70 -28
- package/spec-cli/src/lint.ts +12 -10
- package/spec-cli/src/listen.ts +28 -0
- package/spec-cli/src/localIssues.ts +700 -0
- package/spec-cli/src/materialize.ts +182 -27
- package/spec-cli/src/mentions.ts +192 -0
- package/spec-cli/src/plugin-harness.ts +145 -0
- package/spec-cli/src/pty-bridge.ts +378 -81
- package/spec-cli/src/self.ts +123 -20
- package/spec-cli/src/sessions.ts +461 -298
- package/spec-cli/src/specs.ts +55 -14
- package/spec-cli/src/supervise.ts +23 -3
- package/spec-cli/src/tsx-bin.ts +14 -5
- package/spec-cli/src/uninstall.ts +146 -0
- package/spec-cli/templates/hooks/post-merge +27 -0
- package/spec-cli/templates/hooks/pre-commit +51 -31
- package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
- package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
- package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
- package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
- package/spec-cli/templates/spec/project/.config/spec.md +3 -3
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
- package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +2 -2
- package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +32 -0
- package/spec-dashboard/dist/assets/index-DehTZ-h9.js +145 -0
- package/spec-dashboard/dist/index.html +17 -5
- package/spec-forge/src/cache.ts +16 -0
- package/spec-forge/src/cli.ts +4 -10
- package/spec-forge/src/drivers/github.ts +74 -4
- package/spec-forge/src/drivers.ts +13 -0
- package/spec-forge/src/port.ts +25 -0
- package/spec-forge/src/resident.ts +40 -6
- package/spec-yatsu/src/cli.ts +227 -38
- package/spec-yatsu/src/evaltab.ts +169 -19
- package/spec-yatsu/src/filing.ts +48 -0
- package/spec-yatsu/src/freshness.ts +55 -20
- package/spec-yatsu/src/proof.ts +89 -3
- package/spec-yatsu/src/scenariofresh.ts +92 -0
- package/spec-yatsu/src/sidecar.ts +75 -11
- package/spec-yatsu/src/timeline.ts +47 -0
- package/spec-yatsu/src/yatsu.ts +47 -3
- package/spec-cli/src/relay.ts +0 -28
- package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
- package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
- package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
package/spec-cli/src/cli.ts
CHANGED
|
@@ -1,21 +1,43 @@
|
|
|
1
1
|
export {} // make this a module so top-level await is allowed
|
|
2
2
|
const cmd = process.argv[2]
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
// Registered before any await so a fatal top-level error lands here. Errors we OWN (BackendError, the
|
|
5
|
+
// loud malformed-config ConfigError) are matched BY NAME — to avoid importing them — and rendered as a
|
|
6
|
+
// one-line `spex: <message>` (a user's config typo must read as their typo, not a SpexCode stack dump);
|
|
7
|
+
// anything else prints in full so a real bug keeps its trace. A synchronous throw inside an awaited call
|
|
8
|
+
// (loadConfig on a malformed spexcode.json) surfaces as uncaughtException, not unhandledRejection, so BOTH
|
|
9
|
+
// paths route through the same printer.
|
|
10
|
+
function fatal(e: unknown): never {
|
|
11
|
+
if (e instanceof Error && (e.name === 'BackendError' || e.name === 'ConfigError')) console.error(`spex: ${e.message}`)
|
|
7
12
|
else console.error(e)
|
|
8
13
|
process.exit(1)
|
|
9
|
-
}
|
|
14
|
+
}
|
|
15
|
+
process.on('unhandledRejection', fatal)
|
|
16
|
+
process.on('uncaughtException', fatal)
|
|
10
17
|
|
|
11
18
|
// tiny flag reader: --key value (and bare positionals)
|
|
12
19
|
function flag(name: string): string | undefined {
|
|
13
20
|
const i = process.argv.indexOf(`--${name}`)
|
|
14
21
|
return i >= 0 ? process.argv[i + 1] : undefined
|
|
15
22
|
}
|
|
23
|
+
|
|
24
|
+
// Exit AFTER stdout has flushed. process.exit() force-quits without draining buffered pipe writes, so a
|
|
25
|
+
// large piped dump (`spex issues --json | …`, board, review --json) is silently cut off at the pipe
|
|
26
|
+
// buffer (~64KB). The empty write's callback fires once every prior queued chunk has drained; the returned
|
|
27
|
+
// promise never resolves (process.exit ends the process inside the callback), so `await flushExit(code)`
|
|
28
|
+
// halts execution here exactly like process.exit did — safe to drop in on any unbounded-output verb.
|
|
29
|
+
// EPIPE (a reader that closed early — `| head`, `| jq` exiting) can never drain, so we ALSO exit on the
|
|
30
|
+
// stream error rather than hang: the truncation is the reader's choice then, not ours.
|
|
31
|
+
function flushExit(code = 0): Promise<never> {
|
|
32
|
+
return new Promise<never>(() => {
|
|
33
|
+
const done = () => process.exit(code)
|
|
34
|
+
process.stdout.on('error', done)
|
|
35
|
+
process.stdout.write('', done)
|
|
36
|
+
})
|
|
37
|
+
}
|
|
16
38
|
const has = (name: string) => process.argv.includes(`--${name}`)
|
|
17
39
|
// bare positionals after argv index `from`, skipping flags and their values (selectors for ls/watch).
|
|
18
|
-
const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--harness-session', '--port', '--api-port'])
|
|
40
|
+
const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api-port', '--host', '--preset'])
|
|
19
41
|
function positionals(from: number): string[] {
|
|
20
42
|
const out: string[] = []
|
|
21
43
|
for (let i = from; i < process.argv.length; i++) {
|
|
@@ -26,6 +48,23 @@ function positionals(from: number): string[] {
|
|
|
26
48
|
return out
|
|
27
49
|
}
|
|
28
50
|
|
|
51
|
+
// After a successful launch, nudge the caller to actually MONITOR the session — launch-then-forget is a real
|
|
52
|
+
// gap (a supervisor or human launches and then never watches, so a review/failure goes unnoticed). Goes to
|
|
53
|
+
// STDERR so the JSON on stdout (which callers parse) stays clean; keyed to whoever's calling — a supervising
|
|
54
|
+
// agent has an own-session id, a human at a terminal does not.
|
|
55
|
+
async function launchMonitorReminder(id: string): Promise<void> {
|
|
56
|
+
const { ownSessionId } = await import('./sessions.js')
|
|
57
|
+
const agent = ownSessionId()
|
|
58
|
+
console.error(`\nspex: launched session ${id} — now MONITOR it, or its review/failure goes unnoticed:`)
|
|
59
|
+
if (agent) {
|
|
60
|
+
// a supervising agent: the per-worker monitor is a backgrounded `spex wait`, which exits on an actionable status.
|
|
61
|
+
console.error(` supervising agent → background \`spex wait ${id}\` (blocks until it hits an actionable status, then exits)`)
|
|
62
|
+
console.error(` or watch the whole stream: \`spex watch\``)
|
|
63
|
+
} else {
|
|
64
|
+
console.error(` \`spex watch\` — the live stream of actionable session transitions (or \`spex wait ${id}\` to block on this one)`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
29
68
|
const greeted = new Set<string>()
|
|
30
69
|
async function greetWatchTargets(watcher: string, selectors: string[]): Promise<void> {
|
|
31
70
|
try {
|
|
@@ -76,46 +115,12 @@ async function resolveSelectorOrExit(selector: string): Promise<string> {
|
|
|
76
115
|
process.exit(2)
|
|
77
116
|
}
|
|
78
117
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
Usage: spex <command> [args]
|
|
83
|
-
|
|
84
|
-
Specs / graph
|
|
85
|
-
guide [spec|yatsu] no topic: the setup workflow; spec/yatsu: the file-format manual for authoring nodes
|
|
86
|
-
init [dir] scaffold a repo to adopt SpexCode (seed .spec + install git hooks; default: cwd)
|
|
87
|
-
lint check the spec↔code graph (integrity·living·coverage·drift); when committing, gates on heavy commit-local drift
|
|
88
|
-
ack <node>… --reason stamp Spec-OK on HEAD for one or more nodes (this change keeps their specs valid); --reason required, not stored
|
|
89
|
-
serve run the API server (default :8787). [--port N] sets the listen port (mirrors
|
|
90
|
-
dashboard --api-port, so many projects coexist on one host — cwd picks the project)
|
|
91
|
-
--public --password <pw> expose it on a public IP behind a password + self-signed TLS (no domain
|
|
92
|
-
needed). [--tls-cert F --tls-key F] for your own cert · [--http] to drop TLS
|
|
93
|
-
dashboard serve the dashboard UI on its own port (default 5173), proxying /api to a running
|
|
94
|
-
\`spex serve\`. [--port N] [--api-port N=8787]. The installed replacement for \`npm run web\`.
|
|
95
|
-
board dump the dashboard board state as JSON
|
|
96
|
-
forge <sub> trace a forge's issues/PRs onto spec nodes (read-only): links | eval-pending [--host github] [--node <id>] [--json]
|
|
97
|
-
yatsu <sub> measure a node's scenarios and keep score: scan | eval [.|<node>] [--scenario N] (--pass|--fail|--note T) [--image P|--result P|-] | show [.|<node>] [--json] | clean [--keep-latest|--all]
|
|
98
|
-
hooks <sub> harness-agnostic hook system: compile [--out <file>] (flatten surface:hook nodes into the per-session manifest the dispatcher reads)
|
|
99
|
-
self <sub> diagnose how the workflow reaches THIS self-launched agent: doctor (default) | contract | env
|
|
100
|
-
review <SEL> manager cockpit: review a session (ahead·merge-base diff·gates·proposal) [--json]
|
|
101
|
-
review proof <SEL> render the session's proof of work — self-contained HTML, fully derived (diff·measured yatsu loss·gates) [--open|--out P|--json]
|
|
102
|
-
merge <SEL> manager cockpit: gated atomic merge into main (re-checks gates, then closes) [--keep]
|
|
103
|
-
|
|
104
|
-
Sessions
|
|
105
|
-
ls [SEL…] living-sessions table [--status a,b] [--json]
|
|
106
|
-
watch [SEL…] stream actionable transitions — NEVER EXITS; run it in the BACKGROUND, don't block a turn on it (poll one-shot with \`wait\`) [--as NAME] [--status a,b] [--idle] [--interval N]
|
|
107
|
-
wait <SEL> block until <SEL> is actionable, print it, exit (one-shot — the non-blocking counterpart to watch; draws the graph edge) [--timeout S=1200] [--interval S]
|
|
108
|
-
new "<prompt>" start a session (= session new) [--node X]
|
|
109
|
-
session <sub> new | list | reopen | review | done | merge | exit | close | send | capture | prompt
|
|
110
|
-
session prompt <SEL> print the session's originating prompt (what it was asked to do)
|
|
111
|
-
|
|
112
|
-
SEL = session id (or id-prefix), node, or branch — accepted by every read/control verb (ls·watch·wait·
|
|
113
|
-
review·merge·reopen·exit·close·send·capture·prompt); none (or @all) = every session.`)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// a trailing --help/-h prints the summary and exits BEFORE any verb runs, so a help probe never fires a streaming/mutating command.
|
|
118
|
+
// a trailing --help/-h prints help and exits BEFORE any verb runs, so a help probe never fires a
|
|
119
|
+
// streaming/mutating command. It prints THAT command's usage when an entry exists (the second layer
|
|
120
|
+
// of the help journey — see help.ts), falling back to the map for an unknown token.
|
|
117
121
|
if (cmd && cmd !== 'help' && (has('help') || process.argv.includes('-h'))) {
|
|
118
|
-
|
|
122
|
+
const { commandHelp, overviewHelp } = await import('./help.js')
|
|
123
|
+
console.log(commandHelp(cmd) ?? overviewHelp())
|
|
119
124
|
process.exit(0)
|
|
120
125
|
}
|
|
121
126
|
|
|
@@ -132,18 +137,32 @@ if (cmd === 'serve') {
|
|
|
132
137
|
}
|
|
133
138
|
await import('./supervise.js')
|
|
134
139
|
} else if (cmd === 'dashboard') {
|
|
135
|
-
// the natural post-install UI: serve the bundled dashboard on its OWN
|
|
136
|
-
// the
|
|
140
|
+
// the natural post-install UI: serve the bundled dashboard on its OWN port (loopback by default;
|
|
141
|
+
// --host widens the bind for LAN/tailnet viewing), proxying /api + the terminal socket to a
|
|
142
|
+
// separately-run `spex serve`. Replaces the dogfood-only `npm run web` (vite).
|
|
137
143
|
const { serveDashboardLocal } = await import('./gateway.js')
|
|
138
144
|
const port = Number(flag('port') ?? process.env.SPEXCODE_DASHBOARD_PORT ?? 5173)
|
|
139
145
|
const apiPort = Number(flag('api-port') ?? process.env.PORT ?? 8787)
|
|
146
|
+
const host = flag('host') ?? '127.0.0.1'
|
|
140
147
|
if (!Number.isInteger(port) || !Number.isInteger(apiPort)) { console.error('spex dashboard: --port and --api-port must be integers'); process.exit(2) }
|
|
141
|
-
serveDashboardLocal({ port, apiPort })
|
|
148
|
+
serveDashboardLocal({ port, apiPort, host })
|
|
142
149
|
} else if (cmd === undefined || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
143
|
-
|
|
150
|
+
// `spex help <cmd>` drills into one command; bare help is the map. Both name the next layer down.
|
|
151
|
+
const { commandHelp, overviewHelp } = await import('./help.js')
|
|
152
|
+
const topic = positionals(3)[0]
|
|
153
|
+
if (cmd === 'help' && topic) {
|
|
154
|
+
const h = commandHelp(topic)
|
|
155
|
+
if (!h) { console.error(`spex help: no command '${topic}' — run \`spex help\` for the map`); process.exit(2) }
|
|
156
|
+
console.log(h)
|
|
157
|
+
} else console.log(overviewHelp())
|
|
144
158
|
} else if (cmd === 'guide') {
|
|
145
159
|
const { guideText } = await import('./guide.js')
|
|
146
|
-
|
|
160
|
+
const text = guideText(process.argv[3])
|
|
161
|
+
if (text === null) {
|
|
162
|
+
console.error(`spex guide: no topic '${process.argv[3]}'. Topics: spec, yatsu, config. Run \`spex guide\` (no topic) for the setup workflow, \`spex help\` for the command map.`)
|
|
163
|
+
process.exit(2)
|
|
164
|
+
}
|
|
165
|
+
console.log(text)
|
|
147
166
|
} else if (cmd === 'owner') {
|
|
148
167
|
const { specOwners } = await import('./specs.js')
|
|
149
168
|
const { loadConfig } = await import('./lint.js')
|
|
@@ -200,7 +219,13 @@ if (cmd === 'serve') {
|
|
|
200
219
|
// scaffold a repo to adopt SpexCode: copy the shipped DATA templates (seed spec tree + git hooks)
|
|
201
220
|
// into <targetDir> (default cwd). spex init [targetDir]
|
|
202
221
|
const { specInit } = await import('./init.js')
|
|
203
|
-
await specInit(positionals(3)[0])
|
|
222
|
+
await specInit(positionals(3)[0], flag('preset'))
|
|
223
|
+
} else if (cmd === 'uninstall') {
|
|
224
|
+
// the surgical inverse of init: remove every SpexCode-generated artifact (harness shims/contract/trust, the
|
|
225
|
+
// .gitignore block, the global store, any plugin bundle) — NEVER the user's .spec/.config data or their own
|
|
226
|
+
// prose. Git hooks preserved unless --hooks. spex uninstall [targetDir] [--hooks]
|
|
227
|
+
const { uninstall } = await import('./uninstall.js')
|
|
228
|
+
uninstall(positionals(3)[0], { hooks: has('hooks') })
|
|
204
229
|
} else if (cmd === 'review' && positionals(3)[0] === 'proof') {
|
|
205
230
|
const sel = positionals(3)[1]
|
|
206
231
|
if (!sel) { console.error('usage: spex review proof <selector> [--open | --out <path> | --json]'); process.exit(2) }
|
|
@@ -208,7 +233,7 @@ if (cmd === 'serve') {
|
|
|
208
233
|
const { clientProof } = await import('./client.js')
|
|
209
234
|
const r = await clientProof(id, has('json'))
|
|
210
235
|
if (!r.ok) { console.error(`no proof for ${id} (status ${r.status})`); process.exit(1) }
|
|
211
|
-
if (has('json')) { console.log(r.body);
|
|
236
|
+
if (has('json')) { console.log(r.body); await flushExit(0) }
|
|
212
237
|
const { writeFileSync } = await import('node:fs')
|
|
213
238
|
const { join } = await import('node:path')
|
|
214
239
|
const { tmpdir } = await import('node:os')
|
|
@@ -237,7 +262,6 @@ if (cmd === 'serve') {
|
|
|
237
262
|
console.log(` proposal : ${r.proposal.kind ?? '—'}${r.proposal.note ? ` — ${r.proposal.note}` : ''}`)
|
|
238
263
|
console.log(' gates:')
|
|
239
264
|
console.log(` conflicts w/ main : ${g.conflictsWithMain ? 'YES' : 'no'}`)
|
|
240
|
-
console.log(` typecheck : ${g.typecheck.ok ? 'ok' : `${g.typecheck.errorCount} error(s)`}`)
|
|
241
265
|
console.log(` lint : ${g.lint.errorCount} error(s), ${g.lint.warningCount} warning(s)`)
|
|
242
266
|
console.log(` diff (merge-base, ${r.diff.length} file(s)):`)
|
|
243
267
|
for (const f of r.diff) console.log(` ${f.status.padEnd(12)} +${f.additions} -${f.deletions} ${f.path}`)
|
|
@@ -254,16 +278,32 @@ if (cmd === 'serve') {
|
|
|
254
278
|
} else if (cmd === 'forge') {
|
|
255
279
|
// thin route — all logic lives in spec-forge.
|
|
256
280
|
const { runForge } = await import('../../spec-forge/src/cli.js')
|
|
257
|
-
|
|
281
|
+
await flushExit(await runForge(process.argv.slice(3)))
|
|
258
282
|
} else if (cmd === 'yatsu') {
|
|
259
283
|
// thin route — all logic lives in spec-yatsu.
|
|
260
284
|
const { runYatsu } = await import('../../spec-yatsu/src/cli.js')
|
|
261
|
-
|
|
262
|
-
} else if (cmd === '
|
|
263
|
-
// @@@
|
|
264
|
-
//
|
|
265
|
-
const {
|
|
266
|
-
|
|
285
|
+
await flushExit(await runYatsu(process.argv.slice(3)))
|
|
286
|
+
} else if (cmd === 'blob') {
|
|
287
|
+
// @@@ blob - the bare evidence-transport verb ([[blob-put]]): put bytes in the shared content-addressed
|
|
288
|
+
// cache and print the hash, decoupled from filing a reading. Thin route — the cache lives in spec-yatsu.
|
|
289
|
+
const { runBlob } = await import('../../spec-yatsu/src/cli.js')
|
|
290
|
+
await flushExit(runBlob(process.argv.slice(3)))
|
|
291
|
+
} else if (cmd === 'issues') {
|
|
292
|
+
// @@@ issues - the ONE issues surface ([[issues]]): bare it is THE read — local + forge issues as ONE
|
|
293
|
+
// store-tagged list, the supervisor's/human's drain view; a write first-positional (open|reply|sign|
|
|
294
|
+
// resolve|on|off|status, [[local-issues]]) routes to the local store's write verbs, `promote` moves a
|
|
295
|
+
// thread cross-store. (The pre-rename `spex propose` alias is gone — a deployed post-merge hook still
|
|
296
|
+
// calling it prints an unknown-command line, advisory-only, until `npm run hooks` reinstalls it.)
|
|
297
|
+
const { runIssues } = await import('./issues.js')
|
|
298
|
+
await flushExit(await runIssues(process.argv.slice(3)))
|
|
299
|
+
} else if (cmd === 'remark' || cmd === 'resolve' || cmd === 'retract') {
|
|
300
|
+
// @@@ remark - the resolvable interaction primitive ([[remark-substrate]]): pin a concern to a HOST (a
|
|
301
|
+
// local issue, or a scenario `<node> --scenario <name>`) that a second agent can `resolve` and the author
|
|
302
|
+
// can `retract`. CLI-first — the whole author→resolve→retract loop is these thin store-write wrappers, so
|
|
303
|
+
// the dashboard adds no capability. `spex remark <host> --body -|<text> [--code-sha <sha>]`.
|
|
304
|
+
const m = await import('./localIssues.js')
|
|
305
|
+
const run = cmd === 'remark' ? m.runRemark : cmd === 'resolve' ? m.runResolve : m.runRetract
|
|
306
|
+
await flushExit(await run(process.argv.slice(3)))
|
|
267
307
|
} else if (cmd === 'materialize') {
|
|
268
308
|
// @@@ materialize - the pay-per-change render: surface nodes → manifest + AGENTS.md/CLAUDE.md block +
|
|
269
309
|
// shims + Codex trust, for cwd's project. The cheap shell gate (dispatch.sh) invokes it only on change.
|
|
@@ -275,17 +315,18 @@ if (cmd === 'serve') {
|
|
|
275
315
|
// hooks+handler-existence · backend) over the same HARNESSES materialize renders through; contract prints
|
|
276
316
|
// the surface:system text; env dumps raw facts. Thin route, like forge/yatsu/hooks.
|
|
277
317
|
const { runSelf } = await import('./self.js')
|
|
278
|
-
|
|
318
|
+
await flushExit(await runSelf(process.argv.slice(3)))
|
|
279
319
|
} else if (cmd === 'board') {
|
|
280
320
|
const { buildBoard } = await import('./board.js')
|
|
281
321
|
console.log(JSON.stringify(await buildBoard(), null, 2))
|
|
322
|
+
await flushExit(0)
|
|
282
323
|
} else if (cmd === 'search') {
|
|
283
324
|
const { searchSpecs } = await import('./search.js')
|
|
284
325
|
const query = positionals(3).join(' ')
|
|
285
326
|
if (!query.trim()) { console.error('usage: spex search <query> [--json] [--limit N]'); process.exit(2) }
|
|
286
327
|
const limit = Number(flag('limit')) || 10
|
|
287
328
|
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)`) })
|
|
288
|
-
if (has('json')) { console.log(JSON.stringify(results));
|
|
329
|
+
if (has('json')) { console.log(JSON.stringify(results)); await flushExit(0) }
|
|
289
330
|
if (!results.length) { console.log(`no spec node matches "${query}"`); process.exit(0) }
|
|
290
331
|
results.forEach((r, i) => {
|
|
291
332
|
console.log(`${String(i + 1).padStart(2)}. ${r.title} [${r.id}] · score ${r.score}`)
|
|
@@ -293,21 +334,7 @@ if (cmd === 'serve') {
|
|
|
293
334
|
if (r.snippet) console.log(` ${r.snippet}`)
|
|
294
335
|
})
|
|
295
336
|
process.exit(0)
|
|
296
|
-
} else if (cmd === '
|
|
297
|
-
const { relaySearch } = await import('./relay.js')
|
|
298
|
-
const query = positionals(3).join(' ')
|
|
299
|
-
if (!query.trim()) { console.error('usage: spex relay <query> [--json] [--limit N]'); process.exit(2) }
|
|
300
|
-
const limit = Number(flag('limit')) || 3
|
|
301
|
-
const hits = await relaySearch(query, { limit })
|
|
302
|
-
if (has('json')) { console.log(JSON.stringify(hits)); process.exit(0) }
|
|
303
|
-
if (!hits.length) { console.log(`no spec node matches "${query}"`); process.exit(0) }
|
|
304
|
-
hits.forEach((h, i) => {
|
|
305
|
-
console.log(`${String(i + 1).padStart(2)}. ${h.title} [${h.id}] · score ${h.score}`)
|
|
306
|
-
if (h.code.length) h.code.forEach((c) => console.log(` ${c}`))
|
|
307
|
-
else console.log(` (no governed code: files — a pure-prose node)`)
|
|
308
|
-
})
|
|
309
|
-
process.exit(0)
|
|
310
|
-
} else if (cmd === 'ls' || cmd === 'sessions') {
|
|
337
|
+
} else if (cmd === 'ls') {
|
|
311
338
|
// pretty list of living sessions + states. `spex ls [SEL...] [--status a,b] [--json]`
|
|
312
339
|
// the board comes from the backend (so `spex ls` shows the sessions of whatever SPEXCODE_API_URL points at,
|
|
313
340
|
// incl. a remote machine); selectSessions/formatTable are pure presentation, applied client-side.
|
|
@@ -353,7 +380,9 @@ if (cmd === 'serve') {
|
|
|
353
380
|
// it falls back to an in-process launch only when no backend answers.
|
|
354
381
|
const { createSession } = await import('./sessions.js')
|
|
355
382
|
const prompt = flag('prompt') ?? positionals(3)[0] ?? ''
|
|
356
|
-
|
|
383
|
+
const created = await createSession(flag('node') ?? null, prompt, flag('harness') ?? undefined, flag('launcher') ?? undefined)
|
|
384
|
+
console.log(JSON.stringify(created, null, 2))
|
|
385
|
+
await launchMonitorReminder(created.id)
|
|
357
386
|
} else if (cmd === 'session') {
|
|
358
387
|
const sub = process.argv[3]
|
|
359
388
|
// `s` (sessions.ts) backs the state PRODUCERS that stay local (state/done/park/fail/ask/idle write the
|
|
@@ -368,19 +397,24 @@ if (cmd === 'serve') {
|
|
|
368
397
|
const sess = flag('session')
|
|
369
398
|
// 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.
|
|
370
399
|
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.'
|
|
400
|
+
// 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.
|
|
401
|
+
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.'
|
|
371
402
|
if (sub === 'new') {
|
|
372
403
|
// route through the backend (auth env + concurrency cap); in-process only if no backend is reachable.
|
|
373
404
|
// prompt = --prompt OR the first positional (after `session new`), so `session new "<prompt>"` works the
|
|
374
405
|
// SAME as the `spex new "<prompt>"` shorthand — one prompt-resolution rule, not two.
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
} else if (sub === 'reopen'
|
|
379
|
-
//
|
|
406
|
+
const created = await s.createSession(flag('node') ?? null, flag('prompt') ?? positionals(4)[0] ?? '', flag('harness') ?? undefined, flag('launcher') ?? undefined)
|
|
407
|
+
console.log(JSON.stringify(created, null, 2))
|
|
408
|
+
await launchMonitorReminder(created.id)
|
|
409
|
+
} else if (sub === 'reopen') {
|
|
410
|
+
// bring the agent back up (relaunch ONLY if confirmed offline, the backend owns it); demotes a working
|
|
411
|
+
// `active` to idle but leaves a standing declaration/proposal untouched (see sessions.ts reopen()). The
|
|
412
|
+
// RESUME GUARD refuses a relaunch on a LIVE/unproven agent (that would kill a live worker) — `--force`
|
|
413
|
+
// overrides for a genuinely wedged process. A following prompt is what actually re-drives it.
|
|
380
414
|
const full = await resolveSelectorOrExit(id)
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
console.
|
|
415
|
+
const r = await c.clientReopen(full, process.argv.includes('--force'))
|
|
416
|
+
if (r.ok) console.log(`${full} -> reopened`)
|
|
417
|
+
else { console.error(`spex session reopen: ${r.error || `no such session ${full}`}`); process.exit(2) }
|
|
384
418
|
} else if (sub === 'state') {
|
|
385
419
|
// the agent authors ITS OWN state: active|awaiting|parked|error [--propose] [--note] [--session]
|
|
386
420
|
const st = process.argv[4] as any
|
|
@@ -389,7 +423,15 @@ if (cmd === 'serve') {
|
|
|
389
423
|
} else if (sub === 'done') {
|
|
390
424
|
// sugar for awaiting; --propose merge|nothing|close, optional --note
|
|
391
425
|
const p = (flag('propose') as any) || 'nothing'
|
|
392
|
-
|
|
426
|
+
let closeNote = p === 'close' ? CLOSE_CLEANUP : ''
|
|
427
|
+
if (p === 'close') {
|
|
428
|
+
// the DATA half of the close nudge ([[local-issues]] closeoutNudge): the still-open local issues this
|
|
429
|
+
// session touched, listed by id — empty/OFF/no-identity prints nothing. Loud on failure but never
|
|
430
|
+
// gating: the declaration must land whatever the issue store is doing.
|
|
431
|
+
try { closeNote += (await import('./localIssues.js')).closeoutNudge(sess ?? s.ownSessionId()) }
|
|
432
|
+
catch (e) { console.error(`issue closeout check failed (declaration unaffected): ${e instanceof Error ? e.message : e}`) }
|
|
433
|
+
}
|
|
434
|
+
console.log(s.markDone(p, sess) ? `done (${p})${DECLARED}${closeNote}` : 'no session record')
|
|
393
435
|
} else if (sub === 'park') {
|
|
394
436
|
// sugar: the agent is waiting on a background task; it will self-resume (NOT idle/awaiting)
|
|
395
437
|
console.log(s.markState('parked', { note: flag('note'), sessionId: sess }) ? `parked${DECLARED}` : 'no session record')
|
|
@@ -415,15 +457,6 @@ if (cmd === 'serve') {
|
|
|
415
457
|
// never clobbering a deliberate awaiting/asking/parked/error declaration. Distinct from `ask`
|
|
416
458
|
// (the agent deliberately asking the human) — idle is the undeclared stop the Stop gate missed.
|
|
417
459
|
console.log(s.markIdle(sess) ? 'idle' : 'noop (no session record, or not active)')
|
|
418
|
-
} else if (sub === 'merge') {
|
|
419
|
-
// merge dispatch (same as top-level `spex merge`): reopen the session and hand its OWN agent the merge
|
|
420
|
-
// prompt — the agent runs the --no-ff merge, resolves conflicts, verifies main advanced, and proposes
|
|
421
|
-
// close. The SERVER never touches main. Fail-loud: an unreachable agent prints the reason, exits non-zero.
|
|
422
|
-
const full = await resolveSelectorOrExit(id)
|
|
423
|
-
const r = await c.clientMerge(full)
|
|
424
|
-
if (r.dispatched) console.log(`merge dispatched to ${full} — its agent is landing the merge`)
|
|
425
|
-
else console.error(`merge dispatch failed: ${r.reason}`)
|
|
426
|
-
process.exit(r.dispatched ? 0 : 1)
|
|
427
460
|
} else if (sub === 'exit') {
|
|
428
461
|
// the SOFT stop: kill the agent's tmux + socket but KEEP the worktree, so the session goes offline and
|
|
429
462
|
// can be resumed (reopen/relaunch). Distinct from `close`, which removes the worktree.
|
|
@@ -466,35 +499,64 @@ if (cmd === 'serve') {
|
|
|
466
499
|
if (!r.ok) { console.error(`no prompt recorded for ${full}`); process.exit(1) }
|
|
467
500
|
process.stdout.write(r.prompt.endsWith('\n') ? r.prompt : r.prompt + '\n')
|
|
468
501
|
} else {
|
|
469
|
-
console.error('spex session: new|
|
|
502
|
+
console.error('spex session: new|reopen|done|park|ask|idle|exit|close|send|capture|prompt'); process.exit(2)
|
|
470
503
|
}
|
|
471
|
-
} else if (cmd === '
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
504
|
+
} else if (cmd === 'internal') {
|
|
505
|
+
// @@@ internal - the machine-plumbing namespace: verbs only generated hooks and launch scripts call,
|
|
506
|
+
// kept OUT of the porcelain top level so `spex help`'s vocabulary is exactly what a human/agent types.
|
|
507
|
+
const sub = process.argv[3]
|
|
508
|
+
if (sub === 'trunk') {
|
|
509
|
+
// print the resolved source-of-truth branch (layout.ts mainBranch(): config override → the main
|
|
510
|
+
// checkout's current branch → 'main'). The pre-commit main-guard captures this so it blocks direct
|
|
511
|
+
// commits on whatever the repo's trunk is actually named, never a hardcoded 'main'. One value, one
|
|
512
|
+
// line; GET /api/layout exposes the same resolution.
|
|
513
|
+
const { mainBranch } = await import('./layout.js')
|
|
514
|
+
console.log(mainBranch())
|
|
515
|
+
} else if (sub === 'codex-launch') {
|
|
516
|
+
// BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
|
|
517
|
+
// (codex loads that worktree's config/hooks/AGENTS.md), store the new id on the governed record (keyed by
|
|
518
|
+
// SPEXCODE_SESSION_ID), fire the launch prompt as the FIRST turn — materializing the rollout — and print the
|
|
519
|
+
// thread id. The launch script then `resume`s it in the visible TUI.
|
|
520
|
+
const { codexStartThread, codexTurn, waitForCodexRollout, codexBinary, codexSupportsBypassHookTrust } = await import('./harness.js')
|
|
521
|
+
const { markHarnessSessionId } = await import('./sessions.js')
|
|
522
|
+
const sock = process.argv[4], cwd = process.argv[5]
|
|
523
|
+
const prompt = process.argv.slice(6).join(' ')
|
|
524
|
+
if (!sock || !cwd) { console.error('usage: spex internal codex-launch <sock> <cwd> [prompt...]'); process.exit(2) }
|
|
525
|
+
// On the bypass-trust path (the codex install supports the flag → materialize skipped writeCodexTrust's hash),
|
|
526
|
+
// the thread the BACKEND owns must carry `bypass_hook_trust` in thread/start's config so the app-server fires
|
|
527
|
+
// the worktree's local hooks — mirror materialize's capability decision so the two stay in lockstep.
|
|
528
|
+
const bypassHookTrust = codexSupportsBypassHookTrust(codexBinary(process.env.SPEXCODE_CODEX_CMD || 'codex --yolo'))
|
|
529
|
+
const r = await codexStartThread(sock, cwd, bypassHookTrust)
|
|
530
|
+
if (!r.ok) { console.error(r.error); process.exit(1) }
|
|
531
|
+
if (prompt) {
|
|
532
|
+
const t = await codexTurn(sock, r.threadId, prompt, cwd)
|
|
533
|
+
if (!t.ok) { console.error(t.error); process.exit(1) }
|
|
534
|
+
// The visible TUI resumes this thread from its ON-DISK rollout; a freshly-spawned app-server acks the turn
|
|
535
|
+
// but persists the rollout a few seconds LATE (verified live: the SAME thread's file lands at ~2-4s, not
|
|
536
|
+
// lost). WAIT for it to land BEFORE storing the id / printing it, else FAIL LOUD — never store a
|
|
537
|
+
// non-resumable harness_session_id (that permanently wedges every reopen). The 15s budget exceeds launch.sh's
|
|
538
|
+
// fast-fail threshold, so a real failure exits past it and the retry loop won't spray duplicate-prompt threads.
|
|
539
|
+
if (!await waitForCodexRollout(r.threadId, 20000)) {
|
|
540
|
+
console.error(`codex thread ${r.threadId} started but persisted no rollout within 20s — app-server not ready; not storing a non-resumable id`)
|
|
541
|
+
process.exit(1)
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const sid = process.env.SPEXCODE_SESSION_ID
|
|
545
|
+
if (sid) markHarnessSessionId(sid, r.threadId)
|
|
546
|
+
console.log(r.threadId)
|
|
547
|
+
} else if (sub === 'codex-turn') {
|
|
548
|
+
// fire a follow-up turn on an OWNED thread over the per-project socket (the delivery channel, exposed for
|
|
549
|
+
// tests / scripts). steer-vs-start is chosen live from the thread read.
|
|
550
|
+
const { codexTurn } = await import('./harness.js')
|
|
551
|
+
const sock = process.argv[4], tid = process.argv[5], text = process.argv.slice(6).join(' ')
|
|
552
|
+
if (!sock || !tid || !text) { console.error('usage: spex internal codex-turn <sock> <threadId> <text...>'); process.exit(2) }
|
|
553
|
+
const r = await codexTurn(sock, tid, text)
|
|
554
|
+
if (r.ok) { console.log('ok') } else { console.error(r.error); process.exit(1) }
|
|
555
|
+
} else {
|
|
556
|
+
const { commandHelp } = await import('./help.js')
|
|
557
|
+
console.error(commandHelp('internal'))
|
|
558
|
+
process.exit(2)
|
|
488
559
|
}
|
|
489
|
-
console.log(r.threadId)
|
|
490
|
-
} else if (cmd === 'codex-turn') {
|
|
491
|
-
// fire a follow-up turn on an OWNED thread over the per-project socket (the delivery channel, exposed for
|
|
492
|
-
// tests / scripts). steer-vs-start is chosen live from the thread read.
|
|
493
|
-
const { codexTurn } = await import('./harness.js')
|
|
494
|
-
const sock = process.argv[3], tid = process.argv[4], text = process.argv.slice(5).join(' ')
|
|
495
|
-
if (!sock || !tid || !text) { console.error('usage: spex codex-turn <sock> <threadId> <text...>'); process.exit(2) }
|
|
496
|
-
const r = await codexTurn(sock, tid, text)
|
|
497
|
-
if (r.ok) { console.log('ok') } else { console.error(r.error); process.exit(1) }
|
|
498
560
|
} else {
|
|
499
561
|
console.error(`spex: unknown command '${cmd}' (try: spex help)`)
|
|
500
562
|
process.exit(2)
|
package/spec-cli/src/client.ts
CHANGED
|
@@ -74,10 +74,12 @@ export async function clientMerge(id: string): Promise<{ dispatched: boolean; re
|
|
|
74
74
|
return await r.json().catch(() => ({ dispatched: false, reason: `bad backend response (${r.status})` }))
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
// POST /api/sessions/:id/resume —
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
// POST /api/sessions/:id/resume — bring the agent back (relaunch ONLY if confirmed offline); demotes
|
|
78
|
+
// working→idle, keeps any declaration. The RESUME GUARD REFUSES (409 {refused:true}) on a live/unproven agent;
|
|
79
|
+
// `force` overrides for a wedged-but-alive process. {ok:false} otherwise = no such session (404).
|
|
80
|
+
export async function clientReopen(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
|
|
81
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/resume`, post({ force }))
|
|
82
|
+
return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` }))
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
// POST /api/sessions/:id/exit — the soft stop: kill tmux + socket, KEEP the worktree (session goes offline,
|