spexcode 0.5.7 → 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 +199 -60
  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 +42 -30
  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
@@ -116,21 +116,13 @@ const spexShimRuntime = (cfg) => {
116
116
  return args
117
117
  }
118
118
 
119
- // the per-session rendezvous control socket: bind a line-JSON server on CLAUDE_BG_RENDEZVOUS_SOCK (handed
120
- // by every ownsRendezvous launch; a self-launched bare harness has no env no server) speaking the
121
- // reclaude mini-protocol, so claude's deliverViaRendezvous and socket-listener liveness work UNCHANGED.
122
- // MULTI-connection (unlike reclaude's daemon): a session-liveness probe connect can never kick a concurrent
123
- // delivery, so the sender's atomic reply+repaint chunk always resolves on its own connection. The data
124
- // handler is deliberately SYNCHRONOUS — a chunk's lines parse in one pass and repaint-done (the in-order
125
- // parse barrier) flushes before any other event can run: confirmation means PARSED, not processed; the
126
- // injection (a whole model turn on some hosts) runs BEHIND the confirm. A known-unable inject answers
127
- // reply-rejected BEFORE repaint-done so the sender fails loud instead of confirming a prompt that can
128
- // never land; a LATE inject failure best-effort reply-rejects (the sender has usually resolved and gone).
119
+ // The per-session rendezvous control socket is a best-effort same-turn poke. The timeline remains the
120
+ // message's durable copy, so this listener only receives reply lines and never confirms or rejects them.
129
121
  const serveRendezvous = (inject, opts) => {
130
122
  const sock = (process.env.CLAUDE_BG_RENDEZVOUS_SOCK || "").trim()
131
123
  if (!sock) return null
132
124
  try { __spexUnlink(sock) } catch { /* no stale socket — fine */ }
133
- const reject = (c) => { try { c.write(JSON.stringify({ type: "reply-rejected" }) + "\\n") } catch { /* peer gone */ } }
125
+ const injected = new Set()
134
126
  const server = __spexCreateServer((c) => {
135
127
  let buf = ""
136
128
  c.on("error", () => { /* probes disconnect abruptly — expected */ })
@@ -143,13 +135,14 @@ const spexShimRuntime = (cfg) => {
143
135
  let msg
144
136
  try { msg = JSON.parse(line) } catch { continue }
145
137
  if (msg && msg.type === "reply" && typeof msg.text === "string") {
146
- if (opts && opts.canInject && !opts.canInject()) { reject(c); continue }
138
+ if (opts && opts.canInject && !opts.canInject()) continue
139
+ const mid = typeof msg.mid === "string" ? msg.mid : ""
140
+ if (mid && injected.has(mid)) continue
141
+ if (mid) injected.add(mid)
147
142
  try {
148
143
  const p = inject(msg.text)
149
- if (p && typeof p.catch === "function") p.catch(() => reject(c))
150
- } catch { reject(c) }
151
- } else if (msg && msg.type === "repaint") {
152
- try { c.write(JSON.stringify({ type: "repaint-done" }) + "\\n") } catch { /* peer gone */ }
144
+ if (p && typeof p.catch === "function") p.catch(() => { if (mid) injected.delete(mid) })
145
+ } catch { if (mid) injected.delete(mid) }
153
146
  }
154
147
  }
155
148
  })
@@ -2,7 +2,7 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs'
2
2
  import { readFile, readdir } from 'node:fs/promises'
3
3
  import { join, relative, basename } from 'node:path'
4
4
  import { repoRoot, historyIndex, rowsFor, historyStats, pathsStats, driftIndex, driftFor, fileDiffAt,
5
- sourceIndexes, treeTextFiles, type HistoryIndex, type DriftIndex } from './git.js'
5
+ sourceIndexes, treeTextFiles, primeAncestorClosures, ancestorsOf, inAncestors, type HistoryIndex, type DriftIndex } from './git.js'
6
6
  import { parseCodeEntry, parseRelation, relationClaimsPath } from './anchors.js'
7
7
 
8
8
  // a node is any directory under .spec holding a spec.md; its parent is the nearest ancestor that also holds one.
@@ -257,9 +257,30 @@ export async function loadSpecs(root: string = ROOT, options: LoadSpecsOptions =
257
257
  options.drift === null ? Promise.resolve(null) : options.drift ?? driftIndex(root, tip),
258
258
  ])
259
259
  const [[idx, didx], allRaws] = await Promise.all([indexes, rawsAsync(root, tip, options.snapshot)])
260
+ const prepared = allRaws.map((r) => ({
261
+ r,
262
+ h: idx ? rowsFor(idx, r.relPath) : [],
263
+ codeRel: parseRelation(list(r.fm.code), 'code'),
264
+ relatedRel: parseRelation(list(r.fm.related), 'related'),
265
+ }))
266
+ if (didx) {
267
+ const queries: { hash: string; node: string }[] = []
268
+ for (const { r, h, codeRel, relatedRel } of prepared) {
269
+ if (!h[0]?.hash || (!codeRel.entries.length && !relatedRel.entries.some((entry) => !entry.selectors.length))) continue
270
+ queries.push({ hash: h[0].hash, node: r.id })
271
+ }
272
+ primeAncestorClosures(didx, queries.map(({ hash }) => hash))
273
+ // Only an ack named for this node and outside its version's ancestry becomes a cover. Discover that
274
+ // exact roster from the now-primed bases instead of retaining closures for older, non-covering acks.
275
+ const covers: string[] = []
276
+ for (const [hash, nodes] of didx.acks) if (queries.some(({ hash: baseHash, node }) => {
277
+ const base = ancestorsOf(didx, baseHash)
278
+ return !!base && nodes.has(node) && !inAncestors(didx, base, hash)
279
+ })) covers.push(hash)
280
+ primeAncestorClosures(didx, covers)
281
+ }
260
282
  const loaded = []
261
- for (const r of allRaws) {
262
- const h = idx ? rowsFor(idx, r.relPath) : []
283
+ for (const { r, h, codeRel, relatedRel } of prepared) {
263
284
  // session = the Session: trailer of the node's latest version; frontmatter `session:` is the fallback.
264
285
  const fmSession = str(r.fm.session)
265
286
  const session = h[0]?.session || (fmSession && fmSession !== 'null' ? fmSession : null)
@@ -268,12 +289,12 @@ export async function loadSpecs(root: string = ROOT, options: LoadSpecsOptions =
268
289
  // drift, claims, eval attribution — expects, file-level as before), the scoped entries (path +
269
290
  // selectors) ride separately for lint's anchor engine, and structural problems (duplicates,
270
291
  // bare/scoped mixing, glob selectors, the code cap) surface as lint integrity errors.
271
- const codeRel = parseRelation(list(r.fm.code), 'code')
272
- const relatedRel = parseRelation(list(r.fm.related), 'related')
273
- const code = codeRel.entries.map((e) => e.path)
274
- const codeScoped = codeRel.entries.filter((e) => e.selectors.length > 0)
275
- const related = relatedRel.entries.map((e) => e.path)
276
- const relatedScoped = relatedRel.entries.filter((e) => e.selectors.length > 0)
292
+ const codeEntries = codeRel.entries
293
+ const code = codeEntries.map((e) => e.path)
294
+ const codeScoped = codeEntries.filter((e) => e.selectors.length > 0)
295
+ const relatedEntries = relatedRel.entries
296
+ const related = relatedEntries.map((e) => e.path)
297
+ const relatedScoped = relatedEntries.filter((e) => e.selectors.length > 0)
277
298
  const relationProblems = [...codeRel.problems, ...relatedRel.problems]
278
299
  const S = h[0]?.hash || ''
279
300
  const driftFiles = []
@@ -287,7 +308,7 @@ export async function loadSpecs(root: string = ROOT, options: LoadSpecsOptions =
287
308
  // A SCOPED related entry is excluded here: its file-level movement is silent by design — only a
288
309
  // selector HIT warns, and that verdict needs the anchor engine, so lint derives it, not the loader.
289
310
  const relatedDriftFiles = []
290
- for (const e of relatedRel.entries) {
311
+ for (const e of relatedEntries) {
291
312
  if (e.selectors.length) continue
292
313
  const d = didx ? { file: e.path, behind: driftFor(didx, S, e.path, r.id) } : { file: e.path, behind: 0 }
293
314
  if (d.behind > 0) relatedDriftFiles.push(d)
@@ -304,8 +325,10 @@ export async function loadSpecs(root: string = ROOT, options: LoadSpecsOptions =
304
325
  hue: Number(str(r.fm.hue, '210')),
305
326
  desc: str(r.fm.desc),
306
327
  code,
328
+ codeEntries,
307
329
  codeScoped,
308
330
  related,
331
+ relatedEntries,
309
332
  relatedScoped,
310
333
  relationProblems,
311
334
  version: h.length,
@@ -8,15 +8,6 @@ import { resolveHarnessTargets } from './harness-select.js'
8
8
  import { loadSkillConfig, loadAgentConfig } from './specs.js'
9
9
  import { dematerialize } from './materialize.js'
10
10
 
11
- // @@@ spex-uninstall - materialize(∅) plus the store: the in-tree/global-config backout IS dematerialize (the
12
- // same identity-stamped erase phase every materialize runs first — the forgetting law's empty policy), and this
13
- // command adds only what a materialize never owns per-run: the global per-project store, the plugin-bundle sweep,
14
- // and the optional git hooks. EVERY removal is gated on a SpexCode IDENTITY STAMP (the managed-block
15
- // sentinels, the shim's own dispatch.sh command line, the trust sentinels, the generated mark / name-scoped
16
- // on-demand paths, the plugin name stamp), so it can only ever delete what SpexCode itself generated. The one
17
- // inviolable rule: the user's tracked intent ASSET (.spec/.plugins + spexcode.json) is NEVER touched — uninstall
18
- // removes only generated WIRING and local runtime state, not the spec graph and adoption config they served.
19
-
20
11
  // the standard plugin-host folders a host agent scans (in addition to any named in spexcode.json's `harnesses`).
21
12
  const DEFAULT_PLUGIN_HOSTS = ['.claude', '.codex', '.zcode'] as const
22
13
 
@@ -1,31 +1,253 @@
1
- import { mkdirSync, writeFileSync } from 'node:fs'
1
+ import { randomUUID } from 'node:crypto'
2
+ import { createWriteStream, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, statfsSync, truncateSync, writeFileSync } from 'node:fs'
2
3
  import { tmpdir } from 'node:os'
3
4
  import { basename, join } from 'node:path'
5
+ import { Readable, Transform } from 'node:stream'
6
+ import { pipeline } from 'node:stream/promises'
7
+ import type { ReadableStream as NodeReadableStream } from 'node:stream/web'
8
+ import { repoRoot } from './git.js'
9
+ import { readUploadPolicy, type UploadPolicy } from './layout.js'
4
10
 
5
- // the backend's tmpdir same /tmp home as the rendezvous sockets (sessions.ts), shared with the worker.
11
+ // The backend's tmpdir is the worker's filesystem. Completed files stay directly under this sink; private
12
+ // metadata and partial bytes live below .staging until an exact-length transfer is promoted atomically.
6
13
  const UPLOAD_DIR = join(tmpdir(), 'spexcode-uploads')
14
+ const STAGING_DIR = join(UPLOAD_DIR, '.staging')
7
15
 
8
- // a generous ceiling so a stray huge upload can't quietly fill /tmp — over it we fail loud (the route 413s)
9
- // rather than write. Real screenshots/attachments sit far under this.
10
- export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024
16
+ type UploadMeta = {
17
+ version: 1
18
+ id: string
19
+ name: string
20
+ size: number
21
+ offset: number
22
+ createdAt: number
23
+ updatedAt: number
24
+ }
25
+
26
+ export type UploadStatus = Pick<UploadMeta, 'id' | 'name' | 'size' | 'offset'> & Pick<UploadPolicy,
27
+ 'chunkBytes' | 'concurrency' | 'requestTimeoutMs' | 'retryLimit' | 'retryDelayMs'>
28
+
29
+ export class UploadError extends Error {
30
+ constructor(readonly status: number, message: string, readonly offset?: number) {
31
+ super(message)
32
+ }
33
+ }
34
+
35
+ const uploadPolicy = (): UploadPolicy => readUploadPolicy(repoRoot())
36
+
37
+ export const evidenceMaxBytes = (): number => uploadPolicy().evidenceMaxBytes
38
+
39
+ function ensureDirs(): void {
40
+ mkdirSync(STAGING_DIR, { recursive: true })
41
+ }
11
42
 
12
- // strip a client-supplied filename to a safe basename: no directory parts, only [A-Za-z0-9._-], no leading
13
- // dots — so a crafted name (`../../etc/x`, `.bashrc`) can never escape UPLOAD_DIR. The extension is kept for
14
- // readability; an empty/exotic name falls back to a generic stem.
15
43
  function safeName(name: string): string {
16
44
  const base = basename(name || '').replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '')
17
45
  return base || 'upload'
18
46
  }
19
47
 
20
- let seq = 0
48
+ function validId(id: string): boolean {
49
+ return /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(id)
50
+ }
51
+
52
+ function metaPath(id: string): string {
53
+ return join(STAGING_DIR, `${id}.json`)
54
+ }
55
+
56
+ function partPath(id: string): string {
57
+ return join(STAGING_DIR, `${id}.part`)
58
+ }
59
+
60
+ function removeTransfer(id: string): void {
61
+ rmSync(metaPath(id), { force: true })
62
+ rmSync(partPath(id), { force: true })
63
+ }
64
+
65
+ function readMetaFile(id: string): UploadMeta | null {
66
+ if (!validId(id)) return null
67
+ try {
68
+ const meta = JSON.parse(readFileSync(metaPath(id), 'utf8')) as UploadMeta
69
+ if (meta.version !== 1 || meta.id !== id || typeof meta.name !== 'string' ||
70
+ !Number.isSafeInteger(meta.size) || meta.size <= 0 ||
71
+ !Number.isSafeInteger(meta.offset) || meta.offset < 0 || meta.offset > meta.size ||
72
+ !Number.isSafeInteger(meta.createdAt) || !Number.isSafeInteger(meta.updatedAt)) return null
73
+ return meta
74
+ } catch {
75
+ return null
76
+ }
77
+ }
78
+
79
+ function writeMeta(meta: UploadMeta): void {
80
+ const path = metaPath(meta.id)
81
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`
82
+ writeFileSync(temp, JSON.stringify(meta) + '\n')
83
+ renameSync(temp, path)
84
+ }
85
+
86
+ function actualOffset(meta: UploadMeta): number {
87
+ try {
88
+ return statSync(partPath(meta.id)).size
89
+ } catch {
90
+ return 0
91
+ }
92
+ }
93
+
94
+ function status(meta: UploadMeta, policy: UploadPolicy): UploadStatus {
95
+ const { chunkBytes, concurrency, requestTimeoutMs, retryLimit, retryDelayMs } = policy
96
+ return { id: meta.id, name: meta.name, size: meta.size, offset: meta.offset, chunkBytes, concurrency, requestTimeoutMs, retryLimit, retryDelayMs }
97
+ }
98
+
99
+ function syncOffset(meta: UploadMeta): UploadMeta {
100
+ const offset = actualOffset(meta)
101
+ if (offset > meta.size) throw new UploadError(409, 'upload staging file exceeds its declared length')
102
+ if (offset !== meta.offset) {
103
+ meta.offset = offset
104
+ meta.updatedAt = Date.now()
105
+ writeMeta(meta)
106
+ }
107
+ return meta
108
+ }
109
+
110
+ function loadUpload(id: string, policy: UploadPolicy): UploadMeta {
111
+ const meta = readMetaFile(id)
112
+ if (!meta) throw new UploadError(404, 'upload not found')
113
+ if (Date.now() - meta.updatedAt > policy.incompleteTtlMs) {
114
+ removeTransfer(id)
115
+ throw new UploadError(404, 'upload expired')
116
+ }
117
+ return syncOffset(meta)
118
+ }
119
+
120
+ function reservedBytes(policy: UploadPolicy): number {
121
+ let total = 0
122
+ for (const entry of readdirSync(STAGING_DIR, { withFileTypes: true })) {
123
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue
124
+ const meta = readMetaFile(entry.name.slice(0, -5))
125
+ if (!meta || Date.now() - meta.updatedAt > policy.incompleteTtlMs) continue
126
+ total += Math.max(0, meta.size - actualOffset(meta))
127
+ }
128
+ return total
129
+ }
130
+
131
+ function reserveCapacity(size: number, policy: UploadPolicy): void {
132
+ let available = 0
133
+ try {
134
+ const fs = statfsSync(UPLOAD_DIR)
135
+ available = Number(fs.bavail) * Number(fs.bsize)
136
+ } catch {
137
+ // A filesystem that cannot report capacity will still fail loudly during the stream write.
138
+ return
139
+ }
140
+ if (size > Math.max(0, available - policy.minFreeBytes - reservedBytes(policy))) {
141
+ throw new UploadError(507, 'insufficient backend disk capacity for this upload')
142
+ }
143
+ }
144
+
145
+ export function cleanupExpiredUploads(policy = uploadPolicy()): void {
146
+ ensureDirs()
147
+ const now = Date.now()
148
+ for (const entry of readdirSync(STAGING_DIR, { withFileTypes: true })) {
149
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue
150
+ const id = entry.name.slice(0, -5)
151
+ const meta = readMetaFile(id)
152
+ if (!meta || now - meta.updatedAt > policy.incompleteTtlMs) removeTransfer(id)
153
+ }
154
+ for (const entry of readdirSync(STAGING_DIR, { withFileTypes: true })) {
155
+ if (!entry.isFile() || !entry.name.endsWith('.part')) continue
156
+ const id = entry.name.slice(0, -5)
157
+ if (readMetaFile(id)) continue
158
+ try {
159
+ if (now - statSync(partPath(id)).mtimeMs > policy.incompleteTtlMs) rmSync(partPath(id), { force: true })
160
+ } catch { /* raced a cancellation or another cleanup pass */ }
161
+ }
162
+ }
163
+
164
+ export function startUploadReaper(): void {
165
+ const sweep = () => {
166
+ const policy = uploadPolicy()
167
+ cleanupExpiredUploads(policy)
168
+ const timer = setTimeout(sweep, policy.cleanupIntervalMs)
169
+ timer.unref()
170
+ }
171
+ sweep()
172
+ }
173
+
174
+ export function createUpload(name: unknown, size: unknown): UploadStatus {
175
+ const policy = uploadPolicy()
176
+ if (typeof name !== 'string' || !name.trim()) throw new UploadError(400, 'file name is required')
177
+ if (typeof size !== 'number' || !Number.isSafeInteger(size) || size <= 0) throw new UploadError(400, 'file must not be empty')
178
+ if (size > policy.maxBytes) throw new UploadError(413, 'file exceeds the configured upload limit')
179
+ ensureDirs()
180
+ cleanupExpiredUploads(policy)
181
+ reserveCapacity(size, policy)
182
+ const now = Date.now()
183
+ const meta: UploadMeta = { version: 1, id: randomUUID(), name: safeName(name), size, offset: 0, createdAt: now, updatedAt: now }
184
+ writeMeta(meta)
185
+ return status(meta, policy)
186
+ }
187
+
188
+ export function uploadStatus(id: string): UploadStatus {
189
+ const policy = uploadPolicy()
190
+ return status(loadUpload(id, policy), policy)
191
+ }
192
+
193
+ export async function appendUpload(id: string, offset: unknown, body: ReadableStream<Uint8Array> | null, contentLength?: string): Promise<UploadStatus> {
194
+ const policy = uploadPolicy()
195
+ const meta = loadUpload(id, policy)
196
+ if (typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0) throw new UploadError(400, 'upload offset must be a non-negative integer')
197
+ if (offset !== meta.offset) throw new UploadError(409, 'upload offset does not match the committed bytes', meta.offset)
198
+ if (!body) throw new UploadError(400, 'upload chunk is required')
199
+ const declared = contentLength == null ? null : Number(contentLength)
200
+ if (declared != null && (!Number.isSafeInteger(declared) || declared <= 0)) throw new UploadError(400, 'content-length must be a positive integer')
201
+ if (declared != null && (declared > policy.chunkBytes || meta.offset + declared > meta.size)) {
202
+ throw new UploadError(413, 'upload chunk exceeds its declared bounds')
203
+ }
204
+
205
+ let written = 0
206
+ const before = meta.offset
207
+ const limiter = new Transform({
208
+ transform(chunk, _encoding, callback) {
209
+ written += chunk.length
210
+ if (written > policy.chunkBytes || before + written > meta.size) {
211
+ callback(new UploadError(413, 'upload chunk exceeds its declared bounds'))
212
+ return
213
+ }
214
+ callback(null, chunk)
215
+ },
216
+ })
217
+ try {
218
+ await pipeline(Readable.fromWeb(body as unknown as NodeReadableStream), limiter, createWriteStream(partPath(id), { flags: 'a' }))
219
+ } catch (error) {
220
+ if (error instanceof UploadError) {
221
+ truncateSync(partPath(id), before)
222
+ throw error
223
+ }
224
+ // An interrupted connection may have committed a valid prefix. The next GET reports its actual length.
225
+ meta.offset = actualOffset(meta)
226
+ meta.updatedAt = Date.now()
227
+ writeMeta(meta)
228
+ throw new UploadError(500, `upload write failed: ${(error as Error).message}`)
229
+ }
230
+ if (written === 0) throw new UploadError(400, 'upload chunk is empty')
231
+ meta.offset = actualOffset(meta)
232
+ meta.updatedAt = Date.now()
233
+ writeMeta(meta)
234
+ return status(meta, policy)
235
+ }
236
+
237
+ export function completeUpload(id: string): string {
238
+ const meta = loadUpload(id, uploadPolicy())
239
+ if (meta.offset !== meta.size) throw new UploadError(409, `upload is incomplete (${meta.offset} of ${meta.size} bytes)`, meta.offset)
240
+ const path = join(UPLOAD_DIR, `${Date.now().toString(36)}-${meta.id}-${safeName(meta.name)}`)
241
+ try {
242
+ renameSync(partPath(id), path)
243
+ rmSync(metaPath(id), { force: true })
244
+ return path
245
+ } catch (error) {
246
+ throw new UploadError(500, `upload completion failed: ${(error as Error).message}`)
247
+ }
248
+ }
21
249
 
22
- // write one uploaded file into UPLOAD_DIR under a collision-proof `<time>-<seq>-<name>` stem and return its
23
- // absolute path — the string the dashboard splices into the prompt. Creates the dir on first use.
24
- export async function saveUpload(file: File): Promise<string> {
25
- mkdirSync(UPLOAD_DIR, { recursive: true })
26
- const buf = Buffer.from(await file.arrayBuffer())
27
- const stamp = `${Date.now().toString(36)}-${(seq++).toString(36)}`
28
- const path = join(UPLOAD_DIR, `${stamp}-${safeName(file.name)}`)
29
- writeFileSync(path, buf)
30
- return path
250
+ export function cancelUpload(id: string): void {
251
+ loadUpload(id, uploadPolicy())
252
+ removeTransfer(id)
31
253
  }
@@ -2,22 +2,6 @@ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync } fro
2
2
  import { dirname, join } from 'node:path'
3
3
  import { git } from './git.js'
4
4
 
5
- // @@@ worktree-sources ([[residence]]) - a fresh session worktree is fed by THREE transports, one per
6
- // source kind, and the kind decides the transport — never a mode branch:
7
- // - TRACKED project state (`.spec`, `spexcode.json`) arrives by GIT CHECKOUT: the sources are always
8
- // tracked (git is the database), so `git worktree add` alone delivers them. No symlink — a link is a
9
- // WRITE-SEMANTICS declaration (write-through to the main tree), and spec writes go back through the
10
- // branch/merge ritual, not through a side channel.
11
- // - MATERIALIZED ARTIFACTS (contract blocks, shims, skills) are DERIVED — transported by re-materialize,
12
- // not by link or copy:
13
- // sessions.ts materializes into the worktree at creation, and the git-native anchors (pre-commit /
14
- // post-checkout / post-merge — [[commit-surgery]]) re-materialize on change.
15
- // - HOST state (`spexcode.local.json`, machine-local and never tracked) is COPIED — a snapshot: the worker
16
- // reads the same launchers/policy the host had at dispatch, but its writes land on its own copy and die
17
- // with the worktree, never on the host's real config (a worker once wrote "its" test config through the
18
- // old symlink and wiped the host's launchers → every later dispatch 401'd).
19
- // This module owns only the third transport (plus hiding what it seeds). A failure degrades that worker
20
- // (default launchers/policy), so it is reported, not swallowed.
21
5
  export function seedWorktreeHostState(main: string, wt: string): void {
22
6
  const f = 'spexcode.local.json'
23
7
  try {
@@ -5,4 +5,4 @@ status: active
5
5
  hue: 280
6
6
  desc: Launch a supervisor agent that manages other agents from the main checkout to drive a goal to completion.
7
7
  ---
8
- You are a SpexCode supervisor — a **manager**, not a feature worker. Your work base is the main checkout (the repository root), NOT your own worktree: do all git via `git -C <root>`, everything else via the `spex` CLI, and never write feature code. This preset IS your complete playbook (dispatch → monitor → review → merge → close, and how to parallelize) — the CLI's own `spex help session` is the reference for every verb's exact semantics. Drive the goal: decompose it into worker-sized tasks and dispatch one worker per independent task (`spex session new "<task>"` — give each ONLY its task; a task about one specific node mentions it as `[[<id>]]`, which only sets the branch name and board attribution; the session's real node links come from what it edits), monitor with `spex session watch`, review proposals with `spex session review <id>`, dispatch the merge of good ones back to their own session (`spex session merge <id>`; the doer syncs the base into its own worktree first, so what reaches `<root>` is a trivial merge) and confirm it landed, then close. `<root>` is the fleet's ONE landing door — it takes one landing at a time, so a worker whose merge finds it mid-merge waits rather than racing, and you never fix up another lane's half-merged index yourself. Never let a worker self-merge; keep `spex spec lint` at 0 errors. To READ a worker's current state, use the one-shot snapshots (`spex session review <id>` or `spex session ls` — both return immediately); to WAIT for a worker, background `spex session wait <id>` — it is edge-triggered: it returns only when it OBSERVES the worker transition from non-actionable into an actionable status (an already-actionable arrival state does not return it), printing the observed status path — which is also how you wait for a dispatched merge to actually land; never block on `spex session watch`, which STREAMS forever and will freeze your turn. **Stay parked while your fleet runs:** after dispatching, background a `spex session wait <child>` per worker (per the manager loop) so that when you stop with children still working you land in `parked` — self-resuming on the next child transition — NOT `asking`; only go `asking` when you genuinely need the human. This matters because the dashboard **folds each child under you and shows YOUR own status for the whole group** (session-nesting, no child-status aggregation), so a supervisor that stays parked-while-they-run is what makes that folded group status honest. One footgun that bites a fresh supervisor: before `spex session close <id>`, confirm the merge landed (`git -C <root> log -1` shows HEAD at the new merge commit) — closing an unmerged branch discards the work. **DRAIN THE ISSUES** (issues / local-issues) as part of your loop: `spex issue ls` lists every open concern in one place — the taste concerns finished sessions recorded locally, AND the forge's issues, store-tagged. Cluster the same concern yourself (use judgment — duplicates are a recurrence SIGNAL, not noise; fold them into one) and weigh by recurrence AND novelty — **recurrence is salience, not importance, so never just fix the highest count**: a sharp single-voice concern can outrank a popular gripe. For the ones worth acting on, `spex session new "<task>"` a worker to land it (mention the concern's node as `[[<id>]]` if it has one), then `spex issue ls resolve <id> --as accepted|landed` (or `rejected`, with a reply saying why) so the store reflects the decision. Report progress as you go and when the goal is complete. Your goal follows:
8
+ You are a SpexCode supervisor — a **manager**, not a feature worker. Your work base is the main checkout (the repository root), NOT your own worktree: do all git via `git -C <root>`, everything else via the `spex` CLI, and never write feature code. This preset IS your complete playbook (dispatch → monitor → review → merge → close, and how to parallelize) — the CLI's own `spex help session` is the reference for every verb's exact semantics. Drive the goal: decompose it into worker-sized tasks and dispatch one worker per independent task (`spex session new "<task>"` — give each ONLY its task; a task about one specific node mentions it as `[[<id>]]`, which only sets the branch name and board attribution; the session's real node links come from what it edits), monitor with `spex session watch`, review proposals with `spex session review <id>`, dispatch the merge of good ones back to their own session (`spex session merge <id>`; the doer syncs the base into its own worktree first, so what reaches `<root>` is a trivial merge) and confirm it landed, then close. `<root>` is the fleet's ONE landing door — it takes one landing at a time, so a worker whose merge finds it mid-merge waits rather than racing, and you never fix up another lane's half-merged index yourself. Never let a worker self-merge; keep `spex spec lint` at 0 errors. To READ a worker's current state, use the one-shot snapshots (`spex session review <id>` or `spex session ls` — both return immediately); to WAIT for a worker, background `spex session wait <id>` — it is edge-triggered: it returns only when it OBSERVES the worker transition from non-actionable into an actionable status (an already-actionable arrival state does not return it), printing the observed status path — which is also how you wait for a dispatched merge to actually land; never block on `spex session watch`, which STREAMS forever and will freeze your turn. **Stay parked while your fleet runs:** after dispatching, background a `spex session wait <child>` per worker (per the manager loop) so that when you stop with children still working you land in `parked` — self-resuming on the next child transition — NOT `asking`; only go `asking` when you genuinely need the human. This matters because the dashboard **folds each child under you and shows YOUR own status for the whole group** (session-nesting, no child-status aggregation), so a supervisor that stays parked-while-they-run is what makes that folded group status honest. Two footguns that bite a fresh supervisor. First: before `spex session close <id>`, confirm the merge landed (`git -C <root> log -1` shows HEAD at the new merge commit) — closing an unmerged branch discards the work. Second: `<id>` always names a WORKER YOU DISPATCHED, spelled out — never `.` and never your own id. `.` means the session running the command, so `close .` deletes your own worktree, branch and record mid-turn and takes your fleet's manager down with it; your own ending is a declaration (`done --propose close`), never a close you run on yourself. **DRAIN THE ISSUES** (issues / local-issues) as part of your loop: `spex issue ls` lists every open concern in one place — the taste concerns finished sessions recorded locally, AND the forge's issues, store-tagged. Cluster the same concern yourself (use judgment — duplicates are a recurrence SIGNAL, not noise; fold them into one) and weigh by recurrence AND novelty — **recurrence is salience, not importance, so never just fix the highest count**: a sharp single-voice concern can outrank a popular gripe. For the ones worth acting on, `spex session new "<task>"` a worker to land it (mention the concern's node as `[[<id>]]` if it has one), then `spex issue ls resolve <id> --as accepted|landed` (or `rejected`, with a reply saying why) so the store reflects the decision. Report progress as you go and when the goal is complete. Your goal follows:
@@ -0,0 +1,28 @@
1
+ ---
2
+ title: comment-altitude
3
+ surface: system
4
+ status: active
5
+ hue: 200
6
+ desc: A config plugin — code comments navigate non-obvious local reasoning; specs own product intent and contract.
7
+ ---
8
+ Code comments are navigation aids, not a second specification. The owning spec body is the current contract:
9
+ intent, invariants, externally observable behavior, and product policy belong there once, rather than being
10
+ restated beside each implementation.
11
+
12
+ Before retaining or adding a comment, ask in this order:
13
+
14
+ 1. Does it state intent or a contract? Put that fact in the owning spec body if it is not already there, then
15
+ remove the code comment.
16
+ 2. Does it explain a non-obvious local decision — ordering, a platform or library behavior, a measured pitfall,
17
+ or why an otherwise plausible implementation is unsafe? Keep a short comment beside the decision. It should
18
+ name the fact the reader cannot recover from code alone, not re-explain the whole feature.
19
+ 3. Does it merely translate the next line or restate a type/name? Remove it.
20
+
21
+ Never turn subtraction into amnesia. A measured value, version-specific behavior, rejected alternative, or other
22
+ implementation fact absent from the spec must remain in code unless it is truly product contract, in which case
23
+ move the fact to the spec before deleting the comment. `@@@title - explanation` is reserved for the genuinely
24
+ tricky local reasoning that survives this test; a tag is not a license to repeat the spec.
25
+
26
+ This division gives each information channel one repair path: a changed product promise updates its spec; a changed
27
+ implementation hazard updates its nearby comment; code that makes either false updates both as their distinct facts
28
+ require. The rule complements [[spec-first]]: reading the contract first is what makes redundant code prose visible.
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env bash
2
- # @@@ mark-active - the SINGLE freshness hook, wired to BOTH UserPromptSubmit and PreToolUse. It branches
3
- # on ONE structured signal read straight from the hook payload (stdin JSON), so the state is HARD — never
4
- # text-sniffed from the TUI:
2
+ # @@@ mark-active - the SINGLE turn-boundary hook, wired to BOTH UserPromptSubmit and PreToolUse. It does two
3
+ # jobs, both keyed off the session's global record dir: it keeps the declared FRESHNESS state honest, and it
4
+ # delivers the session's unread MAIL.
5
+ # Freshness branches on ONE structured signal read straight from the hook payload (stdin JSON), so the state is
6
+ # HARD — never text-sniffed from the TUI:
5
7
  # the agent is pausing to ask the HUMAN (hp_is_ask) → status: asking, with the question text as the note
6
8
  # (the deterministic capture of a question).
7
9
  # any other tool, or a prompt submit → the agent is working → status: active (drop a now-stale proposal/note).
@@ -34,6 +36,44 @@ rec="$sdir/session.json"
34
36
  # board-lifecycle gate: only a GOVERNED (dashboard-launched) session has a board state to maintain.
35
37
  grep -q '^[[:space:]]*"governed"[[:space:]]*:[[:space:]]*true,\?$' "$rec" 2>/dev/null || exit 0
36
38
 
39
+ # @@@ the mail read - this hook is the ONE reader of the session's inbox ([[session-timeline]]): a message is
40
+ # delivered by being appended to timeline.ndjson, and the agent finds it here, by mechanism, at a turn
41
+ # boundary — never by remembering to run a command. `cursors.json` names how far this session has been shown
42
+ # ([[session-cursors]]); everything past it is unread, the `sent` lines among it are printed as context, and
43
+ # the cursor advances past ALL of it (a session's own status lines are not mail, but they are consumed, so
44
+ # they can never come back as one).
45
+ # Pure bash builtins, no forks: the common case is "no new lines", and the fast path must not cost a spawn.
46
+ # The read is generous where it cannot be exact — an unparseable cursor reads as 0, which re-shows a message
47
+ # rather than skipping one.
48
+ mail() {
49
+ local tl="$sdir/timeline.ndjson" cur="$sdir/cursors.json" pos=0 i=0 line body unread=""
50
+ [ -f "$tl" ] || return 0
51
+ if [ -f "$cur" ]; then
52
+ while IFS= read -r line; do
53
+ case "$line" in
54
+ *'"inbox":'*) line="${line#*\"inbox\":}"; line="${line%%,*}"; line="${line// /}"; line="${line//$'\t'/}"
55
+ case "$line" in ''|*[!0-9]*) ;; *) pos=$line ;; esac; break ;;
56
+ esac
57
+ done < "$cur"
58
+ fi
59
+ while IFS= read -r line; do
60
+ if [ "$i" -ge "$pos" ]; then
61
+ case "$line" in
62
+ *'"kind":"sent"'*) body=$(hp_field "$line" text)
63
+ [ -n "$body" ] && unread="$unread$body"$'\n\n' ;;
64
+ esac
65
+ fi
66
+ i=$((i + 1))
67
+ done < "$tl"
68
+ # No mail → no cursor write. Unread STATUS lines alone leave the cursor where it is: they print nothing, so
69
+ # advancing past them would buy nothing and cost a spawn on almost every turn. The next scan re-reads them
70
+ # and still prints nothing.
71
+ [ -n "$unread" ] || return 0
72
+ printf 'Messages addressed to you (delivered to your session log while you were working):\n\n%s' "$unread"
73
+ ${SPEX:-spex} internal session-cursor inbox --session "$sid" --to "$i" >/dev/null 2>&1
74
+ }
75
+ mail
76
+
37
77
  # does FIELD's line hold exactly VALUE? The record is written one-field-per-line by the single writer
38
78
  # (sessions.ts writeRecord), so a whole-line match is exact — and, unlike a value regex, it cannot be fooled
39
79
  # by an escaped quote inside a neighbouring note.
@@ -15,6 +15,8 @@ The state is read from ONE structured field in the hook payload, never sniffed f
15
15
 
16
16
  The one activity that does NOT count as the session acting is an IN-PROCESS SUBAGENT's tool call (the harness's Task tool — a sub-conversation inside the same process). Such a call fires the parent's hooks carrying the parent's `session_id`, so without a discriminator a supervising parent could never hold a declared state: its own subagents erased every park/ask within seconds and raced the stop-gate into "undeclared stop". The harness stamps subagent-executed calls with a top-level `agent_id` field the parent's own calls never carry; `hp_is_subagent` reads that stamp deterministically (scanning only the pre-`tool_input` payload prefix, where a tool parameter or file content can never fake an unescaped key), and this hook skips the flip entirely. A subagent working is its parent supervising, not the parent moving on — the parent's own next tool call still flips as before.
17
17
 
18
- It is a board-lifecycle hook, so it acts only on a GOVERNED (dashboard-launched) session — it resolves that session's record in the global per-session store from the payload's `session_id` and no-ops unless `governed: true`. The state it writes lives in that record's `session.json` (state), which it edits directly in shell (the hot path stays jq-free).
18
+ It is a board-lifecycle hook, so it acts only on a GOVERNED (dashboard-launched) session — it resolves that session's record in the global per-session store from the payload's `session_id` and no-ops unless `governed: true`. The state it writes lives in that record's `session.json` (state), but it never edits that file itself: it READS it in pure shell (whole-line matches, the hot path stays jq-free) and hands every write to `spex internal session-state`, the one structured writer the CLI declarations use — an asking note is arbitrary prose, and a shell that substitutes prose into existing JSON eventually writes a record nothing can parse.
19
+
20
+ This hook is also the session's MAIL READER, and that is the same job seen from the other side: a message is delivered by being appended to the session's log (session-timeline), and a turn boundary is exactly when the agent can be shown one. It reads the lines past its own inbox cursor (session-cursors), prints the message bodies so the harness injects them as context, and advances the cursor through `spex internal session-cursor`. So an agent finds its unread mail by mechanism, never by remembering to run a command — identical for a dashboard-launched and a self-launched agent. The scan is pure builtins and writes nothing when there is no mail, so the every-tool-call case still costs no spawn.
19
21
 
20
22
  This is the freshness half of the [[core]] discipline: it keeps the board honest about whether a session is working, waiting, or asking, so the gates and the dashboard read a true present state rather than a stale one.
@@ -129,7 +129,7 @@ fi
129
129
  # of the full-to-terse information gap is recoverable from the entry, none of it from memory.
130
130
  taught="$sdir/stop-gate-taught"
131
131
  if [ -f "$taught" ]; then
132
- printf '{"decision":"block","reason":"undeclared stop — pick the ONE true state and declare it as the LAST call of your turn: `%s session <done --propose merge|nothing|close / park --note <what-you-await> / ask --note <your-question>>`. Which choice is true (and why park is never a default): `%s help session`."}\n' "$S" "$S"
132
+ printf '{"decision":"block","reason":"undeclared stop — declare the ONE true state as your LAST call: `%s session <done --propose merge (review; ONLY clickable merge)|nothing (done; no merge)|close (close-pending) / park (parked; real background wake-up) / ask (asking; human reply)>`. Conditions: `%s help session`."}\n' "$S" "$S"
133
133
  exit 0
134
134
  fi
135
135
  touch "$taught" 2>/dev/null || true
@@ -143,5 +143,5 @@ touch "$taught" 2>/dev/null || true
143
143
  # tool calls honestly re-flips the record to active (mark-active, by design) and re-blocks the next stop;
144
144
  # this block text is the one place every undeclared stopper is guaranteed to read, so the teaching that
145
145
  # kills the park->block->re-park loop at its source lives here.
146
- printf '{"decision":"block","reason":"Your session state is a CLAIM the graph, your supervisor, and other agents act on — not a box to tick to end the turn. Stopping undeclared makes your outcome a guess. Pick the ONE that is TRUE right now and run `%s session <choice>`, choosing the <choice> whose condition holds:\\n • done --propose merge — spec+code COMMITTED on the branch and genuinely ready for a human to review/merge (not just probably-done).\\n • done --propose nothing — committed, but you are NOT proposing a merge; paused for the human to look.\\n • park --note <what-you-await>ONLY when a real BACKGROUND TASK will wake you (a spex session wait you backgrounded, a running build/job). If nothing is actually running to resume you, you are NOT parked you are waiting on the human, so use ask; never use park as a default to clear this gate.\\n • done --propose close — you propose discarding this worktree.\\n • ask --note <your-question> — you need the human: a real question, or you are simply stopped awaiting direction; you resume only when they reply.\\n\\nDECLARE LAST, THEN STOP: finish everything else in the turn first — speak, send your messages, arm your background waits — and make the declaration your FINAL call. Any tool call AFTER it flips your record back to active (mark-active, by design: activity is activity), so the next stop re-blocks and demands a fresh declaration; declaring last kills that loop at its source.\\n\\n(This full explanation shows once per session; later undeclared stops get a one-line reminder. `%s help session` re-explains the choices any time.)"}\n' "$S" "$S"
146
+ printf '{"decision":"block","reason":"Your session state is a CLAIM the graph, your supervisor, and other agents act on — not a box to tick to end the turn. Stopping undeclared makes your outcome a guess. Pick the ONE that is TRUE right now and run `%s session <choice>`, choosing the <choice> whose condition holds:\\n • done --propose merge — spec+code COMMITTED on the branch and genuinely ready for human review. It declares REVIEW and is the ONLY proposal that offers a clickable merge.\\n • done --propose nothing — committed, but you are NOT proposing a merge; paused for the human to look. It declares DONE, never a merge.\\n • done --propose closeyou PROPOSE discarding this worktree; the human performs the close. It declares CLOSE-PENDING, not merge. This is how a session ends ITSELF — never run `session close` on your own id, which would delete your worktree mid-turn.\\n • ask --note <your-question> — you need the human: a real question, or you are simply stopped awaiting direction. It declares ASKING and resumes only when they reply.\\n • park --note <what-you-await> — ONLY when a real BACKGROUND TASK will wake you (a spex session wait you backgrounded, a running build/job). It declares PARKED and self-resumes. If nothing is actually running, you are waiting on the human: use ask, never park as a default.\\n\\nDECLARE LAST, THEN STOP: finish everything else in the turn first — speak, send your messages, arm your background waits — and make the declaration your FINAL call. Any tool call AFTER it flips your record back to active (mark-active, by design: activity is activity), so the next stop re-blocks and demands a fresh declaration; declaring last kills that loop at its source.\\n\\n(This full explanation shows once per session; later undeclared stops get a one-line reminder. `%s help session` re-explains the choices any time.)"}\n' "$S" "$S"
147
147
  exit 0
@@ -54,6 +54,11 @@ Only after verifying the salvage is present (or the branch truly merged), retire
54
54
  `spex session close <id>`, or remove a bare worktree and then its branch. If proof is incomplete, keep the
55
55
  resources and report why.
56
56
 
57
+ `<id>` is the OLD session's id — the one you are inheriting, spelled out. It is never `.` and never your own
58
+ id: `.` means THIS session (the rename step below relies on that), and closing yourself deletes the worktree
59
+ you are running in, mid-turn, along with your branch and record. Retiring the source is the only close this
60
+ skill asks for; your own ending stays a declaration.
61
+
57
62
  ## Rename yourself
58
63
 
59
64
  Last step: mark the inheritance on the board. If you run as a SpexCode session (inside a session
@@ -5,6 +5,18 @@
5
5
  "dashboard": {
6
6
  "showHeadlessLaunchers": false
7
7
  },
8
+ "uploads": {
9
+ "maxBytes": 2147483648,
10
+ "chunkBytes": 8388608,
11
+ "concurrency": 1,
12
+ "requestTimeoutMs": 120000,
13
+ "retryLimit": 2,
14
+ "retryDelayMs": 500,
15
+ "incompleteTtlMs": 86400000,
16
+ "cleanupIntervalMs": 3600000,
17
+ "minFreeBytes": 268435456,
18
+ "evidenceMaxBytes": 52428800
19
+ },
8
20
  "sessions": {
9
21
  "launchers": {
10
22
  "claude": { "harness": "claude", "cmd": "claude" },