spexcode 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/spec-cli/src/cli.ts +68 -47
- package/spec-cli/src/client.ts +1 -1
- package/spec-cli/src/codex-runtime-generations.ts +97 -7
- package/spec-cli/src/commit-surgery.ts +2 -1
- package/spec-cli/src/contract-filter.ts +58 -42
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/git.ts +30 -8
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/harness.ts +11 -9
- package/spec-cli/src/help.ts +8 -13
- package/spec-cli/src/index.ts +8 -1
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +35 -7
- package/spec-cli/src/materialize.ts +150 -107
- package/spec-cli/src/plugin-harness.ts +19 -8
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +55 -11
- package/spec-cli/src/sessions.ts +166 -9
- package/spec-cli/src/uninstall.ts +2 -1
- package/spec-cli/templates/hooks/post-checkout +3 -0
- package/spec-cli/templates/hooks/post-merge +1 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-F9uaAVcH.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-CvAjfRC2.js → Dashboard-Ba_jhxp1.js} +3 -3
- package/spec-dashboard/dist/assets/{EvalsPage-Bz-nMKoS.js → EvalsPage-FixoOg_n.js} +2 -2
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-CuKLFhH3.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-D9L1Va8Z.js → MobileApp-CHgEHORJ.js} +2 -2
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-CQgYymmr.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-hT7UTLvD.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-CtXxakF9.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Dl9v0JFM.js → SessionInterface-Bpie-9fs.js} +12 -12
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-CixDi4PI.js} +1 -1
- package/spec-dashboard/dist/assets/{Settings-BZ1lGRJs.js → Settings-C2MsucfE.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +13 -0
- package/spec-dashboard/dist/assets/{data-Bwd3kAVL.js → data-B-RQmit6.js} +1 -1
- package/spec-dashboard/dist/assets/{index-DAbQBBK_.css → index-CsI8DElI.css} +1 -1
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DrVao0Ep.js} +2 -2
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-BBH02b1v.js} +1 -1
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/reviewFilters.js +5 -0
- package/spec-dashboard/src/session.js +5 -1
- package/spec-eval/src/cli.ts +7 -8
- package/spec-eval/src/evaltab.ts +25 -5
- package/spec-eval/src/freshness.ts +52 -8
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +122 -30
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-eval/src/matrix.ts +0 -693
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spexcode",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
|
|
6
6
|
"license": "MIT",
|
package/spec-cli/src/cli.ts
CHANGED
|
@@ -161,28 +161,13 @@ async function followKit(selectors: string[], verb: string): Promise<{
|
|
|
161
161
|
return { watcher, follow: (emit, opts) => followSessions(emit, { ...opts, targets, self: watcher, row }) }
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (!real.length) return
|
|
172
|
-
const { localCachedSessions } = await import('./client.js')
|
|
173
|
-
const { selectSessions, sendText, sessionHeadline } = await import('./sessions.js')
|
|
174
|
-
const board = localCachedSessions(true)
|
|
175
|
-
// name the watcher by its board HEADLINE (same as the reply-channel footer), delimited as a session title.
|
|
176
|
-
const mine = board.find((s) => s.id === watcher)
|
|
177
|
-
const me = mine ? sessionHeadline(mine) : watcher
|
|
178
|
-
const meWho = me && me !== watcher ? `session "${me}" (${watcher})` : `session ${watcher}`
|
|
179
|
-
for (const target of selectSessions(board, real).map((s) => s.id)) {
|
|
180
|
-
if (target === watcher || greeted.has(target)) continue
|
|
181
|
-
greeted.add(target)
|
|
182
|
-
const text = `🔭 ${meWho} is now supervising you — they started \`spex session watch\` over this session. To reach them directly, run: spex session send ${watcher} "<your message>". (One-time heads-up; reply only if you need to.)`
|
|
183
|
-
void sendText(target, text) // no sender id → the connection notice is not double-counted as comms
|
|
184
|
-
}
|
|
185
|
-
} catch { /* greeting is best-effort — it must never disturb the watch */ }
|
|
164
|
+
async function localWatchTargetsOrExit(selectors: string[], verb: string): Promise<string[]> {
|
|
165
|
+
if (!selectors.length) { console.error(`usage: ${verb} <SEL...>`); process.exit(2) }
|
|
166
|
+
const { localCachedSessions } = await import('./client.js')
|
|
167
|
+
const { selectSessions } = await import('./sessions.js')
|
|
168
|
+
const targets = selectSessions(localCachedSessions(true), selectors).map((session) => session.id)
|
|
169
|
+
if (!targets.length) { console.error(`${verb}: no such local session: ${selectors.join(' ')}`); process.exit(2) }
|
|
170
|
+
return targets
|
|
186
171
|
}
|
|
187
172
|
|
|
188
173
|
async function resolveSelectorOrExit(selector: string): Promise<string> {
|
|
@@ -545,12 +530,12 @@ if (cmd === 'serve') {
|
|
|
545
530
|
const stale = e.fresh ? '' : ` (stale: ${(e.staleAxes || []).join(',')})`
|
|
546
531
|
console.log(` ${e.inSession ? '✦' : ' '} ${verdict}${stale} ${e.scenario} — ${e.ts}${e.evaluator ? ` · ${e.evaluator}` : ''}`)
|
|
547
532
|
}
|
|
548
|
-
} else if (['add', 'ls', 'scenario', '
|
|
533
|
+
} else if (['add', 'ls', 'scenario', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
|
|
549
534
|
// node-scoped verbs — thin route; the logic lives in spec-eval.
|
|
550
535
|
const { runEval } = await import('../../spec-eval/src/cli.js')
|
|
551
536
|
await flushExit(await runEval(process.argv.slice(3)))
|
|
552
537
|
} else {
|
|
553
|
-
console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls/write |
|
|
538
|
+
console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls/write | lint | ok | retract | clean (spex help eval)`)
|
|
554
539
|
if (!sub.startsWith('--')) console.error(` (the old \`spex eval <SEL>\` session read is now \`spex eval ls --session <SEL>\` [--export])`) // dead-words-ok: signpost — one-version tombstone teaching the renamed spelling (0.4.0 removes it)
|
|
555
540
|
process.exit(2)
|
|
556
541
|
}
|
|
@@ -626,8 +611,18 @@ if (cmd === 'serve') {
|
|
|
626
611
|
if (!prompt.trim()) { console.error(`spex session new: --prompt-file ${promptFile === '-' ? 'stdin' : promptFile} is empty — refusing a promptless launch`); process.exit(2) }
|
|
627
612
|
}
|
|
628
613
|
const created = await createSession(prompt, flag('launcher') ?? undefined)
|
|
614
|
+
const { ownSessionId, subscribeSessionWatch } = await import('./sessions.js')
|
|
615
|
+
let watchEstablished = false
|
|
616
|
+
if (created.parent && created.parent === ownSessionId()) {
|
|
617
|
+
try {
|
|
618
|
+
await subscribeSessionWatch(created.parent, [created.id])
|
|
619
|
+
watchEstablished = true
|
|
620
|
+
} catch (error) {
|
|
621
|
+
console.error(`spex session new: child ${created.id} was created, but its managed watch was not established: ${error instanceof Error ? error.message : String(error)}`)
|
|
622
|
+
}
|
|
623
|
+
}
|
|
629
624
|
console.log(JSON.stringify(created, null, 2))
|
|
630
|
-
console.error((await import('./help.js')).sessionLaunchReceipt(created.id))
|
|
625
|
+
console.error((await import('./help.js')).sessionLaunchReceipt(created.id, watchEstablished))
|
|
631
626
|
} else if (sub === 'ls') {
|
|
632
627
|
// pretty list of living sessions + states. `spex session ls [SEL...] [--status a,b] [--json]`
|
|
633
628
|
// the board comes from the backend (so it shows the sessions of whatever SPEXCODE_API_URL points at,
|
|
@@ -648,15 +643,35 @@ if (cmd === 'serve') {
|
|
|
648
643
|
if (has('json')) console.log(JSON.stringify(report, null, 2))
|
|
649
644
|
else console.log((await import('./host-resources.js')).formatResourceReport(report))
|
|
650
645
|
} else if (sub === 'watch') {
|
|
651
|
-
const
|
|
652
|
-
const
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
646
|
+
const [verb, ...rest] = positionals(4)
|
|
647
|
+
const { ownSessionId, subscribeSessionWatch, listSessionWatches, cancelSessionWatch } = await import('./sessions.js')
|
|
648
|
+
const watcher = ownSessionId()
|
|
649
|
+
if (verb === 'stream') {
|
|
650
|
+
const kit = await followKit(rest, 'spex session watch stream')
|
|
651
|
+
await kit.follow((line) => console.log(line), {
|
|
652
|
+
statuses: flag('status')?.split(','),
|
|
653
|
+
includeIdle: has('idle'),
|
|
654
|
+
as: flag('as'),
|
|
655
|
+
intervalMs: (Number(flag('interval')) || 1) * 1000,
|
|
656
|
+
})
|
|
657
|
+
} else if (verb === 'list') {
|
|
658
|
+
if (!watcher) { console.error('spex session watch list: no governed caller session — this caller has no durable watch relation'); process.exit(2) }
|
|
659
|
+
for (const watch of listSessionWatches(watcher)) console.log(`${watch.target}\t${watch.createdAt}`)
|
|
660
|
+
} else if (verb === 'cancel') {
|
|
661
|
+
if (!watcher) { console.error(`spex session watch cancel: no governed caller session — run \`spex session wait ${rest.join(' ')}\` in the background instead`); process.exit(2) }
|
|
662
|
+
const targets = await localWatchTargetsOrExit(rest, 'spex session watch cancel')
|
|
663
|
+
console.log(`cancelled ${cancelSessionWatch(watcher, targets)} watch${targets.length === 1 ? '' : 'es'}`)
|
|
664
|
+
} else {
|
|
665
|
+
const selectors = [verb, ...rest].filter(Boolean)
|
|
666
|
+
if (!watcher) {
|
|
667
|
+
if (!selectors.length) { console.error('usage: spex session watch <SEL...>'); process.exit(2) }
|
|
668
|
+
console.error(`spex session watch: no governed caller session — run \`spex session wait ${selectors.join(' ')}\` in the BACKGROUND to receive the next actionable transition`)
|
|
669
|
+
} else {
|
|
670
|
+
const targets = await localWatchTargetsOrExit(selectors, 'spex session watch')
|
|
671
|
+
const result = await subscribeSessionWatch(watcher, targets)
|
|
672
|
+
console.log(`watching ${result.watched.join(' ')}`)
|
|
673
|
+
}
|
|
674
|
+
}
|
|
660
675
|
} else if (sub === 'wait') {
|
|
661
676
|
const selectors = positionals(4)
|
|
662
677
|
const kit = await followKit(selectors, 'spex session wait')
|
|
@@ -923,25 +938,31 @@ if (cmd === 'serve') {
|
|
|
923
938
|
delete env.SPEXCODE_SESSION_ID
|
|
924
939
|
const runtime = await spawnDetachedRuntime({ cwd, logFile, pidFile, receiptFile, command, args, env })
|
|
925
940
|
console.log(runtime.pid)
|
|
926
|
-
} else if (sub === 'codex-generation-current') {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
const
|
|
941
|
+
} else if (sub === 'codex-generation-current' || sub === 'codex-generation-session') {
|
|
942
|
+
// Both launch spellings resolve ONE endpoint and print it as shell assignments. Starting a root is the same
|
|
943
|
+
// detached spawn either way — a session identity must never ride into the shared, project-scoped daemon.
|
|
944
|
+
const resume = sub === 'codex-generation-session'
|
|
945
|
+
const [root, ...rest] = process.argv.slice(4)
|
|
946
|
+
const [sessionId, threadId, sessionCommand] = rest
|
|
947
|
+
const command = resume ? sessionCommand : rest[0]
|
|
948
|
+
if (!root || !command || (resume && (!sessionId || !threadId))) {
|
|
949
|
+
console.error(resume
|
|
950
|
+
? 'usage: spex internal codex-generation-session <runtime-root> <session-id> <thread-id> <codex-command>'
|
|
951
|
+
: 'usage: spex internal codex-generation-current <runtime-root> <codex-command>')
|
|
952
|
+
process.exit(2)
|
|
953
|
+
}
|
|
954
|
+
const { ensureCodexCurrentGeneration, resolveCodexGenerationForResume } = await import('./codex-runtime-generations.js')
|
|
930
955
|
const { spawnDetachedRuntime } = await import('./runtime-ownership.js')
|
|
931
956
|
const { sessionIdentityEnvVars } = await import('./harness.js')
|
|
932
957
|
const env = { ...process.env }
|
|
933
958
|
for (const key of sessionIdentityEnvVars()) delete env[key]
|
|
934
|
-
const
|
|
959
|
+
const start = async (candidate: { logFile: string; pidFile: string; receiptFile: string; socketPath: string }) => {
|
|
935
960
|
await spawnDetachedRuntime({ cwd: root, logFile: candidate.logFile, pidFile: candidate.pidFile, receiptFile: candidate.receiptFile,
|
|
936
961
|
command, args: ['app-server', '--listen', `unix://${candidate.socketPath}`], env })
|
|
937
|
-
}
|
|
938
|
-
const
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
const [root, sessionId, threadId] = process.argv.slice(4, 7)
|
|
942
|
-
if (!root || !sessionId || !threadId) { console.error('usage: spex internal codex-generation-session <runtime-root> <session-id> <thread-id>'); process.exit(2) }
|
|
943
|
-
const { resolveCodexGenerationForSession } = await import('./codex-runtime-generations.js')
|
|
944
|
-
const endpoint = resolveCodexGenerationForSession(root, sessionId, threadId)
|
|
962
|
+
}
|
|
963
|
+
const endpoint = resume
|
|
964
|
+
? await resolveCodexGenerationForResume(root, sessionId!, threadId!, start)
|
|
965
|
+
: await ensureCodexCurrentGeneration(root, start)
|
|
945
966
|
if (!endpoint) { console.error(`no exact Codex generation binding for session ${sessionId} thread ${threadId}`); process.exit(1) }
|
|
946
967
|
const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
|
|
947
968
|
console.log(`sock=${quote(endpoint.socketPath)}; pid=${quote(endpoint.pidFile)}; receipt=${quote(endpoint.receiptFile)}; log=${quote(endpoint.logFile)}; export SPEXCODE_CODEX_GENERATION=${quote(endpoint.id)}`)
|
package/spec-cli/src/client.ts
CHANGED
|
@@ -41,7 +41,7 @@ function cachedStatus(rec: ReturnType<typeof fromRaw>): DisplayStatus {
|
|
|
41
41
|
function corruptCachedSession(id: string, reason: string): Session {
|
|
42
42
|
const label = `${id.slice(0, 8)} (unreadable record)`
|
|
43
43
|
return {
|
|
44
|
-
id, node: null, branch: null, path: '', label,
|
|
44
|
+
id, node: null, branch: null, path: '', label, title: label, raw: { name: null, title: null },
|
|
45
45
|
parent: null, harness: 'unknown', capabilities: { headless: false }, launcher: null,
|
|
46
46
|
lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
|
|
47
47
|
note: `session record is unreadable: ${reason}`, archived: false, archiveHazard: null,
|
|
@@ -3,7 +3,7 @@ import { createConnection, type Socket } from 'node:net'
|
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
|
-
import { processStartToken, verifyDetachedRuntime } from './process-identity.js'
|
|
6
|
+
import { detachedRuntimeIsGone, processStartToken, verifyDetachedRuntime } from './process-identity.js'
|
|
7
7
|
|
|
8
8
|
export type CodexGenerationEndpoint = Readonly<{
|
|
9
9
|
id: string
|
|
@@ -271,6 +271,32 @@ function hasLegacyResidue(root: string): boolean {
|
|
|
271
271
|
return [endpoint.pidFile, endpoint.receiptFile, endpoint.socketPath].some(existsSync)
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
// A generation whose recorded process is provably gone — the host restarted, the OOM killer fired, a /tmp sweep
|
|
275
|
+
// took the socket with it. Death is a POSITIVE fact, not the absence of proof: such a root holds no threads, no
|
|
276
|
+
// peers, and no protective references, so nothing it once carried can be lost by retiring it. Everything else
|
|
277
|
+
// that fails `endpointIdentity` is AMBIGUOUS — a live process we can no longer address — and keeps its root.
|
|
278
|
+
function goneGeneration(endpoint: CodexGenerationEndpoint): boolean {
|
|
279
|
+
if (endpointIdentity(endpoint)) return false
|
|
280
|
+
let pid: number
|
|
281
|
+
try { pid = Number(readFileSync(endpoint.pidFile, 'utf8').trim()) }
|
|
282
|
+
catch { return false }
|
|
283
|
+
return Number.isInteger(pid) && pid > 0 && detachedRuntimeIsGone(pid, endpoint.receiptFile)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Retire a gone generation inside the caller's ledger lock, returning the published ledger (null when the
|
|
287
|
+
// generation is still there to serve or merely unaddressable). Its bindings deliberately survive: a session's
|
|
288
|
+
// conversation lives in Codex's on-disk rollout, so those rows are stale pointers to re-pin, not lost threads.
|
|
289
|
+
function retireGoneGenerationLocked(root: string, previous: CodexGenerationLedger, id: string): CodexGenerationLedger | null {
|
|
290
|
+
const generation = previous.generations[id]
|
|
291
|
+
if (!generation || generation.state === 'reclaimed' || !goneGeneration(generation.endpoint)) return null
|
|
292
|
+
return writeLedger(root, previous, {
|
|
293
|
+
current: previous.current === id ? null : previous.current,
|
|
294
|
+
pending: previous.pending === id ? null : previous.pending,
|
|
295
|
+
generations: { ...previous.generations, [id]: { state: 'reclaimed', endpoint: generation.endpoint } },
|
|
296
|
+
bindings: previous.bindings,
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
|
|
274
300
|
function bootstrapBindings(root: string, generationId: string): Record<string, CodexGenerationBinding> {
|
|
275
301
|
const result: Record<string, CodexGenerationBinding> = {}
|
|
276
302
|
const sessions = join(root, 'sessions')
|
|
@@ -305,13 +331,18 @@ function bindingProtectsGeneration(root: string, sessionId: string, binding: Cod
|
|
|
305
331
|
function bootstrapLedger(root: string): CodexGenerationLedger {
|
|
306
332
|
const legacy = legacyCodexGenerationEndpoint(root)
|
|
307
333
|
if (!hasLegacyResidue(root)) return emptyLedger()
|
|
308
|
-
|
|
334
|
+
// Residue left by a legacy root that has since died enters the ledger already reclaimed rather than blocking
|
|
335
|
+
// the bootstrap: it has nothing to drain. Its governed bindings are still recorded so those sessions resolve
|
|
336
|
+
// to a stale pointer they can re-pin, instead of losing the thread they own.
|
|
337
|
+
const gone = goneGeneration(legacy)
|
|
338
|
+
if (!gone && !endpointIdentity(legacy))
|
|
339
|
+
throw new Error('legacy Codex root is present but its exact detached PID/start/receipt/socket identity is unproven')
|
|
309
340
|
return {
|
|
310
341
|
version: 3,
|
|
311
342
|
revision: 0,
|
|
312
343
|
current: null,
|
|
313
344
|
pending: null,
|
|
314
|
-
generations: { [legacy.id]: { state: 'draining', endpoint: legacy } },
|
|
345
|
+
generations: { [legacy.id]: { state: gone ? 'reclaimed' : 'draining', endpoint: legacy } },
|
|
315
346
|
bindings: bootstrapBindings(root, legacy.id),
|
|
316
347
|
}
|
|
317
348
|
}
|
|
@@ -373,9 +404,13 @@ export async function ensureCodexCurrentGeneration(root: string, start: (endpoin
|
|
|
373
404
|
}
|
|
374
405
|
if (previous.current) {
|
|
375
406
|
const generation = previous.generations[previous.current]
|
|
376
|
-
if (
|
|
377
|
-
|
|
378
|
-
|
|
407
|
+
if (generation && generation.state === 'current' && endpointIdentity(generation.endpoint))
|
|
408
|
+
return { kind: 'current', endpoint: generation.endpoint }
|
|
409
|
+
// A canonical root that is provably gone is retired here and replaced by the rest of this same call:
|
|
410
|
+
// refusing forever would make one host restart permanently un-routable. Anything else stays a refusal.
|
|
411
|
+
const healed = retireGoneGenerationLocked(root, previous, previous.current)
|
|
412
|
+
if (!healed) throw new Error('canonical Codex generation is missing, replaced, or unproven; refusing to route new traffic')
|
|
413
|
+
previous = healed
|
|
379
414
|
}
|
|
380
415
|
if (previous.pending) {
|
|
381
416
|
const pending = previous.generations[previous.pending]
|
|
@@ -491,12 +526,67 @@ export function prepareCodexGenerationClose(root: string, sessionId: string, thr
|
|
|
491
526
|
if (!binding || binding.threadId !== threadId) throw new Error(`Codex session ${sessionId} has no exact generation binding to close`)
|
|
492
527
|
if (binding.phase === 'record-removing') return
|
|
493
528
|
const generation = previous.generations[binding.generationId]
|
|
494
|
-
|
|
529
|
+
// A retired root can neither route this thread nor be pinned by it, so there is nothing for the removal
|
|
530
|
+
// marker to protect: drop the binding outright and let close proceed. An ABSENT generation is corruption.
|
|
531
|
+
if (!generation) throw new Error(`Codex session ${sessionId} binding names an absent generation`)
|
|
532
|
+
if (generation.state === 'reclaimed') {
|
|
533
|
+
const remaining = { ...previous.bindings }
|
|
534
|
+
delete remaining[sessionId]
|
|
535
|
+
writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations: previous.generations, bindings: remaining })
|
|
536
|
+
return
|
|
537
|
+
}
|
|
495
538
|
const bindings = { ...previous.bindings, [sessionId]: { ...binding, phase: 'record-removing' as const } }
|
|
496
539
|
writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations: previous.generations, bindings })
|
|
497
540
|
})
|
|
498
541
|
}
|
|
499
542
|
|
|
543
|
+
// Re-pin an existing exact binding onto another live generation. The transaction phase is carried over, so a
|
|
544
|
+
// registration or close that is mid-flight keeps its crash boundary while its route is corrected.
|
|
545
|
+
export function repinCodexGeneration(root: string, sessionId: string, threadId: string, generationId: string): void {
|
|
546
|
+
withLedgerLockSync(root, () => {
|
|
547
|
+
const previous = readCodexGenerationLedger(root)
|
|
548
|
+
const binding = previous.bindings[sessionId]
|
|
549
|
+
if (!binding || binding.threadId !== threadId) throw new Error(`Codex session ${sessionId} has no exact binding to re-pin`)
|
|
550
|
+
const generation = previous.generations[generationId]
|
|
551
|
+
if (!generation || generation.state === 'reclaimed') throw new Error(`Codex generation ${generationId} is absent or reclaimed`)
|
|
552
|
+
if (binding.generationId === generationId) return
|
|
553
|
+
const bindings = { ...previous.bindings, [sessionId]: { ...binding, generationId } }
|
|
554
|
+
writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations: previous.generations, bindings })
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// The resume boundary. A binding to a LIVE generation still routes only there — an existing conversation is
|
|
559
|
+
// never moved out from under the client that holds it. But a binding whose root is gone names a process, not a
|
|
560
|
+
// conversation: the thread's durable home is its on-disk rollout, which any generation can load. So the gone
|
|
561
|
+
// root is retired and the session is re-pinned to the canonical one, which loads that same rollout. Missing,
|
|
562
|
+
// mismatched, ambiguous, or unprotected bindings remain refusals — resume never invents a route.
|
|
563
|
+
export async function resolveCodexGenerationForResume(
|
|
564
|
+
root: string,
|
|
565
|
+
sessionId: string,
|
|
566
|
+
threadId: string,
|
|
567
|
+
start: (endpoint: CodexGenerationEndpoint) => Promise<void>,
|
|
568
|
+
): Promise<CodexGenerationEndpoint | null> {
|
|
569
|
+
const ledger = readCodexGenerationLedger(root)
|
|
570
|
+
const binding = ledger.bindings[sessionId]
|
|
571
|
+
if (!binding || binding.threadId !== threadId || !bindingProtectsGeneration(root, sessionId, binding)) return null
|
|
572
|
+
const generation = ledger.generations[binding.generationId]
|
|
573
|
+
if (!generation) return null
|
|
574
|
+
if (generation.state !== 'reclaimed') {
|
|
575
|
+
if (endpointIdentity(generation.endpoint)) return generation.endpoint
|
|
576
|
+
const retired = await withLedgerLock(root, async () => {
|
|
577
|
+
const previous = readCodexGenerationLedger(root)
|
|
578
|
+
if (previous.bindings[sessionId]?.generationId !== binding.generationId) return false
|
|
579
|
+
const observed = previous.generations[binding.generationId]
|
|
580
|
+
if (!observed) return false
|
|
581
|
+
return observed.state === 'reclaimed' || !!retireGoneGenerationLocked(root, previous, binding.generationId)
|
|
582
|
+
})
|
|
583
|
+
if (!retired) return null
|
|
584
|
+
}
|
|
585
|
+
const current = await ensureCodexCurrentGeneration(root, start)
|
|
586
|
+
repinCodexGeneration(root, sessionId, threadId, current.id)
|
|
587
|
+
return current
|
|
588
|
+
}
|
|
589
|
+
|
|
500
590
|
export function resolveCodexGenerationForSession(root: string, sessionId: string, threadId: string): CodexGenerationEndpoint | null {
|
|
501
591
|
const ledger = readCodexGenerationLedger(root)
|
|
502
592
|
const binding = ledger.bindings[sessionId]
|
|
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'
|
|
|
2
2
|
import { relative } from 'node:path'
|
|
3
3
|
import { materialize, stripSpexcodeBlock, GENERATED_MARK } from './materialize.js'
|
|
4
4
|
import { HARNESSES } from './harness.js'
|
|
5
|
+
import { gitBinary } from './git.js'
|
|
5
6
|
|
|
6
7
|
// GIT ENV, deliberately INVERTED from git.ts's git(): every call here PRESERVES the hook's environment —
|
|
7
8
|
// GIT_INDEX_FILE must be honored so the surgery reads/writes the EXACT index this commit is being built
|
|
@@ -9,7 +10,7 @@ import { HARNESSES } from './harness.js'
|
|
|
9
10
|
// operating on the real one would silently miss them). git.ts strips that env for repo DISCOVERY reasons;
|
|
10
11
|
// index surgery is the one place the env is the point.
|
|
11
12
|
const raw = (args: string[], input?: string): string =>
|
|
12
|
-
execFileSync(
|
|
13
|
+
execFileSync(gitBinary(process.env), args, { input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
|
|
13
14
|
|
|
14
15
|
function inHead(p: string): boolean {
|
|
15
16
|
try { raw(['cat-file', '-e', `HEAD:${p}`]); return true } catch { return false }
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, rmSync,
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, chmodSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
2
|
import { join, relative } from 'node:path'
|
|
3
3
|
import { execFileSync } from 'node:child_process'
|
|
4
|
-
import { git } from './git.js'
|
|
4
|
+
import { git, gitBinary } from './git.js'
|
|
5
5
|
import { writeManagedBlock, removeManagedBlock } from './harness.js'
|
|
6
6
|
import { encodeProject, runtimeRoot, treeSlotDir } from './layout.js'
|
|
7
|
+
import { writeFileIfChanged } from './file-write.js'
|
|
7
8
|
|
|
8
9
|
// the three field-sharpened edges this module owes ([[content-filter]]):
|
|
9
10
|
// ① the configured command points at a STABLE shim path and degrades to `cat` (identity) when the shim is
|
|
@@ -20,20 +21,13 @@ function commonDirOf(proj: string): string {
|
|
|
20
21
|
}
|
|
21
22
|
const filterDir = (common: string) => join(common, 'spexcode')
|
|
22
23
|
const shimPath = (common: string) => join(filterDir(common), 'contract-filter.sh')
|
|
23
|
-
const blockPath = (common: string) => join(filterDir(common), 'contract-block.md')
|
|
24
24
|
const rootPath = (common: string) => join(filterDir(common), 'contract-filter-root')
|
|
25
25
|
const bindingsPath = (common: string) => join(filterDir(common), 'contract-filter-bindings')
|
|
26
26
|
const treeFilterDir = (proj: string) => join(treeSlotDir(proj), 'contract-filter')
|
|
27
27
|
const attributesPath = (common: string) => join(common, 'info', 'attributes')
|
|
28
28
|
|
|
29
|
-
const registeredSlots = (proj: string) => git(['-C', proj, 'worktree', 'list', '--porcelain', '-z']).split('\0')
|
|
30
|
-
.filter((row) => row.startsWith('worktree ')).map((row) =>
|
|
31
|
-
join(runtimeRoot(proj), 'trees', encodeProject(row.slice('worktree '.length))))
|
|
32
|
-
const legacyFilterTree = (proj: string) => registeredSlots(proj)
|
|
33
|
-
.some((slot) => existsSync(join(slot, 'content-hash')) && !existsSync(join(slot, 'contract-filter-v2')))
|
|
34
|
-
|
|
35
29
|
export type ContractFilterPayload = { file: string; content: string }
|
|
36
|
-
export type ContractFilterBinding = { file: string; start: string; end: string
|
|
30
|
+
export type ContractFilterBinding = { file: string; start: string; end: string }
|
|
37
31
|
|
|
38
32
|
// The common shim both filter directions run through. Pure shell/awk (no node boot on git's hot path), it
|
|
39
33
|
// resolves the invoking checkout to that tree's payload before mirroring managed-block normalization:
|
|
@@ -48,19 +42,15 @@ set -u
|
|
|
48
42
|
mode="\${1:?usage: contract-filter.sh smudge|clean <path>}"
|
|
49
43
|
path="\${2:?usage: contract-filter.sh smudge|clean <path>}"
|
|
50
44
|
here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
51
|
-
binding="$(awk -F '\t' -v p="$path" '$1 == p { print $2 "\t" $3
|
|
45
|
+
binding="$(awk -F '\t' -v p="$path" '$1 == p { print $2 "\t" $3; exit }' "$here/contract-filter-bindings" 2>/dev/null)"
|
|
52
46
|
[ -n "$binding" ] || { cat; exit 0; }
|
|
53
47
|
start="\${binding%%$'\t'*}"; rest="\${binding#*$'\t'}"
|
|
54
|
-
end="
|
|
48
|
+
end="$rest"
|
|
55
49
|
top="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
|
56
50
|
root="$(cat "$here/contract-filter-root" 2>/dev/null || true)"
|
|
57
51
|
key="$(printf '%s' "$top" | sed 's#[/.]#-#g')"
|
|
58
52
|
manifest="$root/trees/$key/contract-filter/manifest"
|
|
59
53
|
payload="$(awk -F '\t' -v p="$path" '$1 == p { print $2; exit }' "$manifest" 2>/dev/null)"
|
|
60
|
-
# A pre-v2 tree has only the old common payload. Once that tree materializes, its marker makes a missing
|
|
61
|
-
# per-file payload mean identity (for example, AGENTS.md in a Claude-only tree), never global fallback.
|
|
62
|
-
marker="$root/trees/$key/contract-filter-v2"
|
|
63
|
-
if [ ! -r "$payload" ] && [ ! -f "$marker" ] && [ "$legacy" = 1 ] && [ -r "$here/contract-block.md" ]; then payload="$here/contract-block.md"; fi
|
|
64
54
|
strip() {
|
|
65
55
|
awk -v sline="$start" -v eline="$end" 'BEGIN { n = 0 }
|
|
66
56
|
{ lines[n++] = $0 }
|
|
@@ -105,33 +95,54 @@ const filterCmd = (shim: string, mode: 'smudge' | 'clean') =>
|
|
|
105
95
|
|
|
106
96
|
// plant (or refresh) the filter for the given contract files (tracked, or untracked-with-host-content —
|
|
107
97
|
// pre-armed): the shim + the block content it smudges, the per-clone git config, and the attribute lines
|
|
108
|
-
// binding each file to the filter.
|
|
109
|
-
//
|
|
110
|
-
export function plantContractFilter(proj: string, payloads: ContractFilterPayload[], bindings: ContractFilterBinding[]):
|
|
98
|
+
// binding each file to the filter. `contract` is the assembled block body (guide + surface:system).
|
|
99
|
+
// settleIndexStat skips untracked entries (no index blob) by design and only runs after a real filter change.
|
|
100
|
+
export function plantContractFilter(proj: string, payloads: ContractFilterPayload[], bindings: ContractFilterBinding[], changedFiles: readonly string[] = []): boolean {
|
|
111
101
|
const common = commonDirOf(proj)
|
|
112
102
|
mkdirSync(filterDir(common), { recursive: true })
|
|
113
|
-
|
|
114
|
-
chmodSync(shimPath(common), 0o755)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
103
|
+
let changed = writeFileIfChanged(shimPath(common), SHIM)
|
|
104
|
+
if (changed || (statSync(shimPath(common)).mode & 0o777) !== 0o755) chmodSync(shimPath(common), 0o755)
|
|
105
|
+
const commands = new Map([
|
|
106
|
+
['filter.spexcode.smudge', filterCmd(shimPath(common), 'smudge')],
|
|
107
|
+
['filter.spexcode.clean', filterCmd(shimPath(common), 'clean')],
|
|
108
|
+
])
|
|
109
|
+
const configured = new Map<string, string>()
|
|
110
|
+
try {
|
|
111
|
+
for (const row of git(['-C', proj, 'config', '--get-regexp', '^filter\\.spexcode\\.(smudge|clean)$']).trimEnd().split('\n')) {
|
|
112
|
+
const at = row.indexOf(' ')
|
|
113
|
+
if (at > 0) configured.set(row.slice(0, at), row.slice(at + 1))
|
|
114
|
+
}
|
|
115
|
+
} catch {}
|
|
116
|
+
for (const [key, command] of commands) {
|
|
117
|
+
if (configured.get(key) === command) continue
|
|
118
|
+
git(['-C', proj, 'config', key, command])
|
|
119
|
+
changed = true
|
|
120
|
+
}
|
|
121
|
+
changed = writeFileIfChanged(bindingsPath(common), bindings.map((b) => `${b.file}\t${b.start}\t${b.end}`).join('\n') + '\n') || changed
|
|
118
122
|
const dir = treeFilterDir(proj)
|
|
119
|
-
|
|
123
|
+
mkdirSync(dir, { recursive: true })
|
|
120
124
|
const manifest: string[] = []
|
|
125
|
+
const wanted = new Set(['manifest'])
|
|
121
126
|
for (const [i, payload] of payloads.entries()) {
|
|
122
127
|
const target = join(dir, String(i))
|
|
123
|
-
|
|
128
|
+
wanted.add(String(i))
|
|
129
|
+
changed = writeFileIfChanged(target, payload.content.endsWith('\n') ? payload.content : `${payload.content}\n`) || changed
|
|
124
130
|
manifest.push(`${payload.file}\t${target}`)
|
|
125
131
|
}
|
|
132
|
+
for (const entry of readdirSync(dir)) {
|
|
133
|
+
if (!wanted.has(entry)) { rmSync(join(dir, entry), { recursive: true, force: true }); changed = true }
|
|
134
|
+
}
|
|
126
135
|
const manifestPath = join(dir, 'manifest')
|
|
127
|
-
|
|
128
|
-
|
|
136
|
+
changed = writeFileIfChanged(manifestPath, manifest.join('\n') + (manifest.length ? '\n' : '')) || changed
|
|
137
|
+
changed = writeFileIfChanged(rootPath(common), `${runtimeRoot(proj)}\n`) || changed
|
|
129
138
|
// Attribute patterns are checkout-relative; the stable binding set is safe in the common git dir because
|
|
130
139
|
// the driver selects a payload from the invoking checkout's tree slot.
|
|
131
140
|
const entries = bindings.map((b) => `/${b.file} filter=spexcode`).sort().join('\n')
|
|
132
141
|
mkdirSync(join(common, 'info'), { recursive: true })
|
|
133
|
-
writeManagedBlock(attributesPath(common), entries, ['# ', ''])
|
|
134
|
-
|
|
142
|
+
changed = writeManagedBlock(attributesPath(common), entries, ['# ', '']) || changed
|
|
143
|
+
const settle = changed ? payloads.map((p) => join(proj, p.file)) : changedFiles
|
|
144
|
+
if (settle.length) settleIndexStat(proj, [...new Set(settle)])
|
|
145
|
+
return changed
|
|
135
146
|
}
|
|
136
147
|
|
|
137
148
|
// settle the index STAT for each file — the famous filtered-path phantom-`M`: git cannot verify a
|
|
@@ -150,7 +161,7 @@ export function settleIndexStat(proj: string, files: string[]): void {
|
|
|
150
161
|
const rel = relative(proj, f)
|
|
151
162
|
try {
|
|
152
163
|
const indexBlob = git(['-C', proj, 'rev-parse', `:${rel}`]).trim()
|
|
153
|
-
const filtered = execFileSync(
|
|
164
|
+
const filtered = execFileSync(gitBinary(env), ['-C', proj, 'hash-object', '--path', rel, '--stdin'],
|
|
154
165
|
{ input: readFileSync(f), env, stdio: ['pipe', 'pipe', 'ignore'] }).toString().trim()
|
|
155
166
|
if (indexBlob === filtered) git(['-C', proj, 'add', '--renormalize', '--', rel])
|
|
156
167
|
} catch { /* best-effort */ }
|
|
@@ -160,24 +171,29 @@ export function settleIndexStat(proj: string, files: string[]): void {
|
|
|
160
171
|
// the full inverse (edge ③ — call AFTER the managed blocks left the working files): attribute lines out,
|
|
161
172
|
// config keys unset, shim + block content removed. `<common>/spexcode/` may host other spexcode data
|
|
162
173
|
// (evidence blobs), so only OUR two files go, never the dir.
|
|
174
|
+
export function clearContractFilterPayload(proj: string, files: string[] = []): void {
|
|
175
|
+
try { rmSync(treeFilterDir(proj), { recursive: true, force: true }) } catch { /* inaccessible tree */ }
|
|
176
|
+
settleIndexStat(proj, files)
|
|
177
|
+
}
|
|
178
|
+
|
|
163
179
|
export function removeContractFilter(proj: string, files: string[] = [], final = false): void {
|
|
164
180
|
let common: string
|
|
165
181
|
try { common = commonDirOf(proj) } catch { return } // not a git repo → nothing was ever planted
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
clearContractFilterPayload(proj, files)
|
|
183
|
+
const anotherPayload = final ? false : (() => {
|
|
184
|
+
const rows = git(['-C', proj, 'worktree', 'list', '--porcelain', '-z']).split('\0')
|
|
185
|
+
const root = runtimeRoot(proj)
|
|
186
|
+
return rows
|
|
187
|
+
.filter((row) => row.startsWith('worktree '))
|
|
188
|
+
.map((row) => row.slice('worktree '.length))
|
|
189
|
+
.some((tree) => existsSync(join(root, 'trees', encodeProject(tree), 'contract-filter', 'manifest')))
|
|
190
|
+
})()
|
|
191
|
+
if (!final && anotherPayload) return
|
|
172
192
|
removeManagedBlock(attributesPath(common), ['# ', ''], true)
|
|
173
193
|
for (const key of ['filter.spexcode.smudge', 'filter.spexcode.clean']) {
|
|
174
194
|
try { git(['-C', proj, 'config', '--unset-all', key]) } catch { /* not set — already clean */ }
|
|
175
195
|
}
|
|
176
|
-
for (const path of [shimPath(common),
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export function retireLegacyContractBlock(proj: string): void {
|
|
180
|
-
if (!legacyFilterTree(proj)) rmSync(blockPath(commonDirOf(proj)), { force: true })
|
|
196
|
+
for (const path of [shimPath(common), rootPath(common), bindingsPath(common)]) rmSync(path, { force: true })
|
|
181
197
|
}
|
|
182
198
|
|
|
183
199
|
// is the filter currently planted? (the assert-side probe tests use; cheap: one config read)
|
package/spec-cli/src/doctor.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { loadSystemConfig, loadSkillConfig, loadSpecs } from './specs.js'
|
|
|
7
7
|
import { runtimeRoot, treeSlotDir, envSessionId, readAliasedRawRecord, mainCheckout, readJsonConfig } from './layout.js'
|
|
8
8
|
import { loadConfig } from './lint.js'
|
|
9
9
|
import { trackedSourceFiles } from './source-files.js'
|
|
10
|
+
import { gitBinary } from './git.js'
|
|
10
11
|
|
|
11
12
|
// this file lives at <pkgRoot>/src/self.ts, so `..` is the package root — the same derivation init.ts/
|
|
12
13
|
// materialize.ts use (never a hardcoded repo path), so the git-hook template lookup survives a relocated install.
|
|
@@ -14,7 +15,7 @@ const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
|
14
15
|
|
|
15
16
|
// run a git query in `dir`, swallowing git's own stderr — a non-repo returns null (the absence IS the signal).
|
|
16
17
|
function git(dir: string, args: string[]): string | null {
|
|
17
|
-
try { return execFileSync(
|
|
18
|
+
try { return execFileSync(gitBinary(process.env), ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() }
|
|
18
19
|
catch { return null }
|
|
19
20
|
}
|
|
20
21
|
function repoRoot(dir: string): string | null { return git(dir, ['rev-parse', '--show-toplevel']) }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { copyFileSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
export function writeFileIfChanged(path: string, content: string | Uint8Array): boolean {
|
|
4
|
+
const next = Buffer.from(content)
|
|
5
|
+
try {
|
|
6
|
+
if (readFileSync(path).equals(next)) return false
|
|
7
|
+
} catch (error: any) {
|
|
8
|
+
if (error?.code !== 'ENOENT') throw error
|
|
9
|
+
}
|
|
10
|
+
writeFileSync(path, content)
|
|
11
|
+
return true
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function copyFileIfChanged(source: string, target: string): boolean {
|
|
15
|
+
try {
|
|
16
|
+
if (readFileSync(target).equals(readFileSync(source))) return false
|
|
17
|
+
} catch (error: any) {
|
|
18
|
+
if (error?.code !== 'ENOENT') throw error
|
|
19
|
+
}
|
|
20
|
+
copyFileSync(source, target)
|
|
21
|
+
return true
|
|
22
|
+
}
|