spexcode 0.5.8 → 0.5.9

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 (103) hide show
  1. package/package.json +5 -3
  2. package/spec-cli/bin/spex.mjs +1 -13
  3. package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  4. package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
  5. package/spec-cli/hooks/dispatch.sh +15 -19
  6. package/spec-cli/src/anchors.ts +185 -56
  7. package/spec-cli/src/attach.ts +7 -17
  8. package/spec-cli/src/cli.ts +144 -225
  9. package/spec-cli/src/client.ts +114 -132
  10. package/spec-cli/src/cockpit.ts +43 -0
  11. package/spec-cli/src/codex-runtime-generations.ts +556 -0
  12. package/spec-cli/src/commit-surgery.ts +0 -14
  13. package/spec-cli/src/contract-filter.ts +0 -14
  14. package/spec-cli/src/doctor.ts +0 -16
  15. package/spec-cli/src/gateway-auth.ts +0 -13
  16. package/spec-cli/src/gateway.ts +0 -22
  17. package/spec-cli/src/git.ts +151 -48
  18. package/spec-cli/src/graph.ts +28 -7
  19. package/spec-cli/src/graphCache.ts +253 -72
  20. package/spec-cli/src/graphDelta.ts +0 -13
  21. package/spec-cli/src/graphStream.ts +70 -37
  22. package/spec-cli/src/guide.ts +29 -6
  23. package/spec-cli/src/harness.ts +232 -248
  24. package/spec-cli/src/help.ts +27 -26
  25. package/spec-cli/src/host-resources.ts +11 -2
  26. package/spec-cli/src/index.ts +70 -95
  27. package/spec-cli/src/issues-cli.ts +276 -0
  28. package/spec-cli/src/issues.ts +15 -197
  29. package/spec-cli/src/layout.ts +78 -2
  30. package/spec-cli/src/lint.ts +5 -3
  31. package/spec-cli/src/localIssues.ts +22 -185
  32. package/spec-cli/src/loop-in.ts +78 -0
  33. package/spec-cli/src/materialize.ts +0 -42
  34. package/spec-cli/src/mentions.ts +49 -193
  35. package/spec-cli/src/opencode-headless.ts +7 -7
  36. package/spec-cli/src/opencode.ts +2 -5
  37. package/spec-cli/src/pi-headless.ts +5 -5
  38. package/spec-cli/src/pty-bridge.ts +4 -5
  39. package/spec-cli/src/reviewSnapshot.ts +1 -0
  40. package/spec-cli/src/reviews.ts +6 -1
  41. package/spec-cli/src/root-lru.ts +54 -0
  42. package/spec-cli/src/runtime-guard.ts +0 -9
  43. package/spec-cli/src/session-cursors.ts +97 -0
  44. package/spec-cli/src/session-follow.ts +182 -0
  45. package/spec-cli/src/session-timeline.ts +55 -127
  46. package/spec-cli/src/sessions.ts +207 -1022
  47. package/spec-cli/src/shim-runtime.ts +9 -16
  48. package/spec-cli/src/specs.ts +33 -10
  49. package/spec-cli/src/uninstall.ts +0 -9
  50. package/spec-cli/src/uploads.ts +240 -18
  51. package/spec-cli/src/worktree-sources.ts +0 -16
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
  56. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  57. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
  58. package/spec-cli/templates/spexcode.json +12 -0
  59. package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
  60. package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
  61. package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
  62. package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
  63. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
  64. package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
  65. package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
  66. package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
  67. package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
  68. package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
  69. package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
  70. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
  71. package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
  72. package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
  73. package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
  74. package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
  75. package/spec-dashboard/dist/index.html +2 -2
  76. package/spec-dashboard/src/session.js +1 -28
  77. package/spec-eval/src/cache.ts +0 -14
  78. package/spec-eval/src/cli.ts +26 -5
  79. package/spec-eval/src/evaltab.ts +67 -108
  80. package/spec-eval/src/filing.ts +0 -18
  81. package/spec-eval/src/freshness.ts +78 -32
  82. package/spec-eval/src/humanok.ts +0 -15
  83. package/spec-eval/src/scenariofresh.ts +8 -80
  84. package/spec-eval/src/scenarios.ts +16 -67
  85. package/spec-eval/src/sessioneval.ts +157 -78
  86. package/spec-eval/src/sidecar.ts +1 -70
  87. package/spec-eval/src/timeline.ts +0 -23
  88. package/spec-forge/src/cache.ts +34 -12
  89. package/spec-forge/src/cli.ts +0 -6
  90. package/spec-forge/src/drivers/github.ts +1 -12
  91. package/spec-forge/src/drivers/gitlab.ts +4 -19
  92. package/spec-forge/src/drivers.ts +0 -18
  93. package/spec-forge/src/links.ts +0 -4
  94. package/spec-forge/src/needs-eval.ts +0 -1
  95. package/spec-forge/src/port.ts +0 -12
  96. package/spec-forge/src/resident.ts +4 -20
  97. package/spec-cli/src/maintenance-wrapper.ts +0 -276
  98. package/spec-cli/src/session-maintenance.ts +0 -751
  99. package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
  100. package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
  101. package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
  102. package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
  103. /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
@@ -4,7 +4,7 @@ import { watch, mkdirSync, readdirSync, readFileSync, type FSWatcher } from 'nod
4
4
  import { join, dirname, relative, resolve } from 'node:path'
5
5
  import { sessionsRoot, gitCommonDir } from './layout.js'
6
6
  import { hotSignature, warmSignature, listSessions } from './sessions.js'
7
- import { getBoard, invalidateBoard, patrolBoard } from './graphCache.js'
7
+ import { getBoard, getBoardForSessionRefresh, invalidateBoard, patrolBoard } from './graphCache.js'
8
8
  import { unitize, tagOf, diffUnits, type Units } from './graphDelta.js'
9
9
  import {
10
10
  holdSessionEvalProjectionObserver,
@@ -14,27 +14,6 @@ import {
14
14
  setSessionEvalProjectionWarmup,
15
15
  } from '../../spec-eval/src/sessioneval.js'
16
16
 
17
- // @@@ board-stream — the board's freshness is PUSHED, not polled. A dashboard subscribes here ONCE; in
18
- // plain mode it gets a bare `graph-changed` and refetches /api/graph (the legacy protocol, kept verbatim
19
- // for old clients); in DELTA mode (`?mode=delta`) the server itself rebuilds on change and streams the
20
- // hash-chained patch ([[graph-delta]]): a `graph-full {to, graph}` on connect, then `graph-delta
21
- // {from, to, set, del}` per change — a few KB against the ~600KB snapshot, with a full-snapshot send
22
- // whenever the patch wouldn't win (bigger than the board, or the unit decomposition's id-uniqueness
23
- // precondition failed), so a delta subscriber is NEVER worse off than a full refetch.
24
- //
25
- // Every source carries the DOMAIN of the change it saw — 'sessions' (only the session rows moved) or 'full'
26
- // (anything could have) — and fireChanged funnels them into ONE debounced pipeline, escalating to the max
27
- // scope seen in the window so the cache can splice sessions instead of rebuilding whole ([[graph-cache]]).
28
- // Sources: (1) fs.watch on the per-user session store — every lifecycle transition lands as a
29
- // sessions/<id>/session.json write → 'sessions'; (2) fs.watch on the shared git dir's refs (+
30
- // packed-refs/HEAD) — a commit/merge moves a ref, reshaping the tree → 'full'; (3) fs.watch on the git
31
- // worktree REGISTRY (+ each live worktree root and gitdir index) — dirty source/spec/sidecar/rename/stage → 'full';
32
- // (4) two subscriber-gated pollers of the tmux-derived signatures ([[sessions]]) that never touch a file —
33
- // a 100ms HOT syscall poll and a 1s WARM tmux poll, both → 'sessions'; (5) a delta-gated ~15s cold-tick
34
- // PATROL that asks graph-cache to validate its owned input revision — unchanged inputs reuse the anchor,
35
- // while moved inputs select sessions/full there and missed leaf signals stay loud through repair accounting.
36
- // Plain mode without delta subscribers keeps its zero-build behavior: sources just fan out `graph-changed`.
37
-
38
17
  type Scope = 'sessions' | 'full'
39
18
  type EvalTarget = 'all' | { id?: string; path?: string }
40
19
  type Notify = () => void
@@ -230,11 +209,19 @@ export class TreeWatcherRegistry {
230
209
  const plainSubs = new Set<Notify>()
231
210
  const deltaSubs = new Set<DeltaSend>()
232
211
  let debounce: ReturnType<typeof setTimeout> | null = null
233
- let pendingScope: Scope | null = null // the MAX change scope accumulated across the current debounce window
234
- const maxScope = (a: Scope | null, b: Scope): Scope => (a === 'full' || b === 'full' ? 'full' : 'sessions')
212
+ let pendingFull = false
213
+ let pendingSessions = false
214
+ export type PendingGraphChanges = { full: boolean; sessions: boolean }
215
+ export const addPendingGraphChange = (pending: PendingGraphChanges, scope: Scope): PendingGraphChanges => ({
216
+ full: pending.full || scope === 'full',
217
+ sessions: pending.sessions || scope === 'sessions',
218
+ })
235
219
 
236
220
  // under SPEXCODE_BOARD_DEBUG=1, every broadcast logs its changed unit keys + trigger tags + build ms.
237
221
  const DEBUG = process.env.SPEXCODE_BOARD_DEBUG === '1'
222
+ function traceLatency(stage: 'sessions-signal' | 'session-projection-complete' | 'broadcast', detail: Record<string, unknown> = {}): void {
223
+ if (DEBUG) console.warn(`spec-cli: graph latency ${JSON.stringify({ at: Date.now(), stage, ...detail })}`)
224
+ }
238
225
  // the set of trigger tags accrued SINCE THE LAST BROADCAST — each fireChanged adds its scope, the cold tick
239
226
  // adds 'patrol'. Cleared on every broadcast. Its job: prove WHO caused a broadcast, so a change that only
240
227
  // the patrol saw (tag set === {'patrol'}) is flagged as a repair — some leaf watcher was blind.
@@ -261,23 +248,60 @@ let lastFullFrame: Frame | null = null
261
248
  let building = false
262
249
  let dirty = false
263
250
  let patrolPending = false
251
+ let sessionRefreshRequested = false
252
+ let wakeSessionRefresh: (() => void) | null = null
264
253
 
265
- async function rebuildAndBroadcast(patrol = false): Promise<void> {
254
+ async function rebuildAndBroadcast(patrol = false, sessions = false, full = false): Promise<void> {
266
255
  if (patrol) patrolPending = true
267
- if (building) { dirty = true; return }
256
+ if (sessions) sessionRefreshRequested = true
257
+ if (building) {
258
+ dirty = true
259
+ if (sessions) {
260
+ wakeSessionRefresh?.()
261
+ wakeSessionRefresh = null
262
+ }
263
+ return
264
+ }
268
265
  building = true
269
266
  try {
270
267
  do {
271
268
  dirty = false
272
269
  const validate = patrolPending
273
270
  patrolPending = false
271
+ const sessionsFirst = sessionRefreshRequested
272
+ sessionRefreshRequested = false
273
+ let servedSessionProjection = sessionsFirst
274
274
  let board: unknown
275
275
  // share the route's single-flight build ([[graph-cache]]); fireChanged() already invalidated the
276
276
  // cache (at the accumulated scope), so this gets a fresh build/splice (or joins one a concurrent poll
277
277
  // already started). The patrol instead asks that same cache flight to validate its input revision;
278
278
  // equal inputs return the anchor without invoking a producer.
279
279
  const t0 = Date.now()
280
- try { board = await (validate ? patrolBoard() : getBoard()) }
280
+ try {
281
+ let wake!: () => void
282
+ const sessionWake = new Promise<void>((resolve) => { wake = resolve })
283
+ wakeSessionRefresh = wake
284
+ // A sessions-first turn can still fall back to an active route-owned full. Keep its wait wakeable
285
+ // too: a later persisted session change must re-enter the cheap projection rather than queue behind it.
286
+ const boardWait = sessionsFirst
287
+ ? getBoardForSessionRefresh()
288
+ : validate ? patrolBoard() : getBoard()
289
+ const outcome = await Promise.race([
290
+ boardWait.then((value) => ({ value })),
291
+ sessionWake.then(() => ({ value: null as unknown })),
292
+ ])
293
+ if (wakeSessionRefresh === wake) wakeSessionRefresh = null
294
+ if (outcome.value === null) {
295
+ boardWait.catch(() => {})
296
+ board = await getBoardForSessionRefresh()
297
+ servedSessionProjection = true
298
+ if (validate) patrolPending = true
299
+ dirty = true // the full wait was preempted only for delivery; it remains owed.
300
+ } else {
301
+ board = outcome.value
302
+ if (sessionsFirst && validate) { patrolPending = true; dirty = true }
303
+ }
304
+ }
281
305
  catch {
282
306
  // A failed refresh consumes no cause: graph-cache restores the producer scope, so its stream-side
283
307
  // attribution must remain owed too. This also retains watcher causes that arrived while the failed
@@ -287,15 +311,18 @@ async function rebuildAndBroadcast(patrol = false): Promise<void> {
287
311
  continue
288
312
  }
289
313
  const buildMs = Date.now() - t0
314
+ if (servedSessionProjection) traceLatency('session-projection-complete', { patrol: validate })
290
315
  const boardJson = JSON.stringify(board)
291
316
  const { units, ok } = unitize(board as Record<string, unknown>)
292
317
  const tag = tagOf(units)
293
- // the trigger set describes who caused THIS rebuild, so it is consumed by the rebuild — not by the
294
- // broadcast. Clearing it only when content moved let a no-op fire (every poller's first sample is
295
- // one) leave its tag behind forever, and the next genuine patrol repair then read as leaf-signalled
296
- // and went silent — the alarm suppressing itself on the very machines that need it.
318
+ // A session-first frame consumes its own cause, but a full/patrol cause remains owed until structural
319
+ // convergence. Otherwise the first cheap projection would erase patrol accountability before the full
320
+ // result could name it. A normal frame consumes its whole trigger set, including a no-op frame.
297
321
  const tags = [...triggerTags]
298
322
  triggerTags.clear()
323
+ if (servedSessionProjection)
324
+ for (const tag of tags) if (tag === 'full' || tag === 'patrol') triggerTags.add(tag)
325
+ if (sessionsFirst && full) dirty = true
299
326
  if (tag === lastTag) continue
300
327
  // the changed unit keys — computed against the prior anchor when we have one (a first paint has no
301
328
  // anchor, so no repair claim can be made against it).
@@ -314,6 +341,7 @@ async function rebuildAndBroadcast(patrol = false): Promise<void> {
314
341
  // (stopSourcesIfIdle cleared the anchor; leaving lastTag/lastUnits stale-cleared is consistent —
315
342
  // rebuilds only run while delta subscribers exist, so nothing chains from them meanwhile).
316
343
  if (deltaSubs.size) { lastUnits = ok ? units : null; lastTag = tag; lastFullFrame = fullFrame }
344
+ traceLatency('broadcast', { event: frame.event, sessionProjection: servedSessionProjection, tags, changedKeys })
317
345
  for (const send of [...deltaSubs]) { try { send(frame) } catch { /* swept on abort */ } }
318
346
  for (const n of [...plainSubs]) { try { n() } catch { /* swept on abort */ } }
319
347
  // ---- repair accounting: a real (tag-moved) broadcast whose ONLY trigger was the cold-tick patrol
@@ -329,18 +357,21 @@ async function rebuildAndBroadcast(patrol = false): Promise<void> {
329
357
  }
330
358
 
331
359
  // a merge/launch/close touches several record files at once; collapse the burst into ONE signal. Each call
332
- // carries its change SCOPE; the window accumulates the MAX ([[graph-cache]] escalates none→sessions→full).
360
+ // carries its own change SCOPE: full and sessions are independent obligations, not a max-scope replacement.
333
361
  // With delta subscribers the debounced fire rebuilds and broadcasts (plain subs then ride the same
334
362
  // tag-moved gate — no spurious refetches); without them it stays the zero-build legacy notify.
335
363
  function fireChanged(scope: Scope = 'full', evalTarget?: EvalTarget): void {
336
364
  // Advance eval input generations BEFORE invalidating/building the board, so the first frame caused by an
337
365
  // input event is `updating(lastKnown)`. Summary completion calls this function without a target.
366
+ if (scope === 'sessions') traceLatency('sessions-signal')
338
367
  if (evalTarget) invalidateSessionEvalProjections(evalTarget)
339
- pendingScope = maxScope(pendingScope, scope)
340
- // invalidate the route's board cache ([[graph-cache]]) on EVERY change signal, at the accumulated scope,
368
+ const pending = addPendingGraphChange({ full: pendingFull, sessions: pendingSessions }, scope)
369
+ pendingFull = pending.full
370
+ pendingSessions = pending.sessions
371
+ // invalidate the route's board cache ([[graph-cache]]) on EVERY change signal at its OWN scope,
341
372
  // before the debounce guard — a plain-mode client that polls /api/graph (no delta rebuild here) must
342
373
  // still see fresh data on its next poll, and a delta rebuild below re-reads the same now-stale cache.
343
- invalidateBoard(pendingScope)
374
+ invalidateBoard(scope)
344
375
  triggerTags.add(scope)
345
376
  // DEBOUNCE = 25ms. Real fs-event bursts (a merge touching many records) were MEASURED to span 0–5ms, so a
346
377
  // 25ms window collapses them with room to spare while shaving ~125ms off the old 150ms lag; anything
@@ -349,8 +380,10 @@ function fireChanged(scope: Scope = 'full', evalTarget?: EvalTarget): void {
349
380
  if (debounce) return
350
381
  debounce = setTimeout(() => {
351
382
  debounce = null
352
- pendingScope = null
353
- if (deltaSubs.size) void rebuildAndBroadcast()
383
+ const full = pendingFull, sessions = pendingSessions
384
+ pendingFull = false
385
+ pendingSessions = false
386
+ if (deltaSubs.size) void rebuildAndBroadcast(false, sessions, full)
354
387
  else for (const notify of [...plainSubs]) { try { notify() } catch { /* swept on abort */ } }
355
388
  }, 25)
356
389
  }
@@ -39,8 +39,8 @@ the rest, you don't hand-author the spec tree or wire the dashboard yourself.
39
39
  Look these up on demand — the formats an agent authors, and the settings it configures:
40
40
  spex guide spec the spec.md format (frontmatter + body + the rules lint enforces)
41
41
  spex guide eval the eval.md format (scenario schema + how loss is measured and filed)
42
- spex guide settings the spexcode.json / spexcode.local.json settings (launchers, dashboard icon, lint
43
- budgets, layout) — every field, and which of the two files it belongs in
42
+ spex guide settings the spexcode.json / spexcode.local.json settings (launchers, dashboard icon, upload,
43
+ lint budgets, layout) — every field, and which of the two files it belongs in
44
44
  spex guide footprint the footprint model — what SpexCode plants in a repo, and who sees it
45
45
  (committed | ignored | hidden), and every migration recipe`
46
46
 
@@ -252,21 +252,23 @@ code file or the scenario (the eval.md) moves since it was filed.
252
252
  spex eval scenario ls [<node>] the declared contracts; text --unmeasured = blind-spot worklist; --json = canonical declaration index
253
253
  spex eval clean GC the content-addressed evidence cache`
254
254
 
255
+ const UPLOAD_DEFAULTS = uploadPolicyDefaults()
256
+
255
257
  const SETTINGS = `spex guide settings — SpexCode's runtime settings (spexcode.json / spexcode.local.json)
256
258
 
257
259
  SpexCode reads PROJECT runtime settings from TWO optional JSON files at the repo root. There is no imperative
258
260
  settings verb — an agent CONFIGURES SpexCode by EDITING these files directly. The two split by
259
261
  PORTABILITY, and picking the right one is the whole discipline:
260
262
 
261
- spexcode.json COMMITTED — portable, shared by everyone on the repo. Layout, policy, dashboard
262
- identity and launcher visibility, lint policy, resource and doctor health budgets, launcher NAMES. "Git is the database": tracked so the
263
+ spexcode.json COMMITTED — portable, shared by everyone on the repo. Layout, upload policy,
264
+ dashboard identity and launcher visibility, lint policy, resource and doctor health budgets, launcher NAMES. "Git is the database": tracked so the
263
265
  team shares ONE configuration.
264
266
  spexcode.local.json GITIGNORED — host-specific, never committed. Absolute launcher paths, cert/secret
265
267
  paths. Layered OVER spexcode.json (see MERGE
266
268
  below); a targeted env override (SPEXCODE_CODEX_SERVER_CMD, …) still wins at its read site.
267
269
 
268
270
  Rule of thumb — is the value TRUE FOR THE PROJECT or TRUE FOR THIS MACHINE? A branch name, a dashboard
269
- icon or launcher-visibility policy, lint policy, resource and doctor health budgets, and a launcher's name+harness are project facts → committed spexcode.json. The ABSOLUTE
271
+ icon or launcher-visibility policy, upload policy, lint policy, resource and doctor health budgets, and a launcher's name+harness are project facts → committed spexcode.json. The ABSOLUTE
270
272
  PATH of a launcher wrapper or a TLS cert path are machine facts → gitignored spexcode.local.json.
271
273
  Both files are optional; omit any field to take its default, except \`sessions.defaultLauncher\` when using
272
274
  \`spex session new\` or the dashboard without an explicit launcher choice.
@@ -275,7 +277,7 @@ The host-wide gateway has one separate per-user setting, \`gateway.icon\` in
275
277
  \`$SPEXCODE_HOME/config.json\`. It is documented below and never belongs to either project file.
276
278
 
277
279
  MERGE: spexcode.local.json is layered over spexcode.json ONE LEVEL DEEP — per top-level section (dashboard,
278
- sessions, …), the two objects are shallow-merged with LOCAL WINNING per key; sections only one file names
280
+ uploads, sessions, …), the two objects are shallow-merged with LOCAL WINNING per key; sections only one file names
279
281
  pass through untouched. This is exactly what lets a launcher's portable NAME reference (defaultLauncher)
280
282
  sit in the committed file while its host-specific DEFINITION (with the abs cmd) sits in the local file —
281
283
  see LAUNCHERS.
@@ -336,6 +338,26 @@ The report is read-only: \`spex session resources [--json]\`. Reclaim eligibilit
336
338
  projection never issues mutation authority or signals a process. Budgets are project policy and belong in committed spexcode.json;
337
339
  host-specific tuning may override this top-level section in spexcode.local.json.
338
340
 
341
+ ── UPLOADS (spexcode.json — portable transfer policy; local overrides are useful for one machine's disk/network) ──
342
+ uploads.maxBytes maximum bytes in one attached file. Default ${UPLOAD_DEFAULTS.maxBytes}.
343
+ uploads.chunkBytes maximum raw PATCH body and client slice size. Default ${UPLOAD_DEFAULTS.chunkBytes}.
344
+ uploads.concurrency simultaneous attachment streams from one dashboard batch. Default ${UPLOAD_DEFAULTS.concurrency}.
345
+ uploads.requestTimeoutMs browser timeout for one chunk or completion request. Default ${UPLOAD_DEFAULTS.requestTimeoutMs}.
346
+ uploads.retryLimit automatic retries after the initial transient chunk failure. Default ${UPLOAD_DEFAULTS.retryLimit}.
347
+ uploads.retryDelayMs wait between those retries. Default ${UPLOAD_DEFAULTS.retryDelayMs}.
348
+ uploads.incompleteTtlMs idle staging lifetime before an unfinished transfer expires. Default ${UPLOAD_DEFAULTS.incompleteTtlMs}.
349
+ uploads.cleanupIntervalMs stale-staging reaper cadence. Default ${UPLOAD_DEFAULTS.cleanupIntervalMs}.
350
+ uploads.minFreeBytes bytes retained on the backend filesystem while reserving a new attachment.
351
+ Default ${UPLOAD_DEFAULTS.minFreeBytes}.
352
+ uploads.evidenceMaxBytes retained POST-body ceiling for eval evidence. Default ${UPLOAD_DEFAULTS.evidenceMaxBytes}.
353
+ All fields are positive integers except retryLimit, retryDelayMs, and minFreeBytes, which may be zero.
354
+ The seed template is the one default source; omit a field to use it. The backend reads the merged files for
355
+ each transfer and cleanup pass. The dashboard receives chunk, concurrency, timeout, and retry policy from
356
+ the create/status response, so a portable project policy takes effect without another browser setting.
357
+ Put team-wide transfer policy in committed spexcode.json; use the same keys in gitignored spexcode.local.json
358
+ only when the backend machine needs a local override. The normal top-level shallow merge lets a local
359
+ uploads.chunkBytes replace only that key.
360
+
339
361
  ── LAUNCHERS (the profile block, split across the two files) ──
340
362
  A named launcher profile fixes BOTH a session's harness AND its exact launch command; a create picks one
341
363
  by name with --launcher/the dashboard dropdown, and the chosen name is persisted on the record so a resume
@@ -566,3 +588,4 @@ export function guideText(topic?: string): string | null {
566
588
  const t = TOPICS[topic]
567
589
  return t ? t + FOOTER : null
568
590
  }
591
+ import { uploadPolicyDefaults } from './layout.js'