spexcode 0.2.5 → 0.2.7
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/README.md +5 -3
- package/package.json +1 -1
- package/spec-cli/hooks/dispatch.sh +28 -40
- package/spec-cli/hooks/harness.sh +29 -19
- package/spec-cli/src/cli.ts +30 -12
- package/spec-cli/src/commit-surgery.ts +81 -0
- package/spec-cli/src/contract-filter.ts +16 -14
- package/spec-cli/src/doctor.ts +11 -5
- package/spec-cli/src/guide.ts +69 -59
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +23 -14
- package/spec-cli/src/help.ts +23 -17
- package/spec-cli/src/init.ts +5 -40
- package/spec-cli/src/layout.ts +26 -20
- package/spec-cli/src/materialize.ts +102 -126
- package/spec-cli/src/mentions.ts +5 -3
- package/spec-cli/src/plugin-harness.ts +10 -9
- package/spec-cli/src/sessions.ts +56 -28
- package/spec-cli/src/specs.ts +9 -4
- package/spec-cli/src/uninstall.ts +6 -5
- package/spec-cli/src/worktree-sources.ts +6 -4
- package/spec-cli/templates/hooks/post-checkout +22 -0
- package/spec-cli/templates/hooks/post-merge +15 -9
- package/spec-cli/templates/hooks/pre-commit +10 -0
- package/spec-cli/templates/spec/project/.config/distill/digest.mjs +136 -0
- package/spec-cli/templates/spec/project/.config/distill/spec.md +74 -0
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-CMRJGfYI.js → Dashboard-P0B9ukSG.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Dj2mxcfW.js → EvalsPage-BrvAGyc4.js} +1 -1
- package/spec-dashboard/dist/assets/{FoldToggle-BfNpeyRQ.js → FoldToggle-BuQ0lokE.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-DfY315kt.js → IssuesPage-H-D8aHEl.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-BGdC0A0P.js → MobileApp-oZXIeCPb.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-BOBCAR0t.js → SessionInterface-Blr_MEdU.js} +8 -8
- package/spec-dashboard/dist/assets/{SessionWindow-D7YmjV0i.js → SessionWindow-LcCzBMU7.js} +5 -5
- package/spec-dashboard/dist/assets/{Settings-BlSNmpH_.js → Settings-_yOye-In.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BFdzpd_O.js → index-BhIslAau.js} +2 -2
- package/spec-dashboard/dist/index.html +1 -1
- package/spec-yatsu/src/cli.ts +3 -5
- package/spec-yatsu/src/evaltab.ts +3 -2
- package/spec-yatsu/src/filing.ts +3 -4
- package/spec-yatsu/src/freshness.ts +1 -3
- package/spec-yatsu/src/proof.ts +3 -2
- package/spec-yatsu/src/scenariofresh.ts +27 -5
- package/spec-yatsu/src/sidecar.ts +8 -8
- package/spec-yatsu/src/evaluator.ts +0 -24
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type Review
|
|
|
9
9
|
import { loadSpecs } from './specs.js'
|
|
10
10
|
import { defaultHarness, defaultLauncher, harnessById, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
|
|
11
11
|
import { materialize } from './materialize.js'
|
|
12
|
-
import { mainBranch, gitCommonDir, readConfig, runtimeRoot, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
|
|
12
|
+
import { mainBranch, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
|
|
13
13
|
import { stripRefSigil } from './mentions.js'
|
|
14
14
|
|
|
15
15
|
// @@@ sessions - the WORKTREE is the durable unit; tmux is a disposable runtime handle. The per-session
|
|
@@ -76,7 +76,7 @@ function maxActive(): number {
|
|
|
76
76
|
// rvSock is imported only for the two NON-delivery uses that remain product-level: building the launch env var
|
|
77
77
|
// (rvEnv, below) and the best-effort socket sweep on close.
|
|
78
78
|
// env prefix put in front of the spawned agent so it creates this session's rendezvous control socket — and
|
|
79
|
-
// so its hooks + materialize
|
|
79
|
+
// so its hooks + materialize write to the SAME store the backend uses. SPEXCODE_HOME/CODEX_HOME are
|
|
80
80
|
// propagated when set, because the session inherits the tmux SERVER's env (not the backend's), so without this
|
|
81
81
|
// an overridden home would silently leak the session's hook-state + codex-trust to the default ~/.spexcode /
|
|
82
82
|
// ~/.codex. Deterministic: the session's store = the backend's store, never the ambient env's.
|
|
@@ -334,10 +334,13 @@ async function procSnapshot(): Promise<ProcTable> {
|
|
|
334
334
|
}
|
|
335
335
|
// @@@ LiveSnap - the ONE liveness snapshot the whole session list shares. `windows` = our live tmux windows
|
|
336
336
|
// (id → PaneProbe) + one whole-box process table; `sockets` = the ids whose rendezvous socket has a LIVE
|
|
337
|
-
// LISTENER (connect-probed once here, not the file-exists lie — [[harness-adapter]]); `
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
|
|
337
|
+
// LISTENER (connect-probed once here, not the file-exists lie — [[harness-adapter]]); `unproven` = the ids whose
|
|
338
|
+
// LISTENER probe could not conclude (timeout under load / EAGAIN off a full-but-alive backlog — see
|
|
339
|
+
// rendezvousListening's tri-state) — death UNPROVEN, so those rows read `unknown`, never `offline`;
|
|
340
|
+
// `probeFailed` = the tmux window probe itself FAILED (timed out under load), which is DISTINCT from "tmux up,
|
|
341
|
+
// no sessions" — the former means death is UNPROVEN so those rows read `unknown`, the latter is authoritative
|
|
342
|
+
// and reads `offline`.
|
|
343
|
+
export type LiveSnap = { probeFailed: boolean; windows: Map<string, PaneProbe>; sockets: Set<string>; unproven: Set<string> }
|
|
341
344
|
async function liveSnapshot(): Promise<LiveSnap> {
|
|
342
345
|
const windows = new Map<string, PaneProbe>()
|
|
343
346
|
let out: string
|
|
@@ -346,7 +349,7 @@ async function liveSnapshot(): Promise<LiveSnap> {
|
|
|
346
349
|
} catch (e) {
|
|
347
350
|
// a TIMEOUT/kill is a probe FAILURE (we can't tell who's alive → unknown, never a false graveyard). A clean
|
|
348
351
|
// non-zero exit ("no server running" — genuinely zero sessions) is authoritative → the empty map = offline.
|
|
349
|
-
return { probeFailed: probeTimedOut(e), windows, sockets: new Set() }
|
|
352
|
+
return { probeFailed: probeTimedOut(e), windows, sockets: new Set(), unproven: new Set() }
|
|
350
353
|
}
|
|
351
354
|
const procs = await procSnapshot().catch(() => undefined) // codex-only, auxiliary; its failure isn't a liveness failure
|
|
352
355
|
for (const line of out.split('\n')) {
|
|
@@ -356,12 +359,19 @@ async function liveSnapshot(): Promise<LiveSnap> {
|
|
|
356
359
|
windows.set(name, { panePid: Number.isFinite(pid) && pid > 0 ? pid : undefined, procs })
|
|
357
360
|
}
|
|
358
361
|
// LISTENER probe for every windowed session, once, in parallel (tooth: a live listener, not a lingering
|
|
359
|
-
// socket file). A codex session has no rvSock → instant ENOENT →
|
|
362
|
+
// socket file). A codex session has no rvSock → instant ENOENT → proven dead for the socket axis (codex
|
|
363
|
+
// ignores it anyway). The tri-state matters here: 'unproven' (timeout/EAGAIN — a wedged or thrashed but
|
|
364
|
+
// possibly-alive listener) lands in `unproven`, never silently in the not-live bucket, so liveness() can
|
|
365
|
+
// render it `unknown` instead of a false `offline` (issue #40's load-spike graveyard).
|
|
360
366
|
const ids = [...windows.keys()]
|
|
361
367
|
const listening = await Promise.all(ids.map((id) => rendezvousListening(id)))
|
|
362
368
|
const sockets = new Set<string>()
|
|
363
|
-
|
|
364
|
-
|
|
369
|
+
const unproven = new Set<string>()
|
|
370
|
+
ids.forEach((id, i) => {
|
|
371
|
+
if (listening[i] === 'live') sockets.add(id)
|
|
372
|
+
else if (listening[i] === 'unproven') unproven.add(id)
|
|
373
|
+
})
|
|
374
|
+
return { probeFailed: false, windows, sockets, unproven }
|
|
365
375
|
}
|
|
366
376
|
|
|
367
377
|
// @@@ paneTitles - every session pane's RAW tmux title, free from tmux. The worker launches one pane per
|
|
@@ -389,10 +399,11 @@ async function paneTitles(): Promise<Map<string, string>> {
|
|
|
389
399
|
// dashboard waiting for its slow cold-path fallback. Sorted so it only moves on a real change.
|
|
390
400
|
export async function sessionSignature(): Promise<string> {
|
|
391
401
|
const [snap, titles] = await Promise.all([liveSnapshot(), paneTitles()])
|
|
392
|
-
// fold in probe-failure
|
|
393
|
-
// unknown pushes a board-changed immediately, not only
|
|
402
|
+
// fold in probe-failure, the live-listener set AND the unproven set so a socket dying (claude exit), the
|
|
403
|
+
// probe flipping to unknown, or a listener wedging (unproven) pushes a board-changed immediately, not only
|
|
404
|
+
// on window churn.
|
|
394
405
|
return (snap.probeFailed ? 'PROBEFAIL|' : '') + [...snap.windows.keys()].sort().join(',') + '#' +
|
|
395
|
-
[...snap.sockets].sort().join(',') + '|' + [...titles].sort().map(([k, v]) => `${k}=${v}`).join(',')
|
|
406
|
+
[...snap.sockets].sort().join(',') + '~' + [...snap.unproven].sort().join(',') + '|' + [...titles].sort().map(([k, v]) => `${k}=${v}`).join(',')
|
|
396
407
|
}
|
|
397
408
|
|
|
398
409
|
// @@@ paneActivity - the harness-aware live self-summary: the SINGLE place a raw pane title becomes (or does
|
|
@@ -463,6 +474,10 @@ export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
|
|
|
463
474
|
// online-signal hasn't appeared yet reads 'starting' for the boot window, only past it 'offline'.
|
|
464
475
|
const h = harnessById(rec.harness || defaultHarness.id)
|
|
465
476
|
if (h.liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online') return 'online'
|
|
477
|
+
// not provably online — but if this session's LISTENER probe couldn't conclude (timeout under load / EAGAIN
|
|
478
|
+
// off a full-but-alive backlog), death is UNPROVEN: `unknown`, never a false `offline` a supervisor would
|
|
479
|
+
// act on (issue #40 — a wedged-but-alive worker must not read as an actionable corpse).
|
|
480
|
+
if (snap.unproven.has(rec.session)) return 'unknown'
|
|
466
481
|
const at = launchedAt.get(rec.session)
|
|
467
482
|
return at && Date.now() - at < BOOT_GRACE_MS ? 'starting' : 'offline'
|
|
468
483
|
}
|
|
@@ -817,14 +832,17 @@ export const slugify = (s: string | null) =>
|
|
|
817
832
|
// When there is none, the session is node-agnostic and we label it by the first few words of the prompt.
|
|
818
833
|
// The OPTIONAL leading dot is load-bearing: a node id is its dir basename, so a dot-prefixed config root
|
|
819
834
|
// (`.config`) keeps the dot — without `\.?` here `[[.config]]` captures nothing and never resolves to a node.
|
|
820
|
-
|
|
835
|
+
// Token chars are ANY unicode letter/number (slugify's already-made choice): a CJK dir name is a legal node
|
|
836
|
+
// id, so `[[中文节点]]` must bind the session exactly like an ASCII id — ASCII-only here silently launched
|
|
837
|
+
// node-agnostic.
|
|
838
|
+
const MENTION = /\[\[(\.?[\p{L}\p{N}_-]+)\]\]/u
|
|
821
839
|
const mentionedNode = (prompt: string): string | null => prompt.match(MENTION)?.[1] ?? null
|
|
822
840
|
// @@@ identity-token strip - an `@session` actor mention ([[mentions]]) or a bare UUID-shaped token in the
|
|
823
841
|
// prompt is ANOTHER session's identity, never this one's name. A title/slug wearing it misleads every
|
|
824
842
|
// board/git surface — and a worker tasked with cleaning that session can match its OWN worktree and delete
|
|
825
843
|
// it from under itself. Strip both before deriving; whatever prose remains names the session.
|
|
826
844
|
const UUID_TOKEN = /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g
|
|
827
|
-
const stripIdentityTokens = (s: string) => s.replace(/(^|\s)@[
|
|
845
|
+
const stripIdentityTokens = (s: string) => s.replace(/(^|\s)@[\p{L}\p{N}_-]+/gu, '$1').replace(UUID_TOKEN, ' ')
|
|
828
846
|
export function titleFromPrompt(prompt: string): string | null {
|
|
829
847
|
const first = stripIdentityTokens(prompt || '').split('\n').map((l) => l.trim()).find(Boolean) || ''
|
|
830
848
|
const words = first.split(/\s+/).filter(Boolean).slice(0, 7).join(' ')
|
|
@@ -1074,8 +1092,9 @@ export async function newSession(node: string | null, prompt: string, parent: st
|
|
|
1074
1092
|
const branch = `node/${slug}`
|
|
1075
1093
|
const path = join(mainRoot(), '.worktrees', slug)
|
|
1076
1094
|
await gitA(['-C', mainRoot(), 'worktree', 'add', '-b', branch, path, mainBranch()])
|
|
1077
|
-
// the checkout delivers the tracked spec sources and the materialize below delivers the
|
|
1078
|
-
//
|
|
1095
|
+
// the checkout delivers the tracked spec sources and the materialize below delivers the materialized
|
|
1096
|
+
// artifacts; the ONE
|
|
1097
|
+
// thing git cannot carry is the machine-local spexcode.local.json — copied as a snapshot ([[residence]];
|
|
1079
1098
|
// no-op when the main checkout has none).
|
|
1080
1099
|
seedWorktreeHostState(mainRoot(), path)
|
|
1081
1100
|
// prepared but NOT launched: enters the queue as `queued`. drainQueue() below launches it at once when a
|
|
@@ -1097,7 +1116,7 @@ export async function newSession(node: string | null, prompt: string, parent: st
|
|
|
1097
1116
|
}
|
|
1098
1117
|
writeRecord(rec)
|
|
1099
1118
|
writePromptFile(id, prompt) // capture the ORIGINATING prompt (the human/manager's ask) as store metadata (best-effort)
|
|
1100
|
-
//
|
|
1119
|
+
// materialize the harness-discovered artifacts INTO the worktree (CLAUDE.md/AGENTS.md contract block, .claude/.codex
|
|
1101
1120
|
// shims, manifest to the global store) so the launched agent gets the contract + hooks the SAME way a
|
|
1102
1121
|
// self-launched one does — by auto-discovery, not CLI injection. This is why the launch line below carries no
|
|
1103
1122
|
// --append-system-prompt / --settings, and why we no longer hide CLAUDE.md: hiding it suppressed the agent's
|
|
@@ -1121,9 +1140,9 @@ export async function newSession(node: string | null, prompt: string, parent: st
|
|
|
1121
1140
|
return toSession(after, queued ? 'queued' : 'working', queued ? 'offline' : 'starting')
|
|
1122
1141
|
}
|
|
1123
1142
|
|
|
1124
|
-
// @@@ bootstrapMaterialize - the creation-time materialize is BOOTSTRAP, not best-effort: it is what
|
|
1125
|
-
// the worktree's .claude/.codex shims (the settings.json hook wiring) in the first place, and
|
|
1126
|
-
//
|
|
1143
|
+
// @@@ bootstrapMaterialize - the creation-time materialize is BOOTSTRAP, not best-effort: it is what writes
|
|
1144
|
+
// the worktree's .claude/.codex shims (the settings.json hook wiring) in the first place, and every
|
|
1145
|
+
// lifecycle dispatch RIDES ON those hooks — so when this materialize fails, no hook ever fires,
|
|
1127
1146
|
// and the worker comes up ungoverned (no contract block, no stop-gate) with nothing saying so. Fail loud
|
|
1128
1147
|
// instead: log the cause + worktree, and stamp the failure on the record's `note` so the board/watch surface
|
|
1129
1148
|
// it. The launch still proceeds — a visibly degraded worker the human can close + re-dispatch beats a refused
|
|
@@ -1134,7 +1153,7 @@ export function bootstrapMaterialize(rec: SessRec, doMaterialize: (proj: string)
|
|
|
1134
1153
|
doMaterialize(rec.worktreePath)
|
|
1135
1154
|
} catch (e) {
|
|
1136
1155
|
const msg = e instanceof Error ? e.message : String(e)
|
|
1137
|
-
console.error(`spex: materialize failed for worktree ${rec.worktreePath} — hooks/contract not
|
|
1156
|
+
console.error(`spex: materialize failed for worktree ${rec.worktreePath} — hooks/contract not materialized, worker launches UNGOVERNED: ${msg}`)
|
|
1138
1157
|
writeRecord({ ...rec, note: `materialize failed at creation — worker ungoverned (no hooks/contract): ${msg}` })
|
|
1139
1158
|
}
|
|
1140
1159
|
}
|
|
@@ -1256,8 +1275,8 @@ export function markIdle(sessionId?: string): boolean {
|
|
|
1256
1275
|
// done / propose merge. The dogfood ritual lands every change as a COMMIT on the node branch first, so two
|
|
1257
1276
|
// states block a declaration: (1) any uncommitted working-tree change, or (2) 0 commits ahead of main
|
|
1258
1277
|
// (nothing committed to merge). Since the global-store refactor, SpexCode writes NO per-session files into
|
|
1259
|
-
// the worktree (the runtime lives in ~/.spexcode), and the only in-tree SpexCode artifacts are
|
|
1260
|
-
//
|
|
1278
|
+
// the worktree (the runtime lives in ~/.spexcode), and the only in-tree SpexCode artifacts are exclude-
|
|
1279
|
+
// hidden materialized artifacts or filter-covered contract blocks ([[residence]]), so
|
|
1261
1280
|
// neither shows as an uncommitted change — the worktree is pristine and EVERY dirty path is genuine spec/code
|
|
1262
1281
|
// work, no runtime-file filtering needed.
|
|
1263
1282
|
// Runs from cwd = the session worktree; ALL git goes through git() so the hook's exported GIT_DIR/GIT_INDEX_FILE
|
|
@@ -1446,12 +1465,17 @@ export async function exitSession(id: string): Promise<boolean> {
|
|
|
1446
1465
|
// the session's whole global-store record dir — the work is gone, not just stopped. Same stop primitive as
|
|
1447
1466
|
// exitSession (no duplicate kill path), then the git worktree/branch teardown that exit deliberately skips,
|
|
1448
1467
|
// then the store sweep (exit KEEPS the record so the session stays on the board offline; close discards it).
|
|
1468
|
+
// The tree's materialize slot ([[runtime]] trees/<enc>) retires with the worktree — its key needs the live tree,
|
|
1469
|
+
// so it is resolved BEFORE the removal; both sweeps are best-effort (residue is swept at uninstall anyway).
|
|
1449
1470
|
export async function closeSession(id: string): Promise<boolean> {
|
|
1450
1471
|
const wt = await findWorktree(id)
|
|
1451
1472
|
await stopAgentProcess(id)
|
|
1452
1473
|
if (wt) {
|
|
1474
|
+
let slot: string | null = null
|
|
1475
|
+
try { slot = treeSlotDir(wt.path) } catch { /* tree already unresolvable — nothing to key the slot by */ }
|
|
1453
1476
|
await gitA(['-C', mainRoot(), 'worktree', 'remove', '--force', wt.path])
|
|
1454
1477
|
if (wt.branch) await gitA(['-C', mainRoot(), 'branch', '-D', wt.branch])
|
|
1478
|
+
if (slot) { try { rmSync(slot, { recursive: true, force: true }) } catch { /* best-effort GC */ } }
|
|
1455
1479
|
}
|
|
1456
1480
|
try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) } catch { /* best-effort sweep of the global record */ }
|
|
1457
1481
|
void drainQueue() // a close frees a slot — start the next queued session if any
|
|
@@ -1638,8 +1662,12 @@ export type WatchOpts = { source: () => Promise<Session[]>; selectors?: string[]
|
|
|
1638
1662
|
// "block for a worker, then exit" that is GUARANTEED to return. The deadline is checked EVERY poll, before
|
|
1639
1663
|
// EVERY sleep (and even when a poll throws), so a target stuck in ANY non-actionable state
|
|
1640
1664
|
// (`working`/`parked`/`idle`/`queued`/`starting`) can never hang the caller — it exits at the deadline.
|
|
1641
|
-
// `reached` = the target hit an actionable status; the rest are the loud exits.
|
|
1642
|
-
|
|
1665
|
+
// `reached` = the target hit an actionable status; the rest are the loud exits. `backendDown` is a verdict
|
|
1666
|
+
// about the TRANSPORT, never the session — `kind` keeps its two shapes distinct for the caller's outcome
|
|
1667
|
+
// surface: 'unreachable' (nothing listening, the whole timeout was spent retrying) vs 'http' (reachable but
|
|
1668
|
+
// broken, failed loud at once). The caller must surface these OUTSIDE the session-status vocabulary — a
|
|
1669
|
+
// supervisor must never be able to read a transport failure as a session state (issue #40).
|
|
1670
|
+
export type WatchOutcome = { reached: DisplayStatus } | { timedOut: true } | { gone: true } | { backendDown: string; kind: 'unreachable' | 'http' }
|
|
1643
1671
|
export async function watchSessions(emit: (line: string) => void, opts: WatchOpts): Promise<WatchOutcome> {
|
|
1644
1672
|
const { source, selectors = [], statuses, includeIdle = false, intervalMs = 5000, as, until } = opts
|
|
1645
1673
|
const tag = as ? `[${as}] ` : ''
|
|
@@ -1693,7 +1721,7 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
|
|
|
1693
1721
|
// is rebooting its hot-reloaded child behind the stable port on a sibling merge. This is TRANSIENT:
|
|
1694
1722
|
// record it, warn ONCE, and keep polling — the deadline (below) is the only hard wall, so a
|
|
1695
1723
|
// backgrounded `spex wait` survives the ~1s restart instead of dying on the interrupted fetch.
|
|
1696
|
-
if (until && isBackendDown(e) && !isBackendUnreachable(e)) return { backendDown: (e as Error).message }
|
|
1724
|
+
if (until && isBackendDown(e) && !isBackendUnreachable(e)) return { backendDown: (e as Error).message, kind: 'http' }
|
|
1697
1725
|
if (isBackendDown(e)) {
|
|
1698
1726
|
downMsg = (e as Error).message
|
|
1699
1727
|
if (!warnedDown) { warnedDown = true; console.error(`${tag}[spex] watch: ${downMsg}; retrying every ${intervalMs / 1000}s…`) }
|
|
@@ -1703,7 +1731,7 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
|
|
|
1703
1731
|
// guarantees `spex wait` can never hang on a worker stuck outside WATCH_ACTIONABLE — nor spin forever on a
|
|
1704
1732
|
// backend that never comes back. Hitting the deadline while still unreachable reports THAT (`backendDown`),
|
|
1705
1733
|
// not a false "no actionable status" timeout, so the manager sees the honest cause.
|
|
1706
|
-
if (until && Date.now() >= deadline) return downMsg ? { backendDown: downMsg } : { timedOut: true }
|
|
1734
|
+
if (until && Date.now() >= deadline) return downMsg ? { backendDown: downMsg, kind: 'unreachable' } : { timedOut: true }
|
|
1707
1735
|
await sleep(intervalMs)
|
|
1708
1736
|
}
|
|
1709
1737
|
}
|
package/spec-cli/src/specs.ts
CHANGED
|
@@ -102,7 +102,9 @@ function walk(dir: string, parent: string | null, acc: Raw[]) {
|
|
|
102
102
|
// function over this same universe (every spec node), so a colliding leaf carries one canonical id
|
|
103
103
|
// system-wide instead of a second, diverging bare-leaf scheme.
|
|
104
104
|
export function mintIds(segs: string[][]): string[] {
|
|
105
|
-
|
|
105
|
+
// NFC pins one canonical byte form for a non-ASCII dir name (macOS hands out NFD basenames), so a typed
|
|
106
|
+
// `[[中文节点]]` (NFC, what an IME emits) string-matches the minted id on every platform.
|
|
107
|
+
const suffix = (s: string[], k: number) => s.slice(s.length - k).join('_').normalize('NFC')
|
|
106
108
|
return segs.map((s, i) => {
|
|
107
109
|
let k = 1
|
|
108
110
|
while (k < s.length && segs.some((o, j) => j !== i && o.length >= k && suffix(o, k) === suffix(s, k))) k++
|
|
@@ -357,7 +359,10 @@ function loadSurface(surface: 'command' | 'system' | 'hook' | 'skill' | 'agent')
|
|
|
357
359
|
// @@@ skip pending - a `status: pending` plugin is DECLARED INTENT, not yet active. It renders on the
|
|
358
360
|
// board (via loadSpecs) but must NOT gather: neither a command preset, nor folded into a system prompt,
|
|
359
361
|
// nor a live hook. Only built/active plugins surface here, so pending stubs stay inert.
|
|
360
|
-
|
|
362
|
+
// the surface field may name SEVERAL surfaces (comma-separated or a YAML list) — the node plugs
|
|
363
|
+
// into every one it lists, so the match is membership, not equality.
|
|
364
|
+
const surfaces = list(fm.surface).flatMap((v) => String(v).split(',')).map((v) => v.trim()).filter(Boolean)
|
|
365
|
+
if (surfaces.includes(surface) && str(fm.status) !== 'pending') {
|
|
361
366
|
out.push({
|
|
362
367
|
name,
|
|
363
368
|
title: str(fm.title, name),
|
|
@@ -390,10 +395,10 @@ export function loadSystemConfig(): ConfigPreset[] { return loadSurface('system'
|
|
|
390
395
|
// the hook handlers (compiled into the per-session hook manifest the dispatcher reads). Each carries its
|
|
391
396
|
// `events`/`order`/`block` binding + co-located script `files`.
|
|
392
397
|
export function loadHookConfig(): ConfigPreset[] { return loadSurface('hook') }
|
|
393
|
-
// the skill bundles (
|
|
398
|
+
// the skill bundles (materialized into each harness's auto-discovered SKILL.md dir). Each node's `desc` is the
|
|
394
399
|
// load-trigger and its `body` is the on-demand instructions; loadSurface passes the folder basename as `name`.
|
|
395
400
|
export function loadSkillConfig(): ConfigPreset[] { return loadSurface('skill') }
|
|
396
|
-
// the sub-agent definitions (
|
|
401
|
+
// the sub-agent definitions (materialized into each harness's auto-discovered agent dir, e.g. claude's
|
|
397
402
|
// .claude/agents/<name>.md). Like a skill, the node's `desc` is the on-demand load-trigger and its `body` is the
|
|
398
403
|
// agent's system prompt; additionally its `tools` field is the harness tool allowlist for the spawned agent.
|
|
399
404
|
export function loadAgentConfig(): ConfigPreset[] { return loadSurface('agent') }
|
|
@@ -8,8 +8,8 @@ import { loadSkillConfig, loadAgentConfig } from './specs.js'
|
|
|
8
8
|
import { dematerialize } from './materialize.js'
|
|
9
9
|
|
|
10
10
|
// @@@ spex-uninstall - materialize(∅) plus the store: the in-tree/global-config backout IS dematerialize (the
|
|
11
|
-
// same identity-stamped erase phase every
|
|
12
|
-
// command adds only what a
|
|
11
|
+
// same identity-stamped erase phase every materialize runs first — the forgetting law's empty policy), and this
|
|
12
|
+
// command adds only what a materialize never owns per-run: the global per-project store, the plugin-bundle sweep,
|
|
13
13
|
// and the optional git hooks. EVERY removal is gated on a SpexCode IDENTITY STAMP (the managed-block
|
|
14
14
|
// sentinels, the shim's own dispatch.sh command line, the trust sentinels, the generated mark / name-scoped
|
|
15
15
|
// on-demand paths, the plugin name stamp), so it can only ever delete what SpexCode itself generated. The one
|
|
@@ -100,11 +100,12 @@ export function uninstall(targetArg: string | undefined, opts: { hooks?: boolean
|
|
|
100
100
|
|
|
101
101
|
// 1+2. materialize(∅): every harness's artifacts (contract block, shim, trust, skills/agents), the managed
|
|
102
102
|
// .gitignore + info/exclude blocks, any legacy skip-worktree bit, and the content filter — the SAME
|
|
103
|
-
// erase phase every
|
|
103
|
+
// erase phase every materialize runs, asserted against the empty policy. One inverse, never a parallel one.
|
|
104
104
|
dematerialize(proj, arts)
|
|
105
105
|
|
|
106
|
-
// 3. the global per-project store —
|
|
107
|
-
//
|
|
106
|
+
// 3. the global per-project store — the per-tree materialize slots (trees/<enc>: manifest + content-hash +
|
|
107
|
+
// plugin ledger), any legacy pre-slot manifest, and the session records. This is the runtime tier,
|
|
108
|
+
// not the user's spec asset, so the whole dir is ours.
|
|
108
109
|
let store: string | null = null
|
|
109
110
|
try {
|
|
110
111
|
store = runtimeRoot(proj)
|
|
@@ -2,14 +2,16 @@ 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 ([[
|
|
5
|
+
// @@@ worktree-sources ([[residence]]) - a fresh session worktree is fed by THREE transports, one per
|
|
6
6
|
// source kind, and the kind decides the transport — never a mode branch:
|
|
7
7
|
// - TRACKED project state (`.spec`, `spexcode.json`) arrives by GIT CHECKOUT: the sources are always
|
|
8
8
|
// tracked (git is the database), so `git worktree add` alone delivers them. No symlink — a link is a
|
|
9
9
|
// WRITE-SEMANTICS declaration (write-through to the main tree), and spec writes go back through the
|
|
10
10
|
// branch/merge ritual, not through a side channel.
|
|
11
|
-
// -
|
|
12
|
-
//
|
|
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.
|
|
13
15
|
// - HOST state (`spexcode.local.json`, machine-local and never tracked) is COPIED — a snapshot: the worker
|
|
14
16
|
// reads the same launchers/policy the host had at dispatch, but its writes land on its own copy and die
|
|
15
17
|
// with the worktree, never on the host's real config (a worker once wrote "its" test config through the
|
|
@@ -31,7 +33,7 @@ export function seedWorktreeHostState(main: string, wt: string): void {
|
|
|
31
33
|
// what we seed, we hide: a seeded entry git still sees is force-add bait (a real PR once carried seeded
|
|
32
34
|
// files into a product repo). `.git/info/exclude` lives in the COMMON git dir, so one write hides the entry
|
|
33
35
|
// in every linked worktree AND the main checkout. Only entries seeded by THIS call and reported un-ignored
|
|
34
|
-
// by `git check-ignore` are written: idempotent across dispatches, and a repo whose
|
|
36
|
+
// by `git check-ignore` are written: idempotent across dispatches, and a repo whose materialize already ignores
|
|
35
37
|
// the overlay (materialize's block under any policy) writes nothing — the self-heal for a half-configured repo.
|
|
36
38
|
function hideSeededFromGit(wt: string, seeded: string[]): void {
|
|
37
39
|
for (const f of seeded) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# @@@ footprint refresh (post-checkout) ([[commit-surgery]]) - a branch checkout is one of the three git
|
|
3
|
+
# transitions that can move the materialize's inputs: .spec/.config content changes across branches, and a
|
|
4
|
+
# contract file's TRACKEDNESS can flip (switching to a branch that tracks CLAUDE.md checks out the pristine
|
|
5
|
+
# index prose — the re-materialize here writes the block back into the working file, binds the clean/smudge
|
|
6
|
+
# filter, and withdraws the exclude entry; the kind-transition heals itself). Git-native anchors only —
|
|
7
|
+
# never a harness event. Quiet, best-effort: a failed refresh self-heals at the next anchor (pre-commit's
|
|
8
|
+
# unconditional materialize is the correctness backstop).
|
|
9
|
+
#
|
|
10
|
+
# args: <prev-HEAD> <new-HEAD> <flag>; flag=1 is a branch checkout, flag=0 a file checkout (git checkout --
|
|
11
|
+
# <path> restores files and moves nothing the materialize depends on — skip those).
|
|
12
|
+
[ "${3:-0}" = "1" ] || exit 0
|
|
13
|
+
main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
|
|
14
|
+
repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
15
|
+
if command -v spex >/dev/null 2>&1; then
|
|
16
|
+
spex internal refresh-footprint >/dev/null 2>&1 || true
|
|
17
|
+
elif [ -x "$repo_root/node_modules/.bin/spex" ]; then
|
|
18
|
+
"$repo_root/node_modules/.bin/spex" internal refresh-footprint >/dev/null 2>&1 || true
|
|
19
|
+
elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then
|
|
20
|
+
"$main_root/spec-cli/bin/spex.mjs" internal refresh-footprint >/dev/null 2>&1 || true
|
|
21
|
+
fi
|
|
22
|
+
exit 0
|
|
@@ -9,19 +9,25 @@
|
|
|
9
9
|
# the feature is OFF in spexcode.json), so this hook is a THIN resolver + caller: resolve `spex` the same
|
|
10
10
|
# three ways the pre-commit hook does, pass the merged node, echo whatever it prints. No CLI resolvable → no
|
|
11
11
|
# nudge (safe degradation, same as the lint shim's advisory mode).
|
|
12
|
+
main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
|
|
13
|
+
repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
14
|
+
spex_run() {
|
|
15
|
+
if command -v spex >/dev/null 2>&1; then spex "$@"
|
|
16
|
+
elif [ -x "$repo_root/node_modules/.bin/spex" ]; then "$repo_root/node_modules/.bin/spex" "$@"
|
|
17
|
+
elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then "$main_root/spec-cli/bin/spex.mjs" "$@"
|
|
18
|
+
else return 127; fi
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# @@@ footprint refresh ([[commit-surgery]]) - a merge is one of the three git transitions that can move the
|
|
22
|
+
# materialize's inputs (.spec/.config content, contract-file trackedness), so re-materialize here — git-native
|
|
23
|
+
# anchors only, never a harness event. Quiet, best-effort: a failed refresh self-heals at the next anchor.
|
|
24
|
+
spex_run internal refresh-footprint >/dev/null 2>&1 || true
|
|
25
|
+
|
|
12
26
|
subj=$(git log -1 --format=%s 2>/dev/null)
|
|
13
27
|
case "$subj" in
|
|
14
28
|
"merge node/"*) ;;
|
|
15
29
|
*) exit 0 ;;
|
|
16
30
|
esac
|
|
17
31
|
node=$(printf '%s' "$subj" | sed -n 's#^merge \(node/[^:]*\):.*#\1#p')
|
|
18
|
-
|
|
19
|
-
repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
20
|
-
if command -v spex >/dev/null 2>&1; then
|
|
21
|
-
spex issues nudge "$node"
|
|
22
|
-
elif [ -x "$repo_root/node_modules/.bin/spex" ]; then
|
|
23
|
-
"$repo_root/node_modules/.bin/spex" issues nudge "$node"
|
|
24
|
-
elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then
|
|
25
|
-
"$main_root/spec-cli/bin/spex.mjs" issues nudge "$node"
|
|
26
|
-
fi
|
|
32
|
+
spex_run issues nudge "$node"
|
|
27
33
|
exit 0
|
|
@@ -64,6 +64,16 @@ if [ "$branch" = "$trunk" ] && [ ! -f "$git_dir/MERGE_HEAD" ] && [ -z "${SPEXCOD
|
|
|
64
64
|
exit 1
|
|
65
65
|
fi
|
|
66
66
|
|
|
67
|
+
# @@@ footprint surgery ([[commit-surgery]]) - the history anchor: an UNCONDITIONAL materialize (masks fresh
|
|
68
|
+
# at the one moment history is written) + staged-index repair — strip the spexcode sentinel block from any
|
|
69
|
+
# staged blob (source = the staged blob, so `git add -p` partial staging survives), unstage HEAD-untracked
|
|
70
|
+
# generated artifacts. REPAIRS AND PROCEEDS, never rejects; one note per repair on stderr. Runs INSIDE the
|
|
71
|
+
# hook env on purpose: the surgery honors GIT_INDEX_FILE so a pathspec/`-a` commit's TEMPORARY index is the
|
|
72
|
+
# one repaired. Advisory when spex is unresolvable or errors (CI lint enforces); never blocks the commit.
|
|
73
|
+
if [ -n "$spex_kind" ]; then
|
|
74
|
+
spex_cli internal commit-surgery || echo "• SpexCode: footprint surgery skipped (error above) — a generated artifact may be staged; CI still enforces." >&2
|
|
75
|
+
fi
|
|
76
|
+
|
|
67
77
|
# @@@ spec-lint - the hook is just a thin shim over `spex lint`. It blocks on errors only (broken
|
|
68
78
|
# spec↔code links); coverage/drift are warnings. Bypass with SPEXCODE_SKIP_LINT=1. It shims through the
|
|
69
79
|
# shared spex_cli() resolved above (repoRoot() discovers the cwd's toplevel via git.ts, which strips the
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// digest.mjs — locate a dead session's transcript on disk and print a compact markdown digest.
|
|
3
|
+
// Usage: node digest.mjs <session-id | path/to/transcript.jsonl>
|
|
4
|
+
// Read-only: never resumes, prompts, or mutates the session. Exit 1 (loud) when no transcript is found.
|
|
5
|
+
//
|
|
6
|
+
// Harness coverage: claude (projects/<enc-cwd>/<id>.jsonl) and codex (sessions/YYYY/MM/DD/rollout-*<id>.jsonl).
|
|
7
|
+
// The digest keeps the high-signal stream — user prompts in full, assistant text, tool calls as one-liners,
|
|
8
|
+
// error results — and drops the bulk (tool outputs, attachments, sidechains, reasoning).
|
|
9
|
+
|
|
10
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
|
|
14
|
+
const arg = process.argv[2]
|
|
15
|
+
if (!arg) { console.error('usage: digest.mjs <session-id | transcript.jsonl>'); process.exit(1) }
|
|
16
|
+
|
|
17
|
+
const TRUNC = (s, n) => { s = String(s ?? '').trim(); return s.length > n ? s.slice(0, n) + ` …[+${s.length - n} chars]` : s }
|
|
18
|
+
|
|
19
|
+
// ---- locate -------------------------------------------------------------
|
|
20
|
+
const claudeRoots = () => {
|
|
21
|
+
const roots = []
|
|
22
|
+
if (process.env.CLAUDE_CONFIG_DIR) roots.push(process.env.CLAUDE_CONFIG_DIR)
|
|
23
|
+
for (const e of readdirSync(homedir(), { withFileTypes: true }))
|
|
24
|
+
if (e.isDirectory() && e.name.startsWith('.claude')) roots.push(join(homedir(), e.name))
|
|
25
|
+
return roots
|
|
26
|
+
}
|
|
27
|
+
const findClaude = (id) => {
|
|
28
|
+
for (const root of claudeRoots()) {
|
|
29
|
+
const proj = join(root, 'projects')
|
|
30
|
+
if (!existsSync(proj)) continue
|
|
31
|
+
for (const d of readdirSync(proj)) {
|
|
32
|
+
const f = join(proj, d, `${id}.jsonl`)
|
|
33
|
+
if (existsSync(f)) return f
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
const findCodex = (id) => {
|
|
39
|
+
const root = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions')
|
|
40
|
+
if (!existsSync(root)) return null
|
|
41
|
+
const stack = [root]
|
|
42
|
+
while (stack.length) {
|
|
43
|
+
const dir = stack.pop()
|
|
44
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
45
|
+
if (e.isDirectory()) stack.push(join(dir, e.name))
|
|
46
|
+
else if (e.name.startsWith('rollout-') && e.name.includes(id) && e.name.endsWith('.jsonl')) return join(dir, e.name)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
const file = arg.endsWith('.jsonl') ? arg : (findClaude(arg) || findCodex(arg))
|
|
52
|
+
if (!file || !existsSync(file)) { console.error(`no transcript found for "${arg}" (searched claude projects/ and codex sessions/)`); process.exit(1) }
|
|
53
|
+
|
|
54
|
+
// ---- parse --------------------------------------------------------------
|
|
55
|
+
const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean)
|
|
56
|
+
const out = []
|
|
57
|
+
const meta = { cwd: null, branch: null, first: null, last: null }
|
|
58
|
+
const filesTouched = new Set()
|
|
59
|
+
let errors = 0
|
|
60
|
+
const seen = new Set()
|
|
61
|
+
|
|
62
|
+
// harness-injected preamble arrives typed as "user" (system-reminders, AGENTS.md folds, codex permission/skill
|
|
63
|
+
// blocks) — it is noise to a digest, and real human prompts essentially never open with these markers.
|
|
64
|
+
const isInjected = (t) => /^(<|# AGENTS\.md instructions|Caveat: The messages below)/.test(t.trimStart())
|
|
65
|
+
const userBlock = (t) => { t = String(t ?? '').trim(); return t && !isInjected(t) ? `\n## user\n${t}` : null }
|
|
66
|
+
|
|
67
|
+
const toolLine = (name, input = {}) => {
|
|
68
|
+
if ((name === 'Edit' || name === 'Write' || name === 'NotebookEdit') && input.file_path) filesTouched.add(input.file_path)
|
|
69
|
+
const hint = input.description || input.file_path || input.command || input.prompt || input.query || input.pattern || ''
|
|
70
|
+
return `→ ${name}${hint ? ` · ${TRUNC(hint, 160)}` : ''}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const raw of lines) {
|
|
74
|
+
let l; try { l = JSON.parse(raw) } catch { continue }
|
|
75
|
+
const ts = l.timestamp
|
|
76
|
+
if (ts) { meta.first ||= ts; meta.last = ts }
|
|
77
|
+
if (l.cwd) meta.cwd ||= l.cwd
|
|
78
|
+
if (l.gitBranch && l.gitBranch !== 'HEAD') meta.branch ||= l.gitBranch
|
|
79
|
+
|
|
80
|
+
// claude shape: {type: user|assistant, message:{content}, isSidechain}
|
|
81
|
+
if (l.type === 'user' || l.type === 'assistant') {
|
|
82
|
+
if (l.isSidechain) continue // subagent noise
|
|
83
|
+
const c = l.message?.content
|
|
84
|
+
if (typeof c === 'string') { const u = userBlock(c); if (u) out.push(u); continue }
|
|
85
|
+
for (const item of c || []) {
|
|
86
|
+
if (item.type === 'text' && item.text?.trim()) {
|
|
87
|
+
if (l.type === 'user') { const u = userBlock(item.text); if (u) out.push(u) }
|
|
88
|
+
else out.push(TRUNC(item.text, 2000))
|
|
89
|
+
}
|
|
90
|
+
else if (item.type === 'tool_use') out.push(toolLine(item.name, item.input))
|
|
91
|
+
else if (item.type === 'tool_result' && item.is_error) {
|
|
92
|
+
errors++
|
|
93
|
+
const t = Array.isArray(item.content) ? item.content.map((x) => x.text || '').join(' ') : item.content
|
|
94
|
+
out.push(`⚠ tool error: ${TRUNC(t, 400)}`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// codex shape: {type: session_meta|response_item|event_msg, payload:{...}}. The same message is logged
|
|
101
|
+
// BOTH as a response_item and an event_msg (version-dependent which exists) — dedupe on the text.
|
|
102
|
+
const p = l.payload
|
|
103
|
+
if (!p) continue
|
|
104
|
+
if (l.type === 'session_meta') { meta.cwd ||= p.cwd; continue }
|
|
105
|
+
const pushMsg = (role, text) => {
|
|
106
|
+
text = String(text ?? '').trim()
|
|
107
|
+
if (!text || seen.has(text)) return
|
|
108
|
+
seen.add(text)
|
|
109
|
+
if (role === 'user') { const u = userBlock(text); if (u) out.push(u) }
|
|
110
|
+
else if (role === 'assistant') out.push(TRUNC(text, 2000)) // developer/system roles are harness plumbing
|
|
111
|
+
}
|
|
112
|
+
if (l.type === 'event_msg' && (p.type === 'user_message' || p.type === 'agent_message'))
|
|
113
|
+
pushMsg(p.type === 'user_message' ? 'user' : 'assistant', p.message)
|
|
114
|
+
else if (l.type === 'response_item') {
|
|
115
|
+
if (p.type === 'message') {
|
|
116
|
+
pushMsg(p.role, (p.content || []).map((x) => x.text || '').join('\n'))
|
|
117
|
+
} else if (p.type === 'function_call') {
|
|
118
|
+
let input = {}; try { input = JSON.parse(p.arguments || '{}') } catch {}
|
|
119
|
+
out.push(toolLine(p.name, input))
|
|
120
|
+
} else if (p.type === 'function_call_output' && /error/i.test(String(p.output).slice(0, 200))) {
|
|
121
|
+
errors++
|
|
122
|
+
out.push(`⚠ tool error: ${TRUNC(p.output, 400)}`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- emit ---------------------------------------------------------------
|
|
128
|
+
const kb = Math.round(statSync(file).size / 1024)
|
|
129
|
+
console.log(`# transcript digest — ${arg}`)
|
|
130
|
+
console.log(`- file: ${file} (${kb} KB, ${lines.length} lines)`)
|
|
131
|
+
if (meta.first) console.log(`- span: ${meta.first} → ${meta.last}`)
|
|
132
|
+
if (meta.cwd) console.log(`- cwd: ${meta.cwd}`)
|
|
133
|
+
if (meta.branch) console.log(`- branch: ${meta.branch}`)
|
|
134
|
+
console.log(out.join('\n'))
|
|
135
|
+
console.log(`\n---\n- transcript: ${file}\n- tool errors seen: ${errors}`)
|
|
136
|
+
if (filesTouched.size) console.log(`- files edited:\n${[...filesTouched].map((f) => ` - ${f}`).join('\n')}`)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: distill
|
|
3
|
+
surface: skill, command
|
|
4
|
+
status: active
|
|
5
|
+
hue: 210
|
|
6
|
+
desc: Use when the human wants to inherit a past or dead session's knowledge and work — "distill session X / 继承那个 session 的经验 / 接手它的工作 / 把之前 session 的东西捞回来 / harvest, salvage a finished session". Given a session id, read its transcript from disk (NEVER resume or re-prompt it — its cache is cold and a re-prime is expensive), distill goal · decisions · traps · next steps into the current session, and if its worktree/branch never merged, carry the work over and retire the resources.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# distill
|
|
10
|
+
|
|
11
|
+
Inherit a finished (or dead) session's **mind and desk** without waking it — mind = its transcript on disk,
|
|
12
|
+
desk = its worktree/branch. The one iron rule: **never resume, reopen, send to, or otherwise re-prompt the
|
|
13
|
+
old session** (cold cache: any turn pays a full re-prime). Everything below is read-only files and plain git.
|
|
14
|
+
|
|
15
|
+
## 1 · resolve the session
|
|
16
|
+
|
|
17
|
+
Input: a session id — SpexCode's, a bare harness id (claude / codex thread), or a transcript `.jsonl` path.
|
|
18
|
+
|
|
19
|
+
- **SpexCode session** (first choice — the join is first-class): its record is
|
|
20
|
+
`~/.spexcode/projects/*/sessions/<id>/session.json` — glob for the id, prefix ok. Take `worktree_path`,
|
|
21
|
+
`branch`, `harness`, `harness_session_id`, `status`, `title`; the originating goal is
|
|
22
|
+
`spex session prompt <id>`. For a claude-harness session the transcript id IS the SpexCode session id;
|
|
23
|
+
for codex it is `harness_session_id`.
|
|
24
|
+
- **Any other session**: treat the arg as the harness's own id. The transcript carries `cwd` (and, unless
|
|
25
|
+
the worktree was detached, a branch) — the digest header surfaces them; that is your join to its desk.
|
|
26
|
+
|
|
27
|
+
## 2 · digest the transcript — mechanical first, model second
|
|
28
|
+
|
|
29
|
+
`node .spec/<root>/.config/distill/digest.mjs <id-or-path>` locates the transcript (claude:
|
|
30
|
+
`$CLAUDE_CONFIG_DIR` and every `~/.claude*` config dir → `projects/*/<id>.jsonl`; codex: `$CODEX_HOME` or
|
|
31
|
+
`~/.codex` → `sessions/**/rollout-*<id>.jsonl`) and prints a compact digest: the human's prompts in full,
|
|
32
|
+
the agent's own text, tool calls as one-liners, error results, and a footer with the files it edited and
|
|
33
|
+
the raw transcript path. It exits loud when nothing is found — do not fall back to resuming the session.
|
|
34
|
+
|
|
35
|
+
Read the digest yourself when small; big (>~100 KB) → a subagent returns only the distillation below, so
|
|
36
|
+
the inheritance never floods your own context. Its ⚠ error lines and footer are step 3's trap material.
|
|
37
|
+
|
|
38
|
+
## 3 · distill — forward-looking, not narrative
|
|
39
|
+
|
|
40
|
+
Completed work is git's job to remember; do not re-narrate it — and never paste raw transcript. State in
|
|
41
|
+
your reply, and work from, what the transcript knows that git does not:
|
|
42
|
+
|
|
43
|
+
- **Goal & landing** — what it set out to do, and where it actually stopped (merged? proposal pending?
|
|
44
|
+
abandoned mid-flight?).
|
|
45
|
+
- **Decisions & why** — the direction that was settled, including options weighed and rejected.
|
|
46
|
+
- **Traps** — failures, dead ends, gotchas, and every correction the human made. These are the
|
|
47
|
+
highest-value lines in the whole transcript.
|
|
48
|
+
- **Unfinished / next actions** — what it would have done next.
|
|
49
|
+
- **Pointers** — files edited, spec nodes touched, and the raw transcript path itself, so later questions
|
|
50
|
+
drill into the source instead of inheriting everything up front.
|
|
51
|
+
|
|
52
|
+
## 4 · salvage the desk
|
|
53
|
+
|
|
54
|
+
The SpexCode record names the worktree/branch; otherwise the digest's `cwd` may be a linked worktree
|
|
55
|
+
(`git -C <cwd> rev-parse --git-common-dir`). Salvage inside that repo — it need not be the one you sit in.
|
|
56
|
+
Cross-check the digest's files-edited footer against that worktree: a manager-style session's edits often
|
|
57
|
+
live OUTSIDE it (main-checkout config, other repos) — those need a by-hand look, not the recipe below.
|
|
58
|
+
|
|
59
|
+
- **Already merged** (`git merge-base --is-ancestor <branch> <trunk>`) → nothing to salvage; note it and
|
|
60
|
+
go to cleanup. A tip that EQUALS the merge-base carried no commits — say "never committed", not "merged".
|
|
61
|
+
- **Unmerged commits** → carry them onto your current branch: `git cherry-pick <base>..<branch>` (keeps
|
|
62
|
+
authorship and `Session:` trailers); fall back to applying `git diff <base> <branch>` when the history
|
|
63
|
+
is too messy to replay.
|
|
64
|
+
- **Uncommitted changes** in the old worktree → `git -C <wt> status --porcelain`; apply its diff to your
|
|
65
|
+
tree and copy untracked files over. Commit the salvage in your own tree, naming the origin session in
|
|
66
|
+
the message.
|
|
67
|
+
|
|
68
|
+
## 5 · clean up — only after the salvage LANDED
|
|
69
|
+
|
|
70
|
+
Cleanup discards state — verify the salvaged commits are in your tree (or the branch genuinely merged) first.
|
|
71
|
+
|
|
72
|
+
- SpexCode session: `spex session close <id>` retires the session and its worktree in one verb.
|
|
73
|
+
- Bare worktree: `git worktree remove <wt>`, + `git branch -D <branch>` once confirmed carried or merged.
|
|
74
|
+
- In doubt, keep the resources and say so — a kept worktree costs disk; a wrong cleanup costs the work.
|
|
@@ -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. **FIRST, read `<root>/CLAUDE.md` — specifically its "Supervising — the manager loop" section** — that is your complete playbook (dispatch → monitor → review → merge → close, and how to parallelize). Then drive the goal: decompose it into
|
|
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. **FIRST, read `<root>/CLAUDE.md` — specifically its "Supervising — the manager loop" section** — that is your complete playbook (dispatch → monitor → review → merge → close, and how to parallelize). Then drive the goal: decompose it into worker-sized tasks and dispatch one worker per independent task (`spex 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 watch`, review proposals with `spex review <id>`, merge good ones with `git -C <root> merge --no-ff <branch>`, then close. Never let a worker self-merge; keep `spex lint` at 0 errors. To WAIT on a worker, POLL one-shot (`spex review <id>` or `spex ls` — both return immediately); never block on `spex watch`, which STREAMS forever and will freeze your turn. One footgun that bites a fresh supervisor: before `spex session close <id>`, confirm the merge landed (`git -C <root> log -1` shows HEAD at the new merge commit) — closing an unmerged branch discards the work. Report progress as you go and when the goal is complete. Your goal follows:
|