spexcode 0.6.0 → 0.6.2
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 +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +239 -75
- package/spec-cli/src/client.ts +113 -14
- package/spec-cli/src/codex-runtime-generations.ts +97 -7
- package/spec-cli/src/commit-surgery.ts +2 -1
- package/spec-cli/src/contract-filter.ts +58 -42
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +503 -105
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +355 -75
- package/spec-cli/src/help.ts +30 -23
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +109 -14
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +151 -40
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +160 -112
- package/spec-cli/src/plugin-harness.ts +29 -18
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +103 -30
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +748 -111
- package/spec-cli/src/uninstall.ts +3 -2
- package/spec-cli/templates/hooks/post-checkout +3 -0
- package/spec-cli/templates/hooks/post-merge +1 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/reviewFilters.js +5 -0
- package/spec-dashboard/src/session.js +5 -1
- package/spec-eval/src/cli.ts +13 -9
- package/spec-eval/src/evaltab.ts +32 -10
- package/spec-eval/src/freshness.ts +404 -81
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +234 -148
- package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
- package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
- package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
- package/spec-eval/src/matrix.ts +0 -693
package/spec-cli/src/help.ts
CHANGED
|
@@ -19,7 +19,8 @@ none (or @all) means every session.`
|
|
|
19
19
|
|
|
20
20
|
const ROUTING_NOTE = `Backend routing: every backend-touching verb accepts --api <url> (--port <n> = localhost sugar) to name
|
|
21
21
|
its backend explicitly — the flag always wins. Bare, it resolves: worker env / the cwd project's live
|
|
22
|
-
recorded backend / fallback / :8787 (spex guide settings → BACKEND ROUTING)
|
|
22
|
+
recorded backend / fallback / :8787 (spex guide settings → BACKEND ROUTING). A password-gated explicit
|
|
23
|
+
gateway accepts --password <pw> (or SPEXCODE_PASSWORD); its self-signed certificate needs explicit --insecure.`
|
|
23
24
|
|
|
24
25
|
const DOT_NOTE = `\`.\` as a node argument means the node THIS worktree works on (the session's bound node, else the
|
|
25
26
|
node/<id> branch). One-shot payload reads (graph · spec search · session ls/show/review · eval ls ·
|
|
@@ -36,16 +37,23 @@ type SessionVerbHelp = readonly [usage: string | readonly string[], detail: stri
|
|
|
36
37
|
// Keeping the behavioral prose here prevents a compact probe from becoming a second, drifting manual.
|
|
37
38
|
function sessionHelpDefinitions(): Record<string, SessionVerbHelp> {
|
|
38
39
|
return {
|
|
39
|
-
new: ['spex session new "<prompt>" [--prompt-file <path>|-] [--launcher <name>]',
|
|
40
|
+
new: ['spex session new "<prompt>" [--prompt-file <path>|-] [--launcher <name>] [--name <name>] [--base <commit-ish>]',
|
|
40
41
|
`Launch a worker in its own node worktree. The materialized system contract reaches it
|
|
41
42
|
automatically; the prompt supplies the task context. Its first [[id]] mention binds the
|
|
42
43
|
session to that node. --prompt-file <path>|- carries a long prompt without shell quoting
|
|
43
|
-
(exclusive with the inline prompt).
|
|
44
|
+
(exclusive with the inline prompt). --name sets the session's initial display name without changing the prompt.
|
|
45
|
+
--base <commit-ish> pins the fork point instead of the source-of-truth branch's current head, so a run can be
|
|
46
|
+
reproduced against a frozen commit; a base that names no commit is refused before anything is created.
|
|
47
|
+
The successful receipt names what to read, monitor, and reply on.`],
|
|
44
48
|
ls: ['spex session ls [SEL…] [--status a,b] [--all] [--json]',
|
|
45
49
|
'One-shot table of living sessions. Shelved sessions ([[archive]]) are hidden; --all includes them, and naming one explicitly always shows it.', ['selector']],
|
|
46
50
|
resources: ['spex session resources [--json]', 'Read-only host/process ownership, budgets, shared refs, and findings.'],
|
|
47
|
-
|
|
48
|
-
'
|
|
51
|
+
files: [['spex session files add <path>', 'spex session files ls', 'spex session files retract <path>'],
|
|
52
|
+
'Publish, list, or withdraw YOUR session’s live file paths. Posting stores an absolute path beside the session record without copying bytes; the dashboard downloads it only when the human clicks.'],
|
|
53
|
+
web: [['spex session web add <url>', 'spex session web ls', 'spex session web retract <url>'],
|
|
54
|
+
'Publish, list, or withdraw YOUR session’s live loopback web-service URLs. Posting records no bytes and does not contact the service; the dashboard proxies the current HTTP/WebSocket service only when the human opens its preview.'],
|
|
55
|
+
watch: [['spex session watch <SEL…>', 'spex session watch list', 'spex session watch cancel <SEL…>', 'spex session watch stream [SEL…] [--as NAME] [--idle] [--interval N=1]'],
|
|
56
|
+
'With a governed caller, watch registers durable send-backed state delivery and exits. list/cancel manage those relations. Without a governed caller it names the background `session wait` fallback. stream is the human-only continuous log view and blocks until killed.', ['selector']],
|
|
49
57
|
wait: ['spex session wait [SEL…] [--timeout S=1200] [--interval S=1] [--idle]',
|
|
50
58
|
`EDGE-TRIGGERED wait: follows the selected sessions' logs AND your own log, and exits 0 on
|
|
51
59
|
the FIRST thing worth waking for — a followed session TRANSITIONING from a non-actionable
|
|
@@ -58,8 +66,10 @@ It reads files only, so it needs no \`spex serve\` and costs the sessions it fol
|
|
|
58
66
|
session's store is gone.`, ['selector']],
|
|
59
67
|
review: ['spex session review <SEL> [--json]', 'Reports ahead · uncommitted · proposal · gates · merge-base diff.', ['selector']],
|
|
60
68
|
merge: ['spex session merge <SEL>', 'Dispatches a gated merge to the session\'s own agent; it does not close the session.', ['selector', 'project-bound']],
|
|
61
|
-
|
|
62
|
-
|
|
69
|
+
reparent: ['spex session reparent <child-SEL...> --to <parent-SEL>',
|
|
70
|
+
'Move one or more governed children to a replacement parent, replacing only the former parent\'s managed watch relation. It never restarts a child and works when the former parent is offline.', ['selector', 'project-bound']],
|
|
71
|
+
send: [['spex session send <SEL> "<msg>"', 'spex session send <SEL> [--api <url> | --port <n>] -- <option-shaped-msg>', 'spex session send <SEL> --keys "<keys>"'],
|
|
72
|
+
`Plain send delivers a message once its timeline append succeeds; a dead adapter only delays its context. Routing flags may precede or follow ordinary text; use -- before a message that begins with --. --keys is the LAST RESORT:
|
|
63
73
|
raw nav-mode keystrokes to a TUI dialog ("Up Up Enter", C-/M-/S- combos). The raw key surface
|
|
64
74
|
is UNSTABLE and can confirm dangerous dialogs — try a plain send first; use keys only when text
|
|
65
75
|
provably cannot land.`, ['selector', 'project-bound']],
|
|
@@ -79,7 +89,7 @@ you are running in, mid-turn. Your own ending is a declaration: \`done --propose
|
|
|
79
89
|
done: ['spex session done --propose merge|nothing|close [--note T]',
|
|
80
90
|
'`merge` declares review: committed work ready for human review, and it is the ONLY declaration that offers a clickable merge. `nothing` declares done: committed work, but no merge proposal. `close` declares close-pending: PROPOSE discarding this worktree — the human closes it. This declaration is how a session ends itself; never run `session close` on your own id.'],
|
|
81
91
|
park: ['spex session park --note <what-you-await>',
|
|
82
|
-
'Declare parked only when a real background task will wake your own session. It self-resumes; waiting for a human is asking, not parked.'],
|
|
92
|
+
'Declare parked only when a managed watch delivery or real background task will wake your own session. It self-resumes; waiting for a human is asking, not parked.'],
|
|
83
93
|
ask: ['spex session ask --note <your-question>',
|
|
84
94
|
'Declare asking when the session needs a human reply or direction. It resumes only when the human replies; a background wake-up is parked instead.'],
|
|
85
95
|
attach: ['spex session attach <SEL>', `Attaches the current terminal to the worker's tmux (detach: C-b d) and blocks until detached.
|
|
@@ -88,13 +98,13 @@ LOCAL-only (fails loud on a remote backend); show --capture and send are non-int
|
|
|
88
98
|
}
|
|
89
99
|
|
|
90
100
|
const SESSION_HELP_GROUPS = [
|
|
91
|
-
{ title: 'Manager verbs (dispatch, monitor, land)', verbs: ['new', 'ls', 'resources', 'watch', 'wait', 'review', 'merge'] },
|
|
101
|
+
{ title: 'Manager verbs (dispatch, monitor, land)', verbs: ['new', 'ls', 'resources', 'watch', 'wait', 'review', 'merge', 'reparent'] },
|
|
92
102
|
{ title: 'Control another session', verbs: ['send', 'interrupt', 'rename', 'show', 'resume', 'stop', 'archive', 'unarchive', 'close', 'quarantine'] },
|
|
93
|
-
{ title: 'Worker verbs (declare YOUR OWN state — a claim the graph and your supervisor act on)', verbs: ['done', 'park', 'ask'] },
|
|
103
|
+
{ title: 'Worker verbs (declare YOUR OWN state — a claim the graph and your supervisor act on)', verbs: ['done', 'park', 'ask', 'files', 'web'] },
|
|
94
104
|
{ title: 'Human escape hatch', verbs: ['attach'] },
|
|
95
105
|
] as const
|
|
96
106
|
|
|
97
|
-
const SESSION_WRITE_NOTE = `Manager verbs that WRITE (send/interrupt/rename/resume/stop/close/merge) are PROJECT-BOUND: a backend serving
|
|
107
|
+
const SESSION_WRITE_NOTE = `Manager verbs that WRITE (send/interrupt/rename/resume/stop/close/merge/reparent) are PROJECT-BOUND: a backend serving
|
|
98
108
|
another project's repo refuses loudly — name the target with --api <url> to drive it on purpose.`
|
|
99
109
|
|
|
100
110
|
function indent(text: string, spaces: number): string {
|
|
@@ -235,7 +245,7 @@ an admin password exists, and ungated projects serve open.`,
|
|
|
235
245
|
line: 'spec <verb> the governance graph: search · owner · lint · ack',
|
|
236
246
|
body: `Usage: spex spec search <query…> [--limit N=10] [--json]
|
|
237
247
|
spex spec owner <path> [--actionable]
|
|
238
|
-
spex spec lint
|
|
248
|
+
spex spec lint [--json]
|
|
239
249
|
spex spec ack <node-id>… --reason "<why the contract still holds>"
|
|
240
250
|
|
|
241
251
|
search — which spec node GOVERNS a topic, ranked by user-story relevance (which surfaces user-facing
|
|
@@ -259,7 +269,9 @@ leaf id reused) · mention (a [[id]] naming no node). Warns: coverage · drift
|
|
|
259
269
|
"ignore"\` may silence it) · anchor (anchoring a type) · related-drift (a scoped related row warns
|
|
260
270
|
per selector HIT, misses silent) · owners (whole-file governors only; scoped don't count) ·
|
|
261
271
|
confusable-id (two leaf ids one edit apart). spec lint's errors BLOCK commits (the prepared reference hook; bypass SPEXCODE_SKIP_LINT=1);
|
|
262
|
-
contrast \`spex eval lint\`, which is pure advisory and never blocks anyone.
|
|
272
|
+
contrast \`spex eval lint\`, which is pure advisory and never blocks anyone. \`--json\` writes the
|
|
273
|
+
versioned machine report to stdout (source candidates + structured findings); its exit code still blocks
|
|
274
|
+
on errors.
|
|
263
275
|
|
|
264
276
|
ack — stamp Spec-OK on HEAD (an empty stamp commit): the drift remedy when only MECHANICS changed
|
|
265
277
|
and the spec's contract still holds. --reason is required and recorded in the ack commit's body
|
|
@@ -273,7 +285,7 @@ edit the spec instead — same commit as the code.`,
|
|
|
273
285
|
see: 'spex eval ls --session <SEL> (the session’s measured loss) · spex help eval',
|
|
274
286
|
},
|
|
275
287
|
eval: {
|
|
276
|
-
line: 'eval <verb> the measurement system: add · ls · scenario ls/write ·
|
|
288
|
+
line: 'eval <verb> the measurement system: add · ls · scenario ls/write · lint · ok · retract · clean',
|
|
277
289
|
body: `Usage: spex eval add [<node>|.] [--scenario <name>] (--pass|--fail) [--note <text>]
|
|
278
290
|
[--image <png> …repeatable] [--result <path|->] [--video <webm|mp4>] [--timeline <json>]
|
|
279
291
|
spex eval ls [<node>|.] [--json] a node's eval timeline, newest first
|
|
@@ -281,7 +293,6 @@ edit the spec instead — same commit as the code.`,
|
|
|
281
293
|
spex eval ls --session <SEL> --export [--open | --out <path>]
|
|
282
294
|
spex eval scenario ls [<node>|.] [--unmeasured] [--json] declared scenarios; JSON = canonical index
|
|
283
295
|
spex eval scenario write --mutation <json> < eval.md propose one canonical metadata mutation
|
|
284
|
-
spex eval matrix <launcher> [--node <id>] [--rows k1,k2] the harness live-behavior matrix
|
|
285
296
|
spex eval lint [--changed] measurement-layer findings (advisory, always exit 0)
|
|
286
297
|
spex eval ok <node> [--scenario <name>] the HUMAN sign-off on the scenario's latest measurement
|
|
287
298
|
spex eval retract [<node>|.] [--scenario <name>] [--last | --ts <iso>] [--note <why>]
|
|
@@ -305,12 +316,6 @@ scenario write — the fixed-tree declaration writer for an external measurement
|
|
|
305
316
|
eval.md, --mutation is one closed JSON insert/delete request for one scenario's test metadata, and stdout is only
|
|
306
317
|
the proposed eval.md bytes. It reads no worktree or runner and fails without stdout on malformed or ambiguous input.
|
|
307
318
|
|
|
308
|
-
matrix — run the eight-row harness live-behavior matrix against a REAL dispatched session of the named
|
|
309
|
-
launcher (the harness-adapter acceptance rule, defined once in spec-eval/src/matrix.ts): it syncs the
|
|
310
|
-
rows into the \`<harness>-harness\` node's eval.md scenarios, drives one worker through undeclared-stop ·
|
|
311
|
-
pretooluse-block · ask-note · deliver-steer · resume · liveness · commit-gate · close-residue, and files
|
|
312
|
-
a per-row measurement with its evidence transcript. A new harness needs only its launcher + spec node.
|
|
313
|
-
|
|
314
319
|
lint — the measurement layer's findings: malformed eval.md (eval-schema) · unmeasured (eval-missing) ·
|
|
315
320
|
stale (eval-drift) · orphaned remark tracks (eval-dangling) · governed source with no eval.md
|
|
316
321
|
(eval-coverage — the same name and shape as spec lint's coverage, one rule per layer) · over-owned
|
|
@@ -439,10 +444,12 @@ export function commandHelp(name: string, verb?: string): string | null {
|
|
|
439
444
|
return `${header}${e.body}${e.see ? `\n\nsee also: ${e.see}` : ''}\n\nmap: spex help · skills: spex guide`
|
|
440
445
|
}
|
|
441
446
|
|
|
442
|
-
export function sessionLaunchReceipt(id: string): string {
|
|
447
|
+
export function sessionLaunchReceipt(id: string, managedWatch = false): string {
|
|
443
448
|
return `spex: launched session ${id}
|
|
444
449
|
current result: the session JSON is on stdout now; \`spex session ls ${id}\` is the later one-shot snapshot
|
|
445
|
-
next lifecycle change:
|
|
450
|
+
next lifecycle change: ${managedWatch
|
|
451
|
+
? `managed watch registered — this parent receives ${id}'s state changes through its normal send queue; \`spex session watch cancel ${id}\` ends it`
|
|
452
|
+
: `background \`spex session wait ${id}\` (edge-triggered; exits on the next non-actionable→actionable transition); \`spex session watch ${id}\` registers send-backed delivery when the caller is governed`}; \`spex session watch stream ${id}\` NEVER EXITS
|
|
446
453
|
response channel: \`spex session send ${id} "<msg>"\`; \`send --keys\` is an UNSTABLE LAST RESORT after a plain send cannot land`
|
|
447
454
|
}
|
|
448
455
|
|
|
@@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process'
|
|
|
3
3
|
import { randomUUID } from 'node:crypto'
|
|
4
4
|
import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs'
|
|
5
5
|
import { dirname, join } from 'node:path'
|
|
6
|
-
import { defaultHarness, HARNESSES, harnessById, sessionIdentityEnvVars, type HarnessLivenessRecord, type SharedRuntimeDescriptor, type SharedRuntimeProbe } from './harness.js'
|
|
6
|
+
import { defaultHarness, HARNESSES, harnessById, harnessByIdOrNull, sessionIdentityEnvVars, type HarnessLivenessRecord, type SharedRuntimeDescriptor, type SharedRuntimeProbe } from './harness.js'
|
|
7
7
|
import { listSessionIds, readConfig, readJsonConfig, readPublicRecordEntry, readRawRecord, runtimeRoot, type PublicRecordEntry, type RawRecord } from './layout.js'
|
|
8
8
|
import { repoRoot } from './git.js'
|
|
9
9
|
import { endpointRecordPath } from './host.js'
|
|
@@ -219,6 +219,31 @@ const runtimePid = (file: string): number | null => {
|
|
|
219
219
|
catch { return null }
|
|
220
220
|
}
|
|
221
221
|
type SharedEntry = { descriptor: SharedRuntimeDescriptor; recs: RawRecord[] }
|
|
222
|
+
// A session record outlives the config that created it, so a removed plugin harness or a renamed harness id
|
|
223
|
+
// leaves a governed record this registry cannot resolve. That is a normal long-lived state for a host-wide
|
|
224
|
+
// sweep, not a request naming a bad id: the sweep reports the record and keeps every other owner's findings.
|
|
225
|
+
const unresolvableHarness = (rec: RawRecord): string | null => {
|
|
226
|
+
const id = rec.harness || defaultHarness.id
|
|
227
|
+
return harnessByIdOrNull(id) ? null : id
|
|
228
|
+
}
|
|
229
|
+
// Mirrors the corrupt-record owner: a record the sweep could not fully read stays a public owner so its
|
|
230
|
+
// absence from a process-derived group cannot read as health. Here the unknown half is the harness, so
|
|
231
|
+
// lifecycle is unknown and reclaim is closed rather than guessed from the record's own status field.
|
|
232
|
+
const unresolvedHarnessOwners = (recs: RawRecord[], budgets: ResourceBudgets, have: (sessionId: string) => boolean): ResourceOwner[] => {
|
|
233
|
+
const out: ResourceOwner[] = []
|
|
234
|
+
for (const rec of recs) {
|
|
235
|
+
const unresolved = unresolvableHarness(rec)
|
|
236
|
+
if (!unresolved || !rec.governed || rec.stopped || have(rec.session_id)) continue
|
|
237
|
+
out.push({
|
|
238
|
+
kind: 'session', id: rec.session_id, label: `session ${rec.session_id.slice(0, 8)}`, status: rec.status, liveness: 'unknown',
|
|
239
|
+
processes: [], rssMiB: 0, pssMiB: null, cpuPercent: 0,
|
|
240
|
+
budget: { rssMiB: budgets.sessionRssMiB, idleCpuPercent: budgets.idleCpuPercent },
|
|
241
|
+
findings: [`harness-unresolved:${unresolved}`],
|
|
242
|
+
reclaim: { eligible: false, reason: `harness '${unresolved}' is not resolvable by this registry; shared-runtime stop safety cannot be proven` },
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
return out
|
|
246
|
+
}
|
|
222
247
|
const sharedDescriptors = (recs: RawRecord[], retainRegistry = false): Map<string, SharedEntry> => {
|
|
223
248
|
const out = new Map<string, { descriptor: SharedRuntimeDescriptor; recs: RawRecord[] }>()
|
|
224
249
|
for (const harness of HARNESSES) for (const descriptor of harness.sharedRuntimes?.(runtimeRoot()) ?? []) {
|
|
@@ -229,7 +254,8 @@ const sharedDescriptors = (recs: RawRecord[], retainRegistry = false): Map<strin
|
|
|
229
254
|
// reported as an archive hazard. Clean archived records carry stopped:true and no loaded reference, so they
|
|
230
255
|
// contribute nothing to the active set while still preserving exact ownership if the invariant is violated.
|
|
231
256
|
if (!rec.governed) continue
|
|
232
|
-
const harness =
|
|
257
|
+
const harness = harnessByIdOrNull(rec.harness || defaultHarness.id)
|
|
258
|
+
if (!harness) continue // joined to no descriptor; surfaced as a finding on that record's own session owner
|
|
233
259
|
const exactKey = harness.targetDescriptorKey?.({ session: rec.session_id, harnessSessionId: rec.harness_session_id }) ?? null
|
|
234
260
|
for (const descriptor of harness.sharedRuntimes?.(runtimeRoot()) ?? []) {
|
|
235
261
|
if (exactKey && descriptor.key !== exactKey) continue
|
|
@@ -381,6 +407,22 @@ const probeRuntime = async (descriptor: SharedRuntimeDescriptor): Promise<Shared
|
|
|
381
407
|
catch (error) { return { healthy: false, references: [], error: (error as Error).message } }
|
|
382
408
|
}
|
|
383
409
|
|
|
410
|
+
const sessionOwnerBlocker = (id: string, harnessId: string | null, recs: RawRecord[]): string | null => {
|
|
411
|
+
const targetRecord = recs.find((rec) => rec.session_id === id)
|
|
412
|
+
const targetHarness = harnessId ? harnessById(harnessId) : null
|
|
413
|
+
const targetThread = targetRecord?.harness_session_id
|
|
414
|
+
if (!targetHarness || !targetThread) return null
|
|
415
|
+
const ownerCount = recs.filter((rec) => rec.governed && (rec.harness || defaultHarness.id) === targetHarness.id && rec.harness_session_id === targetThread).length
|
|
416
|
+
if (ownerCount === 1) return null
|
|
417
|
+
const label = targetHarness.sharedRuntimes?.(runtimeRoot())[0]?.label ?? targetHarness.id
|
|
418
|
+
return `${label} target thread ${targetThread} has no one exact governed session owner`
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function assertSessionOwnerSafe(id: string, harnessId: string): void {
|
|
422
|
+
const blocker = sessionOwnerBlocker(id, harnessId, rawRecords())
|
|
423
|
+
if (blocker) throw new ResourceConflict(blocker)
|
|
424
|
+
}
|
|
425
|
+
|
|
384
426
|
const sessionStopBlocker = async (
|
|
385
427
|
id: string,
|
|
386
428
|
harnessId: string | null,
|
|
@@ -390,6 +432,9 @@ const sessionStopBlocker = async (
|
|
|
390
432
|
): Promise<string | null> => {
|
|
391
433
|
const targetRecord = recs.find((rec) => rec.session_id === id)
|
|
392
434
|
const targetHarness = harnessId ? harnessById(harnessId) : null
|
|
435
|
+
const targetThread = targetRecord?.harness_session_id
|
|
436
|
+
const ownerBlocker = sessionOwnerBlocker(id, harnessId, recs)
|
|
437
|
+
if (ownerBlocker) return ownerBlocker
|
|
393
438
|
const exactKey = targetHarness?.targetDescriptorKey?.({ session: id, harnessSessionId: targetRecord?.harness_session_id }) ?? null
|
|
394
439
|
const targetDescriptors = targetHarness?.sharedRuntimes?.(runtimeRoot()) ?? []
|
|
395
440
|
if (targetHarness && targetDescriptors.length === 0) return null
|
|
@@ -404,9 +449,9 @@ const sessionStopBlocker = async (
|
|
|
404
449
|
const pid = runtimePid(descriptor.pidFile)
|
|
405
450
|
const ownerCounts = new Map<string, number>()
|
|
406
451
|
for (const rec of entry.recs) if (rec.harness_session_id) ownerCounts.set(rec.harness_session_id, (ownerCounts.get(rec.harness_session_id) ?? 0) + 1)
|
|
407
|
-
const
|
|
408
|
-
if (
|
|
409
|
-
return `${descriptor.label} target thread ${
|
|
452
|
+
const entryTargetThread = entry.recs.find((rec) => rec.session_id === id)?.harness_session_id
|
|
453
|
+
if (entryTargetThread && ownerCounts.get(entryTargetThread) !== 1)
|
|
454
|
+
return `${descriptor.label} target thread ${entryTargetThread} has no one exact governed session owner`
|
|
410
455
|
if (!knownProbes && descriptor.mutationGuard) {
|
|
411
456
|
if (!targetThread) return `${descriptor.label} target has no exact governed thread identity`
|
|
412
457
|
if (!pid) return `${descriptor.label} target-scoped mutation guard has no readable owner PID`
|
|
@@ -506,6 +551,7 @@ export async function collectResourceReport(opts: { procRoot?: string; persist?:
|
|
|
506
551
|
budget: { rssMiB: budgets.sessionRssMiB, idleCpuPercent: budgets.idleCpuPercent },
|
|
507
552
|
findings: [`session-record-corrupt:${entry.error}`], reclaim: { eligible: false, reason: 'session record is corrupt; ownership and lifecycle are unknown' },
|
|
508
553
|
})
|
|
554
|
+
owners.push(...unresolvedHarnessOwners(recs, budgets, (sessionId) => owners.some((owner) => owner.kind === 'session' && owner.id === sessionId)))
|
|
509
555
|
const report: ResourceReport = { version: 1, measuredAt: new Date().toISOString(), projectRoot: root, platform: platform(), available: false, unavailableReason: `host process metrics are unavailable on ${platform()}; shared runtime references remain visible`, host: { memoryTotalMiB: null, memoryUsedMiB: null, memoryAvailableMiB: null, swapTotalMiB: null, swapUsedMiB: null, cpuPercent: null }, budgets, owners, totals: { rssMiB: 0, pssMiB: null, cpuPercent: 0 }, findings: owners.reduce((n, o) => n + o.findings.length, 0) }
|
|
510
556
|
if (opts.persist !== false) atomicJson(join(runtimeRoot(), 'resource-report.json'), report)
|
|
511
557
|
return report
|
|
@@ -562,8 +608,15 @@ export async function collectResourceReport(opts: { procRoot?: string; persist?:
|
|
|
562
608
|
if (rec?.archived) findings.push('archived-runtime-hazard:leaf-still-resident')
|
|
563
609
|
if (totals.rssMiB > rssBudget) findings.push(`rss-over-budget:${Math.round((totals.rssMiB - rssBudget) * 10) / 10}MiB`)
|
|
564
610
|
if (status !== 'active' && status !== 'queued' && totals.cpuPercent > idleBudget) findings.push(`idle-cpu-over-budget:${Math.round((totals.cpuPercent - idleBudget) * 10) / 10}%`)
|
|
565
|
-
|
|
566
|
-
|
|
611
|
+
// The stop guard resolves the record's harness fail-loud, which is right for a stop REQUEST and wrong
|
|
612
|
+
// here: this sweep reads rows nobody named. An unresolvable harness makes stop safety unprovable, so it
|
|
613
|
+
// is reported and reclaim stays closed — never a resolvable-looking zero, never a lost report.
|
|
614
|
+
const unresolved = rec ? unresolvableHarness(rec) : null
|
|
615
|
+
if (unresolved) findings.push(`harness-unresolved:${unresolved}`)
|
|
616
|
+
const stopBlocker = rec && !unresolved ? await sessionStopBlocker(id, rec.harness || defaultHarness.id, inv.recs, sharedProbes) : null
|
|
617
|
+
if (unresolved) {
|
|
618
|
+
reclaim = { eligible: false, reason: `harness '${unresolved}' is not resolvable by this registry; shared-runtime stop safety cannot be proven` }
|
|
619
|
+
} else if (terminal(rec) && totals.cpuPercent <= idleBudget && !stopBlocker) {
|
|
567
620
|
reclaim = { eligible: true, reason: `terminal lifecycle ${rec!.status}/${rec!.proposal}; a future exact action must revalidate every fact` }
|
|
568
621
|
} else reclaim = { eligible: false, reason: stopBlocker ? `shared runtime unsafe: ${stopBlocker}` : terminal(rec) ? 'terminal record is still consuming CPU; liveness contradicts safe retirement' : 'owner is not terminal; budget age/status alone never authorizes stop' }
|
|
569
622
|
} else if (owner.startsWith('shared:')) {
|
|
@@ -621,6 +674,8 @@ export async function collectResourceReport(opts: { procRoot?: string; persist?:
|
|
|
621
674
|
findings: [`session-record-corrupt:${entry.error}`], reclaim: { eligible: false, reason: 'session record is corrupt; ownership and lifecycle are unknown' },
|
|
622
675
|
})
|
|
623
676
|
|
|
677
|
+
owners.push(...unresolvedHarnessOwners(inv.recs, budgets, (sessionId) => owners.some((owner) => owner.kind === 'session' && owner.id === sessionId)))
|
|
678
|
+
|
|
624
679
|
// A referenced shared runtime with no readable process is still operationally important: keep its live or
|
|
625
680
|
// unknown refcount visible instead of silently omitting it from a process-derived report.
|
|
626
681
|
for (const [key, shared] of inv.shared) if (!owners.some((o) => o.kind === 'shared-runtime' && o.id === key)) {
|
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
// One browser-safe identity registry shared by backend validation and every dashboard projection.
|
|
2
2
|
// Geometry is data so the React renderer and favicon serializer cannot drift into separate drawings.
|
|
3
|
+
//
|
|
4
|
+
// @@@ shape keys travel through two renderers - a shape's own attributes override the group's
|
|
5
|
+
// fill/stroke, which is how a multi-colour mark fits a single-fg registry. Both consumers pass the keys
|
|
6
|
+
// through verbatim: the string serializer spreads them as attributes, React spreads them as JSX props.
|
|
7
|
+
// So a key must be valid in BOTH, which rules out hyphenated names (React wants strokeWidth) - hence a
|
|
8
|
+
// painted seam outline rather than a stroked line, since per-shape stroke-width cannot be expressed.
|
|
3
9
|
|
|
4
10
|
export const DEFAULT_PROJECT_ICON = 'spexcode'
|
|
5
11
|
export const DEFAULT_GATEWAY_ICON = 'gateway'
|
|
6
12
|
|
|
7
13
|
export const IDENTITY_PRESETS = Object.freeze([
|
|
8
14
|
{
|
|
9
|
-
|
|
15
|
+
// The brand mark: two brackets holding one file, cleft corner to corner and regenerated on the
|
|
16
|
+
// upper-right of the cleft. Painted rather than stroked — see the paint note below. Geometry is
|
|
17
|
+
// docs/brand/mark-dark.svg scaled by 24/512; the two surface gradients flatten to their midpoints
|
|
18
|
+
// because a 24-unit chip has no room for a ramp and the format carries no defs.
|
|
19
|
+
id: 'spexcode', label: 'SpexCode', bg: '#12161C', fg: '#EFE8D8',
|
|
10
20
|
shapes: [
|
|
11
|
-
{ tag: '
|
|
12
|
-
{ tag: '
|
|
13
|
-
{ tag: '
|
|
14
|
-
{ tag: 'path', d: '
|
|
15
|
-
{ tag: 'path', d: '
|
|
21
|
+
{ tag: 'path', d: 'M 4.500,4.500 L 9.750,4.500 L 9.750,6.656 L 6.656,6.656 L 6.656,17.344 L 9.750,17.344 L 9.750,19.500 L 4.500,19.500 Z', fill: '#EFE8D8', stroke: 'none' },
|
|
22
|
+
{ tag: 'path', d: 'M 19.500,4.500 L 14.250,4.500 L 14.250,6.656 L 17.344,6.656 L 17.344,17.344 L 14.250,17.344 L 14.250,19.500 L 19.500,19.500 Z', fill: '#EFE8D8', stroke: 'none' },
|
|
23
|
+
{ tag: 'path', d: 'M 15.281,15.281 L 11.731,13.057 L 12.269,10.943 L 8.719,8.719 L 8.719,15.281 Z', fill: '#327D95', stroke: 'none' },
|
|
24
|
+
{ tag: 'path', d: 'M 8.719,8.719 L 12.269,10.943 L 11.731,13.057 L 15.281,15.281 L 15.281,12.000 L 12.000,8.719 Z', fill: '#82C3D6', stroke: 'none' },
|
|
25
|
+
{ tag: 'path', d: 'M 8.569,8.957 L 11.946,11.073 L 11.408,13.186 L 15.132,15.520 L 15.431,15.043 L 12.054,12.927 L 12.592,10.814 L 8.868,8.480 Z', fill: '#E4F7FD', stroke: 'none' },
|
|
16
26
|
],
|
|
17
27
|
},
|
|
18
28
|
{
|
package/spec-cli/src/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { serve } from '@hono/node-server'
|
|
2
2
|
import type { Server as HttpServer, ServerResponse as HttpServerResponse } from 'node:http'
|
|
3
3
|
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { createReadStream } from 'node:fs'
|
|
5
|
+
import { Readable } from 'node:stream'
|
|
4
6
|
import { installConnectionReaper } from './reaper.js'
|
|
5
7
|
import { Hono } from 'hono'
|
|
6
8
|
import { cors } from 'hono/cors'
|
|
@@ -15,23 +17,26 @@ import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
|
|
|
15
17
|
import { summarizeLoopIn } from './mentions.js'
|
|
16
18
|
import { resolveLayout, mainBranch } from './layout.js'
|
|
17
19
|
import { getBoardJson } from './graphCache.js'
|
|
18
|
-
import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
|
|
20
|
+
import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged, flushDeferredWorktreeRegistryChange } from './graphStream.js'
|
|
19
21
|
import { gitA, gitTry, repoRoot } from './git.js'
|
|
20
22
|
import { cockpitReview } from './cockpit.js'
|
|
21
23
|
import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, archiveSession, resumeSession, mergeSession, captureSessionResult, sessionPrompt, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, superviseDelivery, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
|
|
22
24
|
import { readTimeline } from './session-timeline.js'
|
|
25
|
+
import { readSessionExecution, sessionExecutionStream } from './session-execution.js'
|
|
23
26
|
import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js'
|
|
24
|
-
import {
|
|
27
|
+
import { readBlobByHash } from '../../spec-eval/src/evaltab.js'
|
|
25
28
|
import { putBlob } from '../../spec-eval/src/cache.js'
|
|
26
29
|
import { fileHumanReading } from '../../spec-eval/src/filing.js'
|
|
27
30
|
import { fileHumanOk } from '../../spec-eval/src/humanok.js'
|
|
28
|
-
import { buildExportModel, renderExportHtml, SessionEvalUnavailableError } from '../../spec-eval/src/sessioneval.js'
|
|
31
|
+
import { buildExportModel, renderExportHtml, buildSessionEvals, SessionEvalUnavailableError } from '../../spec-eval/src/sessioneval.js'
|
|
29
32
|
import { appendUpload, cancelUpload, completeUpload, createUpload, evidenceMaxBytes, startUploadReaper, UploadError, uploadStatus } from './uploads.js'
|
|
33
|
+
import { listSessionFiles, openSessionFile, SESSION_FILE_PREVIEW_MAX_BYTES, sessionFilePreviewKind, SessionFileError } from './session-files.js'
|
|
30
34
|
import { attachViewer, detachViewer, resizeBridge, hideViewer, forwardInput, superviseBridges, type Viewer } from './pty-bridge.js'
|
|
31
35
|
import { installProcessGuards } from './resilience.js'
|
|
32
36
|
import { resolveProjectIdentity } from './project-identity.js'
|
|
33
37
|
import { evalDetailReview, evalsReview, issuesReview } from './reviews.js'
|
|
34
38
|
import { collectResourceReport, ResourceConflict } from './host-resources.js'
|
|
39
|
+
import { reparentRequest, SessionReparentRequestError } from './session-reparent.js'
|
|
35
40
|
|
|
36
41
|
// last-resort net: an unforeseen async throw (e.g. a worktree vanishing mid-read during a worker
|
|
37
42
|
// self-merge) is logged and the server KEEPS SERVING instead of exiting and dropping the public port.
|
|
@@ -47,6 +52,7 @@ app.onError((error, c) => {
|
|
|
47
52
|
// stack would hide exactly the sentence the human needs.
|
|
48
53
|
if (error instanceof SessionRecordUnusable) return c.json({ error: error.message, code: error.code }, 409)
|
|
49
54
|
if (error instanceof ResourceConflict) return c.json({ error: error.message, code: error.code }, 409)
|
|
55
|
+
if (error instanceof SessionReparentRequestError) return c.json({ error: error.message }, 400)
|
|
50
56
|
console.error(error)
|
|
51
57
|
return c.text('Internal Server Error', 500)
|
|
52
58
|
})
|
|
@@ -137,10 +143,7 @@ app.get('/api/edit', async (c) => {
|
|
|
137
143
|
}
|
|
138
144
|
return c.json({ patch })
|
|
139
145
|
})
|
|
140
|
-
//
|
|
141
|
-
// flag, newest-first; `hasEvalFile:false` when none declared. Contract belongs to [[spec-eval]].
|
|
142
|
-
app.get('/api/specs/:id/evals', async (c) => c.json(await evalTimeline(c.req.param('id'))))
|
|
143
|
-
// the eval seam's WRITE half over HTTP ([[spec-eval]] filing.ts) — the REST pair of the GET above: a
|
|
146
|
+
// the eval seam's WRITE half over HTTP ([[spec-eval]] filing.ts): a
|
|
144
147
|
// programmatic caller files a reading (verdict + optional transcript) through the SAME append the CLI
|
|
145
148
|
// uses. The dashboard does not call this — [[event-detail]] reads readings and hosts remarks, never files.
|
|
146
149
|
app.post('/api/specs/:id/evals', async (c) => {
|
|
@@ -230,15 +233,27 @@ app.get('/api/evals', etag(), async (c) => {
|
|
|
230
233
|
const page = await evalsReview(c.req.query('q'), c.req.query('page'), { view: c.req.query('view') })
|
|
231
234
|
return page ? c.json(page) : c.json({ error: 'no such review source' }, 404)
|
|
232
235
|
})
|
|
236
|
+
// The exact impact graph proves a scope's membership and selector decisions. It is deliberately a named
|
|
237
|
+
// read: paged Evals rows retain their own reasons but never transport this scope-sized projection.
|
|
238
|
+
app.get('/api/evals/impact', etag(), async (c) => {
|
|
239
|
+
const scope = c.req.query('scope')?.trim()
|
|
240
|
+
if (!scope) return c.json({ error: 'scope is required' }, 400)
|
|
241
|
+
await ensureBoardFileWatchers(scope)
|
|
242
|
+
const model = await buildSessionEvals(scope)
|
|
243
|
+
return model
|
|
244
|
+
? c.json({ scope, impact: model.impact, evalRevision: model.evalRevision })
|
|
245
|
+
: c.json({ error: 'no such review source' }, 404)
|
|
246
|
+
})
|
|
233
247
|
// ONE bounded detail response for both source roots: the selected scenario's complete A/B history and at
|
|
234
|
-
// most five lightweight neighbors.
|
|
248
|
+
// most five lightweight neighbors. A missing worktree scope resolves explicitly to trunk; it never
|
|
249
|
+
// serializes another scenario's history or the scoped model.
|
|
235
250
|
app.get('/api/evals/detail', etag(), async (c) => {
|
|
236
251
|
await ensureBoardFileWatchers(c.req.query('scope')?.trim() || undefined)
|
|
237
252
|
const node = c.req.query('node')?.trim()
|
|
238
253
|
const scenario = c.req.query('scenario')?.trim()
|
|
239
254
|
if (!node || !scenario) return c.json({ error: 'node and scenario are required' }, 400)
|
|
240
255
|
const detail = await evalDetailReview(node, scenario, c.req.query('scope')?.trim() || null)
|
|
241
|
-
return
|
|
256
|
+
return c.json(detail)
|
|
242
257
|
})
|
|
243
258
|
// the single-thread read ([[issues]]) behind `spex issue show <id>` — the SAME findIssue lookup, from the
|
|
244
259
|
// resident forge slice (instant view, background reconcile — the list route's freshness contract). A local
|
|
@@ -455,12 +470,19 @@ app.post('/api/sessions', async (c) => {
|
|
|
455
470
|
try {
|
|
456
471
|
const body = await c.req.json().catch(() => null)
|
|
457
472
|
const result = await sessionCreateRequest(body, { requestKey, signal: controller.signal })
|
|
473
|
+
// The durable row is now public. Nudge the cheap session projection explicitly so a dashboard does not
|
|
474
|
+
// wait for the best-effort store watcher; any held candidate worktree event remains a separate full claim.
|
|
475
|
+
if (result.status === 201) notifyBoardChanged('sessions')
|
|
476
|
+
// A candidate registry event is intentionally held while Git creates the private worktree. Once the
|
|
477
|
+
// transaction has published or cleaned up its record, release the one deferred full refresh.
|
|
478
|
+
flushDeferredWorktreeRegistryChange()
|
|
458
479
|
if (result.status === 201) {
|
|
459
480
|
c.header('Idempotency-Key', requestKey)
|
|
460
481
|
return c.json(result.session, 201)
|
|
461
482
|
}
|
|
462
483
|
return c.json({ error: result.error, ...(result.code ? { code: result.code } : {}), ...(result.phase ? { phase: result.phase } : {}) }, result.status as any)
|
|
463
484
|
} finally {
|
|
485
|
+
flushDeferredWorktreeRegistryChange()
|
|
464
486
|
rawSignal.removeEventListener('abort', cancelFromRequest)
|
|
465
487
|
outgoing?.off('close', cancel)
|
|
466
488
|
}
|
|
@@ -489,6 +511,14 @@ app.get('/api/sessions/:id/capture', async (c) => {
|
|
|
489
511
|
if (r.reason === 'offline') return c.text('session offline (no live pane)', 409)
|
|
490
512
|
return c.text('capture failed', 502)
|
|
491
513
|
})
|
|
514
|
+
// A live adapter-owned execution observation, intentionally distinct from the durable conversation timeline.
|
|
515
|
+
// The response carries only the backend-normalized latest working note and typed tool rows; no transcript bytes
|
|
516
|
+
// or parser schema cross this API boundary.
|
|
517
|
+
app.get('/api/sessions/:id/execution', (c) => {
|
|
518
|
+
const execution = readSessionExecution(c.req.param('id') || '')
|
|
519
|
+
return execution ? c.json(execution) : c.json({ error: 'no such session' }, 404)
|
|
520
|
+
})
|
|
521
|
+
app.get('/api/sessions/:id/execution/stream', (c) => sessionExecutionStream(c))
|
|
492
522
|
// the session's persisted interaction history ([[session-timeline]]): authored status transitions (with the
|
|
493
523
|
// FULL note text) + delivered prompts, timestamped, oldest first — what a terminal-free surface renders as
|
|
494
524
|
// the conversation. `?limit=<n>` caps the tail (default 500). 404 for an unknown/non-governed id.
|
|
@@ -506,6 +536,39 @@ app.get('/api/sessions/:id', async (c) => {
|
|
|
506
536
|
if (!row) return c.json({ error: 'no such session' }, 404)
|
|
507
537
|
return c.json({ ...row, prompt: await sessionPrompt(id) })
|
|
508
538
|
})
|
|
539
|
+
app.get('/api/sessions/:id/files', (c) => {
|
|
540
|
+
try { return c.json({ files: listSessionFiles(c.req.param('id')) }) }
|
|
541
|
+
catch (error) {
|
|
542
|
+
if (error instanceof SessionFileError) return c.json({ error: error.message }, error.status)
|
|
543
|
+
throw error
|
|
544
|
+
}
|
|
545
|
+
})
|
|
546
|
+
const sessionFileDownload = (c: any) => {
|
|
547
|
+
try {
|
|
548
|
+
const requested = c.req.query('path')
|
|
549
|
+
if (!requested) return c.json({ error: 'download needs a posted path' }, 400)
|
|
550
|
+
const file = openSessionFile(c.req.param('id'), requested)
|
|
551
|
+
const preview = c.req.query('preview') === '1'
|
|
552
|
+
const previewType = preview ? sessionFilePreviewKind(file.path) : null
|
|
553
|
+
if (preview && !previewType) return c.json({ error: 'no preview for this file type; download it instead' }, 415)
|
|
554
|
+
if (preview && file.size > SESSION_FILE_PREVIEW_MAX_BYTES)
|
|
555
|
+
return c.json({ error: `preview is limited to ${SESSION_FILE_PREVIEW_MAX_BYTES / (1024 * 1024)} MiB; download this ${file.size}-byte file instead` }, 413)
|
|
556
|
+
const headers: Record<string, string> = {
|
|
557
|
+
'Cache-Control': 'no-store',
|
|
558
|
+
'Content-Disposition': `${preview ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(file.name)}`,
|
|
559
|
+
'Content-Length': String(file.size),
|
|
560
|
+
'Content-Type': previewType?.contentType ?? 'application/octet-stream',
|
|
561
|
+
}
|
|
562
|
+
if (previewType) headers['X-Spexcode-Preview-Kind'] = previewType.kind
|
|
563
|
+
if (c.req.method === 'HEAD') return c.body(null, 200, headers)
|
|
564
|
+
return c.body(Readable.toWeb(createReadStream(file.path)) as ReadableStream, 200, headers)
|
|
565
|
+
} catch (error) {
|
|
566
|
+
if (error instanceof SessionFileError) return c.json({ error: error.message }, error.status)
|
|
567
|
+
throw error
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
app.get('/api/sessions/:id/files/download', sessionFileDownload)
|
|
571
|
+
app.on('HEAD', '/api/sessions/:id/files/download', sessionFileDownload)
|
|
509
572
|
// lifecycle transitions (thin callers of the session state machine)
|
|
510
573
|
// relaunch ONLY if confirmed offline; demotes working→idle, keeps any declaration. The RESUME GUARD refuses
|
|
511
574
|
// (409) when the agent is alive or its liveness is unproven — restore-on-alive was the incident's kill-shot.
|
|
@@ -516,11 +579,37 @@ app.post('/api/sessions/:id/resume', async (c) => {
|
|
|
516
579
|
const r = await resumeSession(c.req.param('id'), { force })
|
|
517
580
|
return c.json(r, r.ok ? 200 : (r.refused ? 409 : 404))
|
|
518
581
|
})
|
|
519
|
-
//
|
|
520
|
-
//
|
|
582
|
+
// A reviewed dispatch to the session's own agent (it runs the merge), never a server merge. The caller returns
|
|
583
|
+
// the exact branch/base OIDs with a durable key; sessions.ts validates and accepts them once before ensure-live.
|
|
521
584
|
app.post('/api/sessions/:id/merge', async (c) => {
|
|
522
|
-
const
|
|
523
|
-
|
|
585
|
+
const requestKey = c.req.header('idempotency-key')
|
|
586
|
+
if (requestKey === undefined) {
|
|
587
|
+
return c.json({ dispatched: false, reason: 'Idempotency-Key is required with reviewed merge authority', code: 'session_merge_invalid_request' }, 400)
|
|
588
|
+
}
|
|
589
|
+
const rawBody = await c.req.text()
|
|
590
|
+
let body: unknown = {}
|
|
591
|
+
if (rawBody.trim()) {
|
|
592
|
+
try { body = JSON.parse(rawBody) }
|
|
593
|
+
catch { return c.json({ dispatched: false, reason: 'body must be valid JSON', code: 'session_merge_invalid_request' }, 400) }
|
|
594
|
+
}
|
|
595
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
596
|
+
return c.json({ dispatched: false, reason: 'body must be a JSON object', code: 'session_merge_invalid_request' }, 400)
|
|
597
|
+
}
|
|
598
|
+
const input = body as Record<string, unknown>
|
|
599
|
+
const keys = Object.keys(input)
|
|
600
|
+
const allowed = new Set(['expectedBranchHead', 'expectedBaseHead'])
|
|
601
|
+
if (keys.some((key) => !allowed.has(key))) {
|
|
602
|
+
return c.json({ dispatched: false, reason: `unknown session-merge field(s): ${keys.filter((key) => !allowed.has(key)).join(', ')}`, code: 'session_merge_invalid_request' }, 400)
|
|
603
|
+
}
|
|
604
|
+
if (typeof input.expectedBranchHead !== 'string' || typeof input.expectedBaseHead !== 'string') {
|
|
605
|
+
return c.json({ dispatched: false, reason: 'expectedBranchHead and expectedBaseHead must be strings', code: 'session_merge_invalid_request' }, 400)
|
|
606
|
+
}
|
|
607
|
+
const r = await mergeSession(c.req.param('id'), {
|
|
608
|
+
requestKey,
|
|
609
|
+
expectedBranchHead: input.expectedBranchHead,
|
|
610
|
+
expectedBaseHead: input.expectedBaseHead,
|
|
611
|
+
})
|
|
612
|
+
return c.json(r, r.dispatched ? 200 : (r.status ?? 409))
|
|
524
613
|
})
|
|
525
614
|
|
|
526
615
|
// one WS owns one native tmux client (pty-bridge): server→client = that client's rendered PTY bytes (binary);
|
|
@@ -626,6 +715,11 @@ app.post('/api/sessions/:id/input', async (c) => {
|
|
|
626
715
|
}
|
|
627
716
|
return c.json({ error: 'input needs kind: "text" | "command" | "keys"' }, 400)
|
|
628
717
|
})
|
|
718
|
+
app.post('/api/sessions/reparent', async (c) => {
|
|
719
|
+
const result = await reparentRequest(await c.req.json().catch(() => null))
|
|
720
|
+
notifyBoardChanged('sessions')
|
|
721
|
+
return c.json(result)
|
|
722
|
+
})
|
|
629
723
|
// soft stop: kill the agent's tmux + socket but KEEP the worktree (resumable). Distinct from close, which
|
|
630
724
|
// removes the worktree. {ok:false} = no such session.
|
|
631
725
|
app.post('/api/sessions/:id/stop', async (c) => {
|
|
@@ -639,7 +733,8 @@ app.post('/api/sessions/:id/interrupt', async (c) => {
|
|
|
639
733
|
})
|
|
640
734
|
app.post('/api/sessions/:id/close', async (c) => {
|
|
641
735
|
const sessionId = c.req.param('id')
|
|
642
|
-
const
|
|
736
|
+
const body = await c.req.json().catch(() => ({}))
|
|
737
|
+
const ok = await closeSession(sessionId, body?.source)
|
|
643
738
|
// The close route owns its write's visible boundary: filesystem watchers can be unavailable, so cache
|
|
644
739
|
// invalidation must happen before the success response rather than leaving the confirming board to patrol.
|
|
645
740
|
if (ok) notifyBoardChanged('sessions')
|
package/spec-cli/src/init.ts
CHANGED
|
@@ -1,29 +1,20 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, copyFileSync, readFileSync, readdirSync, renameSync, rmSync, statSync, chmodSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { createHash } from 'node:crypto'
|
|
3
2
|
import { join, resolve, relative, dirname } from 'node:path'
|
|
4
3
|
import { fileURLToPath } from 'node:url'
|
|
5
4
|
import { execFileSync } from 'node:child_process'
|
|
6
5
|
import { readConfig, readJsonConfig } from './layout.js'
|
|
7
6
|
import { resolveHarnessTargets, parseHarnessFlag, NATIVE_HARNESS_IDS } from './harness-select.js'
|
|
7
|
+
import { gitBinary } from './git.js'
|
|
8
8
|
|
|
9
9
|
// this file lives at <pkgRoot>/src/init.ts, so `..` is the package root — the same derivation the
|
|
10
10
|
// launch paths use, never a hardcoded repo path (so a relocated/installed package still finds its data).
|
|
11
11
|
const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
12
12
|
const TEMPLATES = join(pkgRoot, 'templates')
|
|
13
13
|
const MANAGED_HOOK_HEADER = '# spexcode-managed-hook-v1'
|
|
14
|
-
const LEGACY_MANAGED_HOOKS: Record<string, Set<string>> = {
|
|
15
|
-
'pre-commit': new Set(['fc33a6ff2b444fa47210a2594b6a714613d9c62fd8ec77a9a13aec81143de8a7']),
|
|
16
|
-
'prepare-commit-msg': new Set(['08b26ad8ffb305a64b7cddc868231854c84d539b8e5e9324702bd2c293044183']),
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function hookDigest(path: string): string {
|
|
20
|
-
return createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
21
|
-
}
|
|
22
14
|
|
|
23
|
-
function isManagedHook(path: string
|
|
15
|
+
function isManagedHook(path: string): boolean {
|
|
24
16
|
const source = readFileSync(path, 'utf8')
|
|
25
17
|
return source.split('\n').slice(0, 4).includes(MANAGED_HOOK_HEADER)
|
|
26
|
-
|| LEGACY_MANAGED_HOOKS[name]?.has(createHash('sha256').update(source).digest('hex')) === true
|
|
27
18
|
}
|
|
28
19
|
|
|
29
20
|
function replaceHook(source: string, dest: string): void {
|
|
@@ -71,7 +62,7 @@ function copyTreeNoClobber(srcDir: string, destDir: string, base: string): strin
|
|
|
71
62
|
// hooks/ (shared across all worktrees). Returns null (with a loud reason) when <dir> isn't a git repo.
|
|
72
63
|
function resolveHooksDir(dir: string): string | null {
|
|
73
64
|
try {
|
|
74
|
-
const common = execFileSync(
|
|
65
|
+
const common = execFileSync(gitBinary(process.env), ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
|
75
66
|
encoding: 'utf8',
|
|
76
67
|
stdio: ['ignore', 'pipe', 'ignore'], // swallow git's own "fatal: not a git repository" — our warning is the signal
|
|
77
68
|
}).trim()
|
|
@@ -86,8 +77,8 @@ function resolveHooksDir(dir: string): string | null {
|
|
|
86
77
|
// turn that feature branch into trunk).
|
|
87
78
|
function adoptionMainBranch(dir: string): string {
|
|
88
79
|
try {
|
|
89
|
-
const common = execFileSync(
|
|
90
|
-
const branch = execFileSync(
|
|
80
|
+
const common = execFileSync(gitBinary(process.env), ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { encoding: 'utf8' }).trim()
|
|
81
|
+
const branch = execFileSync(gitBinary(process.env), ['-C', dirname(common), 'symbolic-ref', '--short', 'HEAD'], { encoding: 'utf8' }).trim()
|
|
91
82
|
if (branch) return branch
|
|
92
83
|
} catch { /* render the one product-level repair below */ }
|
|
93
84
|
const error = new Error('cannot determine the source-of-truth branch at adoption — check out the trunk in the root checkout, or set "mainBranch" in spexcode.json before re-running `spex init`')
|
|
@@ -113,7 +104,7 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
113
104
|
// NOT `git init` for them: creating a repo is a side effect beyond init's remit (a subdir, a dir not
|
|
114
105
|
// meant as a repo root), and `git init` is one deliberate command.
|
|
115
106
|
try {
|
|
116
|
-
execFileSync(
|
|
107
|
+
execFileSync(gitBinary(process.env), ['-C', targetDir, 'rev-parse', '--is-inside-work-tree'], { stdio: ['ignore', 'ignore', 'ignore'] })
|
|
117
108
|
} catch {
|
|
118
109
|
console.error(`spex init: ${targetDir} is not a git repository. SpexCode is git-backed (git is the version database; the hooks live in .git). Run \`git init\` there first, then \`spex init\`.`)
|
|
119
110
|
process.exit(1)
|
|
@@ -210,8 +201,8 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
210
201
|
const source = join(hooksSrc, e.name)
|
|
211
202
|
const dest = join(hooksDir, e.name)
|
|
212
203
|
if (existsSync(dest)) {
|
|
213
|
-
if (
|
|
214
|
-
if (isManagedHook(dest
|
|
204
|
+
if (readFileSync(dest).equals(readFileSync(source))) continue
|
|
205
|
+
if (isManagedHook(dest)) {
|
|
215
206
|
replaceHook(source, dest)
|
|
216
207
|
refreshed.push(e.name)
|
|
217
208
|
} else {
|