spexcode 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/hooks/dispatch.sh +10 -0
  3. package/spec-cli/src/cli.ts +6 -5
  4. package/spec-cli/src/contract-filter.ts +73 -29
  5. package/spec-cli/src/git.ts +80 -40
  6. package/spec-cli/src/harness.ts +127 -8
  7. package/spec-cli/src/index.ts +9 -3
  8. package/spec-cli/src/materialize.ts +118 -49
  9. package/spec-cli/src/reviewSnapshot.ts +4 -0
  10. package/spec-cli/src/reviews.ts +10 -5
  11. package/spec-cli/src/sessions.ts +113 -9
  12. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
  13. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  14. package/spec-dashboard/dist/assets/{App-C5vbTw8Q.js → App-B72LuS5I.js} +2 -2
  15. package/spec-dashboard/dist/assets/Dashboard-C5X4Va3V.js +27 -0
  16. package/spec-dashboard/dist/assets/{EvalsPage-BS7ITcNo.js → EvalsPage-BTvJIW8Q.js} +2 -2
  17. package/spec-dashboard/dist/assets/IssuesPage-Bn94h_HQ.js +1 -0
  18. package/spec-dashboard/dist/assets/{MobileApp-DVLnk9hz.js → MobileApp-ClbtwZ1e.js} +2 -2
  19. package/spec-dashboard/dist/assets/{Modal-6mHq6fbZ.js → Modal-6l_QtCKF.js} +1 -1
  20. package/spec-dashboard/dist/assets/{PageScroll-CAY4S4g4.js → PageScroll-B2kxcqJJ.js} +1 -1
  21. package/spec-dashboard/dist/assets/{ProjectsPage-UQyzsTWN.js → ProjectsPage-C8IPsMKV.js} +1 -1
  22. package/spec-dashboard/dist/assets/{SessionInterface-DKU4c1Z-.js → SessionInterface-B5jf7dW7.js} +11 -11
  23. package/spec-dashboard/dist/assets/SessionWindow-Dag_GiJB.js +1 -0
  24. package/spec-dashboard/dist/assets/{Settings-igR17pns.js → Settings-J3aibcXo.js} +1 -1
  25. package/spec-dashboard/dist/assets/{Thread-B-ZUarN1.js → Thread-Dg35J-Pu.js} +3 -3
  26. package/spec-dashboard/dist/assets/{TimelineChat-sc49Qj5d.js → TimelineChat-f0UF9fXq.js} +1 -1
  27. package/spec-dashboard/dist/assets/{data-B1ot4PF0.js → data-SNi0AmVT.js} +1 -1
  28. package/spec-dashboard/dist/assets/{index-BqBNCa1V.js → index-BUKLPN_4.js} +10 -10
  29. package/spec-dashboard/dist/index.html +1 -1
  30. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +0 -27
  31. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +0 -1
  32. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +0 -1
@@ -37,6 +37,8 @@ export type HarnessLaunchReadinessFence = {
37
37
  readonly proof: Readonly<Record<string, unknown>>
38
38
  validate(current: () => HarnessLaunchReadyRecord | null): Promise<boolean>
39
39
  }
40
+ export type TurnFailure = { message: string; completedAt: number | null }
41
+ export type FailureSubscription = { close(): void; readonly closed: Promise<string | null> }
40
42
  // the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
41
43
  // the pane's root pid (tmux `#{pane_pid}`), the hot-tier `pidAlive` verdict, and — ONLY on the legacy path —
42
44
  // one whole-box pid→(ppid, comm) table (a single `ps` spawn).
@@ -132,6 +134,8 @@ export async function adapterLoadedReferenceState(
132
134
 
133
135
  export interface Harness {
134
136
  readonly id: HarnessId
137
+ // the id baked into the materialized shim. Headless variants reuse their native family's shim.
138
+ readonly dispatchId: 'claude' | 'codex' | 'opencode' | 'pi'
135
139
  // whether this harness runs without an interactive TUI. The dashboard launcher picker hides headless
136
140
  // adapters by default ([[launcher-visibility]]); CLI launcher resolution never consumes that policy.
137
141
  readonly headless: boolean
@@ -192,6 +196,9 @@ export interface Harness {
192
196
  // --- materialize: shim + contract + trust ([[harness-delivery]]) ---
193
197
  // the auto-discovered hook shim file for this harness (.claude/settings.json vs .codex/hooks.json).
194
198
  shimFile(proj: string): string
199
+ // whether that shim belongs to one checkout or the whole project. This is adapter placement data: Codex
200
+ // reads one root-checkout hook file for every linked tree; the other harnesses discover their tree-local file.
201
+ shimScope: 'tree' | 'project'
195
202
  // a LINKED WORKTREE's extra shim copy — the worktree-side `.codex` hook file that ANCHORS codex's project
196
203
  // config layer, or null when the harness needs none. codex-rs only builds a project config layer (and thus
197
204
  // only DISCOVERS a worktree thread's hooks) for a dir in [cwd..project_root] that contains a `.codex/`
@@ -267,6 +274,9 @@ export interface Harness {
267
274
  // (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
268
275
  // Returns ok=false with a reason that propagates to the API.
269
276
  deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
277
+ // Observe native turn failures that this harness does not expose as a lifecycle hook. The adapter owns the
278
+ // transport subscription; sessions owns observer reconciliation and the active-only lifecycle CAS.
279
+ observeTurnFailures?(rec: HarnessDeliveryRecord, onFailure: (failure: TurnFailure) => void): FailureSubscription
270
280
  // Hard-interrupt the current turn through the harness's native control plane. Optional because a harness
271
281
  // without a confirmed native interrupt must refuse rather than emulate one with a signal or PTY key.
272
282
  interrupt?(rec: HarnessDeliveryRecord): Promise<DispatchResult>
@@ -304,7 +314,7 @@ export interface Harness {
304
314
  // skill/agent files named in `arts` — never the user's surrounding prose, their other settings, or any .spec
305
315
  // data. materialize calls it for every UNSELECTED harness, so dropping a harness from spexcode.json's
306
316
  // `harnesses` prunes that harness's products on the next re-materialize.
307
- clean(proj: string, arts: HarnessArtifacts): void
317
+ clean(proj: string, arts: HarnessArtifacts, preserveProject?: boolean): void
308
318
  // the inverse of writeTrust: strip THIS project's spexcode trust block from the harness's global config.
309
319
  // Codex removes its `~/.codex/config.toml` block; Claude is a no-op (it wrote none).
310
320
  removeTrust(proj: string): void
@@ -816,6 +826,106 @@ function drainWsFrames(s: FrameState, conn: Socket, onText: (json: string) => vo
816
826
  const WS_UPGRADE = (key: string) => `GET /rpc HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${key}\r\n\r\n`
817
827
  const wsInitialize: JsonRpc = { id: 1, method: 'initialize', params: { clientInfo: { name: 'spexcode', title: 'SpexCode', version: '0.0.0' }, capabilities: { experimentalApi: true, requestAttestation: false } } }
818
828
 
829
+ // Codex has no StopFailure hook, but its app-server has the stronger native signal: every subscribed turn ends
830
+ // with turn/completed and a final completed/interrupted/failed status. Rejoin is atomic with subscription, so
831
+ // this observer also survives backend replacement; a thread already in systemError is reconciled from its
832
+ // latest turn before later live notifications take over.
833
+ export function codexTurnFailureObserver(
834
+ rec: HarnessDeliveryRecord,
835
+ onFailure: (failure: TurnFailure) => void,
836
+ ): FailureSubscription {
837
+ const threadId = rec.harnessSessionId
838
+ if (!threadId) return { close: () => {}, closed: Promise.resolve(null) }
839
+ const sock = codexAppServerSock(rec.runtimeDir || runtimeRoot())
840
+ const conn: Socket = createConnection(sock)
841
+ const frames: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
842
+ let upgraded = false, settled = false
843
+ let reconciliationTimer: ReturnType<typeof setTimeout> | null = null
844
+ let resolveClosed!: (reason: string | null) => void
845
+ const closed = new Promise<string | null>((resolve) => { resolveClosed = resolve })
846
+ const cancelReconciliation = () => {
847
+ if (!reconciliationTimer) return
848
+ clearTimeout(reconciliationTimer)
849
+ reconciliationTimer = null
850
+ }
851
+ const finish = (reason: string | null) => {
852
+ if (settled) return
853
+ settled = true
854
+ clearTimeout(timer)
855
+ cancelReconciliation()
856
+ try { conn.destroy() } catch {}
857
+ resolveClosed(reason)
858
+ }
859
+ const timer = setTimeout(() => finish('Codex turn observer did not subscribe within 5000ms'), 5000)
860
+ timer.unref?.()
861
+ const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
862
+ const report = (turn: unknown, fallbackMessage?: string) => {
863
+ const value = turn as { status?: unknown; completedAt?: unknown; error?: { message?: unknown } | null }
864
+ if (value?.status !== 'failed' && !fallbackMessage) return
865
+ const nativeMessage = typeof value?.error?.message === 'string' ? value.error.message.trim() : ''
866
+ onFailure({
867
+ message: nativeMessage || fallbackMessage || 'Codex turn failed',
868
+ completedAt: typeof value?.completedAt === 'number' && Number.isFinite(value.completedAt) ? value.completedAt : null,
869
+ })
870
+ }
871
+ conn.on('error', (error) => finish(`Codex turn observer connection failed: ${rpcError(error)}`))
872
+ conn.on('close', () => finish('Codex turn observer connection closed'))
873
+ conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
874
+ const handle = (json: string) => {
875
+ let message: JsonRpc
876
+ try { message = JSON.parse(json) } catch { return }
877
+ if (message.error) return finish(`Codex turn observer request failed: ${message.error.message || JSON.stringify(message.error)}`)
878
+ if (message.id === 1 && message.result) {
879
+ send({ method: 'initialized', params: {} })
880
+ return send({
881
+ id: 2,
882
+ method: 'thread/resume',
883
+ params: { threadId, excludeTurns: true, initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } },
884
+ })
885
+ }
886
+ if (message.id === 2 && message.result) {
887
+ clearTimeout(timer)
888
+ const result = message.result as { thread?: { status?: { type?: unknown } }; initialTurnsPage?: { data?: unknown } }
889
+ if (result.thread?.status?.type === 'systemError') {
890
+ const turns = result.initialTurnsPage?.data
891
+ const latest = Array.isArray(turns) ? turns[0] : null
892
+ // Give a concurrently-starting turn's native notification precedence over this historical snapshot.
893
+ reconciliationTimer = setTimeout(() => {
894
+ reconciliationTimer = null
895
+ report(latest, 'Codex thread entered systemError before the turn observer subscribed')
896
+ }, 100)
897
+ reconciliationTimer.unref?.()
898
+ }
899
+ return
900
+ }
901
+ if (message.method === 'turn/started') {
902
+ const params = message.params as { threadId?: unknown } | undefined
903
+ if (params?.threadId === threadId) cancelReconciliation()
904
+ }
905
+ if (message.method === 'turn/completed') {
906
+ const params = message.params as { threadId?: unknown; turn?: unknown } | undefined
907
+ if (params?.threadId === threadId) {
908
+ cancelReconciliation()
909
+ report(params.turn)
910
+ }
911
+ }
912
+ }
913
+ conn.on('data', (chunk: Buffer) => {
914
+ frames.buf = Buffer.concat([frames.buf, chunk])
915
+ if (!upgraded) {
916
+ const split = frames.buf.indexOf('\r\n\r\n')
917
+ if (split < 0) return
918
+ const head = frames.buf.slice(0, split).toString('utf8')
919
+ if (!/^HTTP\/1\.1 101/.test(head)) return finish(`Codex app-server refused turn observer: ${head.split('\r\n')[0]}`)
920
+ upgraded = true
921
+ frames.buf = frames.buf.slice(split + 4)
922
+ send(wsInitialize)
923
+ }
924
+ if (drainWsFrames(frames, conn, handle)) finish('Codex app-server closed the turn observer')
925
+ })
926
+ return { close: () => finish(null), closed }
927
+ }
928
+
819
929
  // Protocol-verified cold/restore seam. The Codex schema (`codex app-server generate-json-schema --experimental`)
820
930
  // defines thread/archive and thread/unarchive with {threadId}; no guessed method or process command is used.
821
931
  type CodexGenerationFence = { dir: string; generation: string }
@@ -1787,16 +1897,16 @@ function isTrackedFile(proj: string, f: string): boolean {
1787
1897
  // block; the skill/agent files sit at name-scoped paths reconstructed from `arts`. So it removes ONLY our own
1788
1898
  // blocks and our own named products — never a user's CLAUDE.md/AGENTS.md prose, a hand-made settings.json, or
1789
1899
  // a sibling skill/agent the user added, and NEVER any .spec data.
1790
- function cleanHarness(h: Harness, proj: string, arts: HarnessArtifacts): void {
1900
+ function cleanHarness(h: Harness, proj: string, arts: HarnessArtifacts, preserveProject = false): void {
1791
1901
  // deleteIfEmpty ONLY for an UNTRACKED contract file: a wholly-ours generated file goes; a HOST-TRACKED file
1792
1902
  // that carried nothing but our block (an empty committed CLAUDE.md we folded into) is stripped back to its
1793
1903
  // pristine emptiness but never deleted — deleting a tracked file would surface as a `D` in the host's status.
1794
1904
  for (const f of h.contractFiles(proj)) removeManagedBlock(f, ['<!-- ', ' -->'], !isTrackedFile(proj, f))
1795
1905
  const shim = h.shimFile(proj)
1796
- if (existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh')) rmSync(shim, { force: true })
1906
+ if ((h.shimScope === 'tree' || !preserveProject) && existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh')) rmSync(shim, { force: true })
1797
1907
  const anchor = h.worktreeHookAnchor(proj) // the linked-worktree anchor copy, same identity gate as the shim
1798
1908
  if (anchor && existsSync(anchor) && readFileSync(anchor, 'utf8').includes('dispatch.sh')) rmSync(anchor, { force: true })
1799
- h.removeTrust(proj)
1909
+ if (!preserveProject) h.removeTrust(proj)
1800
1910
  const sd = h.skillDir(proj)
1801
1911
  if (sd) for (const n of arts.skills) rmSync(join(sd, n), { recursive: true, force: true })
1802
1912
  const ad = h.agentDir(proj)
@@ -1953,6 +2063,7 @@ const noLaunchEnv = (): string[] => []
1953
2063
 
1954
2064
  export const claudeHarness: Harness = {
1955
2065
  id: 'claude',
2066
+ dispatchId: 'claude',
1956
2067
  headless: false,
1957
2068
  events: CLAUDE_EVENTS,
1958
2069
  ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
@@ -1963,6 +2074,7 @@ export const claudeHarness: Harness = {
1963
2074
  sessionEnvVar: 'CLAUDE_CODE_SESSION_ID',
1964
2075
  launchEnv: rendezvousLaunchEnv,
1965
2076
  shimFile: (proj) => join(proj, '.claude', 'settings.json'),
2077
+ shimScope: 'tree',
1966
2078
  worktreeHookAnchor: () => null, // claude's shim already lives in the worktree (.claude/settings.json) — self-anchors, no root rewrite
1967
2079
  contractFiles: (proj) => [join(proj, 'CLAUDE.md')],
1968
2080
  skillDir: (proj) => join(proj, '.claude', 'skills'),
@@ -1970,7 +2082,7 @@ export const claudeHarness: Harness = {
1970
2082
  shim: (dispatch, spex) => buildShim('claude', CLAUDE_EVENTS, dispatch, spex),
1971
2083
  writeTrust: () => [], // Claude relies on folder-trust — no artifact to report
1972
2084
  removeTrust: () => { /* Claude wrote no trust — nothing to strip */ },
1973
- clean(proj, arts) { cleanHarness(this, proj, arts) },
2085
+ clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
1974
2086
  slashCommands: claudeSlashCommands,
1975
2087
  // online iff the window is up AND a LIVE LISTENER is on the rendezvous socket (`socketLive`, connect-probed by
1976
2088
  // the caller) — NOT the mere existence of a stale socket FILE a crashed claude leaves behind (the 30-min
@@ -2020,6 +2132,7 @@ export const claudeHeadlessHarness: Harness = {
2020
2132
 
2021
2133
  export const codexHarness: Harness = {
2022
2134
  id: 'codex',
2135
+ dispatchId: 'codex',
2023
2136
  headless: false,
2024
2137
  sharedRuntimeSpawn: true,
2025
2138
  events: CODEX_EVENTS,
@@ -2038,6 +2151,7 @@ export const codexHarness: Harness = {
2038
2151
  // per-worktree (codex loads THOSE by walking the thread cwd). dispatch.sh resolves `proj` from the thread
2039
2152
  // cwd, so one shared shim serves every worktree.
2040
2153
  shimFile: (proj) => join(mainCheckout(proj), '.codex', 'hooks.json'),
2154
+ shimScope: 'project',
2041
2155
  // a LINKED worktree also needs its OWN `.codex/hooks.json` so codex-rs anchors the project config layer for
2042
2156
  // the worktree cwd (without a `.codex/` under the worktree root, codex builds no layer, so the rewritten
2043
2157
  // root-checkout hooks are never discovered and NO hooks fire — bypass_hook_trust cannot rescue a layer that
@@ -2070,7 +2184,7 @@ export const codexHarness: Harness = {
2070
2184
  writeTrust: (proj, cmdFor) => [writeCodexTrust(mainCheckout(proj), CODEX_EVENTS, cmdFor)],
2071
2185
  // trust is keyed by the MAIN checkout (where the codex shim materializes) — strip it at the same key.
2072
2186
  removeTrust: (proj) => removeCodexTrust(mainCheckout(proj)),
2073
- clean(proj, arts) { cleanHarness(this, proj, arts) },
2187
+ clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
2074
2188
  slashCommands: codexSlashCommands,
2075
2189
  // online iff the tmux window is up AND the agent is live. PRIMARY: the launch-registered `agent.pid` hot-tier
2076
2190
  // verdict (`pidAlive`) — a 100ms syscall (kill-0), no ps scan. LEGACY: a pre-registration session has no
@@ -2086,6 +2200,7 @@ export const codexHarness: Harness = {
2086
2200
  },
2087
2201
  leafOwnerNeedle: (rec) => rec.harnessSessionId ?? null,
2088
2202
  deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
2203
+ observeTurnFailures: codexTurnFailureObserver,
2089
2204
  cleanupRuntime: async () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
2090
2205
  coldRetirementPreflight: async (rec) => {
2091
2206
  if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
@@ -2354,6 +2469,7 @@ export const codexHeadlessHarness: Harness = {
2354
2469
  // one-run defence. See pi-harness.ts for the extension source + trust mechanics.
2355
2470
  export const piHarness: Harness = {
2356
2471
  id: 'pi',
2472
+ dispatchId: 'pi',
2357
2473
  headless: false,
2358
2474
  events: PI_EVENTS,
2359
2475
  ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
@@ -2364,6 +2480,7 @@ export const piHarness: Harness = {
2364
2480
  sessionEnvVar: 'PI_SESSION_ID', // exported by the generated extension at session_start; tool subprocesses inherit it
2365
2481
  launchEnv: rendezvousLaunchEnv,
2366
2482
  shimFile: (proj) => join(proj, '.pi', 'extensions', 'spexcode.ts'),
2483
+ shimScope: 'tree',
2367
2484
  worktreeHookAnchor: () => null, // the extension lives in the worktree and self-anchors, like claude
2368
2485
  contractFiles: (proj) => [join(proj, 'AGENTS.md')], // pi auto-loads AGENTS.md context files (shared with codex — writeManagedBlock is idempotent)
2369
2486
  skillDir: (proj) => join(proj, '.pi', 'skills'), // Agent Skills standard dirs, discovered after project trust
@@ -2374,7 +2491,7 @@ export const piHarness: Harness = {
2374
2491
  }),
2375
2492
  writeTrust: (proj) => [writePiTrust(mainCheckout(proj))], // trust keys on the MAIN checkout; nearest-parent lookup covers worktrees
2376
2493
  removeTrust: (proj) => removePiTrust(mainCheckout(proj)),
2377
- clean(proj, arts) { cleanHarness(this, proj, arts) },
2494
+ clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
2378
2495
  slashCommands: piSlashCommands,
2379
2496
  // claude's exact liveness: the window is up AND a live LISTENER answers on the rendezvous socket — the
2380
2497
  // socket the generated extension binds. socketLive is already probed for every windowed session.
@@ -2409,6 +2526,7 @@ export const piHeadlessHarness: Harness = {
2409
2526
 
2410
2527
  export const opencodeHarness: Harness = {
2411
2528
  id: 'opencode',
2529
+ dispatchId: 'opencode',
2412
2530
  headless: false,
2413
2531
  events: OPENCODE_EVENTS,
2414
2532
  // LITERALLY true: the generated plugin ([[opencode-harness]], opencode.ts) BINDS the per-session rendezvous
@@ -2428,6 +2546,7 @@ export const opencodeHarness: Harness = {
2428
2546
  // the "shim" is a generated opencode PLUGIN in the worktree's own tree — opencode auto-loads project plugins
2429
2547
  // by walking the cwd, so like claude it self-anchors and needs no root-checkout rewrite or worktree anchor.
2430
2548
  shimFile: (proj) => join(proj, '.opencode', 'plugins', 'spexcode.ts'),
2549
+ shimScope: 'tree',
2431
2550
  worktreeHookAnchor: () => null,
2432
2551
  contractFiles: (proj) => [join(proj, 'AGENTS.md')], // opencode reads AGENTS.md natively (same file codex owns; the managed block is idempotent across writers)
2433
2552
  skillDir: (proj) => join(proj, '.opencode', 'skills'),
@@ -2437,7 +2556,7 @@ export const opencodeHarness: Harness = {
2437
2556
  shim: (dispatch, spex) => ({ content: opencodePluginSource(dispatch, spex), cmd: (e) => `SPEX='${spex}' bash ${dispatch} opencode ${e}` }),
2438
2557
  writeTrust: () => [], // permission policy stays with the launcher command; no trust artifact to report
2439
2558
  removeTrust: () => { /* nothing was written */ },
2440
- clean(proj, arts) { cleanHarness(this, proj, arts) },
2559
+ clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
2441
2560
  slashCommands: opencodeSlashCommands,
2442
2561
  // online iff the window is up AND the agent answers on a channel: PREFER the rendezvous socket listener
2443
2562
  // (the plugin is alive), FALL BACK to the launch-registered agent.pid (kill-0) so a plugin that failed to
@@ -15,7 +15,7 @@ import { resolveLayout, mainBranch } from './layout.js'
15
15
  import { getBoardJson } from './graphCache.js'
16
16
  import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
17
17
  import { gitA, gitTry, repoRoot } from './git.js'
18
- import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, archiveSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
18
+ import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, archiveSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
19
19
  import { superviseTimeline, readTimeline } from './session-timeline.js'
20
20
  import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js'
21
21
  import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
@@ -632,13 +632,18 @@ app.post('/api/sessions/:id/input', async (c) => {
632
632
  app.post('/api/sessions/:id/stop', async (c) => {
633
633
  const sessionId = c.req.param('id')
634
634
  const authorization = await operationAuthorization(c.req.header.bind(c.req), { op: 'stop', sessionId })
635
- return c.json({ ok: await stopSession(sessionId, { authorization }) })
635
+ const ok = await stopSession(sessionId, { authorization })
636
+ return c.json(ok ? { ok: true } : { ok: false, error: `no stop transition was committed for session ${sessionId}` }, ok ? 200 : 404)
636
637
  })
637
638
  app.post('/api/sessions/:id/interrupt', async (c) => {
638
639
  const result = await interruptSession(c.req.param('id'))
639
640
  return c.json(result, result.ok ? 200 : 502)
640
641
  })
641
- app.post('/api/sessions/:id/close', async (c) => c.json({ ok: await closeSession(c.req.param('id')) }))
642
+ app.post('/api/sessions/:id/close', async (c) => {
643
+ const sessionId = c.req.param('id')
644
+ const ok = await closeSession(sessionId)
645
+ return c.json(ok ? { ok: true } : { ok: false, error: `no close transition was committed for session ${sessionId}` }, ok ? 200 : 404)
646
+ })
642
647
  // archive / legacy unarchive signpost ([[archive]]) — archive proves exact cold/offline ownership before filing;
643
648
  // `{on:false}` enters the same resume transition and recreates the preserved conversation. {ok:false}=no such session.
644
649
  app.post('/api/sessions/:id/archive', async (c) => {
@@ -684,6 +689,7 @@ installConnectionReaper(server as unknown as HttpServer)
684
689
  injectWebSocket(server)
685
690
  superviseBridges() // restore visible helpers after failure; their viewer subscriptions survive replacement
686
691
  superviseQueue() // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
692
+ superviseTurnFailures() // reconcile adapter-owned native failure subscriptions across backend replacement
687
693
  superviseTimeline() // record authored-lifecycle transitions to each session's durable timeline ([[session-timeline]])
688
694
  console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`)
689
695
 
@@ -1,4 +1,4 @@
1
- import { writeFileSync, mkdirSync, readFileSync, existsSync, readdirSync, rmSync, rmdirSync } from 'node:fs'
1
+ import { writeFileSync, mkdirSync, readFileSync, existsSync, readdirSync, renameSync, rmSync, rmdirSync } from 'node:fs'
2
2
  import { join, dirname, relative } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { execFileSync } from 'node:child_process'
@@ -6,10 +6,10 @@ import { loadSystemConfig, loadSkillConfig, loadAgentConfig, loadConfig } from '
6
6
  import { compileManifest } from './hooks.js'
7
7
  import { writeManagedBlock, removeManagedBlock, HARNESSES, type HarnessArtifacts } from './harness.js'
8
8
  import { git } from './git.js'
9
- import { runtimeRoot, treeSlotDir, mainCheckout, readConfig } from './layout.js'
9
+ import { runtimeRoot, treeSlotDir, mainCheckout, readConfig, encodeProject } from './layout.js'
10
10
  import { resolveHarnessTargets, partitionHarnesses } from './harness-select.js'
11
11
  import { emitPlugin, cleanPlugin, pluginBundleDir, pluginVersion } from './plugin-harness.js'
12
- import { plantContractFilter, removeContractFilter, settleIndexStat } from './contract-filter.js'
12
+ import { plantContractFilter, removeContractFilter, retireLegacyContractBlock, settleIndexStat, type ContractFilterBinding, type ContractFilterPayload } from './contract-filter.js'
13
13
 
14
14
  export type MaterializedArtifact = {
15
15
  kind: 'hook manifest' | 'contract' | 'shim' | 'skill' | 'agent' | 'plugin bundle' | 'trust'
@@ -34,8 +34,9 @@ export type MaterializeResult = { contentHash: string; planted: MaterializedArti
34
34
  // implementation is ERASE-THEN-ASSERT over a CLOSED set of landing points: each is first erased
35
35
  // unconditionally by its IDENTITY STAMP (sentinel blocks, the shim's dispatch.sh command line, the generated
36
36
  // mark on skills/agents, the filter config namespace, the skip-worktree bit), then rewritten per the current
37
- // policy (possibly to nothing). No ledger of past states, no pairwise migration branches the erase IS the
38
- // migration, whatever the previous state was.
37
+ // policy (possibly to nothing). There are no policy-pair branches. The one cross-tree migration receipt below
38
+ // preserves old common ignore entries until every registered tree owns its local projection; it never reads or
39
+ // reconstructs a sibling policy.
39
40
 
40
41
  const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
41
42
  const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
@@ -64,8 +65,8 @@ export function contentHash(proj: string): string {
64
65
  // @@@ footprint kinds ([[residence]]) - the vote axis is RETIRED: materialized artifacts carry no facts, so
65
66
  // they are NEVER tracked — there is exactly ONE residence behavior, not three. `.spec` + `spexcode.json` are ALWAYS
66
67
  // tracked (git is the database — no knob can untrack them); machine facts (shims, spexcode.local.json),
67
- // run residue (.worktrees/), and wholly-ours artifacts are hidden by the per-clone .git/info/exclude (the
68
- // host's tracked .gitignore is never touched); a contract file the host TRACKS — or one the user has begun
68
+ // run residue (.worktrees/) stays in the common exclude; tree-selected artifacts are hidden by a managed
69
+ // working .gitignore block whose tracked bytes stay pristine through the content filter. A contract file the host TRACKS — or one the user has begun
69
70
  // writing THEIR OWN prose into — is covered by the clean/smudge content filter ([[content-filter]]). An
70
71
  // environment without the generator (a teammate's clone, CI, a cloud agent) runs `spex materialize` in its
71
72
  // setup step — there is no committed-artifact delivery mode.
@@ -74,7 +75,7 @@ export function retiredAxisNotice(cfg: { render?: string; private?: boolean }):
74
75
  const field = cfg.render?.trim() ? `"render": "${cfg.render.trim()}"` : '"private": true'
75
76
  console.error(
76
77
  `spexcode: the render vote is retired — ${field} is ignored. Materialized artifacts are never tracked:\n` +
77
- ` ignore rules live in the per-clone .git/info/exclude, a host-tracked contract file is covered by the\n` +
78
+ ` tree-local ignore rules live in a filtered working .gitignore, and a host-tracked contract is covered by the\n` +
78
79
  ` clean/smudge filter, and a clone without spex runs \`spex materialize\` in its setup step. Remove the\n` +
79
80
  ` field from spexcode.json / spexcode.local.json to retire this notice (see \`spex guide footprint\`).`,
80
81
  )
@@ -90,6 +91,38 @@ function isTracked(proj: string, file: string): boolean {
90
91
  try { git(['-C', proj, 'ls-files', '--error-unmatch', file]); return true } catch { return false }
91
92
  }
92
93
 
94
+ function registeredTrees(proj: string): string[] {
95
+ const rows = git(['-C', mainCheckout(proj), 'worktree', 'list', '--porcelain', '-z']).split('\0')
96
+ return rows.filter((row) => row.startsWith('worktree ')).map((row) => row.slice('worktree '.length))
97
+ }
98
+
99
+ const TREE_IGNORE_RECEIPT = 'tree-ignore-v1'
100
+
101
+ function hasLegacyTreeIgnore(proj: string): boolean {
102
+ return registeredTrees(proj).some((tree) => {
103
+ const slot = join(runtimeRoot(proj), 'trees', encodeProject(tree))
104
+ return existsSync(join(slot, 'content-hash')) && !existsSync(join(slot, TREE_IGNORE_RECEIPT))
105
+ })
106
+ }
107
+
108
+ function managedExcludeEntries(file: string): string[] {
109
+ if (!existsSync(file)) return []
110
+ const lines = readFileSync(file, 'utf8').split('\n')
111
+ const start = lines.indexOf('# spexcode:start')
112
+ const end = lines.indexOf('# spexcode:end', start + 1)
113
+ return start >= 0 && end > start ? lines.slice(start + 1, end).filter(Boolean) : []
114
+ }
115
+
116
+ function selectionBody(selected: typeof HARNESSES, plugin = false): string {
117
+ return [...new Set([...selected.map((h) => h.dispatchId), ...(plugin ? ['plugin'] : [])])].sort().join('\n') + '\n'
118
+ }
119
+
120
+ function publishSelection(path: string, body: string): void {
121
+ const prepared = `${path}.${process.pid}.tmp`
122
+ writeFileSync(prepared, body)
123
+ renameSync(prepared, path)
124
+ }
125
+
93
126
  // @@@ contract kind detection ([[residence]]) - a contract file's residence is a LIVE CONTENT FACT, not
94
127
  // an install-time choice, re-judged on every materialize: TRACKED → filter domain; untracked + wholly ours
95
128
  // (nothing left after stripping our sentinel block) → exclude domain; untracked + HOST CONTENT present (the
@@ -144,11 +177,11 @@ function sweepGeneratedAgents(dir: string | null): void {
144
177
  // (edge ③ in [[content-filter]] — a block outliving its clean filter surfaces as an uncommitted change).
145
178
  // `arts` (live skill/agent node names) widens the sweep to pre-stamp legacy files; the GENERATED_MARK sweep
146
179
  // covers everything materialized since, including products of renamed/deleted nodes.
147
- export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { skills: [], agents: [] }): void {
180
+ function eraseTree(proj: string, arts: HarnessArtifacts, preserveProject: boolean): void {
148
181
  for (const h of HARNESSES) {
149
182
  // h.clean = the adapter's surgical inverse: contract block (sentinels, deleteIfEmpty), the dispatch.sh-
150
183
  // stamped shim + worktree anchor, the trust block, and the arts-named skill/agent files.
151
- h.clean(proj, arts)
184
+ h.clean(proj, arts, preserveProject)
152
185
  for (const f of h.contractFiles(proj)) clearSkipWorktree(proj, f) // legacy private-overlay bit — erase-only
153
186
  sweepGeneratedSkills(h.skillDir(proj))
154
187
  sweepGeneratedAgents(h.agentDir(proj))
@@ -156,8 +189,7 @@ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { s
156
189
  // same authorship rule as the contract files: deleteIfEmpty only when .gitignore is UNTRACKED (wholly-ours
157
190
  // generated file); a HOST-TRACKED .gitignore that carried nothing but our block is stripped, never deleted.
158
191
  removeManagedBlock(join(proj, '.gitignore'), ['# ', ''], !isTracked(proj, '.gitignore'))
159
- try { removeManagedBlock(infoExcludePath(proj), ['# ', ''], false) } catch { /* not a git repo */ }
160
- removeContractFilter(proj) // AFTER the blocks left the working files
192
+ removeContractFilter(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')])
161
193
  // the block-strip left tracked contract files stat-dirty (under a filter git NEVER content-verifies them,
162
194
  // and even unfiltered the phantom-`M` lingers) — settle the index stat, content-guarded so a user's real
163
195
  // unstaged edit is never staged ([[content-filter]] edge 2).
@@ -179,6 +211,22 @@ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { s
179
211
  }
180
212
  }
181
213
 
214
+ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { skills: [], agents: [] }): void {
215
+ const trees = registeredTrees(proj)
216
+ const current = git(['-C', proj, 'rev-parse', '--show-toplevel']).trim()
217
+ for (const tree of trees) {
218
+ if (!existsSync(tree)) throw new Error(`cannot dematerialize project while registered worktree ${tree} is inaccessible — repair or remove/prune it first`)
219
+ git(['-C', tree, 'rev-parse', '--show-toplevel'])
220
+ }
221
+ for (const tree of trees) {
222
+ // Only the caller's live spec may widen the legacy name sweep. Siblings are identity-stamp-only: the
223
+ // same name there may be user-owned or may not exist in this tree's divergent spec at all.
224
+ eraseTree(tree, tree === current ? arts : { skills: [], agents: [] }, false)
225
+ }
226
+ try { removeManagedBlock(infoExcludePath(proj), ['# ', ''], false) } catch { /* not a git repo */ }
227
+ removeContractFilter(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')], true)
228
+ }
229
+
182
230
  // the whole pay-per-change materialize. proj defaults to cwd. Its receipt is populated at each successful
183
231
  // write so callers report the actual selected footprint instead of maintaining a second artifact inventory.
184
232
  export function materialize(proj = process.cwd()): MaterializeResult {
@@ -198,9 +246,9 @@ export function materialize(proj = process.cwd()): MaterializeResult {
198
246
  // own hand-written prose is not folded in — repo-local notes belong in the harness file's own
199
247
  // block-outside region (untracked, per-clone), and anything that must reach EVERY agent is a plugin node.
200
248
  const contract = loadSystemConfig().map((c) => c.body.trim()).filter(Boolean).join('\n\n')
201
- // WHICH harnesses to deliver into ([[harness-select]]): the spexcode.json `harnesses` set (default = every
202
- // native harness). resolveHarnessTargets FAILS LOUD on an illegal set (plugin+native, plugin w/o folder).
203
- const cfg = readConfig(mainCheckout(proj))
249
+ // WHICH harnesses to deliver into ([[harness-select]]): this tree's explicit spexcode.json `harnesses` set.
250
+ // resolveHarnessTargets FAILS LOUD on an illegal set (plugin+native, plugin w/o folder).
251
+ const cfg = readConfig(proj)
204
252
  const targets = resolveHarnessTargets(cfg.harnesses)
205
253
  retiredAxisNotice(cfg) // [[residence]] — the vote axis is retired
206
254
  const { selected, plugins } = partitionHarnesses(targets)
@@ -212,7 +260,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
212
260
  // ---- ERASE (the forgetting law): every landing point cleared by identity stamp, whatever policy — or
213
261
  // legacy mode — wrote it last. Unselected harnesses need no separate prune branch: the erase already
214
262
  // forgot them, and only the selected ones are asserted below.
215
- dematerialize(proj, arts)
263
+ eraseTree(proj, arts, true)
216
264
 
217
265
  // ---- ASSERT: rewrite each landing point per the CURRENT policy.
218
266
  // a skill node → the agentskills.io SKILL.md primitive: `name`+`description` frontmatter (the load-trigger)
@@ -236,18 +284,29 @@ export function materialize(proj = process.cwd()): MaterializeResult {
236
284
  const contractPaths: string[] = []
237
285
  for (const h of selected) {
238
286
  if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract); contractPaths.push(f); record('contract', f) }
239
- const shimFile = h.shimFile(proj)
240
- mkdirSync(dirname(shimFile), { recursive: true })
241
287
  const shim = h.shim(DISPATCH, SPEX)
242
- writeFileSync(shimFile, shim.content)
243
- record('shim', shimFile)
244
- for (const f of h.writeTrust(proj, shim.cmd)) record('trust', f)
245
- machinePaths.push(shimFile)
288
+ if (h.shimScope === 'tree') {
289
+ const shimFile = h.shimFile(proj)
290
+ mkdirSync(dirname(shimFile), { recursive: true })
291
+ writeFileSync(shimFile, shim.content)
292
+ record('shim', shimFile)
293
+ machinePaths.push(shimFile)
294
+ }
246
295
  // a linked-worktree ANCHOR copy of the shim, when the harness needs one (codex: the shim lives at the main
247
296
  // checkout, so the worktree gets no `.codex/` unless we place one). One adapter line; null otherwise.
248
297
  const anchor = h.worktreeHookAnchor(proj)
249
298
  if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.content); machinePaths.push(anchor); record('shim', anchor) }
250
299
  }
300
+ const selectedByDispatch = new Map(selected.map((h) => [h.dispatchId, h]))
301
+ for (const h of selectedByDispatch.values()) {
302
+ const shim = h.shim(DISPATCH, SPEX)
303
+ if (h.shimScope === 'project') {
304
+ const file = h.shimFile(proj)
305
+ mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, shim.content)
306
+ record('shim', file)
307
+ }
308
+ for (const file of h.writeTrust(proj, shim.cmd)) record('trust', file)
309
+ }
251
310
  // (6) skills + (7) sub-agents — each surface node → the file the harness auto-discovers, one per selected
252
311
  // harness that has the primitive (skillDir/agentDir null skips — the divergence is the adapter's line).
253
312
  for (const sk of skillNodes) {
@@ -299,44 +358,54 @@ export function materialize(proj = process.cwd()): MaterializeResult {
299
358
  }
300
359
  }
301
360
  writeFileSync(ledger, curFolders.join('\n'))
302
- // (9) the ignore rules ALWAYS the per-clone .git/info/exclude ([[residence]]): the exclude is not a
303
- // history guard (the pre-commit surgery owns history [[commit-surgery]]) but the ignored-bit
304
- // DECLARATION every other git door consults (checkout may overwrite, clean -fd spares, status/add -A/
305
- // stash stay silent). The host's tracked .gitignore is never touched.
306
- // Entries must be CHECKOUT-INVARIANT: the exclude lives in the COMMON git dir shared by the main checkout
307
- // and every worktree, so each entry is anchored to the checkout it LIVES under — proj-relative when inside
308
- // proj, else MAIN-checkout-relative (the codex shim resolves to `.codex/hooks.json` from any checkout; a
309
- // pattern naming a main-only path is a harmless no-op in a worktree). A path under neither root is dropped.
361
+ // (9) ignore + mixed text. Only checkout-invariant residue and project-shared shims belong in the COMMON
362
+ // info/exclude; selection-dependent paths live in this tree's filtered working .gitignore.
310
363
  const mc = mainCheckout(proj)
311
- const anchor = (abs: string): string | null => {
312
- const p = relative(proj, abs); if (!p.startsWith('..')) return p
313
- const m = relative(mc, abs); if (!m.startsWith('..')) return m
314
- return null
315
- }
316
- // machine facts + run residue, ignored under EVERY policy: the shims/anchors/bundles (bake this install's
317
- // abs path), spexcode.local.json (the host overlay — a `git add -A` must never leak it), and the session
318
- // residue (`.worktrees/` where launches plant worktrees; `.session` is the legacy per-worktree state file
319
- // an old backend wrote). Static strings stay checkout-invariant.
320
364
  const bundlePaths = curFolders.map((f) => pluginBundleDir(proj, f))
321
- const machineEntries = [
322
- ...[...machinePaths, ...bundlePaths].map(anchor).filter((p): p is string => p !== null),
365
+ const commonEntries = [
366
+ ...[...new Set(HARNESSES.filter((h) => h.shimScope === 'project' && existsSync(h.shimFile(proj)) &&
367
+ readFileSync(h.shimFile(proj), 'utf8').includes('dispatch.sh')).map((h) => relative(mc, h.shimFile(proj))))]
368
+ .filter((p) => !p.startsWith('..')),
323
369
  'spexcode.local.json', '.worktrees/', '.session',
324
370
  ]
325
- // the contract three-state ([[residence]]): tracked filter; untracked+wholly-ours → exclude;
326
- // untracked+host-content NO exclude (never hide user prose) + the clean filter pre-armed, so the user's
327
- // own eventual `git add` strips our block — tracking stays entirely their act.
371
+ const entries = (list: string[]) => [...new Set(list)].sort().join('\n')
372
+ const priorCommonEntries = managedExcludeEntries(infoExcludePath(proj))
373
+
374
+ // Contract residence stays a live fact. Selection-dependent untracked products are ignored by this tree's
375
+ // working .gitignore, whose own managed block is filtered when the host tracks/owns that file.
328
376
  const filterContracts: string[] = []
329
377
  const oursContracts: string[] = []
330
378
  for (const f of contractPaths) {
331
379
  if (isTracked(proj, f) || hostContentOf(f).trim()) filterContracts.push(f)
332
380
  else oursContracts.push(f)
333
381
  }
334
- if (contract && filterContracts.length) plantContractFilter(proj, filterContracts, contract)
335
- const artifactEntries = [...artifactPaths, ...oursContracts].map(anchor).filter((p): p is string => p !== null)
336
- const entries = (list: string[]) => [...new Set(list)].sort().join('\n')
337
- writeManagedBlock(infoExcludePath(proj), entries([...machineEntries, ...artifactEntries]), ['# ', ''])
338
- // (5) stamp the content-hash marker LAST (a diagnostic freshness record; a crash mid-materialize leaves it stale).
382
+ const localEntries = [...machinePaths, ...bundlePaths, ...artifactPaths, ...oursContracts]
383
+ .map((p) => relative(proj, p)).filter((p) => !p.startsWith('..'))
384
+ const ignoreFile = join(proj, '.gitignore')
385
+ const ignoreTracked = isTracked(proj, ignoreFile)
386
+ const ignoreHost = existsSync(ignoreFile) ? readFileSync(ignoreFile, 'utf8') : ''
387
+ if (!ignoreTracked && !ignoreHost.trim()) localEntries.push('.gitignore')
388
+ const ignoreBody = entries(localEntries)
389
+ writeManagedBlock(ignoreFile, ignoreBody, ['# ', ''])
390
+
391
+ const payloads: ContractFilterPayload[] = filterContracts.map((file) => ({ file: relative(proj, file), content: contract }))
392
+ if (ignoreTracked || ignoreHost.trim()) payloads.push({ file: '.gitignore', content: ignoreBody })
393
+ const bindings: ContractFilterBinding[] = [
394
+ ...[...new Set(HARNESSES.flatMap((h) => h.contractFiles(proj).map((file) => relative(proj, file))))]
395
+ .map((file) => ({ file, start: '<!-- spexcode:start -->', end: '<!-- spexcode:end -->', legacy: true })),
396
+ { file: '.gitignore', start: '# spexcode:start', end: '# spexcode:end' },
397
+ ]
398
+ if (payloads.length) plantContractFilter(proj, payloads, bindings)
399
+ // (5) finish diagnostics/migration, then atomically publish the allowlist LAST. Dispatch consumes only that
400
+ // final receipt; a killed writer leaves the preceding successful selection intact.
339
401
  const h = contentHash(proj)
340
402
  writeFileSync(join(rt, 'content-hash'), h)
403
+ writeFileSync(join(rt, 'contract-filter-v2'), '')
404
+ writeFileSync(join(rt, TREE_IGNORE_RECEIPT), '')
405
+ writeFileSync(join(runtimeRoot(proj), 'harness-selection-v1'), '')
406
+ retireLegacyContractBlock(proj)
407
+ const legacyEntries = hasLegacyTreeIgnore(proj) ? priorCommonEntries : []
408
+ writeManagedBlock(infoExcludePath(proj), entries([...commonEntries, ...legacyEntries]), ['# ', ''])
409
+ publishSelection(join(rt, 'harnesses'), selectionBody(selected, plugins.length > 0))
341
410
  return { contentHash: h, planted }
342
411
  }
@@ -21,3 +21,7 @@ export function readReviewSnapshot(): ReviewSnapshot {
21
21
  if (!current) throw new Error('review snapshot is unavailable before the first successful graph build')
22
22
  return current
23
23
  }
24
+
25
+ export function hasReviewSnapshot(): boolean {
26
+ return current !== null
27
+ }