spexcode 0.3.0 → 0.4.1
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 +1 -1
- package/spec-cli/src/anchors.ts +341 -0
- package/spec-cli/src/cli.ts +20 -20
- package/spec-cli/src/gateway.ts +8 -1
- package/spec-cli/src/git.ts +21 -13
- package/spec-cli/src/graph.ts +13 -7
- package/spec-cli/src/guide.ts +63 -15
- package/spec-cli/src/harness.ts +85 -34
- package/spec-cli/src/help.ts +33 -17
- package/spec-cli/src/index.ts +39 -7
- package/spec-cli/src/init.ts +5 -4
- package/spec-cli/src/lint.ts +103 -28
- package/spec-cli/src/localIssues.ts +19 -0
- package/spec-cli/src/migrate-table.ts +27 -16
- package/spec-cli/src/search.bench.mjs +2 -2
- package/spec-cli/src/session-timeline.ts +148 -0
- package/spec-cli/src/sessions.ts +29 -4
- package/spec-cli/src/specs.ts +44 -21
- package/spec-cli/templates/hooks/prepare-commit-msg +6 -1
- package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +16 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +17 -0
- package/spec-cli/templates/spec/project/.plugins/spec.md +5 -2
- package/spec-cli/templates/spec/project/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-C7Bzsv86.js → Dashboard-C_fGmOKK.js} +3 -3
- package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-D5iB4Ac2.js → FoldToggle-x9gtO1OQ.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-CMFTsQhg.js → IssuesPage-5f_vL-JV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +66 -0
- package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +13 -0
- package/spec-dashboard/dist/assets/{Settings-BW5f0OaW.js → Settings-DKb5Ji_X.js} +1 -1
- package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +41 -0
- package/spec-dashboard/dist/assets/index-BbMkwuix.css +1 -0
- package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +6 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-eval/src/cli.ts +82 -21
- package/spec-eval/src/evaltab.ts +15 -6
- package/spec-eval/src/humanok.ts +43 -0
- package/spec-eval/src/scenarios.ts +116 -4
- package/spec-eval/src/sidecar.ts +35 -9
- package/spec-cli/templates/presets/careful/.plugins/clarify-before-code/spec.md +0 -11
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
- package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-DwuTKgdP.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-CBS5_cmK.js +0 -71
- package/spec-dashboard/dist/assets/SessionWindow-CqAnjWfI.js +0 -9
- package/spec-dashboard/dist/assets/index-Cc26X4ce.css +0 -1
- package/spec-dashboard/dist/assets/index-Ce0wDyQS.js +0 -41
- /package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{tidy → commands/tidy}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{forge-link → prompts/forge-link}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{memory-hygiene → prompts/memory-hygiene}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{reproduce-before-fix → prompts/reproduce-before-fix}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/spec.md +0 -0
package/spec-cli/src/guide.ts
CHANGED
|
@@ -62,9 +62,22 @@ FRONTMATTER (YAML between the opening and closing --- lines; every field optiona
|
|
|
62
62
|
Drives drift + eval freshness. Many nodes MAY govern the same file (ordinary
|
|
63
63
|
composition); a file governed by > maxOwners nodes warns (the \`owners\` rule — split it). Omit
|
|
64
64
|
for a pure-prose node: a cross-cutting contract no file owns.
|
|
65
|
+
The entry may pin named units — ANCHORS: one or more \`path#symbol\` rows, ALL on the same
|
|
66
|
+
base file (\`#Class.method\` for a class method; top-level functions, arrow/const declarations,
|
|
67
|
+
classes, enums anchor cleanly; a type/interface anchor warns). One-govern counts DISTINCT
|
|
68
|
+
base paths, so selectors never widen govern past one file. Several selectors are OR: a
|
|
69
|
+
commit hitting ANY upgrades drift to the blocking \`anchor-drift\` error (one error per
|
|
70
|
+
entry, naming the hit selectors — a commit counts once). Without an anchor, drift stays
|
|
71
|
+
advisory forever. A base path is either whole-file (bare) or selector-scoped, never both;
|
|
72
|
+
duplicates, globs/directories with a selector, and dead/ambiguous units all error loud. A
|
|
73
|
+
selector-scoped governor claims units, not the file, so it stays out of the \`owners\` bound
|
|
74
|
+
(spex spec owner still displays it, marked "(scoped)"). Anchors are optional.
|
|
65
75
|
related: files this node REFERENCES but does not own — a YAML list, same path forms. Carries coverage
|
|
66
76
|
(never drift, never eval freshness, nothing to ack); it is the many-to-many net that claims the files
|
|
67
|
-
govern doesn't. Every listed path must exist (lint integrity error otherwise).
|
|
77
|
+
govern doesn't. Every listed path must exist (lint integrity error otherwise). A related row
|
|
78
|
+
may also pin \`path#symbol\`: the node then hears about a commit ONLY when it moves that
|
|
79
|
+
unit — a hit is a soft \`related-drift\` warn naming the selector, a miss is SILENT (a scoped
|
|
80
|
+
related file's ordinary file-level nudge is off). Still never blocks, no ack, no eval freshness.
|
|
68
81
|
surface plugin-system/.plugins nodes only: one or MORE of system (folded into every agent's prompt) |
|
|
69
82
|
command (a /command) | skill (an on-demand SKILL.md the harness loads when a task matches the
|
|
70
83
|
node's desc) | agent (a spawnable sub-agent definition; its \`tools:\` list is the spawned
|
|
@@ -84,7 +97,18 @@ Bodies without those headings are read whole. Link sibling nodes with [[node-id]
|
|
|
84
97
|
a REAL node (lint's mention rule; backtick a placeholder like \`[[node]]\` so it reads as sample text).
|
|
85
98
|
|
|
86
99
|
WHAT lint CHECKS (spex spec lint; the pre-commit hook gates on errors):
|
|
87
|
-
integrity (error) every code:/related: path exists
|
|
100
|
+
integrity (error) every code:/related: path exists — and every anchor RESOLVES: a dead anchor (unit
|
|
101
|
+
deleted/renamed), an ambiguous one (two same-named units in one file), a file that
|
|
102
|
+
no longer parses, a language with no designated extractor, or an extractor that
|
|
103
|
+
can't run here (e.g. no host typescript — 'npm i -D typescript' or drop the anchor)
|
|
104
|
+
all error, never silently pass. So do a relation's STRUCTURAL defects: a duplicate
|
|
105
|
+
entry, a base path both bare and selector-scoped, or a selector on a
|
|
106
|
+
glob/directory.
|
|
107
|
+
anchor-drift (error) a commit since the node's version intersected an ANCHORED unit's lines (judged
|
|
108
|
+
from the file as it existed AT each commit) and no Spec-OK ack covers it — the
|
|
109
|
+
blocking tier of drift. Selectors on one file are OR'd: one error per entry, the
|
|
110
|
+
hit selectors named, each commit counted once. Remedy: update the spec, or
|
|
111
|
+
\`spex spec ack\` with a real reason (recorded in the ack commit body).
|
|
88
112
|
one-govern (error) a node governs (code:) at most ONE file — keep the true subject, move the rest
|
|
89
113
|
to related:.
|
|
90
114
|
living (error) no "## vN" changelog headings — the body is current-state.
|
|
@@ -98,11 +122,21 @@ WHAT lint CHECKS (spex spec lint; the pre-commit hook gates on errors):
|
|
|
98
122
|
twin; is an intermediate grouping layer missing?
|
|
99
123
|
coverage (warn) every source file is claimed by ≥1 node — via code: OR related: (related is the net).
|
|
100
124
|
drift (warn) a governed file has commits newer than the node's spec version — it may be stale.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
125
|
+
ALWAYS advisory: unanchored drift never blocks a commit (the blocking tier is
|
|
126
|
+
anchor-drift above). On a selector-SCOPED code file whose window has NO hit (a
|
|
127
|
+
miss), this advisory stays by default; the committed \`lint.scopedCodeMiss:
|
|
128
|
+
"ignore"\` silences ONLY it (hit blocks, bare drift, integrity, acks, related,
|
|
129
|
+
eval freshness all untouched). Remedy: edit the spec to the new intent
|
|
130
|
+
(re-versions the node), OR \`spex spec ack <node> --reason "…"\` when only
|
|
131
|
+
mechanics changed and the contract still holds.
|
|
132
|
+
anchor (warn) an anchor pins a type/interface — types reshape with every refactor; anchor the
|
|
133
|
+
behaviour-bearing unit instead.
|
|
134
|
+
related-drift (warn) a related: file moved ahead of the node — a soft nudge, one summary line, never
|
|
135
|
+
blocks. A selector-scoped related row instead warns per HIT (selector named);
|
|
136
|
+
its file-level misses are silent.
|
|
137
|
+
owners (warn) a file governed WHOLE-FILE by > maxOwners nodes (default 3) does too much — SPLIT
|
|
138
|
+
it so each governor owns its own module (or merge the nodes, or give it one
|
|
139
|
+
foundation owner). Selector-scoped governors don't count toward the bound.
|
|
106
140
|
confusable-id (warn) two leaf ids one edit apart read as the same word — rename one to read apart.
|
|
107
141
|
|
|
108
142
|
LIFECYCLE: author each node on a node/<id> branch, one node per commit; \`spex spec lint\` must reach 0 errors
|
|
@@ -125,8 +159,13 @@ FRONTMATTER: a \`scenarios:\` list (a YAML block sequence of mappings). Each sce
|
|
|
125
159
|
\`frontend-e2e, backend-api, cli, desktop, mobile\`). A tag outside the library is rejected —
|
|
126
160
|
use an existing one, or add it to the library to mint it. Tags classify a scenario (surface,
|
|
127
161
|
device) so it can be filtered and, later, routed to the right driver.
|
|
128
|
-
test optional.
|
|
129
|
-
|
|
162
|
+
test optional. Either a repo-path scalar (the backward-compatible shorthand) or a strict object:
|
|
163
|
+
test:
|
|
164
|
+
path: tests/auth.spec.ts
|
|
165
|
+
name: rejects an expired session
|
|
166
|
+
Both forms normalize in JSON to \`{ "path": "..." }\` with optional \`"name"\`. The object
|
|
167
|
+
requires exactly \`path\` + \`name\`; its case name is opaque text preserved for the measuring
|
|
168
|
+
hand. The path must exist. SpexCode does not parse WDIO/Playwright or execute anything.
|
|
130
169
|
code optional. The file THIS scenario GOVERNS, ideally one (a comma list / flow list \`[a, b]\` is
|
|
131
170
|
allowed) — its own slice of the code freshness axis, so scenarios on one node go stale
|
|
132
171
|
independently. Absent → it inherits the node's \`code:\` list. A file governed by > maxOwners
|
|
@@ -338,9 +377,16 @@ the guard (the flag is the declaration of intent). Reads point anywhere.
|
|
|
338
377
|
lint.altitude body budgets: { lineBudget, charBudget, sizeable, dense, steps }
|
|
339
378
|
(defaults 50 / 4200 / 35 / 1.3 / 3).
|
|
340
379
|
lint.maxChildren breadth budget: warn at >= this many direct children (default 8).
|
|
341
|
-
lint.
|
|
342
|
-
|
|
343
|
-
|
|
380
|
+
lint.maxOwners warn when a file is governed WHOLE-FILE by > this many nodes (default 3).
|
|
381
|
+
Selector-scoped governors (code: path#symbol) don't count toward the bound.
|
|
382
|
+
(lint.driftErrorThreshold is RETIRED: the count-based commit gate is replaced
|
|
383
|
+
by code anchors — \`code: path#symbol\` — whose hits error unconditionally; a
|
|
384
|
+
leftover key is ignored.)
|
|
385
|
+
lint.scopedCodeMiss "warn" (default) | "ignore" — the file-level drift ADVISORY on a selector-
|
|
386
|
+
scoped code: file whose window commits hit no selector (a miss). "ignore"
|
|
387
|
+
silences ONLY that advisory; it never touches hit blocks (anchor-drift),
|
|
388
|
+
bare-path drift, integrity, Spec-OK acks, related semantics, or eval
|
|
389
|
+
freshness. A project policy → committed spexcode.json.
|
|
344
390
|
lint.scenarioTags the closed vocabulary an eval scenario's tags: must draw from (default
|
|
345
391
|
["frontend-e2e","backend-api","cli","desktop","mobile"]); extend to mint a tag.
|
|
346
392
|
Example — govern your own source dir and loosen the altitude budget:
|
|
@@ -359,9 +405,11 @@ const FOOTPRINT = `spex guide footprint — what SpexCode plants in a repo, and
|
|
|
359
405
|
|
|
360
406
|
SpexCode claims software engineering's HEAD (the recording of intent) and TAIL (the storage of
|
|
361
407
|
measurement) and leaves the MIDDLE — construction — to the harness/agent/test framework; freshness
|
|
362
|
-
stitches the two ends into a closed loop.
|
|
363
|
-
|
|
364
|
-
|
|
408
|
+
stitches the two ends into a closed loop. Materialize is the base operation of harness ADAPTATION:
|
|
409
|
+
one pass renders the spec tree into whatever artifacts the selected harness auto-discovers, so that
|
|
410
|
+
is how SpexCode reaches an agent — never a launch-time flag. The footprint follows: the head+tail
|
|
411
|
+
(.spec, spexcode.json, evals) is the ASSET and lives in git like source; everything else is derived
|
|
412
|
+
wiring or a machine fact. Materialized artifacts carry no facts, so they are NEVER tracked — there is exactly one residence
|
|
365
413
|
behavior, decided per KIND (and, for a contract file, by its live CONTENT).
|
|
366
414
|
|
|
367
415
|
── THE FOUR KINDS (all fixed) ──
|
package/spec-cli/src/harness.ts
CHANGED
|
@@ -133,13 +133,21 @@ export interface Harness {
|
|
|
133
133
|
// grace lives in the caller (sessions.ts liveness), so a still-booting pane reads starting, not offline.
|
|
134
134
|
liveness(rec: HarnessLivenessRecord, tmuxAlive: boolean, runtimeDir?: string, pane?: PaneProbe, socketLive?: boolean): 'online' | 'offline'
|
|
135
135
|
// deliver a follow-up prompt to a LIVE session and report whether it landed. claude: through the rendezvous
|
|
136
|
-
// control socket —
|
|
137
|
-
//
|
|
138
|
-
//
|
|
136
|
+
// control socket — an ATOMIC reply+repaint chunk whose `repaint-done` proves the reply was PARSED; a close
|
|
137
|
+
// before it proves a concurrent connect kicked the chunk (the daemon is single-connection) → resend; a wall
|
|
138
|
+
// expiry on a still-open connection is a busy-not-lost agent → optimistic ok (see replyViaSocket). codex:
|
|
139
|
+
// JSON-RPC on the same app-server WebSocket the
|
|
139
140
|
// visible TUI uses — it reads the thread live and either `turn/steer`s the message INTO an in-progress turn
|
|
140
141
|
// (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
|
|
141
142
|
// Returns ok=false with a reason that propagates to the API.
|
|
142
143
|
deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
|
|
144
|
+
// the ONE pane state where this harness SWALLOWS a prompt that its delivery channel confirms (so no
|
|
145
|
+
// socket-side check can see it): given the live pane text, return the loud human-readable refusal (naming
|
|
146
|
+
// the recovery) or null when the pane can take a prompt. sendText captures the pane once and consults this
|
|
147
|
+
// BEFORE delivering; absent on harnesses with no such state (codex delivery ignores the pane). claude: the
|
|
148
|
+
// TUI's sessions panel ("← for agents") enqueues an injected reply to the panel context and never drains it
|
|
149
|
+
// — verified live: parsed + enqueued, no dequeue, no turn, daemon silent.
|
|
150
|
+
deliveryBlockedBy?(paneText: string): string | null
|
|
143
151
|
// --- materialize: clean (the inverse of write — [[harness-select]] prunes a deselected harness) ---
|
|
144
152
|
// clean is the EXACT inverse of materialize's per-harness write: SURGICALLY remove ONLY SpexCode's own
|
|
145
153
|
// artifacts — the managed contract block (sentinels), the generated shim file, the trust block, and the
|
|
@@ -161,11 +169,11 @@ export interface Harness {
|
|
|
161
169
|
}
|
|
162
170
|
|
|
163
171
|
// a prompt-dispatch outcome. ok=true means delivery is confirmed at the layer that harness proves it: claude at
|
|
164
|
-
// the
|
|
165
|
-
//
|
|
166
|
-
// `turn/steer`/`turn/start`). `error` carries a human-readable reason that propagates to the API route
|
|
167
|
-
// and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts
|
|
168
|
-
// its existing importers.
|
|
172
|
+
// the DAEMON-PARSE layer (the atomic reply+repaint chunk answered `repaint-done`, or the wall expired on a
|
|
173
|
+
// still-open connection — busy, not lost; see replyViaSocket); codex at the application layer (the app-server
|
|
174
|
+
// accepted `turn/steer`/`turn/start`). `error` carries a human-readable reason that propagates to the API route
|
|
175
|
+
// (non-2xx) and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts
|
|
176
|
+
// re-exports it for its existing importers.
|
|
169
177
|
export type DispatchResult = { ok: boolean; error?: string }
|
|
170
178
|
export type HarnessDeliveryRecord = { session: string; worktreePath?: string; harnessSessionId?: string | null; runtimeDir?: string }
|
|
171
179
|
// the on-demand surface artifacts a materialize pass wrote, by node NAME — so clean() knows EXACTLY which
|
|
@@ -252,50 +260,84 @@ function shQuote(s: string): string {
|
|
|
252
260
|
const PKG = fileURLToPath(new URL('..', import.meta.url))
|
|
253
261
|
const SPEX = join(PKG, 'bin', 'spex.mjs')
|
|
254
262
|
|
|
255
|
-
// @@@ replyViaSocket -
|
|
256
|
-
// `
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
263
|
+
// @@@ replyViaSocket - ATOMIC parse-confirmed delivery. The daemon is SINGLE-CONNECTION: a new connect
|
|
264
|
+
// `destroy()`s the previous socket, discarding any received-but-not-yet-parsed line with it — and our own
|
|
265
|
+
// `rendezvousListening` liveness probe IS such a connect, fired for every session on every board snapshot. So
|
|
266
|
+
// the previous optimistic write (return ok once the reply line flushed) LOST prompts whenever a probe landed in
|
|
267
|
+
// the write→parse window, a window that widens exactly when claude is busy mid-turn (field: dashboard messages
|
|
268
|
+
// recorded `sent` with no trace in the claude transcript; measured 2/10 lost under a 20ms probe hammer). The
|
|
269
|
+
// daemon parses a chunk's complete lines in ONE synchronous loop, so writing `{type:reply}` + `{type:repaint}`
|
|
270
|
+
// as ONE chunk makes the pair indivisible — a kick loses BOTH or NEITHER — and the outcome decidable from this
|
|
271
|
+
// connection alone:
|
|
272
|
+
// `repaint-done` arrives → the reply line before it was parsed (in-order barrier) → ok, CONFIRMED.
|
|
273
|
+
// 'close' before it → the chunk was never parsed (kicked by a concurrent connect) → resolve kicked:true
|
|
274
|
+
// so deliverViaRendezvous RESENDS — a proven loss, so the retry cannot duplicate.
|
|
275
|
+
// wall expires, conn open → a busy event loop is DELAYING, not losing (the 2500ms-wall lesson: ack absence is
|
|
276
|
+
// NOT non-delivery) → ok, OPTIMISTIC — never a false failure on a busy worker.
|
|
277
|
+
// `reply-rejected`/`auth-rejected`/`shutting-down` → loud failure, not retried.
|
|
278
|
+
// Other daemon lines (heartbeat, state patches) are ignored. Never throws.
|
|
279
|
+
type ReplyOutcome = DispatchResult & { kicked?: boolean }
|
|
280
|
+
function replyViaSocket(sock: string, text: string, wallMs = 10_000): Promise<ReplyOutcome> {
|
|
270
281
|
return new Promise((resolve) => {
|
|
271
282
|
let settled = false
|
|
272
283
|
let c: ReturnType<typeof createConnection>
|
|
273
|
-
const done = (r:
|
|
284
|
+
const done = (r: ReplyOutcome) => {
|
|
274
285
|
if (settled) return
|
|
275
286
|
settled = true
|
|
287
|
+
clearTimeout(wall)
|
|
276
288
|
try { c?.destroy() } catch { /* */ }
|
|
277
289
|
resolve(r)
|
|
278
290
|
}
|
|
291
|
+
const wall = setTimeout(() => done({ ok: true }), wallMs)
|
|
279
292
|
try {
|
|
280
293
|
c = createConnection({ path: sock })
|
|
281
294
|
} catch (e) {
|
|
282
295
|
done({ ok: false, error: `rendezvous socket connect threw: ${String(e)}` })
|
|
283
296
|
return
|
|
284
297
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
298
|
+
// ECONNRESET/EPIPE are the KICK surfacing as an error: the daemon destroy()s the previous connection the
|
|
299
|
+
// moment a new one connects, and destroying a socket with OUR chunk still unread raises RST — whereas a
|
|
300
|
+
// parsed chunk answers repaint-done (readable even after a later close) before any clean FIN. So both codes
|
|
301
|
+
// PROVE the chunk was never parsed → retryable, same as the clean pre-parse close. ECONNREFUSED/ENOENT
|
|
302
|
+
// (daemon gone) stay loud.
|
|
303
|
+
c.on('error', (e: NodeJS.ErrnoException) => {
|
|
304
|
+
const code = e?.code || String(e)
|
|
305
|
+
const kicked = code === 'ECONNRESET' || code === 'EPIPE'
|
|
306
|
+
done({ ok: false, ...(kicked ? { kicked } : {}), error: `rendezvous socket error: ${code} — prompt NOT delivered` })
|
|
307
|
+
})
|
|
308
|
+
c.on('close', () => done({ ok: false, kicked: true, error: 'rendezvous connection was closed before the daemon parsed the prompt (kicked by a concurrent connect)' }))
|
|
309
|
+
c.on('connect', () => c.write(JSON.stringify({ type: 'reply', text }) + '\n' + JSON.stringify({ type: 'repaint' }) + '\n'))
|
|
310
|
+
let buf = ''
|
|
311
|
+
c.on('data', (d) => {
|
|
312
|
+
buf += d.toString('utf8')
|
|
313
|
+
let nl
|
|
314
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
315
|
+
const line = buf.slice(0, nl)
|
|
316
|
+
buf = buf.slice(nl + 1)
|
|
317
|
+
let type = ''
|
|
318
|
+
try { type = (JSON.parse(line) as { type?: string })?.type ?? '' } catch { continue }
|
|
319
|
+
if (type === 'repaint-done') return done({ ok: true })
|
|
320
|
+
if (type === 'reply-rejected' || type === 'auth-rejected') return done({ ok: false, error: `rendezvous daemon rejected the prompt (${type}) — prompt NOT delivered` })
|
|
321
|
+
if (type === 'shutting-down') return done({ ok: false, error: 'agent is shutting down — prompt NOT delivered' })
|
|
322
|
+
}
|
|
323
|
+
})
|
|
290
324
|
})
|
|
291
325
|
}
|
|
292
326
|
// claude's deliver: the pre-write LIVENESS gate — fail loud BEFORE attempting the socket if it isn't there (a
|
|
293
|
-
// clearer message than a raw connect error, and the delivery's confirmation layer: socket present = agent
|
|
294
|
-
// Then
|
|
295
|
-
|
|
327
|
+
// clearer message than a raw connect error, and the delivery's confirmation layer: socket present = agent
|
|
328
|
+
// alive). Then the atomic parse-confirmed write; a KICKED outcome is a proven whole-chunk loss, so it resends
|
|
329
|
+
// (bounded attempts + jitter so re-collision with the probe cadence is unlikely); exhausted retries fail loud.
|
|
330
|
+
const DELIVER_ATTEMPTS = 3
|
|
331
|
+
export async function deliverViaRendezvous(id: string, text: string, wallMs?: number): Promise<DispatchResult> {
|
|
296
332
|
const sock = rvSock(id)
|
|
297
|
-
if (!existsSync(sock)) return
|
|
298
|
-
|
|
333
|
+
if (!existsSync(sock)) return { ok: false, error: `no rendezvous control socket for session ${id} (socketless/old session, or the agent is offline) — prompt NOT delivered` }
|
|
334
|
+
let last: ReplyOutcome = { ok: false, error: 'not attempted' }
|
|
335
|
+
for (let attempt = 1; attempt <= DELIVER_ATTEMPTS; attempt++) {
|
|
336
|
+
last = await replyViaSocket(sock, text, wallMs)
|
|
337
|
+
if (last.ok || !last.kicked) return { ok: last.ok, ...(last.error ? { error: last.error } : {}) }
|
|
338
|
+
await new Promise((r) => setTimeout(r, 60 + Math.random() * 140))
|
|
339
|
+
}
|
|
340
|
+
return { ok: false, error: `rendezvous delivery was kicked by concurrent connects ${DELIVER_ATTEMPTS}× — prompt NOT delivered, retry the send` }
|
|
299
341
|
}
|
|
300
342
|
|
|
301
343
|
type JsonRpc = { id?: number; method?: string; params?: unknown; result?: unknown; error?: { code?: number; message?: string } }
|
|
@@ -997,6 +1039,15 @@ export const claudeHarness: Harness = {
|
|
|
997
1039
|
// dead-pane-reads-working bug). See rendezvousListening.
|
|
998
1040
|
liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
|
|
999
1041
|
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
1042
|
+
// the TUI's sessions panel ("← for agents"): a reply injected here is parsed + enqueued to the PANEL context
|
|
1043
|
+
// and never drained (verified live: `queue-operation: enqueue` with no dequeue, no turn, daemon silent), so
|
|
1044
|
+
// the parse-confirmed delivery above would still report a false success into it. Matched on the panel's own
|
|
1045
|
+
// strings — the new-session composer placeholder, or its footer key hints together (either alone could drift
|
|
1046
|
+
// across claude versions; requiring the footer PAIR keeps a prose false-positive unlikely).
|
|
1047
|
+
deliveryBlockedBy: (paneText) =>
|
|
1048
|
+
paneText.includes('describe a task for a new session') || (paneText.includes('enter to return') && paneText.includes('space to reply'))
|
|
1049
|
+
? 'the claude TUI is focused on its sessions panel ("← for agents"), which silently swallows injected prompts — press Enter in the session terminal to return to the composer, then resend'
|
|
1050
|
+
: null,
|
|
1000
1051
|
resumeArg: (rec) => `--resume ${rec.session}`,
|
|
1001
1052
|
}
|
|
1002
1053
|
|
package/spec-cli/src/help.ts
CHANGED
|
@@ -45,7 +45,7 @@ derived status, title, and attention badges (drift:N · stale:N · issues:N · g
|
|
|
45
45
|
},
|
|
46
46
|
init: {
|
|
47
47
|
line: 'init [dir] adopt SpexCode on a repo: seed .spec + hooks + materialize [--preset name]',
|
|
48
|
-
body: `Usage: spex init [dir=cwd] [--preset default
|
|
48
|
+
body: `Usage: spex init [dir=cwd] [--preset default]
|
|
49
49
|
|
|
50
50
|
Scaffolds adoption in one shot: seeds a starter .spec tree (project root + .plugins plugins), plants
|
|
51
51
|
spexcode.json, installs the git hooks, and materializes the harness artifacts (contract block +
|
|
@@ -55,15 +55,17 @@ a tracked/mixed CLAUDE.md/AGENTS.md covered by the clean/smudge filter (see spex
|
|
|
55
55
|
see: 'spex guide (the full setup workflow) · spex uninstall (the inverse) · spex spec lint (adoption TODO)',
|
|
56
56
|
},
|
|
57
57
|
materialize: {
|
|
58
|
-
line: 'materialize
|
|
58
|
+
line: 'materialize the base pass of harness adaptation: render .spec/.plugins into your harness’s artifacts',
|
|
59
59
|
body: `Usage: spex materialize
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
61
|
+
The base operation of HARNESS ADAPTATION: one pass renders the spec tree's surface nodes into the
|
|
62
|
+
artifacts each selected harness auto-discovers — the managed <!-- spexcode --> block of
|
|
63
|
+
CLAUDE.md/AGENTS.md, the .claude/.codex shims, the skills/agents — and prints the content hash.
|
|
64
|
+
The outputs are derived and never tracked: to change one, edit its source (.plugins, spexcode.json)
|
|
65
|
+
and re-materialize — never the artifact. Not a one-time setup: it anchors on git-native events
|
|
66
|
+
(init · this verb · session-worktree creation · the pre-commit/post-checkout/post-merge hooks) —
|
|
67
|
+
run it by hand after a toolchain update, or in the setup step of any clone that has no spex-planted
|
|
68
|
+
hooks yet (CI, a cloud agent): generated and excluded, the artifacts never arrive via git.`,
|
|
67
69
|
see: 'spex doctor (verify the materialized artifacts actually reach an agent)',
|
|
68
70
|
},
|
|
69
71
|
doctor: {
|
|
@@ -121,16 +123,24 @@ owner — the reverse edge: a file's GOVERNORS (code: — drives drift + eval fr
|
|
|
121
123
|
— coverage only), with the verdict spelled out (uncovered / related-only / sanely governed /
|
|
122
124
|
over-owned → split the file). --actionable prints NOTHING unless action is needed (hook use).
|
|
123
125
|
|
|
124
|
-
lint — checks the whole spec↔code graph and exits non-zero on errors (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
lint — checks the whole spec↔code graph and exits non-zero on errors. Errors: integrity (a
|
|
127
|
+
code:/related: file does not exist; a dead/ambiguous/unverifiable \`path#symbol\` selector; a selector
|
|
128
|
+
whose language has no designated extractor or whose extractor can't run here; a duplicate entry, a
|
|
129
|
+
base path both bare and scoped, or a selector on a glob/directory) · anchor-drift (a
|
|
130
|
+
commit since the spec's version touched an ANCHORED unit's lines, unacked — the blocking tier of
|
|
131
|
+
drift; same-file selectors OR'd, one error naming the hit selectors) · one-govern (a node
|
|
132
|
+
governs >1 DISTINCT file) · living (a "## vN" changelog heading) · id-format (an
|
|
133
|
+
id char outside the whitelist — ascii [a-z0-9-] or a non-ascii unicode letter/number, CJK ok — or a
|
|
134
|
+
leaf id reused) · mention (a [[id]] naming no node). Warns: altitude · breadth · coverage · drift
|
|
135
|
+
(UNANCHORED drift — always advisory, never blocks; on a scoped file's MISS, \`lint.scopedCodeMiss:
|
|
136
|
+
"ignore"\` may silence it) · anchor (anchoring a type) · related-drift (a scoped related row warns
|
|
137
|
+
per selector HIT, misses silent) · owners (whole-file governors only; scoped don't count) ·
|
|
138
|
+
confusable-id (two leaf ids one edit apart). spec lint's errors BLOCK commits (the pre-commit shim; bypass SPEXCODE_SKIP_LINT=1);
|
|
130
139
|
contrast \`spex eval lint\`, which is pure advisory and never blocks anyone.
|
|
131
140
|
|
|
132
141
|
ack — stamp Spec-OK on HEAD (an empty stamp commit): the drift remedy when only MECHANICS changed
|
|
133
|
-
and the spec's contract still holds. --reason is required
|
|
142
|
+
and the spec's contract still holds. --reason is required and recorded in the ack commit's body
|
|
143
|
+
(quieting an anchor hit is a strong claim — the why must be durable). If the intent DID change,
|
|
134
144
|
edit the spec instead — same commit as the code.`,
|
|
135
145
|
see: 'spex guide spec (the file format + every lint rule) · spex graph (browse the whole tree)',
|
|
136
146
|
},
|
|
@@ -192,7 +202,7 @@ ${MENTION_NOTE}`,
|
|
|
192
202
|
see: 'spex eval ls --session <SEL> (the session’s measured loss) · spex help eval',
|
|
193
203
|
},
|
|
194
204
|
eval: {
|
|
195
|
-
line: 'eval <verb> the measurement system: add · ls · scenario ls · lint · retract · clean',
|
|
205
|
+
line: 'eval <verb> the measurement system: add · ls · scenario ls · lint · ok · retract · clean',
|
|
196
206
|
body: `Usage: spex eval add [<node>|.] [--scenario <name>] (--pass|--fail) [--note <text>]
|
|
197
207
|
[--image <png> …repeatable] [--result <path|->] [--video <webm|mp4>] [--timeline <json>]
|
|
198
208
|
spex eval ls [<node>|.] [--json] a node's eval timeline, newest first
|
|
@@ -200,6 +210,7 @@ ${MENTION_NOTE}`,
|
|
|
200
210
|
spex eval ls --session <SEL> --export [--open | --out <path>]
|
|
201
211
|
spex eval scenario ls [<node>|.] [--unmeasured] [--json] declared scenarios; bare = every node
|
|
202
212
|
spex eval lint [--changed] measurement-layer findings (advisory, always exit 0)
|
|
213
|
+
spex eval ok <node> [--scenario <name>] the HUMAN sign-off on the scenario's latest reading
|
|
203
214
|
spex eval retract [<node>|.] [--scenario <name>] [--last | --ts <iso>] [--note <why>]
|
|
204
215
|
spex eval clean [--keep-latest | --all] GC the content-addressed evidence cache
|
|
205
216
|
|
|
@@ -213,7 +224,7 @@ ls — node-scoped bare (its per-scenario eval history); session-scoped with an
|
|
|
213
224
|
✦-marked ahead of the inherited baseline. --export writes that evaluation as ONE self-contained
|
|
214
225
|
HTML artifact (diff · evidence inlined · gates) for CI/sharing.
|
|
215
226
|
|
|
216
|
-
scenario ls — the DECLARED contracts (name · tags · latest verdict), no evals: bare lists every
|
|
227
|
+
scenario ls — the DECLARED contracts (name · tags · normalized test reference · latest verdict), no evals: bare lists every
|
|
217
228
|
measurable node's scenarios; --unmeasured keeps only the never-measured — the blind-spot worklist.
|
|
218
229
|
|
|
219
230
|
lint — the measurement layer's findings: malformed eval.md (eval-schema) · unmeasured (eval-missing) ·
|
|
@@ -222,6 +233,11 @@ stale (eval-drift) · orphaned remark tracks (eval-dangling) · governed source
|
|
|
222
233
|
files (eval-owners). --changed scopes to the nodes THIS branch touched. spec lint's errors block
|
|
223
234
|
commits; eval lint is PURE ADVISORY, always exit 0 — a measurement gap never blocks anyone.
|
|
224
235
|
|
|
236
|
+
ok — the human's reviewed-and-agreed mark on the scenario's LATEST reading: an appended, monotonic
|
|
237
|
+
sign-off bound to that one immutable reading (a newer reading or staleness releases it on its own —
|
|
238
|
+
no un-ok exists). The evals feed default-hides a fresh, ok'd scenario; a governed session is refused
|
|
239
|
+
(an agent's judgment on a reading is a remark, never a self-blessing).
|
|
240
|
+
|
|
225
241
|
retract — the sanctioned undo for a botched filing: APPENDS a retraction event (traceable, never
|
|
226
242
|
deletes a line); the previous eval becomes latest again, or the scenario honestly returns to
|
|
227
243
|
unmeasured.
|
package/spec-cli/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { Hono } from 'hono'
|
|
|
5
5
|
import { cors } from 'hono/cors'
|
|
6
6
|
import { etag } from 'hono/etag'
|
|
7
7
|
import { createNodeWebSocket } from '@hono/node-ws'
|
|
8
|
-
import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig } from './specs.js'
|
|
8
|
+
import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig, loadReviewConfig } from './specs.js'
|
|
9
9
|
import { issuesEnabled, remarkOnHost, resolveRemark, retractRemark } from './localIssues.js'
|
|
10
10
|
import { closeIssue, createIssue, findIssue, issueStores, mergedIssues, promote, replyIssue } from './issues.js'
|
|
11
11
|
import { residentForgeState, refreshForgeNow } from '../../spec-forge/src/resident.js'
|
|
@@ -16,11 +16,13 @@ import { getBoardJson } from './graphCache.js'
|
|
|
16
16
|
import { boardStream, notifyBoardChanged } from './graphStream.js'
|
|
17
17
|
import { gitA, gitTry, repoRoot } from './git.js'
|
|
18
18
|
import { newSession, listSessions, sendText, rawKey, stopSession, closeSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, superviseQueue } from './sessions.js'
|
|
19
|
+
import { superviseTimeline, readTimeline } from './session-timeline.js'
|
|
19
20
|
import { defaultHarness, HARNESSES, launcherList, launcherDefault } from './harness.js'
|
|
20
21
|
import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
|
|
21
22
|
import { putBlob } from '../../spec-eval/src/cache.js'
|
|
22
23
|
import { evalNodes } from '../../spec-eval/src/scenarios.js'
|
|
23
24
|
import { fileHumanReading } from '../../spec-eval/src/filing.js'
|
|
25
|
+
import { fileHumanOk } from '../../spec-eval/src/humanok.js'
|
|
24
26
|
import { buildExportModel, renderExportHtml, buildSessionEvals } from '../../spec-eval/src/sessioneval.js'
|
|
25
27
|
import { saveUpload, MAX_UPLOAD_BYTES } from './uploads.js'
|
|
26
28
|
import { attachViewer, detachViewer, resizeBridge, forwardWheel, superviseBridges, type Viewer } from './pty-bridge.js'
|
|
@@ -63,15 +65,19 @@ app.get('/api/specs', async (c) => c.json(await loadSpecs()))
|
|
|
63
65
|
// the search corpus ([[graph-lean]]): a filesystem-only {id,title,path,desc,body} for every node, NO git. The
|
|
64
66
|
// board omits `body` to stay lean, so the search palette fetches this ONCE when it opens (cached client-side)
|
|
65
67
|
// to rank nodes over their prose — off the board's hot poll. A literal segment, before the `:id` routes.
|
|
66
|
-
// Scenario prose rides the same corpus: the board's `scenarios` fold is slim ({name, tags}), so a measurable
|
|
67
|
-
// node's row here carries its declared scenarios' description/expected (+ per-scenario code) — one fetch
|
|
68
|
+
// Scenario prose rides the same corpus: the board's `scenarios` fold is slim ({name, tags, test}), so a measurable
|
|
69
|
+
// node's row here carries its declared scenarios' description/expected (+ test reference and per-scenario code) — one fetch
|
|
68
70
|
// serves both the palette's scenario plane and the focus-panel preview.
|
|
69
71
|
app.get('/api/specs/lite', (c) => {
|
|
70
72
|
const scByNode = new Map(evalNodes(repoRoot()).map((y) => [y.id, y.scenarios]))
|
|
71
73
|
return c.json(loadSpecsLite().map((row) => {
|
|
72
74
|
const sc = scByNode.get(row.id)
|
|
73
75
|
return sc?.length
|
|
74
|
-
? { ...row, scenarios: sc.map((s) => ({
|
|
76
|
+
? { ...row, scenarios: sc.map((s) => ({
|
|
77
|
+
name: s.name, description: s.description, expected: s.expected,
|
|
78
|
+
...(s.test ? { test: s.test } : {}),
|
|
79
|
+
...(s.code?.length ? { code: s.code } : {}),
|
|
80
|
+
})) }
|
|
75
81
|
: row
|
|
76
82
|
}))
|
|
77
83
|
})
|
|
@@ -113,6 +119,19 @@ app.post('/api/specs/:id/evals', async (c) => {
|
|
|
113
119
|
const r = fileHumanReading(c.req.param('id'), b)
|
|
114
120
|
return r.ok ? c.json({ ok: true, reading: r.reading }) : c.json({ error: r.error }, 400)
|
|
115
121
|
})
|
|
122
|
+
// the HUMAN SIGN-OFF write ([[human-ok]]) — the dashboard's ok affordance and `spex eval ok` share this ONE
|
|
123
|
+
// write (LAW L: no dashboard-only path). Identity is SERVER-DERIVED 'human', never the request body (the
|
|
124
|
+
// same rule as /api/remarks). The write appends a monotonic human-ok event bound to the scenario's latest
|
|
125
|
+
// reading and — on the trunk checkout — commits it straight to trunk; the board cache is invalidated
|
|
126
|
+
// atomically with persistence so the writer's own refetch never races a stale cache.
|
|
127
|
+
app.post('/api/specs/:id/evals/ok', async (c) => {
|
|
128
|
+
const b = await c.req.json().catch(() => null)
|
|
129
|
+
if (!b || typeof b.scenario !== 'string') return c.json({ error: 'body needs { scenario }' }, 400)
|
|
130
|
+
const r = fileHumanOk(c.req.param('id'), b.scenario, 'human')
|
|
131
|
+
if (!r.ok) return c.json({ error: r.error }, 400)
|
|
132
|
+
notifyBoardChanged('full')
|
|
133
|
+
return c.json({ ok: true, already: r.already, humanOk: r.humanOk })
|
|
134
|
+
})
|
|
116
135
|
// serve a reading's evidence blob by content hash (bytes never enter git): bad hash → 400, missing → 404,
|
|
117
136
|
// else the bytes with a sniffed MIME and an immutable cache header (the name IS the content hash).
|
|
118
137
|
// HTTP Range is honored — a <video> can only SEEK when the server answers byte ranges (a browser clamps
|
|
@@ -157,7 +176,9 @@ app.get('/api/settings', async (c) => c.json({
|
|
|
157
176
|
// the `surface: command` plugin-root nodes (built/active only) for the new-session `/` dropdown — each with
|
|
158
177
|
// its prompt `body` ({{targets}} placeholder), `kind`, and folder `dir` + co-located `files`. surface is a
|
|
159
178
|
// frontmatter field, not a dir (specs.ts loadSurface); `surface: system` siblings are gathered elsewhere.
|
|
160
|
-
|
|
179
|
+
// `?surface=review` lists the review-track presets instead ([[review-commands]] — the eval detail's
|
|
180
|
+
// remark-composer `/` dropdown); the exposed surfaces stay this explicit whitelist, never a passthrough.
|
|
181
|
+
app.get('/api/plugins', (c) => c.json(c.req.query('surface') === 'review' ? loadReviewConfig() : loadConfig()))
|
|
161
182
|
// the ISSUES read surface ([[issues]]) for the dashboard's issues page — the merged list over every store
|
|
162
183
|
// (local threads + the resident forge slice), the SAME mergedIssues() the CLI drain reads, verbatim
|
|
163
184
|
// (the dashboard computes nothing over it: no re-sort, no salience ranking). The `enabled` flag mirrors
|
|
@@ -390,6 +411,14 @@ app.get('/api/sessions/:id/capture', async (c) => {
|
|
|
390
411
|
if (r.reason === 'offline') return c.text('session offline (no live pane)', 409)
|
|
391
412
|
return c.text('capture failed', 502)
|
|
392
413
|
})
|
|
414
|
+
// the session's persisted interaction history ([[session-timeline]]): authored status transitions (with the
|
|
415
|
+
// FULL note text) + delivered prompts, timestamped, oldest first — what a terminal-free surface renders as
|
|
416
|
+
// the conversation. `?limit=<n>` caps the tail (default 500). 404 for an unknown/non-governed id.
|
|
417
|
+
app.get('/api/sessions/:id/timeline', (c) => {
|
|
418
|
+
const limit = Number(c.req.query('limit'))
|
|
419
|
+
const r = readTimeline(c.req.param('id'), Number.isFinite(limit) && limit > 0 ? limit : undefined)
|
|
420
|
+
return r ? c.json(r) : c.json({ error: 'no such session' }, 404)
|
|
421
|
+
})
|
|
393
422
|
// the session RECORD detail (`spex session show`): the board row (status · node · branch · launcher · …)
|
|
394
423
|
// plus the full originating prompt (the row itself carries only the preview). One id-addressed read backs
|
|
395
424
|
// the CLI's show; 404 for an unknown id.
|
|
@@ -461,8 +490,10 @@ app.post('/api/sessions/:id/input', async (c) => {
|
|
|
461
490
|
const body = await c.req.json().catch(() => ({}))
|
|
462
491
|
if (body?.kind === 'text') {
|
|
463
492
|
// `from` (the sender's session id) rides only an agent-to-agent send → the backend records the comms
|
|
464
|
-
// edge ([[comms-edge]]); a raw human dispatch omits it and is not logged.
|
|
465
|
-
|
|
493
|
+
// edge ([[comms-edge]]); a raw human dispatch omits it and is not logged. `replyVia:"note"` marks a
|
|
494
|
+
// terminal-free sender ([[session-timeline]]): the server appends the note-reply insert to the delivery.
|
|
495
|
+
const r = await sendText(c.req.param('id'), typeof body?.text === 'string' ? body.text : '', typeof body?.from === 'string' ? body.from : undefined,
|
|
496
|
+
body?.replyVia === 'note' ? { replyVia: 'note' } : {})
|
|
466
497
|
return c.json(r, r.ok ? 200 : 502)
|
|
467
498
|
}
|
|
468
499
|
if (body?.kind === 'keys') {
|
|
@@ -517,6 +548,7 @@ installConnectionReaper(server as unknown as HttpServer)
|
|
|
517
548
|
injectWebSocket(server)
|
|
518
549
|
superviseBridges() // keep a warm tmux client per live session, so opening a tab is instant
|
|
519
550
|
superviseQueue() // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
|
|
551
|
+
superviseTimeline() // record authored-lifecycle transitions to each session's durable timeline ([[session-timeline]])
|
|
520
552
|
console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`)
|
|
521
553
|
|
|
522
554
|
// graceful drain (the other half of zero-downtime reload, supervise.ts): on SIGTERM stop accepting new
|
package/spec-cli/src/init.ts
CHANGED
|
@@ -11,11 +11,12 @@ const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
|
11
11
|
const TEMPLATES = join(pkgRoot, 'templates')
|
|
12
12
|
|
|
13
13
|
// the cumulative preset chain, lean → cautious (see [[init-preset]]). `default` is the live `.plugins`
|
|
14
|
-
// instance set (planted from templates/spec);
|
|
15
|
-
// templates/presets/<tier>/ that seeding stacks ON TOP — a superset
|
|
16
|
-
//
|
|
14
|
+
// instance set (planted from templates/spec); a higher tier would be a SEPARATE package under
|
|
15
|
+
// templates/presets/<tier>/ that seeding stacks ON TOP — a superset. No non-default tier ships today
|
|
16
|
+
// (the `careful` package was retired); the chain mechanism stays for when one earns its keep.
|
|
17
|
+
// Selection matters ONLY here at seed time; the running repo just
|
|
17
18
|
// walks whatever `.plugins` ended up planted, so there is no launcher-side preset gate.
|
|
18
|
-
const PRESET_TIERS = ['default'
|
|
19
|
+
const PRESET_TIERS = ['default'] as const
|
|
19
20
|
const presetRank = (name: string): number => (PRESET_TIERS as readonly string[]).indexOf(name)
|
|
20
21
|
|
|
21
22
|
// recursively copy srcDir -> destDir, NEVER overwriting an existing file. Returns the repo-relative
|