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.
Files changed (88) hide show
  1. package/README.md +97 -149
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/harness.sh +26 -6
  4. package/spec-cli/src/anchors.ts +27 -5
  5. package/spec-cli/src/cli.ts +239 -75
  6. package/spec-cli/src/client.ts +113 -14
  7. package/spec-cli/src/codex-runtime-generations.ts +97 -7
  8. package/spec-cli/src/commit-surgery.ts +2 -1
  9. package/spec-cli/src/contract-filter.ts +58 -42
  10. package/spec-cli/src/delivery-queue.ts +62 -4
  11. package/spec-cli/src/doctor.ts +2 -1
  12. package/spec-cli/src/execution-trace.ts +444 -0
  13. package/spec-cli/src/file-write.ts +22 -0
  14. package/spec-cli/src/gateway-hub.ts +10 -8
  15. package/spec-cli/src/gateway.ts +99 -6
  16. package/spec-cli/src/git.ts +503 -105
  17. package/spec-cli/src/graph.ts +56 -6
  18. package/spec-cli/src/graphCache.ts +86 -17
  19. package/spec-cli/src/graphStream.ts +79 -11
  20. package/spec-cli/src/guide.ts +93 -4
  21. package/spec-cli/src/harness-select.ts +2 -2
  22. package/spec-cli/src/harness.ts +355 -75
  23. package/spec-cli/src/help.ts +30 -23
  24. package/spec-cli/src/host-resources.ts +62 -7
  25. package/spec-cli/src/identity-presets.js +16 -6
  26. package/spec-cli/src/index.ts +109 -14
  27. package/spec-cli/src/init.ts +8 -17
  28. package/spec-cli/src/layout.ts +151 -40
  29. package/spec-cli/src/lint.ts +37 -7
  30. package/spec-cli/src/materialize.ts +160 -112
  31. package/spec-cli/src/plugin-harness.ts +29 -18
  32. package/spec-cli/src/process-identity.ts +13 -0
  33. package/spec-cli/src/reviews.ts +103 -30
  34. package/spec-cli/src/session-execution.ts +68 -0
  35. package/spec-cli/src/session-files.ts +121 -0
  36. package/spec-cli/src/session-follow.ts +4 -4
  37. package/spec-cli/src/session-reparent.ts +33 -0
  38. package/spec-cli/src/session-timeline.ts +163 -14
  39. package/spec-cli/src/session-web.ts +135 -0
  40. package/spec-cli/src/sessions.ts +748 -111
  41. package/spec-cli/src/uninstall.ts +3 -2
  42. package/spec-cli/templates/hooks/post-checkout +3 -0
  43. package/spec-cli/templates/hooks/post-merge +1 -0
  44. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  46. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  47. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  48. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  49. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  50. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  51. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  53. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
  54. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  56. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
  57. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  58. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
  59. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
  60. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
  61. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  62. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
  63. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  64. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  65. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  66. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
  67. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  68. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
  69. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  70. package/spec-dashboard/dist/index.html +2 -2
  71. package/spec-dashboard/src/reviewFilters.js +5 -0
  72. package/spec-dashboard/src/session.js +5 -1
  73. package/spec-eval/src/cli.ts +13 -9
  74. package/spec-eval/src/evaltab.ts +32 -10
  75. package/spec-eval/src/freshness.ts +404 -81
  76. package/spec-eval/src/scenariofresh.ts +58 -8
  77. package/spec-eval/src/scenarios.ts +60 -12
  78. package/spec-eval/src/sessioneval.ts +234 -148
  79. package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
  80. package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
  81. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
  82. package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
  83. package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
  84. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  85. package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
  86. package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
  87. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
  88. package/spec-eval/src/matrix.ts +0 -693
@@ -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). The successful receipt names what to read, monitor, and reply on.`],
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
- watch: ['spex session watch [SEL…] [--as NAME] [--idle] [--interval N=1]',
48
- 'Streams lifecycle transitions and blocks until killed; `session wait` is the one-shot alternative.', ['selector']],
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
- send: [['spex session send <SEL> "<msg>"', 'spex session send <SEL> --keys "<keys>"'],
62
- `Plain send delivers a message once its timeline append succeeds; a dead adapter only delays its context. --keys is the LAST RESORT:
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 · matrix · lint · ok · retract · clean',
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: background \`spex session wait ${id}\` (edge-triggered; exits on the next non-actionable→actionable transition); \`spex session watch ${id}\` streams and NEVER EXITS
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 = harnessById(rec.harness || defaultHarness.id)
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 targetThread = entry.recs.find((rec) => rec.session_id === id)?.harness_session_id
408
- if (targetThread && ownerCounts.get(targetThread) !== 1)
409
- return `${descriptor.label} target thread ${targetThread} has no one exact governed session owner`
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
- const stopBlocker = rec ? await sessionStopBlocker(id, rec.harness || defaultHarness.id, inv.recs, sharedProbes) : null
566
- if (terminal(rec) && totals.cpuPercent <= idleBudget && !stopBlocker) {
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
- id: 'spexcode', label: 'SpexCode', bg: '#166534', fg: '#f0fdf4',
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: 'circle', cx: 12, cy: 6.2, r: 2.2 },
12
- { tag: 'circle', cx: 6.2, cy: 17.5, r: 2 },
13
- { tag: 'circle', cx: 17.8, cy: 17.5, r: 2 },
14
- { tag: 'path', d: 'M12 8.4v2.1c0 2.2-5.8 1.8-5.8 5' },
15
- { tag: 'path', d: 'M12 10.5c0 2.2 5.8 1.8 5.8 5' },
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
  {
@@ -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 { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
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
- // a node's eval timeline (read half of `spex eval`): eval-sidecar readings joined with a live freshness
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. It never serializes another scenario's history or the scoped model.
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 detail ? c.json(detail) : c.json({ error: 'no such review source' }, 404)
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
- // a dispatch to the session's own agent (it runs the merge), never a server merge the server never touches
520
- // main's tree. 200 {dispatched:true} once the prompt is accepted, 409 {dispatched:false} if the agent is unreachable.
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 r = await mergeSession(c.req.param('id'))
523
- return c.json(r, r.dispatched ? 200 : 409)
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 ok = await closeSession(sessionId)
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')
@@ -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, name: string): boolean {
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('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
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('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { encoding: 'utf8' }).trim()
90
- const branch = execFileSync('git', ['-C', dirname(common), 'symbolic-ref', '--short', 'HEAD'], { encoding: 'utf8' }).trim()
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('git', ['-C', targetDir, 'rev-parse', '--is-inside-work-tree'], { stdio: ['ignore', 'ignore', 'ignore'] })
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 (hookDigest(dest) === hookDigest(source)) continue
214
- if (isManagedHook(dest, e.name)) {
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 {