spexcode 0.5.8 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/spec-cli/bin/spex.mjs +1 -13
- package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/spec-cli/hooks/dispatch.sh +15 -19
- package/spec-cli/src/anchors.ts +185 -56
- package/spec-cli/src/attach.ts +7 -17
- package/spec-cli/src/cli.ts +122 -225
- package/spec-cli/src/client.ts +114 -132
- package/spec-cli/src/cockpit.ts +43 -0
- package/spec-cli/src/codex-runtime-generations.ts +556 -0
- package/spec-cli/src/commit-surgery.ts +0 -14
- package/spec-cli/src/contract-filter.ts +0 -14
- package/spec-cli/src/delivery-queue.ts +107 -0
- package/spec-cli/src/doctor.ts +0 -16
- package/spec-cli/src/gateway-auth.ts +0 -13
- package/spec-cli/src/gateway.ts +0 -22
- package/spec-cli/src/git.ts +151 -48
- package/spec-cli/src/graph.ts +28 -7
- package/spec-cli/src/graphCache.ts +253 -72
- package/spec-cli/src/graphDelta.ts +0 -13
- package/spec-cli/src/graphStream.ts +70 -37
- package/spec-cli/src/guide.ts +29 -6
- package/spec-cli/src/harness.ts +232 -248
- package/spec-cli/src/help.ts +27 -26
- package/spec-cli/src/host-resources.ts +11 -2
- package/spec-cli/src/index.ts +71 -95
- package/spec-cli/src/issues-cli.ts +276 -0
- package/spec-cli/src/issues.ts +15 -197
- package/spec-cli/src/layout.ts +78 -2
- package/spec-cli/src/lint.ts +5 -3
- package/spec-cli/src/localIssues.ts +22 -185
- package/spec-cli/src/loop-in.ts +78 -0
- package/spec-cli/src/materialize.ts +0 -42
- package/spec-cli/src/mentions.ts +49 -193
- package/spec-cli/src/opencode-headless.ts +7 -7
- package/spec-cli/src/opencode.ts +2 -5
- package/spec-cli/src/pi-headless.ts +5 -5
- package/spec-cli/src/pty-bridge.ts +4 -5
- package/spec-cli/src/reviewSnapshot.ts +1 -0
- package/spec-cli/src/reviews.ts +6 -1
- package/spec-cli/src/root-lru.ts +54 -0
- package/spec-cli/src/runtime-guard.ts +0 -9
- package/spec-cli/src/session-cursors.ts +91 -0
- package/spec-cli/src/session-follow.ts +182 -0
- package/spec-cli/src/session-timeline.ts +55 -127
- package/spec-cli/src/sessions.ts +235 -1021
- package/spec-cli/src/shim-runtime.ts +9 -16
- package/spec-cli/src/specs.ts +33 -10
- package/spec-cli/src/uninstall.ts +0 -9
- package/spec-cli/src/uploads.ts +240 -18
- package/spec-cli/src/worktree-sources.ts +0 -16
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +7 -3
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
- package/spec-cli/templates/spexcode.json +12 -0
- package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
- package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
- package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
- package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
- package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
- package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/session.js +1 -28
- package/spec-eval/src/cache.ts +0 -14
- package/spec-eval/src/cli.ts +26 -5
- package/spec-eval/src/evaltab.ts +67 -108
- package/spec-eval/src/filing.ts +0 -18
- package/spec-eval/src/freshness.ts +78 -32
- package/spec-eval/src/humanok.ts +0 -15
- package/spec-eval/src/scenariofresh.ts +8 -80
- package/spec-eval/src/scenarios.ts +16 -67
- package/spec-eval/src/sessioneval.ts +157 -78
- package/spec-eval/src/sidecar.ts +1 -70
- package/spec-eval/src/timeline.ts +0 -23
- package/spec-forge/src/cache.ts +34 -12
- package/spec-forge/src/cli.ts +0 -6
- package/spec-forge/src/drivers/github.ts +1 -12
- package/spec-forge/src/drivers/gitlab.ts +4 -19
- package/spec-forge/src/drivers.ts +0 -18
- package/spec-forge/src/links.ts +0 -4
- package/spec-forge/src/needs-eval.ts +0 -1
- package/spec-forge/src/port.ts +0 -12
- package/spec-forge/src/resident.ts +4 -20
- package/spec-cli/src/maintenance-wrapper.ts +0 -276
- package/spec-cli/src/session-maintenance.ts +0 -751
- package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
- package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
- package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
- /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
|
-
//
|
|
120
|
-
//
|
|
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
|
|
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())
|
|
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(() =>
|
|
150
|
-
} catch {
|
|
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
|
})
|
package/spec-cli/src/specs.ts
CHANGED
|
@@ -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
|
|
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
|
|
272
|
-
const
|
|
273
|
-
const
|
|
274
|
-
const
|
|
275
|
-
const related =
|
|
276
|
-
const relatedScoped =
|
|
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
|
|
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
|
|
package/spec-cli/src/uploads.ts
CHANGED
|
@@ -1,31 +1,253 @@
|
|
|
1
|
-
import {
|
|
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
|
-
//
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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.
|
|
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,11 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# @@@ mark-active - the SINGLE
|
|
3
|
-
#
|
|
4
|
-
#
|
|
2
|
+
# @@@ mark-active - the SINGLE turn-boundary hook, wired to BOTH UserPromptSubmit and PreToolUse. It has ONE
|
|
3
|
+
# job, keyed off the session's global record dir: keep the declared FRESHNESS state honest. It carries no
|
|
4
|
+
# conversation — a message reaches this agent as an ordinary prompt through the harness adapter
|
|
5
|
+
# ([[delivery-queue]]), which is the only way anything enters a turn. A hook that also injected mail handed
|
|
6
|
+
# every message over twice and made the agent's context depend on which of two paths won a race.
|
|
7
|
+
# Freshness branches on ONE structured signal read straight from the hook payload (stdin JSON), so the state is
|
|
8
|
+
# HARD — never text-sniffed from the TUI:
|
|
5
9
|
# the agent is pausing to ask the HUMAN (hp_is_ask) → status: asking, with the question text as the note
|
|
6
10
|
# (the deterministic capture of a question).
|
|
7
11
|
# any other tool, or a prompt submit → the agent is working → status: active (drop a now-stale proposal/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),
|
|
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 carries no conversation. A message addressed to the session reaches its agent as an ordinary prompt through the harness adapter (delivery-queue), which is the only way anything enters a turn, so an inter-agent message is indistinguishable from a human one at the point of arrival. A hook that also injected mail delivered every message a second time and made the agent's context depend on which of two paths won a race; a freshness signal reports a fact about the session and hands nothing over.
|
|
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 —
|
|
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
|
|
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 close — you 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" },
|