spexcode 0.5.7 → 0.5.9
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 +5 -3
- package/spec-cli/bin/spex.mjs +1 -13
- package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/spec-cli/hooks/dispatch.sh +15 -19
- package/spec-cli/src/anchors.ts +199 -60
- package/spec-cli/src/attach.ts +7 -17
- package/spec-cli/src/cli.ts +144 -225
- package/spec-cli/src/client.ts +114 -132
- package/spec-cli/src/cockpit.ts +43 -0
- package/spec-cli/src/codex-runtime-generations.ts +556 -0
- package/spec-cli/src/commit-surgery.ts +0 -14
- package/spec-cli/src/contract-filter.ts +0 -14
- package/spec-cli/src/doctor.ts +0 -16
- package/spec-cli/src/gateway-auth.ts +0 -13
- package/spec-cli/src/gateway.ts +0 -22
- package/spec-cli/src/git.ts +151 -48
- package/spec-cli/src/graph.ts +28 -7
- package/spec-cli/src/graphCache.ts +253 -72
- package/spec-cli/src/graphDelta.ts +0 -13
- package/spec-cli/src/graphStream.ts +70 -37
- package/spec-cli/src/guide.ts +29 -6
- package/spec-cli/src/harness.ts +232 -248
- package/spec-cli/src/help.ts +27 -26
- package/spec-cli/src/host-resources.ts +11 -2
- package/spec-cli/src/index.ts +70 -95
- package/spec-cli/src/issues-cli.ts +276 -0
- package/spec-cli/src/issues.ts +15 -197
- package/spec-cli/src/layout.ts +78 -2
- package/spec-cli/src/lint.ts +42 -30
- package/spec-cli/src/localIssues.ts +22 -185
- package/spec-cli/src/loop-in.ts +78 -0
- package/spec-cli/src/materialize.ts +0 -42
- package/spec-cli/src/mentions.ts +49 -193
- package/spec-cli/src/opencode-headless.ts +7 -7
- package/spec-cli/src/opencode.ts +2 -5
- package/spec-cli/src/pi-headless.ts +5 -5
- package/spec-cli/src/pty-bridge.ts +4 -5
- package/spec-cli/src/reviewSnapshot.ts +1 -0
- package/spec-cli/src/reviews.ts +6 -1
- package/spec-cli/src/root-lru.ts +54 -0
- package/spec-cli/src/runtime-guard.ts +0 -9
- package/spec-cli/src/session-cursors.ts +97 -0
- package/spec-cli/src/session-follow.ts +182 -0
- package/spec-cli/src/session-timeline.ts +55 -127
- package/spec-cli/src/sessions.ts +207 -1022
- package/spec-cli/src/shim-runtime.ts +9 -16
- package/spec-cli/src/specs.ts +33 -10
- package/spec-cli/src/uninstall.ts +0 -9
- package/spec-cli/src/uploads.ts +240 -18
- package/spec-cli/src/worktree-sources.ts +0 -16
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
- package/spec-cli/templates/spexcode.json +12 -0
- package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
- package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
- package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
- package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
- package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
- package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/session.js +1 -28
- package/spec-eval/src/cache.ts +0 -14
- package/spec-eval/src/cli.ts +26 -5
- package/spec-eval/src/evaltab.ts +67 -108
- package/spec-eval/src/filing.ts +0 -18
- package/spec-eval/src/freshness.ts +78 -32
- package/spec-eval/src/humanok.ts +0 -15
- package/spec-eval/src/scenariofresh.ts +8 -80
- package/spec-eval/src/scenarios.ts +16 -67
- package/spec-eval/src/sessioneval.ts +157 -78
- package/spec-eval/src/sidecar.ts +1 -70
- package/spec-eval/src/timeline.ts +0 -23
- package/spec-forge/src/cache.ts +34 -12
- package/spec-forge/src/cli.ts +0 -6
- package/spec-forge/src/drivers/github.ts +1 -12
- package/spec-forge/src/drivers/gitlab.ts +4 -19
- package/spec-forge/src/drivers.ts +0 -18
- package/spec-forge/src/links.ts +0 -4
- package/spec-forge/src/needs-eval.ts +0 -1
- package/spec-forge/src/port.ts +0 -12
- package/spec-forge/src/resident.ts +4 -20
- package/spec-cli/src/maintenance-wrapper.ts +0 -276
- package/spec-cli/src/session-maintenance.ts +0 -751
- package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
- package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
- package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
- /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { createConnection, type Socket } from 'node:net'
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { processStartToken, verifyDetachedRuntime } from './process-identity.js'
|
|
7
|
+
|
|
8
|
+
export type CodexGenerationEndpoint = Readonly<{
|
|
9
|
+
id: string
|
|
10
|
+
pidFile: string
|
|
11
|
+
receiptFile: string
|
|
12
|
+
logFile: string
|
|
13
|
+
socketPath: string
|
|
14
|
+
}>
|
|
15
|
+
|
|
16
|
+
type CodexGenerationState = 'current' | 'draining' | 'reclaimed' | 'starting'
|
|
17
|
+
type GenerationReservation = Readonly<{ pid: number; startToken: string }>
|
|
18
|
+
type CodexGeneration = Readonly<{ state: CodexGenerationState; endpoint: CodexGenerationEndpoint; reservation?: GenerationReservation }>
|
|
19
|
+
export type CodexGenerationBinding = Readonly<{ generationId: string; threadId: string; phase?: 'record-pending' | 'record-removing' }>
|
|
20
|
+
|
|
21
|
+
export type CodexGenerationLedger = Readonly<{
|
|
22
|
+
version: 3
|
|
23
|
+
revision: number
|
|
24
|
+
current: string | null
|
|
25
|
+
pending: string | null
|
|
26
|
+
generations: Record<string, CodexGeneration>
|
|
27
|
+
bindings: Record<string, CodexGenerationBinding>
|
|
28
|
+
}>
|
|
29
|
+
|
|
30
|
+
const generationsDir = (root: string) => join(root, 'codex-app-server-generations')
|
|
31
|
+
const ledgerPath = (root: string) => join(root, 'codex-app-server-generations.json')
|
|
32
|
+
const lockPath = (root: string) => join(root, 'codex-app-server-generations.lock')
|
|
33
|
+
const lockOwnerPath = (root: string) => join(lockPath(root), 'owner.json')
|
|
34
|
+
const lockReaperPath = (root: string) => join(lockPath(root), 'reaper')
|
|
35
|
+
const lockReaperOwnerPath = (root: string) => join(lockReaperPath(root), 'owner.json')
|
|
36
|
+
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
|
37
|
+
|
|
38
|
+
export function codexGenerationSocketPath(root: string, generationId?: string): string {
|
|
39
|
+
const base = process.env.SPEXCODE_CODEX_SOCKET_DIR || join(tmpdir(), `spexcode-cx-${process.getuid?.() ?? 0}`)
|
|
40
|
+
mkdirSync(base, { recursive: true, mode: 0o700 })
|
|
41
|
+
// No suffix preserves the deployed legacy location. Every detached-v3 generation has an independent socket.
|
|
42
|
+
const key = generationId ? `${root}\0${generationId}` : root
|
|
43
|
+
return join(base, `spexcode-cx-${createHash('sha1').update(key).digest('hex').slice(0, 16)}.sock`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function legacyCodexGenerationEndpoint(root: string): CodexGenerationEndpoint {
|
|
47
|
+
return Object.freeze({
|
|
48
|
+
id: 'legacy',
|
|
49
|
+
pidFile: join(root, 'codex-app-server.pid'),
|
|
50
|
+
receiptFile: join(root, 'codex-app-server.detached.json'),
|
|
51
|
+
logFile: join(root, 'codex-app-server.log'),
|
|
52
|
+
socketPath: codexGenerationSocketPath(root),
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function newEndpoint(root: string, id = `detached-v3-${randomUUID()}`): CodexGenerationEndpoint {
|
|
57
|
+
const dir = join(generationsDir(root), id)
|
|
58
|
+
return Object.freeze({
|
|
59
|
+
id,
|
|
60
|
+
pidFile: join(dir, 'pid'),
|
|
61
|
+
receiptFile: join(dir, 'detached.json'),
|
|
62
|
+
logFile: join(dir, 'app-server.log'),
|
|
63
|
+
socketPath: codexGenerationSocketPath(root, id),
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const emptyLedger = (): CodexGenerationLedger => Object.freeze({ version: 3, revision: 0, current: null, pending: null, generations: {}, bindings: {} })
|
|
68
|
+
|
|
69
|
+
function isEndpoint(value: unknown): value is CodexGenerationEndpoint {
|
|
70
|
+
if (!value || typeof value !== 'object') return false
|
|
71
|
+
const candidate = value as Partial<CodexGenerationEndpoint>
|
|
72
|
+
return typeof candidate.id === 'string' && typeof candidate.pidFile === 'string' && typeof candidate.receiptFile === 'string' &&
|
|
73
|
+
typeof candidate.logFile === 'string' && typeof candidate.socketPath === 'string'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isReservation(value: unknown): value is GenerationReservation {
|
|
77
|
+
if (!value || typeof value !== 'object') return false
|
|
78
|
+
const candidate = value as Partial<GenerationReservation>
|
|
79
|
+
return Number.isInteger(candidate.pid) && candidate.pid! > 0 && typeof candidate.startToken === 'string' && !!candidate.startToken
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseLedger(value: unknown): CodexGenerationLedger {
|
|
83
|
+
if (!value || typeof value !== 'object') throw new Error('Codex generation ledger is malformed')
|
|
84
|
+
const raw = value as Partial<CodexGenerationLedger>
|
|
85
|
+
if (raw.version !== 3 || !Number.isSafeInteger(raw.revision) || raw.revision! < 0 ||
|
|
86
|
+
(raw.current !== null && typeof raw.current !== 'string') || (raw.pending !== null && typeof raw.pending !== 'string') ||
|
|
87
|
+
!raw.generations || typeof raw.generations !== 'object' || !raw.bindings || typeof raw.bindings !== 'object') {
|
|
88
|
+
throw new Error('Codex generation ledger has the wrong detached-v3 shape')
|
|
89
|
+
}
|
|
90
|
+
for (const [id, generation] of Object.entries(raw.generations)) {
|
|
91
|
+
if (!generation || typeof generation !== 'object' || !['current', 'draining', 'reclaimed', 'starting'].includes((generation as CodexGeneration).state) ||
|
|
92
|
+
!isEndpoint((generation as CodexGeneration).endpoint) || (generation as CodexGeneration).endpoint.id !== id ||
|
|
93
|
+
((generation as CodexGeneration).state === 'starting' && !isReservation((generation as CodexGeneration).reservation))) {
|
|
94
|
+
throw new Error(`Codex generation ledger has malformed generation ${id}`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const [sessionId, binding] of Object.entries(raw.bindings)) {
|
|
98
|
+
if (!binding || typeof binding !== 'object' || typeof (binding as CodexGenerationBinding).generationId !== 'string' ||
|
|
99
|
+
typeof (binding as CodexGenerationBinding).threadId !== 'string' || !(binding as CodexGenerationBinding).threadId ||
|
|
100
|
+
((binding as CodexGenerationBinding).phase !== undefined && (binding as CodexGenerationBinding).phase !== 'record-pending' &&
|
|
101
|
+
(binding as CodexGenerationBinding).phase !== 'record-removing')) {
|
|
102
|
+
throw new Error(`Codex generation ledger has malformed binding ${sessionId}`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return raw as CodexGenerationLedger
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function readCodexGenerationLedger(root: string): CodexGenerationLedger {
|
|
109
|
+
if (!existsSync(ledgerPath(root))) return emptyLedger()
|
|
110
|
+
return parseLedger(JSON.parse(readFileSync(ledgerPath(root), 'utf8')))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type LedgerWrite = Omit<CodexGenerationLedger, 'revision' | 'version'>
|
|
114
|
+
function writeLedger(root: string, previous: CodexGenerationLedger, next: LedgerWrite): CodexGenerationLedger {
|
|
115
|
+
const observed = readCodexGenerationLedger(root)
|
|
116
|
+
if (observed.revision !== previous.revision) throw new Error('Codex generation ledger CAS lost; retry')
|
|
117
|
+
const value: CodexGenerationLedger = {
|
|
118
|
+
...next,
|
|
119
|
+
version: 3,
|
|
120
|
+
revision: previous.revision + 1,
|
|
121
|
+
}
|
|
122
|
+
mkdirSync(dirname(ledgerPath(root)), { recursive: true, mode: 0o700 })
|
|
123
|
+
const temp = `${ledgerPath(root)}.${process.pid}.${randomUUID()}.tmp`
|
|
124
|
+
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
|
|
125
|
+
renameSync(temp, ledgerPath(root))
|
|
126
|
+
return value
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type LedgerLockOwner = Readonly<{ pid: number; startToken: string; nonce: string | null }>
|
|
130
|
+
type LedgerLockObservation = Readonly<{ owner: LedgerLockOwner | null; mtimeMs: number }>
|
|
131
|
+
|
|
132
|
+
function readLedgerLockOwner(path: string): LedgerLockOwner | null {
|
|
133
|
+
try {
|
|
134
|
+
const value: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
135
|
+
if (!isReservation(value)) return null
|
|
136
|
+
const nonce = (value as { nonce?: unknown }).nonce
|
|
137
|
+
return typeof nonce === 'string' && nonce ? { ...(value as GenerationReservation), nonce } : { ...(value as GenerationReservation), nonce: null }
|
|
138
|
+
} catch { return null }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function ledgerLockObservation(root: string): LedgerLockObservation | null {
|
|
142
|
+
try { return { owner: readLedgerLockOwner(lockOwnerPath(root)), mtimeMs: statSync(lockPath(root)).mtimeMs } }
|
|
143
|
+
catch { return null }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const sameLedgerLockOwner = (left: LedgerLockOwner | null, right: LedgerLockOwner | null) =>
|
|
147
|
+
!!left && !!right && left.pid === right.pid && left.startToken === right.startToken && left.nonce === right.nonce
|
|
148
|
+
|
|
149
|
+
function staleLedgerLock(observation: LedgerLockObservation): boolean {
|
|
150
|
+
const owner = observation.owner
|
|
151
|
+
if (owner) return processStartToken(owner.pid) !== owner.startToken
|
|
152
|
+
// A creator publishes owner.json immediately after mkdir. A short grace avoids mistaking that tiny window
|
|
153
|
+
// for an abandoned lock, while a crashed creator remains recoverable instead of wedging every retry.
|
|
154
|
+
return Date.now() - observation.mtimeMs > 500
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function releaseLedgerLock(root: string, owner: LedgerLockOwner): void {
|
|
158
|
+
if (!sameLedgerLockOwner(readLedgerLockOwner(lockOwnerPath(root)), owner) || existsSync(lockReaperPath(root))) return
|
|
159
|
+
rmSync(lockPath(root), { recursive: true, force: true })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function releaseLedgerReaper(root: string, owner: LedgerLockOwner): void {
|
|
163
|
+
if (sameLedgerLockOwner(readLedgerLockOwner(lockReaperOwnerPath(root)), owner))
|
|
164
|
+
rmSync(lockReaperPath(root), { recursive: true, force: true })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function reapStaleLedgerLock(root: string, observed: LedgerLockObservation): boolean {
|
|
168
|
+
const token = processStartToken(process.pid)
|
|
169
|
+
if (!token) throw new Error('cannot prove coordinator process identity for Codex generation ledger reaper')
|
|
170
|
+
const reaper: LedgerLockOwner = { pid: process.pid, startToken: token, nonce: randomUUID() }
|
|
171
|
+
try {
|
|
172
|
+
mkdirSync(lockReaperPath(root), { mode: 0o700 })
|
|
173
|
+
try { writeFileSync(lockReaperOwnerPath(root), `${JSON.stringify(reaper)}\n`, { mode: 0o600 }) }
|
|
174
|
+
catch (error) { rmSync(lockReaperPath(root), { recursive: true, force: true }); throw error }
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
177
|
+
const existing = readLedgerLockOwner(lockReaperOwnerPath(root))
|
|
178
|
+
let stale = existing ? processStartToken(existing.pid) !== existing.startToken : false
|
|
179
|
+
if (!existing) { try { stale = Date.now() - statSync(lockReaperPath(root)).mtimeMs > 500 } catch { stale = false } }
|
|
180
|
+
if (stale) rmSync(lockReaperPath(root), { recursive: true, force: true })
|
|
181
|
+
return false
|
|
182
|
+
}
|
|
183
|
+
const current = ledgerLockObservation(root)
|
|
184
|
+
const sameObservedOwner = !!current && ((!current.owner && !observed.owner) || sameLedgerLockOwner(current.owner, observed.owner))
|
|
185
|
+
// Creating reaper/ updates the parent directory mtime, so an ownerless creator crash is judged from the
|
|
186
|
+
// stable observation made before that child directory existed. An owned lock is judged from its identity.
|
|
187
|
+
const remainsStale = current && current.owner ? staleLedgerLock(current) : staleLedgerLock(observed)
|
|
188
|
+
if (!sameObservedOwner || !current || !remainsStale ||
|
|
189
|
+
!sameLedgerLockOwner(readLedgerLockOwner(lockReaperOwnerPath(root)), reaper)) {
|
|
190
|
+
releaseLedgerReaper(root, reaper)
|
|
191
|
+
return false
|
|
192
|
+
}
|
|
193
|
+
// The reaper lease is an atomic election inside the still-present lock directory. A live owner sees that
|
|
194
|
+
// lease and never releases the parent; another reaper cannot remove this one while its exact PID/start lives.
|
|
195
|
+
rmSync(lockPath(root), { recursive: true, force: true })
|
|
196
|
+
return true
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function createLedgerLock(root: string): LedgerLockOwner | null {
|
|
200
|
+
const lock = lockPath(root)
|
|
201
|
+
const token = processStartToken(process.pid)
|
|
202
|
+
if (!token) throw new Error('cannot prove coordinator process identity for Codex generation ledger lock')
|
|
203
|
+
const owner: LedgerLockOwner = { pid: process.pid, startToken: token, nonce: randomUUID() }
|
|
204
|
+
mkdirSync(root, { recursive: true, mode: 0o700 })
|
|
205
|
+
try { mkdirSync(lock, { mode: 0o700 }) }
|
|
206
|
+
catch (error) {
|
|
207
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') return null
|
|
208
|
+
throw error
|
|
209
|
+
}
|
|
210
|
+
try { writeFileSync(lockOwnerPath(root), `${JSON.stringify(owner)}\n`, { mode: 0o600 }) }
|
|
211
|
+
catch (error) { rmSync(lock, { recursive: true, force: true }); throw error }
|
|
212
|
+
return owner
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function acquireLedgerLock(root: string): Promise<LedgerLockOwner> {
|
|
216
|
+
const deadline = Date.now() + 10_000
|
|
217
|
+
for (;;) {
|
|
218
|
+
const owner = createLedgerLock(root)
|
|
219
|
+
if (owner) return owner
|
|
220
|
+
const observed = ledgerLockObservation(root)
|
|
221
|
+
if (observed && staleLedgerLock(observed) && reapStaleLedgerLock(root, observed)) continue
|
|
222
|
+
if (Date.now() >= deadline) throw new Error('Codex generation ledger lock is busy; retry')
|
|
223
|
+
await sleep(25)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function acquireLedgerLockSync(root: string): LedgerLockOwner {
|
|
228
|
+
const deadline = Date.now() + 5_000
|
|
229
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4))
|
|
230
|
+
for (;;) {
|
|
231
|
+
const owner = createLedgerLock(root)
|
|
232
|
+
if (owner) return owner
|
|
233
|
+
const observed = ledgerLockObservation(root)
|
|
234
|
+
if (observed && staleLedgerLock(observed) && reapStaleLedgerLock(root, observed)) continue
|
|
235
|
+
if (Date.now() >= deadline) throw new Error('Codex generation ledger lock is busy; retry')
|
|
236
|
+
Atomics.wait(sleeper, 0, 0, 10)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function withLedgerLock<T>(root: string, body: () => Promise<T>): Promise<T> {
|
|
241
|
+
const owner = await acquireLedgerLock(root)
|
|
242
|
+
try { return await body() }
|
|
243
|
+
finally { releaseLedgerLock(root, owner) }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function withLedgerLockSync<T>(root: string, body: () => T): T {
|
|
247
|
+
const owner = acquireLedgerLockSync(root)
|
|
248
|
+
try { return body() }
|
|
249
|
+
finally { releaseLedgerLock(root, owner) }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
type EndpointIdentity = { pid: number; token: string; socketDev: number; socketIno: number }
|
|
253
|
+
|
|
254
|
+
function endpointIdentity(endpoint: CodexGenerationEndpoint): EndpointIdentity | null {
|
|
255
|
+
try {
|
|
256
|
+
const pid = Number(readFileSync(endpoint.pidFile, 'utf8').trim())
|
|
257
|
+
const detached = verifyDetachedRuntime(pid, endpoint.receiptFile)
|
|
258
|
+
const socket = statSync(endpoint.socketPath)
|
|
259
|
+
if (!detached.ok || !socket.isSocket()) return null
|
|
260
|
+
return {
|
|
261
|
+
pid,
|
|
262
|
+
token: `${detached.identity.pid}|${detached.identity.startToken}|${detached.identity.processGroupId}|${detached.identity.linuxSessionId ?? '-'}`,
|
|
263
|
+
socketDev: socket.dev,
|
|
264
|
+
socketIno: socket.ino,
|
|
265
|
+
}
|
|
266
|
+
} catch { return null }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function hasLegacyResidue(root: string): boolean {
|
|
270
|
+
const endpoint = legacyCodexGenerationEndpoint(root)
|
|
271
|
+
return [endpoint.pidFile, endpoint.receiptFile, endpoint.socketPath].some(existsSync)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function bootstrapBindings(root: string, generationId: string): Record<string, CodexGenerationBinding> {
|
|
275
|
+
const result: Record<string, CodexGenerationBinding> = {}
|
|
276
|
+
const sessions = join(root, 'sessions')
|
|
277
|
+
let ids: string[] = []
|
|
278
|
+
try { ids = readdirSync(sessions) } catch { return result }
|
|
279
|
+
for (const sessionId of ids) {
|
|
280
|
+
try {
|
|
281
|
+
const record = JSON.parse(readFileSync(join(sessions, sessionId, 'session.json'), 'utf8')) as Record<string, unknown>
|
|
282
|
+
const harness = record.harness
|
|
283
|
+
const threadId = record.harness_session_id
|
|
284
|
+
if (record.governed === true && (harness === 'codex' || harness === 'codex-headless') && typeof threadId === 'string' && threadId) {
|
|
285
|
+
result[sessionId] = { generationId, threadId }
|
|
286
|
+
}
|
|
287
|
+
} catch { /* unreadable records remain unbound and fail loud at use */ }
|
|
288
|
+
}
|
|
289
|
+
return result
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function pendingBindingHasRecord(root: string, sessionId: string, binding: CodexGenerationBinding): boolean {
|
|
293
|
+
if (!sessionId || sessionId.includes('/') || sessionId.includes('\\')) return false
|
|
294
|
+
try {
|
|
295
|
+
const record = JSON.parse(readFileSync(join(root, 'sessions', sessionId, 'session.json'), 'utf8')) as Record<string, unknown>
|
|
296
|
+
const harness = record.harness
|
|
297
|
+
return record.governed === true && (harness === 'codex' || harness === 'codex-headless') && record.harness_session_id === binding.threadId
|
|
298
|
+
} catch { return false }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function bindingProtectsGeneration(root: string, sessionId: string, binding: CodexGenerationBinding): boolean {
|
|
302
|
+
return binding.phase === undefined || pendingBindingHasRecord(root, sessionId, binding)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function bootstrapLedger(root: string): CodexGenerationLedger {
|
|
306
|
+
const legacy = legacyCodexGenerationEndpoint(root)
|
|
307
|
+
if (!hasLegacyResidue(root)) return emptyLedger()
|
|
308
|
+
if (!endpointIdentity(legacy)) throw new Error('legacy Codex root is present but its exact detached PID/start/receipt/socket identity is unproven')
|
|
309
|
+
return {
|
|
310
|
+
version: 3,
|
|
311
|
+
revision: 0,
|
|
312
|
+
current: null,
|
|
313
|
+
pending: null,
|
|
314
|
+
generations: { [legacy.id]: { state: 'draining', endpoint: legacy } },
|
|
315
|
+
bindings: bootstrapBindings(root, legacy.id),
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function waitForEndpoint(endpoint: CodexGenerationEndpoint, timeoutMs = 10_000): Promise<boolean> {
|
|
320
|
+
const deadline = Date.now() + timeoutMs
|
|
321
|
+
while (Date.now() < deadline) {
|
|
322
|
+
if (endpointIdentity(endpoint)) {
|
|
323
|
+
const listening = await new Promise<boolean>((resolve) => {
|
|
324
|
+
let settled = false
|
|
325
|
+
const done = (value: boolean) => { if (!settled) { settled = true; client?.destroy(); resolve(value) } }
|
|
326
|
+
let client: Socket | null = null
|
|
327
|
+
try { client = createConnection(endpoint.socketPath) }
|
|
328
|
+
catch { return resolve(false) }
|
|
329
|
+
client.once('connect', () => done(true))
|
|
330
|
+
client.once('error', () => done(false))
|
|
331
|
+
})
|
|
332
|
+
if (listening) return true
|
|
333
|
+
}
|
|
334
|
+
await sleep(25)
|
|
335
|
+
}
|
|
336
|
+
return false
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
type EnsureAction =
|
|
340
|
+
| { kind: 'start'; endpoint: CodexGenerationEndpoint }
|
|
341
|
+
| { kind: 'publish'; endpoint: CodexGenerationEndpoint }
|
|
342
|
+
| { kind: 'wait' }
|
|
343
|
+
| { kind: 'current'; endpoint: CodexGenerationEndpoint }
|
|
344
|
+
|
|
345
|
+
function publishPendingGeneration(root: string, endpoint: CodexGenerationEndpoint): Promise<CodexGenerationEndpoint> {
|
|
346
|
+
return withLedgerLock(root, async () => {
|
|
347
|
+
const previous = readCodexGenerationLedger(root)
|
|
348
|
+
if (previous.current) {
|
|
349
|
+
const current = previous.generations[previous.current]
|
|
350
|
+
if (!current || current.state !== 'current' || !endpointIdentity(current.endpoint))
|
|
351
|
+
throw new Error('another Codex generation switch published an unproven current endpoint')
|
|
352
|
+
return current.endpoint
|
|
353
|
+
}
|
|
354
|
+
if (previous.pending !== endpoint.id || !endpointIdentity(endpoint))
|
|
355
|
+
throw new Error('Codex generation switch CAS lost or candidate identity changed before publication')
|
|
356
|
+
const generations = {
|
|
357
|
+
...previous.generations,
|
|
358
|
+
[endpoint.id]: { state: 'current' as const, endpoint },
|
|
359
|
+
}
|
|
360
|
+
writeLedger(root, previous, { current: endpoint.id, pending: null, generations, bindings: previous.bindings })
|
|
361
|
+
return endpoint
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export async function ensureCodexCurrentGeneration(root: string, start: (endpoint: CodexGenerationEndpoint) => Promise<void>): Promise<CodexGenerationEndpoint> {
|
|
366
|
+
const deadline = Date.now() + 30_000
|
|
367
|
+
for (;;) {
|
|
368
|
+
const action = await withLedgerLock(root, async (): Promise<EnsureAction> => {
|
|
369
|
+
let previous = readCodexGenerationLedger(root)
|
|
370
|
+
if (previous.revision === 0 && !existsSync(ledgerPath(root))) {
|
|
371
|
+
const bootstrapped = bootstrapLedger(root)
|
|
372
|
+
previous = writeLedger(root, previous, bootstrapped)
|
|
373
|
+
}
|
|
374
|
+
if (previous.current) {
|
|
375
|
+
const generation = previous.generations[previous.current]
|
|
376
|
+
if (!generation || generation.state !== 'current' || !endpointIdentity(generation.endpoint))
|
|
377
|
+
throw new Error('canonical Codex generation is missing, replaced, or unproven; refusing to route new traffic')
|
|
378
|
+
return { kind: 'current', endpoint: generation.endpoint }
|
|
379
|
+
}
|
|
380
|
+
if (previous.pending) {
|
|
381
|
+
const pending = previous.generations[previous.pending]
|
|
382
|
+
if (!pending || pending.state !== 'starting') throw new Error('Codex generation ledger pending switch is malformed')
|
|
383
|
+
// A coordinator can crash after the detached endpoint proves live but before the CAS publish. The
|
|
384
|
+
// durable pending record is sufficient for any later coordinator to complete that same switch.
|
|
385
|
+
if (endpointIdentity(pending.endpoint)) return { kind: 'publish', endpoint: pending.endpoint }
|
|
386
|
+
if (pending.reservation && processStartToken(pending.reservation.pid) === pending.reservation.startToken)
|
|
387
|
+
return { kind: 'wait' }
|
|
388
|
+
const generations = { ...previous.generations }
|
|
389
|
+
delete generations[pending.endpoint.id]
|
|
390
|
+
writeLedger(root, previous, { current: null, pending: null, generations, bindings: previous.bindings })
|
|
391
|
+
}
|
|
392
|
+
const latest = readCodexGenerationLedger(root)
|
|
393
|
+
const endpoint = newEndpoint(root)
|
|
394
|
+
const startToken = processStartToken(process.pid)
|
|
395
|
+
if (!startToken) throw new Error('cannot prove coordinator process identity for Codex generation reservation')
|
|
396
|
+
mkdirSync(dirname(endpoint.pidFile), { recursive: true, mode: 0o700 })
|
|
397
|
+
writeLedger(root, latest, {
|
|
398
|
+
current: latest.current,
|
|
399
|
+
pending: endpoint.id,
|
|
400
|
+
generations: { ...latest.generations, [endpoint.id]: { state: 'starting', endpoint, reservation: { pid: process.pid, startToken } } },
|
|
401
|
+
bindings: latest.bindings,
|
|
402
|
+
})
|
|
403
|
+
return { kind: 'start', endpoint }
|
|
404
|
+
})
|
|
405
|
+
if (action.kind === 'current') return action.endpoint
|
|
406
|
+
if (action.kind === 'publish') return publishPendingGeneration(root, action.endpoint)
|
|
407
|
+
if (action.kind === 'wait') {
|
|
408
|
+
if (Date.now() >= deadline) throw new Error('Codex generation switch reservation owner is still live but did not publish; retry after it exits')
|
|
409
|
+
await sleep(50)
|
|
410
|
+
continue
|
|
411
|
+
}
|
|
412
|
+
try { await start(action.endpoint) }
|
|
413
|
+
catch (error) {
|
|
414
|
+
await withLedgerLock(root, async () => {
|
|
415
|
+
const previous = readCodexGenerationLedger(root)
|
|
416
|
+
if (previous.pending !== action.endpoint.id) return
|
|
417
|
+
if (endpointIdentity(action.endpoint)) return
|
|
418
|
+
const generations = { ...previous.generations }
|
|
419
|
+
delete generations[action.endpoint.id]
|
|
420
|
+
writeLedger(root, previous, { current: previous.current, pending: null, generations, bindings: previous.bindings })
|
|
421
|
+
})
|
|
422
|
+
throw error
|
|
423
|
+
}
|
|
424
|
+
if (!await waitForEndpoint(action.endpoint)) {
|
|
425
|
+
await withLedgerLock(root, async () => {
|
|
426
|
+
const previous = readCodexGenerationLedger(root)
|
|
427
|
+
if (previous.pending !== action.endpoint.id || endpointIdentity(action.endpoint)) return
|
|
428
|
+
const generations = { ...previous.generations }
|
|
429
|
+
delete generations[action.endpoint.id]
|
|
430
|
+
writeLedger(root, previous, { current: previous.current, pending: null, generations, bindings: previous.bindings })
|
|
431
|
+
})
|
|
432
|
+
throw new Error(`candidate Codex generation ${action.endpoint.id} did not prove a live detached endpoint; current pointer was not switched`)
|
|
433
|
+
}
|
|
434
|
+
return publishPendingGeneration(root, action.endpoint)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function bindCodexGeneration(root: string, sessionId: string, threadId: string, generationId: string | null): void {
|
|
439
|
+
withLedgerLockSync(root, () => {
|
|
440
|
+
const previous = readCodexGenerationLedger(root)
|
|
441
|
+
if (generationId === null && previous.revision === 0 && !previous.current && !Object.keys(previous.generations).length) return
|
|
442
|
+
const bindings = { ...previous.bindings }
|
|
443
|
+
if (generationId === null) delete bindings[sessionId]
|
|
444
|
+
else {
|
|
445
|
+
const generation = previous.generations[generationId]
|
|
446
|
+
if (!generation || generation.state === 'reclaimed') throw new Error(`Codex generation ${generationId} is absent or reclaimed`)
|
|
447
|
+
bindings[sessionId] = { generationId, threadId }
|
|
448
|
+
}
|
|
449
|
+
writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations: previous.generations, bindings })
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Native thread creation and session.json persistence are separate writes. A pending registration can be
|
|
454
|
+
// recovered only when the durable record names the same exact thread; otherwise it cannot pin a root forever.
|
|
455
|
+
export function prepareCodexGenerationRegistration(root: string, sessionId: string, threadId: string, generationId: string): void {
|
|
456
|
+
withLedgerLockSync(root, () => {
|
|
457
|
+
const previous = readCodexGenerationLedger(root)
|
|
458
|
+
const generation = previous.generations[generationId]
|
|
459
|
+
if (!generation || generation.state === 'reclaimed') throw new Error(`Codex generation ${generationId} is absent or reclaimed`)
|
|
460
|
+
const existing = previous.bindings[sessionId]
|
|
461
|
+
if (existing) {
|
|
462
|
+
if (existing.generationId !== generationId || existing.threadId !== threadId)
|
|
463
|
+
throw new Error(`Codex session ${sessionId} already has a different exact generation binding`)
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
const bindings = { ...previous.bindings, [sessionId]: { generationId, threadId, phase: 'record-pending' as const } }
|
|
467
|
+
writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations: previous.generations, bindings })
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function commitCodexGenerationRegistration(root: string, sessionId: string, threadId: string, generationId: string): void {
|
|
472
|
+
withLedgerLockSync(root, () => {
|
|
473
|
+
const previous = readCodexGenerationLedger(root)
|
|
474
|
+
const binding = previous.bindings[sessionId]
|
|
475
|
+
const generation = previous.generations[generationId]
|
|
476
|
+
if (!binding || binding.generationId !== generationId || binding.threadId !== threadId || !generation || generation.state === 'reclaimed')
|
|
477
|
+
throw new Error(`Codex session ${sessionId} registration no longer names its exact generation and thread`)
|
|
478
|
+
if (binding.phase !== 'record-pending') return
|
|
479
|
+
const bindings = { ...previous.bindings, [sessionId]: { generationId, threadId } }
|
|
480
|
+
writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations: previous.generations, bindings })
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Close has the inverse crash boundary: retain the exact binding while its record still exists, but mark it
|
|
485
|
+
// record-removing before any destructive step so an interrupted removal cannot leave a committed ghost binding.
|
|
486
|
+
export function prepareCodexGenerationClose(root: string, sessionId: string, threadId: string): void {
|
|
487
|
+
withLedgerLockSync(root, () => {
|
|
488
|
+
const previous = readCodexGenerationLedger(root)
|
|
489
|
+
if (previous.revision === 0 && !previous.current && !Object.keys(previous.generations).length) return
|
|
490
|
+
const binding = previous.bindings[sessionId]
|
|
491
|
+
if (!binding || binding.threadId !== threadId) throw new Error(`Codex session ${sessionId} has no exact generation binding to close`)
|
|
492
|
+
if (binding.phase === 'record-removing') return
|
|
493
|
+
const generation = previous.generations[binding.generationId]
|
|
494
|
+
if (!generation || generation.state === 'reclaimed') throw new Error(`Codex session ${sessionId} binding names an absent or reclaimed generation`)
|
|
495
|
+
const bindings = { ...previous.bindings, [sessionId]: { ...binding, phase: 'record-removing' as const } }
|
|
496
|
+
writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations: previous.generations, bindings })
|
|
497
|
+
})
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function resolveCodexGenerationForSession(root: string, sessionId: string, threadId: string): CodexGenerationEndpoint | null {
|
|
501
|
+
const ledger = readCodexGenerationLedger(root)
|
|
502
|
+
const binding = ledger.bindings[sessionId]
|
|
503
|
+
if (!binding || binding.threadId !== threadId || !bindingProtectsGeneration(root, sessionId, binding)) return null
|
|
504
|
+
const generation = ledger.generations[binding.generationId]
|
|
505
|
+
return generation && generation.state !== 'reclaimed' ? generation.endpoint : null
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function codexGenerationBindingForSession(root: string, sessionId: string): CodexGenerationBinding | null {
|
|
509
|
+
return readCodexGenerationLedger(root).bindings[sessionId] ?? null
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function currentCodexGeneration(root: string): CodexGenerationEndpoint | null {
|
|
513
|
+
const ledger = readCodexGenerationLedger(root)
|
|
514
|
+
const generation = ledger.current ? ledger.generations[ledger.current] : null
|
|
515
|
+
return generation?.state === 'current' ? generation.endpoint : null
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function codexGenerationEndpoints(root: string): CodexGenerationEndpoint[] {
|
|
519
|
+
return Object.values(readCodexGenerationLedger(root).generations)
|
|
520
|
+
.filter((generation) => generation.state !== 'reclaimed')
|
|
521
|
+
.map((generation) => generation.endpoint)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export async function reclaimDrainingCodexGeneration(
|
|
525
|
+
root: string,
|
|
526
|
+
generationId: string,
|
|
527
|
+
census: (endpoint: CodexGenerationEndpoint) => Promise<{ healthy: boolean; referenceIds: string[]; peerCount: number }>,
|
|
528
|
+
): Promise<{ reclaimed: boolean; reason: string }> {
|
|
529
|
+
return withLedgerLock(root, async () => {
|
|
530
|
+
const previous = readCodexGenerationLedger(root)
|
|
531
|
+
const generation = previous.generations[generationId]
|
|
532
|
+
if (!generation) return { reclaimed: false, reason: 'generation is absent' }
|
|
533
|
+
if (generation.state === 'reclaimed') return { reclaimed: true, reason: 'generation is already reclaimed' }
|
|
534
|
+
if (generation.state !== 'draining') return { reclaimed: false, reason: 'only draining generations may be reclaimed' }
|
|
535
|
+
const identity = endpointIdentity(generation.endpoint)
|
|
536
|
+
if (!identity) return { reclaimed: false, reason: 'exact PID/start/receipt/socket identity is unproven' }
|
|
537
|
+
const bound = Object.entries(previous.bindings).filter(([sessionId, binding]) => binding.generationId === generationId && bindingProtectsGeneration(root, sessionId, binding))
|
|
538
|
+
if (bound.length) return { reclaimed: false, reason: `generation retains ${bound.length} governed binding(s)` }
|
|
539
|
+
const before = await census(generation.endpoint)
|
|
540
|
+
if (!before.healthy) return { reclaimed: false, reason: 'native reference census is unhealthy' }
|
|
541
|
+
if (before.referenceIds.length || before.peerCount) return { reclaimed: false, reason: `generation retains ${before.referenceIds.length} loaded references and ${before.peerCount} peers` }
|
|
542
|
+
const again = endpointIdentity(generation.endpoint)
|
|
543
|
+
if (!again || again.token !== identity.token || again.socketDev !== identity.socketDev || again.socketIno !== identity.socketIno)
|
|
544
|
+
return { reclaimed: false, reason: 'generation identity changed during reclaim guard' }
|
|
545
|
+
const after = await census(generation.endpoint)
|
|
546
|
+
if (!after.healthy || after.referenceIds.length || after.peerCount)
|
|
547
|
+
return { reclaimed: false, reason: 'generation reference census changed during reclaim guard' }
|
|
548
|
+
try { process.kill(identity.pid, 'SIGTERM') }
|
|
549
|
+
catch (error) { return { reclaimed: false, reason: `could not stop exact drained generation: ${(error as Error).message}` } }
|
|
550
|
+
for (let attempt = 0; attempt < 100 && processStartToken(identity.pid); attempt++) await sleep(20)
|
|
551
|
+
if (processStartToken(identity.pid)) return { reclaimed: false, reason: 'exact drained generation did not exit after SIGTERM' }
|
|
552
|
+
const generations = { ...previous.generations, [generationId]: { state: 'reclaimed' as const, endpoint: generation.endpoint } }
|
|
553
|
+
writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations, bindings: previous.bindings })
|
|
554
|
+
return { reclaimed: true, reason: 'exact drained generation reclaimed after zero-reference census' }
|
|
555
|
+
})
|
|
556
|
+
}
|
|
@@ -3,20 +3,6 @@ import { relative } from 'node:path'
|
|
|
3
3
|
import { materialize, stripSpexcodeBlock, GENERATED_MARK } from './materialize.js'
|
|
4
4
|
import { HARNESSES } from './harness.js'
|
|
5
5
|
|
|
6
|
-
// @@@ commit-surgery ([[commit-surgery]]) - the history anchor: pre-commit runs an UNCONDITIONAL materialize
|
|
7
|
-
// (the masks are provably fresh at the only moment history is written — the temporal invariant "materialize
|
|
8
|
-
// must never be stale" collapses into this one event) and then REPAIRS the staged index instead of rejecting:
|
|
9
|
-
// - a staged contract blob carrying our sentinel block → clean it IN PLACE (source = the STAGED BLOB, never
|
|
10
|
-
// the worktree: `git add -p`'s partial staging survives byte-for-byte; only the block is removed);
|
|
11
|
-
// - a staged generated/machine artifact that HEAD does not track → evict it (its tracked contribution is
|
|
12
|
-
// zero bytes by definition — an empty husk is worse than absence);
|
|
13
|
-
// - anything HEAD already tracks is never deleted by a hook — a legacy committed artifact heals by the
|
|
14
|
-
// block-strip above, converging history toward pristine without a surprise deletion commit.
|
|
15
|
-
// Both operations carry zero intent ambiguity (the block's content is never the user's; a wholly-ours file
|
|
16
|
-
// holds no user byte), so there is no question to ask and no rejection — one printed note per repair, and
|
|
17
|
-
// the commit proceeds. The worst full path costs the user ONE bump, and it is git's own: `git add` refused
|
|
18
|
-
// on an excluded path, git itself suggests -f, and this surgery makes that native escape hatch safe.
|
|
19
|
-
//
|
|
20
6
|
// GIT ENV, deliberately INVERTED from git.ts's git(): every call here PRESERVES the hook's environment —
|
|
21
7
|
// GIT_INDEX_FILE must be honored so the surgery reads/writes the EXACT index this commit is being built
|
|
22
8
|
// from (a `git commit <path>` pathspec commit and `git commit -a` both run hooks against a TEMPORARY index;
|
|
@@ -5,20 +5,6 @@ import { git } from './git.js'
|
|
|
5
5
|
import { writeManagedBlock, removeManagedBlock } from './harness.js'
|
|
6
6
|
import { encodeProject, runtimeRoot, treeSlotDir } from './layout.js'
|
|
7
7
|
|
|
8
|
-
// @@@ contract-filter ([[content-filter]]) - the answer for a MIXED-CONTENT contract file: a
|
|
9
|
-
// CLAUDE.md/AGENTS.md the HOST TRACKS — or has begun writing its OWN prose into — where "generate + ignore"
|
|
10
|
-
// is either a no-op (git ignores only untracked paths) or would hide USER content. A git clean/smudge
|
|
11
|
-
// content filter keeps the two contents on their own sides of the index: the REPO stores the
|
|
12
|
-
// pristine host prose (clean strips our sentinel block on stage/diff), the WORKING TREE carries prose + block
|
|
13
|
-
// (smudge re-injects it on checkout). Everything the filter needs is PER-CLONE — `git config
|
|
14
|
-
// filter.spexcode.*` + a managed block in `.git/info/attributes` + two files under `<common>/spexcode/` —
|
|
15
|
-
// zero repo footprint. The sentinels are the load-bearing anchor: the invariant
|
|
16
|
-
// is clean(smudge(x)) == x (for text ending in one newline — see the shim), so `git status` stays clean.
|
|
17
|
-
// Planted only where mixed content EXISTS or is imminent (a tracked contract file, or an untracked one the
|
|
18
|
-
// user's prose entered — pre-armed so their eventual `git add` strips the block; a wholly-ours file needs
|
|
19
|
-
// only the exclude). materialize plants/refreshes/erases it per that live kind detection ([[residence]]
|
|
20
|
-
// / the forgetting law).
|
|
21
|
-
|
|
22
8
|
// the three field-sharpened edges this module owes ([[content-filter]]):
|
|
23
9
|
// ① the configured command points at a STABLE shim path and degrades to `cat` (identity) when the shim is
|
|
24
10
|
// missing — a bare missing filter command makes git spray "cannot fork" fatals on EVERY operation;
|
package/spec-cli/src/doctor.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
// @@@ spex doctor - the opt-in, read-only project DIAGNOSIS surface ([[doctor]]). Bare doctor composes
|
|
2
|
-
// spec-health findings with the delivery audit: when a user launches their OWN agent with no SpexCode
|
|
3
|
-
// process in the launch, the workflow reaches it only through the files materialize() writes. The audit
|
|
4
|
-
// loops the same HARNESSES adapter materialize uses and catches missing delivery or duplicate discovery.
|
|
5
|
-
// `--contract` and `--conflicts` remain focused representations of that same diagnosis.
|
|
6
1
|
import { existsSync, readFileSync, readdirSync, accessSync, constants } from 'node:fs'
|
|
7
2
|
import { join, dirname, basename } from 'node:path'
|
|
8
3
|
import { fileURLToPath } from 'node:url'
|
|
@@ -179,8 +174,6 @@ function healthReport(findings: HealthFinding[], adopted: boolean): string[] {
|
|
|
179
174
|
return lines
|
|
180
175
|
}
|
|
181
176
|
|
|
182
|
-
// @@@ contractText - the layer-2 payload: the composed `surface:system` bodies, the SAME join materialize()
|
|
183
|
-
// folds into each harness's contract file — so a BYOA agent reads byte-identical guidance.
|
|
184
177
|
function contractText(): { names: string[]; body: string } {
|
|
185
178
|
let cfgs: { name: string; body: string }[] = []
|
|
186
179
|
try { cfgs = loadSystemConfig().map((c) => ({ name: c.name, body: c.body })) } catch { /* tree-less cwd → empty */ }
|
|
@@ -223,15 +216,6 @@ function manifestScripts(text: string): string[] {
|
|
|
223
216
|
return [...out]
|
|
224
217
|
}
|
|
225
218
|
|
|
226
|
-
// @@@ double-delivery - the SILENT conflict on the OTHER axis from under-delivery: not "did the contract
|
|
227
|
-
// land?" but "did it land TWICE?". A self-launched agent can be reached by BOTH the loose native delivery
|
|
228
|
-
// materialize() writes into the worktree AND a `spexcode` plugin bundle the user installed independently
|
|
229
|
-
// (Claude marketplace) or a stale leftover — doubling every hook (dispatch.sh fires per copy), shadowing
|
|
230
|
-
// skills, confusing the `/` menu. We never sniff payload: every count is by IDENTITY STAMP — a shim's
|
|
231
|
-
// `dispatch.sh` command line (the hook-routing stamp, the same one cleanHarness keys on), a plugin.json
|
|
232
|
-
// `name:"spexcode"` (the bundle stamp), and our own materialized skill NAMES. Per harness we count, on three
|
|
233
|
-
// channels, how many spexcode-stamped copies reach the agent; any channel >1 is a double-delivery conflict.
|
|
234
|
-
|
|
235
219
|
// a plugin bundle dir found under a harness's plugins root, carrying a spexcode stamp.
|
|
236
220
|
type Bundle = { dir: string; name: string; scope: string; hooksToDispatch: boolean; skillsDir: string }
|
|
237
221
|
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
// @@@ gateway auth - the ONE authorization mechanism of the multi-project gateway ([[gateway-hub]]).
|
|
2
|
-
// Everything about passwords and visitors lives HERE, at the gateway: the project backends stay loopback
|
|
3
|
-
// internal services that never see a credential, a cookie, or a visitor identity. Two signed scopes only:
|
|
4
|
-
// an ADMIN session grants /projects (management) plus every /p/:projectId route; a PROJECT session grants
|
|
5
|
-
// exactly its own /p/:projectId route. A project with no configured password is open; with no admin
|
|
6
|
-
// password, loopback may manage implicitly while non-loopback /projects stays locked.
|
|
7
|
-
//
|
|
8
|
-
// Secrets discipline: password VERIFIERS (scrypt, salted) live only in the per-user private store
|
|
9
|
-
// (~/.spexcode/gateway/auth.json, 0600) — never in a repo, never in a backend record, never plaintext.
|
|
10
|
-
// Session tokens are HMAC-signed claims under a random per-user secret, so they survive a gateway restart
|
|
11
|
-
// with no server-side session table, verified in constant time. Each verifier carries a random `gen` that
|
|
12
|
-
// rotates on every set/clear and is embedded in the tokens it authenticates — changing or clearing a
|
|
13
|
-
// password instantly invalidates every session it minted.
|
|
14
1
|
import { createHmac, createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
|
|
15
2
|
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
16
3
|
import { dirname, join } from 'node:path'
|