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.
- package/package.json +1 -1
- package/spec-cli/hooks/dispatch.sh +10 -0
- package/spec-cli/src/cli.ts +6 -5
- package/spec-cli/src/contract-filter.ts +73 -29
- package/spec-cli/src/git.ts +80 -40
- package/spec-cli/src/harness.ts +127 -8
- package/spec-cli/src/index.ts +9 -3
- package/spec-cli/src/materialize.ts +118 -49
- package/spec-cli/src/reviewSnapshot.ts +4 -0
- package/spec-cli/src/reviews.ts +10 -5
- package/spec-cli/src/sessions.ts +113 -9
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-dashboard/dist/assets/{App-C5vbTw8Q.js → App-B72LuS5I.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-C5X4Va3V.js +27 -0
- package/spec-dashboard/dist/assets/{EvalsPage-BS7ITcNo.js → EvalsPage-BTvJIW8Q.js} +2 -2
- package/spec-dashboard/dist/assets/IssuesPage-Bn94h_HQ.js +1 -0
- package/spec-dashboard/dist/assets/{MobileApp-DVLnk9hz.js → MobileApp-ClbtwZ1e.js} +2 -2
- package/spec-dashboard/dist/assets/{Modal-6mHq6fbZ.js → Modal-6l_QtCKF.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-CAY4S4g4.js → PageScroll-B2kxcqJJ.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-UQyzsTWN.js → ProjectsPage-C8IPsMKV.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-DKU4c1Z-.js → SessionInterface-B5jf7dW7.js} +11 -11
- package/spec-dashboard/dist/assets/SessionWindow-Dag_GiJB.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-igR17pns.js → Settings-J3aibcXo.js} +1 -1
- package/spec-dashboard/dist/assets/{Thread-B-ZUarN1.js → Thread-Dg35J-Pu.js} +3 -3
- package/spec-dashboard/dist/assets/{TimelineChat-sc49Qj5d.js → TimelineChat-f0UF9fXq.js} +1 -1
- package/spec-dashboard/dist/assets/{data-B1ot4PF0.js → data-SNi0AmVT.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BqBNCa1V.js → index-BUKLPN_4.js} +10 -10
- package/spec-dashboard/dist/index.html +1 -1
- package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +0 -27
- package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +0 -1
- package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +0 -1
package/spec-cli/src/harness.ts
CHANGED
|
@@ -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
|
package/spec-cli/src/index.ts
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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).
|
|
38
|
-
//
|
|
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/)
|
|
68
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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]]):
|
|
202
|
-
//
|
|
203
|
-
const cfg = readConfig(
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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)
|
|
303
|
-
//
|
|
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
|
|
322
|
-
...[...
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
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
|
+
}
|