spexcode 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +25 -1
- package/spec-cli/hooks/dispatch.sh +24 -1
- package/spec-cli/src/anchors.ts +192 -58
- package/spec-cli/src/attach.ts +11 -8
- package/spec-cli/src/claude-headless.ts +116 -66
- package/spec-cli/src/cli.ts +221 -72
- package/spec-cli/src/client.ts +143 -5
- package/spec-cli/src/gateway-hub.ts +2 -6
- package/spec-cli/src/gateway.ts +96 -11
- package/spec-cli/src/git.ts +1395 -96
- package/spec-cli/src/graph.ts +21 -17
- package/spec-cli/src/graphCache.ts +405 -61
- package/spec-cli/src/graphStream.ts +581 -182
- package/spec-cli/src/guide.ts +13 -3
- package/spec-cli/src/harness.ts +965 -70
- package/spec-cli/src/headless-controller.ts +63 -0
- package/spec-cli/src/help.ts +120 -64
- package/spec-cli/src/host-resources.ts +706 -0
- package/spec-cli/src/host.ts +1 -0
- package/spec-cli/src/index.ts +95 -21
- package/spec-cli/src/init.ts +47 -8
- package/spec-cli/src/issues.ts +36 -3
- package/spec-cli/src/layout.ts +193 -37
- package/spec-cli/src/lint.ts +152 -27
- package/spec-cli/src/localIssues.ts +5 -1
- package/spec-cli/src/maintenance-wrapper.ts +276 -0
- package/spec-cli/src/materialize.ts +6 -7
- package/spec-cli/src/opencode-headless.ts +117 -7
- package/spec-cli/src/opencode.ts +8 -4
- package/spec-cli/src/pi-headless.ts +18 -60
- package/spec-cli/src/process-identity.ts +49 -0
- package/spec-cli/src/project-store.ts +17 -0
- package/spec-cli/src/pty-bridge.ts +37 -7
- package/spec-cli/src/pty-helper.mjs +16 -6
- package/spec-cli/src/pty-native-helper.mjs +22 -0
- package/spec-cli/src/reviews.ts +17 -13
- package/spec-cli/src/runtime-ownership.ts +99 -0
- package/spec-cli/src/session-maintenance.ts +750 -0
- package/spec-cli/src/session-timeline.ts +29 -23
- package/spec-cli/src/sessions.ts +1262 -200
- package/spec-cli/src/sh.ts +3 -0
- package/spec-cli/src/source-files.ts +27 -2
- package/spec-cli/src/specs.ts +58 -25
- package/spec-cli/src/supervise.ts +31 -11
- package/spec-cli/src/uninstall.ts +7 -6
- package/spec-cli/templates/hooks/commit-msg +14 -0
- package/spec-cli/templates/hooks/pre-commit +26 -6
- package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
- package/spec-cli/templates/hooks/reference-transaction +105 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
- package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
- package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
- package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
- package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CAY4S4g4.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
- package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
- package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
- package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
- package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
- package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
- package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
- package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
- package/spec-dashboard/dist/index.html +5 -4
- package/spec-dashboard/src/reviewFilters.js +46 -8
- package/spec-dashboard/src/session.js +64 -9
- package/spec-eval/src/cli.ts +144 -32
- package/spec-eval/src/evaltab.ts +16 -9
- package/spec-eval/src/freshness.ts +336 -54
- package/spec-eval/src/scenariofresh.ts +51 -1
- package/spec-eval/src/scenarios.ts +324 -25
- package/spec-eval/src/sessioneval.ts +969 -173
- package/spec-cli/src/message-stream.ts +0 -147
- package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +0 -2
- package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +0 -13
- package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +0 -1
- package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +0 -1
- package/spec-dashboard/dist/assets/index-BKaTHjmU.js +0 -41
- package/spec-dashboard/dist/assets/index-DcnCaBAC.css +0 -1
package/spec-cli/src/cli.ts
CHANGED
|
@@ -20,7 +20,8 @@ const cmd = process.argv[2]
|
|
|
20
20
|
// (loadConfig on a malformed spexcode.json) surfaces as uncaughtException, not unhandledRejection, so BOTH
|
|
21
21
|
// paths route through the same printer.
|
|
22
22
|
function fatal(e: unknown): never {
|
|
23
|
-
if (e instanceof Error &&
|
|
23
|
+
if (e instanceof Error && e.name === 'SessionMaintenanceError') console.error(`spex: ${(e as Error & { code?: string }).code ?? 'maintenance_error'}: ${e.message}`)
|
|
24
|
+
else if (e instanceof Error && ['BackendError', 'ConfigError', 'UsageError', 'GuardError'].includes(e.name)) console.error(`spex: ${e.message}`)
|
|
24
25
|
else console.error(e)
|
|
25
26
|
process.exit(1)
|
|
26
27
|
}
|
|
@@ -49,7 +50,7 @@ function flushExit(code = 0): Promise<never> {
|
|
|
49
50
|
}
|
|
50
51
|
const has = (name: string) => process.argv.includes(`--${name}`)
|
|
51
52
|
// bare positionals after argv index `from`, skipping flags and their values (selectors for ls/watch).
|
|
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'])
|
|
53
|
+
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', '--allow-stop', '--allow-resume', '--ttl-ms', '--wait-ms'])
|
|
53
54
|
function positionals(from: number): string[] {
|
|
54
55
|
const out: string[] = []
|
|
55
56
|
for (let i = from; i < process.argv.length; i++) {
|
|
@@ -126,25 +127,6 @@ function signpost(oldSpelling: string, newSpelling: string): never {
|
|
|
126
127
|
}
|
|
127
128
|
if (cmd !== undefined && SIGNPOSTS[cmd]) signpost(`spex ${cmd}`, SIGNPOSTS[cmd])
|
|
128
129
|
|
|
129
|
-
// After a successful launch, nudge the caller to actually MONITOR the session — launch-then-forget is a real
|
|
130
|
-
// gap (a supervisor or human launches and then never watches, so a review/failure goes unnoticed). Goes to
|
|
131
|
-
// STDERR so the JSON on stdout (which callers parse) stays clean; keyed to whoever's calling — a supervising
|
|
132
|
-
// agent has an own-session id, a human at a terminal does not. The hint also names the COMM channel
|
|
133
|
-
// (`spex session send`) — field-tested gap: callers who couldn't find it reached for raw tmux keystrokes instead.
|
|
134
|
-
async function launchMonitorReminder(id: string): Promise<void> {
|
|
135
|
-
const { ownSessionId } = await import('./sessions.js')
|
|
136
|
-
const agent = ownSessionId()
|
|
137
|
-
console.error(`\nspex: launched session ${id} — now MONITOR it, or its review/failure goes unnoticed:`)
|
|
138
|
-
if (agent) {
|
|
139
|
-
// a supervising agent: the per-worker monitor is a backgrounded `spex session wait`, which is edge-triggered.
|
|
140
|
-
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\`)`)
|
|
141
|
-
console.error(` or watch the whole stream: \`spex session watch\``)
|
|
142
|
-
} else {
|
|
143
|
-
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)`)
|
|
144
|
-
}
|
|
145
|
-
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)`)
|
|
146
|
-
}
|
|
147
|
-
|
|
148
130
|
const greeted = new Set<string>()
|
|
149
131
|
async function greetWatchTargets(watcher: string, selectors: string[]): Promise<void> {
|
|
150
132
|
try {
|
|
@@ -216,8 +198,9 @@ async function evalExport(id: string): Promise<never> {
|
|
|
216
198
|
process.exit(0)
|
|
217
199
|
}
|
|
218
200
|
|
|
219
|
-
// appended to a done/ask/block declaration:
|
|
220
|
-
|
|
201
|
+
// appended to a done/ask/block declaration: the note is durable conversation history even though the
|
|
202
|
+
// CURRENT board projection correctly flips back to active on the next tool call.
|
|
203
|
+
const DECLARED = ' — recorded; the human sees it in the dashboard. This declaration remains in the session timeline; your next tool call flips only the current graph state back to active (the mark-active hook, by design).'
|
|
221
204
|
// 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.
|
|
222
205
|
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.'
|
|
223
206
|
|
|
@@ -249,9 +232,16 @@ async function stateKit() {
|
|
|
249
232
|
}
|
|
250
233
|
// a state writer from a non-repo cwd throws git's not-a-repo before it can return false — map exactly
|
|
251
234
|
// that throw to the no-record path (noRecord re-probes and names the cwd); anything else stays loud.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
235
|
+
// A record that exists but CANNOT carry state (unreadable bytes, or a worktree that is gone) is its own
|
|
236
|
+
// answer: the writer refused on purpose and already knows why, so that reason is what the caller prints —
|
|
237
|
+
// never the no-record diagnosis, which would send the author chasing a wrong cwd ([[sessions-core]]).
|
|
238
|
+
const mark = (fn: () => boolean): { ok: boolean; reason?: string } => {
|
|
239
|
+
try { return { ok: fn() } }
|
|
240
|
+
catch (e) {
|
|
241
|
+
if (e instanceof s.SessionRecordUnusable) return { ok: false, reason: e.message }
|
|
242
|
+
if (/not a git repository/i.test(String((e as any)?.stderr ?? e))) return { ok: false }
|
|
243
|
+
throw e
|
|
244
|
+
}
|
|
255
245
|
}
|
|
256
246
|
// truncation transparency ([[state]]): the session table shows only the first NOTE_BOARD_LIMIT chars of a
|
|
257
247
|
// note. When a declared note overflows that cap, the confirmation says so — length, what the board shows,
|
|
@@ -277,12 +267,12 @@ async function stateKit() {
|
|
|
277
267
|
|
|
278
268
|
// a trailing --help/-h prints help and exits BEFORE any verb runs, so a help probe never fires a
|
|
279
269
|
// streaming/mutating command. It prints THAT command's usage when an entry exists (the second layer
|
|
280
|
-
// of the help journey — see help.ts)
|
|
281
|
-
// drawer
|
|
282
|
-
// table above already exited.)
|
|
270
|
+
// of the help journey — see help.ts); session noun-verb probes project the exact verb from the shared
|
|
271
|
+
// drawer definition. Unknown tokens preserve the existing drawer/map fallback. (Removed spellings never
|
|
272
|
+
// reach here — the signpost table above already exited.)
|
|
283
273
|
if (cmd && cmd !== 'help' && (has('help') || process.argv.includes('-h'))) {
|
|
284
274
|
const { commandHelp, overviewHelp } = await import('./help.js')
|
|
285
|
-
console.log(commandHelp(cmd) ?? overviewHelp())
|
|
275
|
+
console.log(commandHelp(cmd, cmd === 'session' ? process.argv[3] : undefined) ?? overviewHelp())
|
|
286
276
|
process.exit(0)
|
|
287
277
|
}
|
|
288
278
|
|
|
@@ -454,14 +444,21 @@ if (cmd === 'serve') {
|
|
|
454
444
|
console.log(`${rel} is governed whole-file by ${whole.length} specs (all claims: ${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}`)
|
|
455
445
|
}
|
|
456
446
|
} else if (sub === 'lint') {
|
|
457
|
-
const { specLint, DRIFT_GUIDANCE } = await import('./lint.js')
|
|
458
|
-
const
|
|
447
|
+
const { specLint, pendingTouchesGoverned, DRIFT_GUIDANCE } = await import('./lint.js')
|
|
448
|
+
const pending = flag('pending')
|
|
449
|
+
if (pending && process.env.SPEXCODE_GATE_SCOPE_ONLY === '1') {
|
|
450
|
+
const touches = await pendingTouchesGoverned(process.cwd(), pending)
|
|
451
|
+
if (!touches) process.exit(76)
|
|
452
|
+
}
|
|
453
|
+
const findings = await specLint(undefined, undefined, {
|
|
454
|
+
tip: pending || 'HEAD',
|
|
455
|
+
})
|
|
459
456
|
const errors = findings.filter((f) => f.level === 'error')
|
|
460
457
|
for (const f of findings) console.error(` ${f.level === 'error' ? '✗' : '•'} ${f.rule}: ${f.msg}`)
|
|
461
458
|
console.error(`spex spec lint: ${errors.length} error(s), ${findings.length - errors.length} warning(s)`)
|
|
462
459
|
// drift teaches from the ONE `spex spec lint` (no flag). Unanchored drift stays advisory forever; the
|
|
463
|
-
//
|
|
464
|
-
//
|
|
460
|
+
// Blocking tier is anchor-drift ([[code-anchor]]) — CI/default lint judges HEAD; the local armed hook
|
|
461
|
+
// supplies --pending <real commit oid>; canonical pre-commit leaves the whole lint to that one run.
|
|
465
462
|
if (findings.some((f) => f.rule === 'drift' || f.rule === 'anchor-drift')) console.error(`\n${DRIFT_GUIDANCE}`)
|
|
466
463
|
process.exit(errors.length ? 1 : 0)
|
|
467
464
|
} else if (sub === 'ack') {
|
|
@@ -512,9 +509,9 @@ if (cmd === 'serve') {
|
|
|
512
509
|
} else if (sub === 'ls' && flag('session') !== undefined) {
|
|
513
510
|
// the session EVAL read ([[session-eval]]'s interactive face as a CLI verb): the dashboard Eval tab's
|
|
514
511
|
// text twin. Renders the session's changed nodes with each DECLARED scenario at its CURRENT score
|
|
515
|
-
// (latest reading per scenario, worktree-rooted) —
|
|
516
|
-
//
|
|
517
|
-
// artifact instead.
|
|
512
|
+
// (latest reading per scenario, worktree-rooted) — filed results keep the backend's newest-first order
|
|
513
|
+
// across ownership, the session's OWN measurements are ✦-marked, and blind spots follow. --export writes
|
|
514
|
+
// the self-contained HTML artifact instead.
|
|
518
515
|
const id = await resolveSelectorOrExit(flag('session')!)
|
|
519
516
|
if (has('export')) await evalExport(id)
|
|
520
517
|
const { clientEvals } = await import('./client.js')
|
|
@@ -522,35 +519,31 @@ if (cmd === 'serve') {
|
|
|
522
519
|
if (!r.ok) { console.error(`no evals for ${id} (status ${r.status})`); process.exit(1) }
|
|
523
520
|
if (has('json')) { console.log(JSON.stringify(r.model, null, 2)); await flushExit(0) }
|
|
524
521
|
const m = r.model
|
|
525
|
-
const byNode = new Map<string, any[]>()
|
|
526
|
-
for (const item of m.items) {
|
|
527
|
-
const rows = byNode.get(item.node) ?? []
|
|
528
|
-
rows.push(item)
|
|
529
|
-
byNode.set(item.node, rows)
|
|
530
|
-
}
|
|
531
|
-
const groups = [...byNode].map(([node, rows]) => ({ node, rows }))
|
|
532
522
|
const own = m.items.filter((item) => item.inSession).length
|
|
533
523
|
console.log(`eval session [${m.id}]`)
|
|
534
524
|
console.log(` gates : ${m.gates.map((g) => `${g.ok ? '✓' : '✗'} ${g.label} — ${g.detail}`).join(' · ')}`)
|
|
535
|
-
if (own) console.log(` ✦ : ${own} scenario(s) measured by THIS session (unmarked rows =
|
|
525
|
+
if (own) console.log(` ✦ : ${own} scenario(s) measured by THIS session (unmarked rows = evals filed by other sessions)`)
|
|
536
526
|
if (!m.items.length) console.log('\n no affected scenarios to evaluate yet')
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
if (!e.inSession && !divided && rows.some((x) => x.filterKind === 'result' && x.inSession)) { console.log(` ── inherited baseline (other sessions' latest evals) ──`); divided = true }
|
|
543
|
-
const verdict = e.verdict?.status === 'pass' ? '✓ pass' : e.verdict?.status === 'fail' ? '✗ fail' : '· unscored'
|
|
544
|
-
const stale = e.fresh ? '' : ` (stale: ${(e.staleAxes || []).join(',')})`
|
|
545
|
-
console.log(` ${e.inSession ? '✦' : ' '} ${verdict}${stale} ${e.scenario} — ${e.ts}${e.evaluator ? ` · ${e.evaluator}` : ''}`)
|
|
527
|
+
let lastNode: string | null = null
|
|
528
|
+
for (const e of m.items) {
|
|
529
|
+
if (e.node !== lastNode) {
|
|
530
|
+
console.log(`\n${e.node}`)
|
|
531
|
+
lastNode = e.node
|
|
546
532
|
}
|
|
533
|
+
if (e.filterKind === 'blind') {
|
|
534
|
+
console.log(` ∅ unmeasured ${e.scenario} — declared, never measured (blind spot)`)
|
|
535
|
+
continue
|
|
536
|
+
}
|
|
537
|
+
const verdict = e.verdict?.status === 'pass' ? '✓ pass' : e.verdict?.status === 'fail' ? '✗ fail' : '· unscored'
|
|
538
|
+
const stale = e.fresh ? '' : ` (stale: ${(e.staleAxes || []).join(',')})`
|
|
539
|
+
console.log(` ${e.inSession ? '✦' : ' '} ${verdict}${stale} ${e.scenario} — ${e.ts}${e.evaluator ? ` · ${e.evaluator}` : ''}`)
|
|
547
540
|
}
|
|
548
541
|
} else if (['add', 'ls', 'scenario', 'matrix', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
|
|
549
542
|
// node-scoped verbs — thin route; the logic lives in spec-eval.
|
|
550
543
|
const { runEval } = await import('../../spec-eval/src/cli.js')
|
|
551
544
|
await flushExit(await runEval(process.argv.slice(3)))
|
|
552
545
|
} else {
|
|
553
|
-
console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls | matrix | lint | ok | retract | clean (spex help eval)`)
|
|
546
|
+
console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls/write | matrix | lint | ok | retract | clean (spex help eval)`)
|
|
554
547
|
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)
|
|
555
548
|
process.exit(2)
|
|
556
549
|
}
|
|
@@ -623,6 +616,51 @@ if (cmd === 'serve') {
|
|
|
623
616
|
console.log((await import('./help.js')).commandHelp('session'))
|
|
624
617
|
} else if (SESSION_SIGNPOSTS[sub]) {
|
|
625
618
|
signpost(`spex session ${sub}`, SESSION_SIGNPOSTS[sub])
|
|
619
|
+
} else if (sub === 'maintain') {
|
|
620
|
+
const args = process.argv.slice(4)
|
|
621
|
+
if (args.length === 1 && args[0] === '--status') {
|
|
622
|
+
const state = await (await import('./client.js')).clientMaintenanceStatus()
|
|
623
|
+
console.log(JSON.stringify(state, null, 2))
|
|
624
|
+
} else {
|
|
625
|
+
const separator = args.indexOf('--')
|
|
626
|
+
if (separator < 0 || separator === args.length - 1) {
|
|
627
|
+
console.error('usage: spex session maintain [--allow-stop SEL] [--allow-resume SEL[:force]] [--ttl-ms MS] [--wait-ms MS] -- <command> [args...]')
|
|
628
|
+
process.exit(2)
|
|
629
|
+
}
|
|
630
|
+
const options = args.slice(0, separator)
|
|
631
|
+
const command = args.slice(separator + 1)
|
|
632
|
+
const capabilities: import('./session-maintenance.js').Capability[] = []
|
|
633
|
+
let ttlMs = 30_000
|
|
634
|
+
let waitMs = 10_000
|
|
635
|
+
for (let i = 0; i < options.length; i++) {
|
|
636
|
+
const option = options[i]
|
|
637
|
+
if (option === '--api' || option === '--port') { i++; continue }
|
|
638
|
+
if (option === '--ttl-ms' || option === '--wait-ms') {
|
|
639
|
+
const value = Number(options[++i])
|
|
640
|
+
if (!Number.isSafeInteger(value)) { console.error(`spex session maintain: ${option} requires an integer millisecond value`); process.exit(2) }
|
|
641
|
+
if (option === '--ttl-ms') ttlMs = value
|
|
642
|
+
else waitMs = value
|
|
643
|
+
continue
|
|
644
|
+
}
|
|
645
|
+
if (option === '--allow-stop' || option === '--allow-resume') {
|
|
646
|
+
const raw = options[++i]
|
|
647
|
+
if (!raw) { console.error(`spex session maintain: ${option} requires a session selector`); process.exit(2) }
|
|
648
|
+
if (option === '--allow-stop') {
|
|
649
|
+
capabilities.push({ op: 'stop', sessionId: await resolveSelectorOrExit(raw) })
|
|
650
|
+
} else {
|
|
651
|
+
const force = raw.endsWith(':force')
|
|
652
|
+
const selector = force ? raw.slice(0, -':force'.length) : raw
|
|
653
|
+
capabilities.push({ op: 'resume', sessionId: await resolveSelectorOrExit(selector), force })
|
|
654
|
+
}
|
|
655
|
+
continue
|
|
656
|
+
}
|
|
657
|
+
console.error(`spex session maintain: unknown option ${option}`)
|
|
658
|
+
process.exit(2)
|
|
659
|
+
}
|
|
660
|
+
if (capabilities.length === 0) { console.error('spex session maintain: at least one exact stop/resume capability is required'); process.exit(2) }
|
|
661
|
+
const { runMaintenanceWrapper } = await import('./maintenance-wrapper.js')
|
|
662
|
+
process.exit(await runMaintenanceWrapper({ capabilities, ttlMs, waitMs, command }))
|
|
663
|
+
}
|
|
626
664
|
} else if (sub === 'new') {
|
|
627
665
|
// spex session new "<prompt>" (prompt = first positional or --prompt, or --prompt-file
|
|
628
666
|
// <path>|- so a long multi-paragraph prompt never fights shell quoting — [[prompt-file]]).
|
|
@@ -647,22 +685,40 @@ if (cmd === 'serve') {
|
|
|
647
685
|
}
|
|
648
686
|
const created = await createSession(prompt, flag('launcher') ?? undefined)
|
|
649
687
|
console.log(JSON.stringify(created, null, 2))
|
|
650
|
-
await
|
|
688
|
+
console.error((await import('./help.js')).sessionLaunchReceipt(created.id))
|
|
651
689
|
} else if (sub === 'ls') {
|
|
652
690
|
// pretty list of living sessions + states. `spex session ls [SEL...] [--status a,b] [--json]`
|
|
653
691
|
// the board comes from the backend (so it shows the sessions of whatever SPEXCODE_API_URL points at,
|
|
654
692
|
// incl. a remote machine); selectSessions/formatTable are pure presentation, applied client-side.
|
|
655
693
|
const { selectSessions, formatTable } = await import('./sessions.js')
|
|
656
694
|
const { clientListSessions } = await import('./client.js')
|
|
657
|
-
|
|
695
|
+
// The backend's default projection excludes cold archives. --all and an explicit selector request the
|
|
696
|
+
// history projection so an operator can still inspect or unarchive one deliberately.
|
|
697
|
+
const selectors = positionals(4)
|
|
698
|
+
const all = await clientListSessions(has('all') || selectors.length > 0)
|
|
699
|
+
const visible = all
|
|
700
|
+
const picked = selectSessions(visible, selectors, flag('status')?.split(','))
|
|
658
701
|
console.log(has('json') ? JSON.stringify(picked, null, 2) : formatTable(picked))
|
|
702
|
+
} else if (sub === 'resources') {
|
|
703
|
+
rejectUnknownFlags('spex session resources', 4, ['json', 'api', 'port'])
|
|
704
|
+
const { clientResources } = await import('./client.js')
|
|
705
|
+
const report = await clientResources()
|
|
706
|
+
if (has('json')) console.log(JSON.stringify(report, null, 2))
|
|
707
|
+
else console.log((await import('./host-resources.js')).formatResourceReport(report))
|
|
659
708
|
} else if (sub === 'watch') {
|
|
660
709
|
const { watchSessions } = await import('./sessions.js')
|
|
661
710
|
const { clientListSessions } = await import('./client.js')
|
|
662
711
|
const selectors = positionals(4)
|
|
663
712
|
const intervalMs = (Number(flag('interval')) || 5) * 1000
|
|
713
|
+
// Broad watch keeps the default active-only event population, so the archive shelf is not replayed into
|
|
714
|
+
// ordinary monitoring. Presence is a separate all-record read: hiding a cold row must not turn it into
|
|
715
|
+
// "gone"/closed. An explicit selector opts into the history population so its archive/offline transition
|
|
716
|
+
// remains observable.
|
|
717
|
+
const history = () => clientListSessions(true)
|
|
718
|
+
const events = selectors.length ? history : () => clientListSessions(false)
|
|
664
719
|
await withWatchEdge(selectors, intervalMs, () => watchSessions((line) => console.log(line), {
|
|
665
|
-
source:
|
|
720
|
+
source: events,
|
|
721
|
+
presenceSource: history,
|
|
666
722
|
selectors,
|
|
667
723
|
statuses: flag('status')?.split(','),
|
|
668
724
|
includeIdle: has('idle'),
|
|
@@ -682,8 +738,12 @@ if (cmd === 'serve') {
|
|
|
682
738
|
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.`)
|
|
683
739
|
const intervalMs = (Number(flag('interval')) || 2) * 1000
|
|
684
740
|
const timeoutSec = Number(flag('timeout')) || 1200
|
|
741
|
+
// `wait` addresses one explicit record. Read its history row for both events and presence so archive is an
|
|
742
|
+
// offline transition, not a vanished session; only a missing all-record row is a genuine gone/closed result.
|
|
743
|
+
const history = () => clientListSessions(true)
|
|
685
744
|
const r = await withWatchEdge([id], intervalMs, () => watchSessions(() => {}, {
|
|
686
|
-
source:
|
|
745
|
+
source: history,
|
|
746
|
+
presenceSource: history,
|
|
687
747
|
selectors: [id],
|
|
688
748
|
includeIdle: has('idle'),
|
|
689
749
|
intervalMs,
|
|
@@ -731,6 +791,11 @@ if (cmd === 'serve') {
|
|
|
731
791
|
console.log(' gates:')
|
|
732
792
|
console.log(` conflicts w/ main : ${g.conflictsWithMain ? 'YES' : 'no'}`)
|
|
733
793
|
console.log(` lint : ${g.lint.errorCount} error(s), ${g.lint.warningCount} warning(s)`)
|
|
794
|
+
// measured loss is REPORTED, never graded — and a projection that isn't ready says so instead of
|
|
795
|
+
// printing four zeros that would read as a clean session.
|
|
796
|
+
console.log(` evals : ${g.evals.phase === 'ready'
|
|
797
|
+
? `${g.evals.freshPass} fresh pass, ${g.evals.freshFail} fresh fail, ${g.evals.needReview} need review, ${g.evals.blind} blind`
|
|
798
|
+
: `not measured yet (${g.evals.phase})`}`)
|
|
734
799
|
console.log(` diff (merge-base, ${r.diff.length} file(s)):`)
|
|
735
800
|
for (const f of r.diff) console.log(` ${f.status.padEnd(12)} +${f.additions} -${f.deletions} ${f.path}`)
|
|
736
801
|
}
|
|
@@ -770,30 +835,44 @@ if (cmd === 'serve') {
|
|
|
770
835
|
try { closeNote += (await import('./localIssues.js')).closeoutNudge(sess ?? s.ownSessionId()) }
|
|
771
836
|
catch (e) { console.error(`issue closeout check failed (declaration unaffected): ${e instanceof Error ? e.message : e}`) }
|
|
772
837
|
}
|
|
773
|
-
|
|
838
|
+
const done = mark(() => s.markDone(p, sess, flag('note')))
|
|
839
|
+
console.log(done.ok ? `done (${p})${DECLARED}${noteEcho(flag('note'))}${closeNote}` : done.reason ?? noRecord())
|
|
774
840
|
} else if (sub === 'park') {
|
|
775
841
|
// sugar: the agent is waiting on a background task; it will self-resume (NOT idle/awaiting)
|
|
776
842
|
const { s, sess, mark, noRecord, noteEcho } = await stateKit()
|
|
777
|
-
|
|
843
|
+
const parked = mark(() => s.markState('parked', { note: flag('note'), sessionId: sess }))
|
|
844
|
+
console.log(parked.ok ? `parked${DECLARED}${noteEcho(flag('note'))}` : parked.reason ?? noRecord())
|
|
778
845
|
} else if (sub === 'ask') {
|
|
779
846
|
// the agent DELIBERATELY declares it is pausing to ask the human a question (like `done`/`park`, an
|
|
780
847
|
// authored state — NOT guarded active-only). The --note carries the question. Distinct from `park`
|
|
781
848
|
// (waiting on a background task, self-resumes): an asking agent resumes only when the human replies.
|
|
782
849
|
const { s, sess, mark, noRecord, noteEcho } = await stateKit()
|
|
783
|
-
|
|
850
|
+
const asked = mark(() => s.markState('asking', { note: flag('note'), sessionId: sess }))
|
|
851
|
+
console.log(asked.ok ? `asking${DECLARED}${noteEcho(flag('note'))}` : asked.reason ?? noRecord())
|
|
784
852
|
} else if (sub === 'stop') {
|
|
785
853
|
// the SOFT stop: kill the agent's tmux + socket but KEEP the worktree, so the session goes offline and
|
|
786
854
|
// can be resumed (`session resume`). Distinct from `close`, which removes the worktree.
|
|
787
855
|
const full = await resolveSelectorOrExit(id)
|
|
788
|
-
|
|
856
|
+
const stopped = await c.clientStop(full)
|
|
857
|
+
if (!stopped) { console.error(`spex session stop: no such session ${full} (no stop transition was committed)`); process.exit(1) }
|
|
858
|
+
console.log(`stopped ${full} (worktree kept — resumable)`)
|
|
789
859
|
} else if (sub === 'interrupt') {
|
|
790
860
|
const full = await resolveSelectorOrExit(id)
|
|
791
861
|
const r = await c.clientInterrupt(full)
|
|
792
862
|
console.log(r.ok ? `interrupted ${full}` : `interrupt failed: ${r.error}`)
|
|
793
863
|
process.exit(r.ok ? 0 : 1)
|
|
864
|
+
} else if (sub === 'archive' || sub === 'unarchive') {
|
|
865
|
+
// ARCHIVING/legacy unarchive ([[archive]]) — archive exact-stops before filing; unarchive signposts to
|
|
866
|
+
// resume and recreates the same conversation.
|
|
867
|
+
const on = sub === 'archive'
|
|
868
|
+
const full = await resolveSelectorOrExit(id)
|
|
869
|
+
const ok = await c.clientArchive(full, on)
|
|
870
|
+
console.log(!ok ? `no such session ${full}` : `${on ? 'archived' : 'resumed'} ${full}`)
|
|
794
871
|
} else if (sub === 'close') {
|
|
795
872
|
const full = await resolveSelectorOrExit(id)
|
|
796
|
-
|
|
873
|
+
const closed = await c.clientClose(full)
|
|
874
|
+
if (!closed) { console.error(`spex session close: no such session ${full} (record remains; no close was committed)`); process.exit(1) }
|
|
875
|
+
console.log(`closed ${full}`)
|
|
797
876
|
} else if (sub === 'send') {
|
|
798
877
|
const full = await resolveSelectorOrExit(id)
|
|
799
878
|
if (has('keys')) {
|
|
@@ -871,7 +950,7 @@ if (cmd === 'serve') {
|
|
|
871
950
|
// machine's) and terminal-only (an agent must never block its turn on it — capture/send).
|
|
872
951
|
const { assertLocalBackend, attachSession } = await import('./attach.js')
|
|
873
952
|
await assertLocalBackend()
|
|
874
|
-
await attachSession(await resolveSelectorOrExit(id))
|
|
953
|
+
process.exit(await attachSession(await resolveSelectorOrExit(id)))
|
|
875
954
|
} else {
|
|
876
955
|
console.error(`spex session: unknown verb '${sub}' — new | ls | show | watch | wait | review | merge | send | interrupt | rename | resume | stop | close | attach | done | park | ask (spex help session)`)
|
|
877
956
|
process.exit(2)
|
|
@@ -900,6 +979,58 @@ if (cmd === 'serve') {
|
|
|
900
979
|
if (!path) throw new Error(`governor '${owner.id}' has no live spec path`)
|
|
901
980
|
console.log(`${owner.id}\t${path}`)
|
|
902
981
|
}
|
|
982
|
+
} else if (sub === 'maintenance-begin') {
|
|
983
|
+
const ownerPid = Number(process.argv[4]), sessionId = process.argv[5]
|
|
984
|
+
if (!Number.isInteger(ownerPid) || ownerPid <= 0 || !sessionId) {
|
|
985
|
+
console.error('usage: spex internal maintenance-begin <owner-pid> <session-id>')
|
|
986
|
+
process.exit(2)
|
|
987
|
+
}
|
|
988
|
+
const { exactProcessIdentity, maintenanceErrorPayload, sessionMaintenance } = await import('./session-maintenance.js')
|
|
989
|
+
const owner = exactProcessIdentity(ownerPid)
|
|
990
|
+
if (!owner) { console.error('maintenance_identity_unknown: hook dispatcher owner identity is not exact'); process.exit(2) }
|
|
991
|
+
try {
|
|
992
|
+
console.log(sessionMaintenance().beginExternalOperation({ op: 'hook-state', sessionId }, owner))
|
|
993
|
+
} catch (error) {
|
|
994
|
+
const payload = maintenanceErrorPayload(error)
|
|
995
|
+
console.error(payload ? `${payload.code}: ${payload.error}` : String(error))
|
|
996
|
+
process.exit(2)
|
|
997
|
+
}
|
|
998
|
+
} else if (sub === 'maintenance-end') {
|
|
999
|
+
const ticket = process.argv[4], ownerPid = Number(process.argv[5])
|
|
1000
|
+
if (!ticket || !Number.isInteger(ownerPid) || ownerPid <= 0) {
|
|
1001
|
+
console.error('usage: spex internal maintenance-end <ticket-id> <owner-pid>')
|
|
1002
|
+
process.exit(2)
|
|
1003
|
+
}
|
|
1004
|
+
const { exactProcessIdentity, sessionMaintenance } = await import('./session-maintenance.js')
|
|
1005
|
+
const owner = exactProcessIdentity(ownerPid)
|
|
1006
|
+
if (!owner) { console.error('maintenance_identity_unknown: hook dispatcher owner identity is not exact'); process.exit(2) }
|
|
1007
|
+
sessionMaintenance().finishExternalOperation(ticket, owner)
|
|
1008
|
+
} else if (sub === 'shared-runtime-spawn') {
|
|
1009
|
+
const [cwd, logFile, pidFile, isolationFile, command] = process.argv.slice(4, 9)
|
|
1010
|
+
const args = process.argv.slice(9)
|
|
1011
|
+
if (!cwd || !logFile || !pidFile || !isolationFile || !command) {
|
|
1012
|
+
console.error('usage: spex internal shared-runtime-spawn <cwd> <log> <pid-file> <isolation-file> <command> [args...]')
|
|
1013
|
+
process.exit(2)
|
|
1014
|
+
}
|
|
1015
|
+
const { readFileSync } = await import('node:fs')
|
|
1016
|
+
const { runSessionOperation } = await import('./session-maintenance.js')
|
|
1017
|
+
const { spawnDetachedRuntime } = await import('./runtime-ownership.js')
|
|
1018
|
+
const delegateFdValue = process.env.SPEXCODE_MAINTENANCE_DELEGATE_FD
|
|
1019
|
+
const delegateChannelPresent = delegateFdValue !== undefined
|
|
1020
|
+
const delegateFd = Number(delegateFdValue)
|
|
1021
|
+
let delegate = ''
|
|
1022
|
+
if (delegateChannelPresent && Number.isInteger(delegateFd) && delegateFd >= 3) {
|
|
1023
|
+
try { delegate = readFileSync(delegateFd, 'utf8').trim() } catch { delegate = '' }
|
|
1024
|
+
}
|
|
1025
|
+
const sessionId = process.env.SPEXCODE_MAINTENANCE_SESSION_ID?.trim()
|
|
1026
|
+
|| process.env.SPEXCODE_SESSION_ID?.trim() || 'shared-runtime'
|
|
1027
|
+
const env = { ...process.env }
|
|
1028
|
+
delete env.SPEXCODE_MAINTENANCE_DELEGATE_FD
|
|
1029
|
+
delete env.SPEXCODE_MAINTENANCE_SESSION_ID
|
|
1030
|
+
delete env.SPEXCODE_SESSION_ID
|
|
1031
|
+
const runtime = await runSessionOperation({ op: 'shared-spawn', sessionId, ...(delegateChannelPresent ? { delegate } : {}) }, () =>
|
|
1032
|
+
spawnDetachedRuntime({ cwd, logFile, pidFile, isolationFile, command, args, env }))
|
|
1033
|
+
console.log(runtime.pid)
|
|
903
1034
|
} else if (sub === 'codex-launch') {
|
|
904
1035
|
// BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
|
|
905
1036
|
// (codex loads that worktree's config/hooks/AGENTS.md), store the new id on the governed record (keyed by
|
|
@@ -914,7 +1045,11 @@ if (cmd === 'serve') {
|
|
|
914
1045
|
// the thread the BACKEND owns must carry `bypass_hook_trust` in thread/start's config so the app-server fires
|
|
915
1046
|
// the worktree's local hooks — mirror materialize's capability decision so the two stay in lockstep.
|
|
916
1047
|
const bypassHookTrust = codexSupportsBypassHookTrust(codexBinary(process.env.SPEXCODE_CODEX_CMD || 'codex'))
|
|
917
|
-
|
|
1048
|
+
// The governed record id rides into the thread's own shell environment (shell_environment_policy.set), so
|
|
1049
|
+
// every command this thread spawns knows which session it is — the codex equivalent of the launch-injected
|
|
1050
|
+
// id claude gets. codex-launch is exactly where both ids are known ([[harness-adapter]]).
|
|
1051
|
+
const ownId = process.env.SPEXCODE_SESSION_ID?.trim()
|
|
1052
|
+
const r = await codexStartThread(sock, cwd, bypassHookTrust, ownId ? { SPEXCODE_SESSION_ID: ownId } : undefined)
|
|
918
1053
|
if (!r.ok) { console.error(r.error); process.exit(1) }
|
|
919
1054
|
if (prompt) {
|
|
920
1055
|
const t = await codexTurn(sock, r.threadId, prompt, cwd)
|
|
@@ -993,24 +1128,38 @@ if (cmd === 'serve') {
|
|
|
993
1128
|
const { s, sess, mark, noRecord, noteEcho } = await stateKit()
|
|
994
1129
|
const st = process.argv[4] as any
|
|
995
1130
|
const ok = mark(() => s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess }))
|
|
996
|
-
console.log(ok ? `state -> ${st}${noteEcho(flag('note'))}` : noRecord())
|
|
1131
|
+
console.log(ok.ok ? `state -> ${st}${noteEcho(flag('note'))}` : ok.reason ?? noRecord())
|
|
997
1132
|
} else if (sub === 'session-fail') {
|
|
998
1133
|
// the StopFailure hook marks its session (--session from the payload) as error (turn died on an API error)
|
|
999
1134
|
const { s, sess, mark, noRecord } = await stateKit()
|
|
1000
|
-
|
|
1135
|
+
const failed = mark(() => s.markError(sess))
|
|
1136
|
+
console.log(failed.ok ? 'marked error' : failed.reason ?? noRecord())
|
|
1137
|
+
} else if (sub === 'session-turn-fail') {
|
|
1138
|
+
// Headless adapters report an ephemeral turn's non-zero exit through this one shared CAS. A declaration
|
|
1139
|
+
// that landed before teardown wins, so a late child close can never erase an agent-authored state.
|
|
1140
|
+
const sessionId = process.argv[4], harness = process.argv[5], exitCode = process.argv[6]
|
|
1141
|
+
if (!sessionId || !harness || !exitCode) {
|
|
1142
|
+
console.error('usage: spex internal session-turn-fail <session-id> <harness> <exit-code|signal>')
|
|
1143
|
+
process.exit(2)
|
|
1144
|
+
}
|
|
1145
|
+
const { markHeadlessTurnFailure } = await import('./sessions.js')
|
|
1146
|
+
console.log(markHeadlessTurnFailure(sessionId, harness, exitCode) ? `marked error (${harness} ${exitCode})` : 'noop (no active session record)')
|
|
1001
1147
|
} else if (sub === 'session-idle') {
|
|
1002
1148
|
// the Notification(idle_prompt) hook marks its session (--session from the payload) idle when claude waits
|
|
1003
1149
|
// at its prompt. INFERRED, so guarded active-only: it no-ops unless the current status is exactly `active`,
|
|
1004
1150
|
// never clobbering a deliberate awaiting/asking/parked/error declaration. Distinct from `session ask`
|
|
1005
1151
|
// (the agent deliberately asking the human) — idle is the undeclared stop the Stop gate missed.
|
|
1006
|
-
const { s, sess } = await stateKit()
|
|
1007
|
-
|
|
1152
|
+
const { s, sess, mark } = await stateKit()
|
|
1153
|
+
const idled = mark(() => s.markIdle(sess))
|
|
1154
|
+
console.log(idled.ok ? 'idle' : idled.reason ?? 'noop (no session record, or not active)')
|
|
1008
1155
|
} else if (sub === 'commit-gate') {
|
|
1009
1156
|
// the Stop gate's deterministic commit check (from cwd = the worktree): exit 0 if the node branch is
|
|
1010
|
-
// ready to declare done
|
|
1157
|
+
// ready to declare done, else print the reason and exit 1. Takes the PROPOSAL being judged — `merge`
|
|
1158
|
+
// additionally requires commits ahead of main, `nothing` only a clean tree (see mergeReadiness). Uses
|
|
1011
1159
|
// git() so the hook's exported GIT_DIR/GIT_INDEX_FILE don't misdirect repo discovery (see git.ts).
|
|
1012
1160
|
const { s } = await stateKit()
|
|
1013
|
-
const
|
|
1161
|
+
const kind = positionals(4)[0] === 'nothing' ? 'nothing' : 'merge'
|
|
1162
|
+
const r = s.mergeReadiness(kind)
|
|
1014
1163
|
if (r.ready) { console.log('ready'); process.exit(0) }
|
|
1015
1164
|
console.log(r.reason)
|
|
1016
1165
|
process.exit(1)
|