spexcode 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +239 -75
- package/spec-cli/src/client.ts +113 -14
- 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/delivery-queue.ts +62 -4
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +503 -105
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +355 -75
- package/spec-cli/src/help.ts +30 -23
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +109 -14
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +151 -40
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +160 -112
- package/spec-cli/src/plugin-harness.ts +29 -18
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +103 -30
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +748 -111
- package/spec-cli/src/uninstall.ts +3 -2
- 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/comment-altitude/spec.md +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- 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-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- 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 +13 -9
- package/spec-eval/src/evaltab.ts +32 -10
- package/spec-eval/src/freshness.ts +404 -81
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +234 -148
- package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
- package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
- package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
- package/spec-eval/src/matrix.ts +0 -693
package/spec-cli/src/layout.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
2
2
|
import { join, dirname, resolve } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
|
-
import { git, repoRoot, gitA, headSha, worktreeSpecSig, worktreeSpecDelta, type NodeOp } from './git.js'
|
|
4
|
+
import { git, repoRoot, gitA, gitAbortError, currentGitBuildAbortSignal, gitInterpretationIdentity, headSha, worktreeSpecSig, worktreeSpecDelta, worktreeSpecDeltas, withGitAbortSignal, type NodeOp } from './git.js'
|
|
5
5
|
import { guardWorktree } from './resilience.js'
|
|
6
6
|
import { HARNESSES, type HarnessId } from './harness.js'
|
|
7
7
|
import { encodeProject, projectRuntimeRoot, spexcodeHome } from './project-store.js'
|
|
@@ -170,10 +170,28 @@ export function readUploadPolicy(root: string): UploadPolicy {
|
|
|
170
170
|
// the shared git common dir (env-stripped git() so a hook's exported GIT_DIR can't misdirect it). Memoized:
|
|
171
171
|
// it's a process constant, but mainBranch()/mainRoot() resolve it per call (~60 git rev-parse forks per board build without the cache).
|
|
172
172
|
let commonDirCache: string | null = null
|
|
173
|
+
const commonDirsByPath = new Map<string, string>()
|
|
174
|
+
const topsByPath = new Map<string, string>()
|
|
173
175
|
export function gitCommonDir(): string {
|
|
174
176
|
if (commonDirCache === null) commonDirCache = git(['rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
|
|
175
177
|
return commonDirCache
|
|
176
178
|
}
|
|
179
|
+
function commonDirFor(proj: string): string {
|
|
180
|
+
const key = resolve(proj)
|
|
181
|
+
const known = commonDirsByPath.get(key)
|
|
182
|
+
if (known) return known
|
|
183
|
+
const common = git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
|
|
184
|
+
commonDirsByPath.set(key, common)
|
|
185
|
+
return common
|
|
186
|
+
}
|
|
187
|
+
function topFor(proj: string): string {
|
|
188
|
+
const key = resolve(proj)
|
|
189
|
+
const known = topsByPath.get(key)
|
|
190
|
+
if (known) return known
|
|
191
|
+
const top = git(['-C', proj, 'rev-parse', '--show-toplevel']).trim()
|
|
192
|
+
topsByPath.set(key, top)
|
|
193
|
+
return top
|
|
194
|
+
}
|
|
177
195
|
|
|
178
196
|
export function mainBranch(): string {
|
|
179
197
|
let checkout: string
|
|
@@ -186,9 +204,7 @@ export function mainBranch(): string {
|
|
|
186
204
|
// `.codex` (codex-rs hooks_config_folder override), NOT the worktree's, so the codex hooks shim + trust
|
|
187
205
|
// materialize here while AGENTS.md/skills stay per-worktree — see [[harness-adapter]] (harness.ts).
|
|
188
206
|
export function mainCheckout(proj?: string): string {
|
|
189
|
-
const gcd = proj
|
|
190
|
-
? git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
|
|
191
|
-
: gitCommonDir()
|
|
207
|
+
const gcd = proj ? commonDirFor(proj) : gitCommonDir()
|
|
192
208
|
return dirname(gcd)
|
|
193
209
|
}
|
|
194
210
|
|
|
@@ -222,9 +238,7 @@ export function mainRoot(proj?: string): string {
|
|
|
222
238
|
// proj-aware for `spex init <dir>` / materialize(proj); cwd-based default for the hooks/board. The shell
|
|
223
239
|
// hooks mirror this as hp_runtime_dir.
|
|
224
240
|
export function runtimeRoot(proj?: string): string {
|
|
225
|
-
const gcd = proj
|
|
226
|
-
? git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
|
|
227
|
-
: gitCommonDir()
|
|
241
|
+
const gcd = proj ? commonDirFor(proj) : gitCommonDir()
|
|
228
242
|
return projectRuntimeRoot(gcd)
|
|
229
243
|
}
|
|
230
244
|
// the per-WORKTREE materialize slot — <runtime>/trees/<enc(worktree-toplevel)> — holding the materialize
|
|
@@ -236,7 +250,7 @@ export function runtimeRoot(proj?: string): string {
|
|
|
236
250
|
// land on the same slot from the same tree, and only from the same tree. Throws when `wt` is not a live
|
|
237
251
|
// git tree (fail loud); a best-effort caller (the close-time GC) wraps it.
|
|
238
252
|
export function treeSlotDir(wt: string): string {
|
|
239
|
-
const top =
|
|
253
|
+
const top = topFor(wt)
|
|
240
254
|
return join(runtimeRoot(wt), 'trees', encodeProject(top || wt))
|
|
241
255
|
}
|
|
242
256
|
// this project's per-session records dir, one session's dir, its structured record, and a sibling artifact —
|
|
@@ -262,6 +276,7 @@ export type RawRecord = {
|
|
|
262
276
|
launch_cmd?: string // the RESOLVED base launcher command PINNED at creation, so a resume replays the EXACT launcher (and its config-dir env) that made the conversation, never a since-changed default ([[launcher-select]] resume-launcher-pin); absent → old record, fall back to the launcher name / ambient
|
|
263
277
|
create_request_id?: string // SHA-256 digest of the create Idempotency-Key; the raw key is never persisted
|
|
264
278
|
create_payload_hash?: string // normalized create payload bound to create_request_id
|
|
279
|
+
base?: string // the exact fork point pinned at creation; absent/empty → the auto-detected source-of-truth branch
|
|
265
280
|
launch_readiness_pending?: '' | RawLaunchReadinessPending
|
|
266
281
|
}
|
|
267
282
|
|
|
@@ -431,6 +446,10 @@ export function readAliasedRawRecord(id: string): RawRecord | null {
|
|
|
431
446
|
export function readAliasedRecordEntry(id: string): RecordEntry {
|
|
432
447
|
const direct = readRecordEntry(id)
|
|
433
448
|
if (direct.kind !== 'absent') return direct
|
|
449
|
+
// @@@ absence splits in two, and only one half is an alias question - an id owning a store dir is already
|
|
450
|
+
// one of ours (the sentinel-only agent above), so its emptiness is settled; searching would let an unrelated
|
|
451
|
+
// record answer under a live session's own name, and costs a whole-store re-parse per 1s supervisor tick.
|
|
452
|
+
if (existsSync(sessionStoreDir(id))) return { kind: 'absent' }
|
|
434
453
|
for (const sid of listSessionIds()) {
|
|
435
454
|
const r = readRawRecord(sid)
|
|
436
455
|
if (r && r.harness_session_id && r.harness_session_id === id) return { kind: 'ok', raw: r }
|
|
@@ -448,35 +467,113 @@ export function listSessionIds(): string[] {
|
|
|
448
467
|
return ents.filter((d) => d.isDirectory()).map((d) => d.name)
|
|
449
468
|
}
|
|
450
469
|
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
// .
|
|
470
|
+
// @@@ branch -> session, so a moved ref can DERIVE its invalidation scope - a session's evaluation depends
|
|
471
|
+
// on its own branch tip, the base branch tip and their merge-base; no other ref participates. Without this
|
|
472
|
+
// map a ref watcher knows only that "something under refs/ moved" and the honest fallback is to invalidate
|
|
473
|
+
// every session ([[taste]] 19). One record read per session, and only when the session store itself moved.
|
|
474
|
+
export function sessionBranchIndex(): Map<string, string> {
|
|
475
|
+
const index = new Map<string, string>()
|
|
476
|
+
for (const id of listSessionIds()) {
|
|
477
|
+
let branch: unknown
|
|
478
|
+
try { branch = readRawRecord(id)?.branch } catch { continue } // a corrupt record narrows nothing
|
|
479
|
+
if (typeof branch === 'string' && branch) index.set(branch, id)
|
|
480
|
+
}
|
|
481
|
+
return index
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Retain only completed per-worktree overlays. Interpretation + main tip + HEAD + working signature completely
|
|
485
|
+
// determine the merge-base and projection; a landed main tip therefore dissolves now-moot ops on the next read.
|
|
455
486
|
const deltaCache = new Map<string, { key: string; ops: NodeOp[] }>()
|
|
456
487
|
const safeHead = (p: string): string => { try { return headSha(p) } catch { return '' } }
|
|
457
|
-
const safeMergeBase = async (wtPath: string, mainRef: string): Promise<string> => {
|
|
458
|
-
try { return (await gitA(['-C', wtPath, 'merge-base', mainRef, 'HEAD'])).trim() } catch { return '' }
|
|
459
|
-
}
|
|
460
488
|
let layoutHeadWarned = false
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
489
|
+
type LayoutDeltaOutcome = { ops: NodeOp[] } | { error: unknown }
|
|
490
|
+
type LayoutDeltaFlight = {
|
|
491
|
+
promise: Promise<Map<string, LayoutDeltaOutcome>>
|
|
492
|
+
controller: AbortController
|
|
493
|
+
waiters: Set<symbol>
|
|
494
|
+
settled: boolean
|
|
495
|
+
}
|
|
496
|
+
const layoutDeltaFlights = new Map<string, LayoutDeltaFlight>()
|
|
497
|
+
|
|
498
|
+
// One exact public layout generation owns the cold overlay computation. The map is only an in-flight join:
|
|
499
|
+
// the entry is deleted at settlement and deltaCache remains the sole retained result state.
|
|
500
|
+
async function layoutDeltas(paths: string[], main: string, mainRef: string, mainSha: string): Promise<Map<string, LayoutDeltaOutcome>> {
|
|
501
|
+
const snapshots = paths.map((path) => ({ path, head: safeHead(path), sig: worktreeSpecSig(path) }))
|
|
502
|
+
const interpretation = gitInterpretationIdentity(main)
|
|
503
|
+
const flightKey = JSON.stringify([interpretation, mainRef, mainSha, snapshots.map(({ path, head, sig }) => [path, head, sig]).sort((a, b) => a[0].localeCompare(b[0]))])
|
|
504
|
+
let flight = layoutDeltaFlights.get(flightKey)
|
|
505
|
+
if (flight?.controller.signal.aborted) {
|
|
506
|
+
layoutDeltaFlights.delete(flightKey)
|
|
507
|
+
flight = undefined
|
|
508
|
+
}
|
|
509
|
+
if (!flight) {
|
|
510
|
+
const controller = new AbortController()
|
|
511
|
+
const entry: LayoutDeltaFlight = { promise: Promise.resolve(new Map()), controller, waiters: new Set(), settled: false }
|
|
512
|
+
entry.promise = withGitAbortSignal(controller.signal, async () => {
|
|
513
|
+
const outcomes = new Map<string, LayoutDeltaOutcome>()
|
|
514
|
+
const misses: typeof snapshots = []
|
|
515
|
+
for (const snapshot of snapshots) {
|
|
516
|
+
const key = `${interpretation}\0${mainSha}\0${snapshot.head}\0${snapshot.sig}`
|
|
517
|
+
const hit = snapshot.head && mainSha ? deltaCache.get(snapshot.path) : null
|
|
518
|
+
if (hit?.key === key) outcomes.set(snapshot.path, { ops: hit.ops })
|
|
519
|
+
else misses.push(snapshot)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const valid = misses.filter(({ head }) => !!head && !!mainSha)
|
|
523
|
+
const batched = await worktreeSpecDeltas(main, mainSha, valid.map(({ path, head }) => ({ path, head })), interpretation)
|
|
524
|
+
for (const snapshot of valid) {
|
|
525
|
+
const outcome = batched.get(snapshot.path) ?? { error: new Error(`layout overlay batch omitted ${snapshot.path}`) }
|
|
526
|
+
if ('error' in outcome) outcomes.set(snapshot.path, outcome)
|
|
527
|
+
else {
|
|
528
|
+
const key = `${interpretation}\0${mainSha}\0${snapshot.head}\0${snapshot.sig}`
|
|
529
|
+
deltaCache.set(snapshot.path, { key, ops: outcome.ops })
|
|
530
|
+
outcomes.set(snapshot.path, { ops: outcome.ops })
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
await Promise.all(misses.filter(({ head }) => !head || !mainSha).map(async (snapshot) => {
|
|
535
|
+
if (!existsSync(snapshot.path)) {
|
|
536
|
+
outcomes.set(snapshot.path, { error: new Error(`worktree ${snapshot.path} is absent`) })
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
if (!layoutHeadWarned) {
|
|
540
|
+
layoutHeadWarned = true
|
|
541
|
+
console.warn('spec-cli: layout overlay cache bypassed (unreadable HEAD/main tip), recomputing every read')
|
|
542
|
+
}
|
|
543
|
+
try { outcomes.set(snapshot.path, { ops: await worktreeSpecDelta(snapshot.path, mainRef) }) }
|
|
544
|
+
catch (error) { outcomes.set(snapshot.path, { error }) }
|
|
545
|
+
}))
|
|
546
|
+
return outcomes
|
|
547
|
+
}).finally(() => {
|
|
548
|
+
entry.settled = true
|
|
549
|
+
if (layoutDeltaFlights.get(flightKey) === entry) layoutDeltaFlights.delete(flightKey)
|
|
550
|
+
})
|
|
551
|
+
layoutDeltaFlights.set(flightKey, entry)
|
|
552
|
+
flight = entry
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const token = Symbol(flightKey)
|
|
556
|
+
const callerSignal = currentGitBuildAbortSignal()
|
|
557
|
+
flight.waiters.add(token)
|
|
558
|
+
let onAbort: (() => void) | null = null
|
|
559
|
+
try {
|
|
560
|
+
if (!callerSignal) return await flight.promise
|
|
561
|
+
if (callerSignal.aborted) throw gitAbortError()
|
|
562
|
+
return await Promise.race([
|
|
563
|
+
flight.promise,
|
|
564
|
+
new Promise<Map<string, LayoutDeltaOutcome>>((_, reject) => {
|
|
565
|
+
onAbort = () => reject(gitAbortError())
|
|
566
|
+
callerSignal.addEventListener('abort', onAbort, { once: true })
|
|
567
|
+
}),
|
|
568
|
+
])
|
|
569
|
+
} finally {
|
|
570
|
+
if (onAbort) callerSignal?.removeEventListener('abort', onAbort)
|
|
571
|
+
flight.waiters.delete(token)
|
|
572
|
+
if (!flight.settled && flight.waiters.size === 0) flight.controller.abort()
|
|
470
573
|
}
|
|
471
|
-
const key = `${base}\0${wtHead}\0${mainSha}\0${worktreeSpecSig(wtPath)}`
|
|
472
|
-
const hit = deltaCache.get(wtPath)
|
|
473
|
-
if (hit && hit.key === key) return hit.ops
|
|
474
|
-
const ops = await worktreeSpecDelta(wtPath, mainRef, base)
|
|
475
|
-
deltaCache.set(wtPath, { key, ops })
|
|
476
|
-
return ops
|
|
477
574
|
}
|
|
478
575
|
|
|
479
|
-
export async function resolveLayout(): Promise<Layout> {
|
|
576
|
+
export async function resolveLayout(options: { activeSessionIds?: readonly string[] } = {}): Promise<Layout> {
|
|
480
577
|
const root = repoRoot()
|
|
481
578
|
const main = dirname(gitCommonDir()) // the main checkout — same answer from main OR any linked worktree
|
|
482
579
|
const cfg = readConfig(main)
|
|
@@ -496,22 +593,36 @@ export async function resolveLayout(): Promise<Layout> {
|
|
|
496
593
|
const publicEntries = listSessionIds().map((id) => readPublicRecordEntry(id))
|
|
497
594
|
.filter((entry) => entry.kind === 'corrupt' ? entry.governed !== false : entry.kind === 'ok' && entry.raw.governed)
|
|
498
595
|
const records = publicEntries.flatMap((entry) => entry.kind === 'ok' ? [entry] : [])
|
|
596
|
+
const projectedActive = options.activeSessionIds ? new Set(options.activeSessionIds) : null
|
|
597
|
+
const isActive = (record: RawRecord): boolean => projectedActive
|
|
598
|
+
? projectedActive.has(record.session_id)
|
|
599
|
+
: !record.archived
|
|
499
600
|
// main's tip, resolved ONCE per board read — a component of every worktree's overlay cache key
|
|
500
601
|
// ([[worktree-linker]]: landed content must dissolve the ops it made moot).
|
|
501
602
|
const mainSha = await (async () => {
|
|
502
603
|
try { return (await gitA(['-C', main, 'rev-parse', '--verify', `${mainRef}^{commit}`])).trim() } catch { return '' }
|
|
503
604
|
})()
|
|
605
|
+
const activePaths = records.filter(({ raw }) => isActive(raw)).map(({ raw }) => raw.worktree_path)
|
|
606
|
+
const deltas = await layoutDeltas(activePaths, main, mainRef, mainSha)
|
|
504
607
|
const rows = await Promise.all(records.map(({ raw: r, liveness }) => {
|
|
505
608
|
const node = r.node ?? (r.branch && r.branch.startsWith(convention.branchPrefix) ? r.branch.slice(convention.branchPrefix.length) : null)
|
|
506
609
|
const base: Worktree = { path: r.worktree_path, branch: r.branch, node, session: r.session_id, status: r.status, isMain: false, ...(liveness ? { liveness } : {}), ops: [] }
|
|
507
|
-
// @@@
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
if (r.archived) return Promise.resolve(base)
|
|
610
|
+
// @@@ projected shelves cost nothing - a cold archived session ([[archive]]) keeps its record but leaves
|
|
611
|
+
// the working-set projection and skips the per-worktree spec delta. An archived runtime hazard is still
|
|
612
|
+
// projected active by listSessions, so it deliberately retains the same ops in both full and splice builds.
|
|
613
|
+
if (!isActive(r)) return Promise.resolve(base)
|
|
512
614
|
return guardWorktree<Worktree>(r.worktree_path,
|
|
513
|
-
|
|
514
|
-
|
|
615
|
+
(): Worktree => {
|
|
616
|
+
const outcome = deltas.get(r.worktree_path)
|
|
617
|
+
if (!outcome) throw new Error(`layout overlay missing ${r.worktree_path}`)
|
|
618
|
+
if ('error' in outcome) throw outcome.error
|
|
619
|
+
return { ...base, ops: outcome.ops }
|
|
620
|
+
},
|
|
621
|
+
(): Worktree => {
|
|
622
|
+
const cached = deltaCache.get(r.worktree_path)
|
|
623
|
+
if (!cached) throw new Error(`layout overlay failed before ${r.worktree_path} had a last-known result`)
|
|
624
|
+
return { ...base, ops: cached.ops }
|
|
625
|
+
})
|
|
515
626
|
}))
|
|
516
627
|
const corruptRows: Worktree[] = publicEntries.flatMap((entry) => entry.kind === 'corrupt'
|
|
517
628
|
? [{ path: '', branch: null, node: null, session: entry.sessionId, status: 'corrupt', liveness: 'unknown', isMain: false, ops: [] }]
|
|
@@ -521,9 +632,9 @@ export async function resolveLayout(): Promise<Layout> {
|
|
|
521
632
|
const mainRow: Worktree = { path: main, branch: base, node: null, session: null, status: null, isMain: true, ops: [] }
|
|
522
633
|
const worktrees = [mainRow, ...sessionWorktrees]
|
|
523
634
|
// drop cache entries for worktrees that may no longer hold one — closed sessions (gone from the store) AND
|
|
524
|
-
// newly-archived ones (which no longer compute a delta), so archiving SELF-EVICTS its cached ops instead of
|
|
635
|
+
// newly-cold archived ones (which no longer compute a delta), so archiving SELF-EVICTS its cached ops instead of
|
|
525
636
|
// stranding them in a map nothing prunes.
|
|
526
|
-
const live = new Set(records.filter(({ raw }) =>
|
|
637
|
+
const live = new Set(records.filter(({ raw }) => isActive(raw)).map(({ raw }) => raw.worktree_path))
|
|
527
638
|
for (const k of [...deltaCache.keys()]) if (!live.has(k)) deltaCache.delete(k)
|
|
528
639
|
return { main: convention.main || main || root, convention, worktrees }
|
|
529
640
|
}
|
package/spec-cli/src/lint.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { repoRoot, git, sourceIndexes, rowsFor, treeFilePaths, treeFileText, type DriftPathEvent } from './git.js'
|
|
3
|
+
import { repoRoot, git, sourceIndexes, rowsFor, treeFilePaths, treeFileText, withEventLedgerBuild, type DriftPathEvent } from './git.js'
|
|
4
4
|
import { loadSpecs, parseFrontmatter } from './specs.js'
|
|
5
5
|
import { readJsonConfig } from './layout.js'
|
|
6
6
|
import { extractors, extractorFor, extOf, parseCodeEntry, relationClaimsPath, resolveAnchor, resolveSelectors, windowEvents, anchorHitQueries } from './anchors.js'
|
|
7
7
|
import { DEFAULT_TEST_GLOBS, sourcePolicyDescription, trackedSourceFiles } from './source-files.js'
|
|
8
8
|
|
|
9
9
|
export type Finding = { level: 'error' | 'warn'; rule: string; spec?: string; file?: string; msg: string }
|
|
10
|
+
export const SPEC_LINT_REPORT_PROJECTION = 'spex.spec-lint.report'
|
|
11
|
+
export const SPEC_LINT_REPORT_SCHEMA_VERSION = 1
|
|
12
|
+
export type SpecLintReport = {
|
|
13
|
+
projection: typeof SPEC_LINT_REPORT_PROJECTION
|
|
14
|
+
schemaVersion: typeof SPEC_LINT_REPORT_SCHEMA_VERSION
|
|
15
|
+
sourceFiles: string[]
|
|
16
|
+
findings: Finding[]
|
|
17
|
+
}
|
|
10
18
|
|
|
11
19
|
export type LintConfig = {
|
|
12
20
|
governedRoots: string[] // dirs whose tracked source files must each be governed by a spec. '.' = whole project.
|
|
@@ -69,6 +77,7 @@ export function normalizeConfig(cfg: LintConfig): LintConfig {
|
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
export type SpecLintOptions = { tip?: string }
|
|
80
|
+
type SpecLintRun = { sourceFiles: string[]; findings: Finding[] }
|
|
72
81
|
|
|
73
82
|
function untrackedAdoptionFiles(root: string): string[] {
|
|
74
83
|
if (git(['-C', root, 'rev-parse', '--is-inside-work-tree']).trim() !== 'true') return []
|
|
@@ -126,6 +135,24 @@ export async function pendingTouchesGoverned(root: string, tip: string): Promise
|
|
|
126
135
|
}
|
|
127
136
|
|
|
128
137
|
export async function specLint(root = repoRoot(), regs = extractors(root), options: SpecLintOptions = {}): Promise<Finding[]> {
|
|
138
|
+
return (await specLintRun(root, regs, options)).findings
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function specLintReport(root = repoRoot(), regs = extractors(root), options: SpecLintOptions = {}): Promise<SpecLintReport> {
|
|
142
|
+
const run = await specLintRun(root, regs, options)
|
|
143
|
+
return {
|
|
144
|
+
projection: SPEC_LINT_REPORT_PROJECTION,
|
|
145
|
+
schemaVersion: SPEC_LINT_REPORT_SCHEMA_VERSION,
|
|
146
|
+
sourceFiles: run.sourceFiles,
|
|
147
|
+
findings: run.findings,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function specLintRun(root: string, regs: ReturnType<typeof extractors>, options: SpecLintOptions): Promise<SpecLintRun> {
|
|
152
|
+
return withEventLedgerBuild(root, () => specLintInLedger(root, regs, options))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function specLintInLedger(root: string, regs: ReturnType<typeof extractors>, options: SpecLintOptions): Promise<SpecLintRun> {
|
|
129
156
|
const tip = options.tip ?? 'HEAD'
|
|
130
157
|
const pending = tip !== 'HEAD'
|
|
131
158
|
const changed = pending ? pendingChangedPaths(root, tip) : []
|
|
@@ -145,11 +172,14 @@ export async function specLint(root = repoRoot(), regs = extractors(root), optio
|
|
|
145
172
|
if (untracked.length) {
|
|
146
173
|
const shown = untracked.slice(0, 6)
|
|
147
174
|
const suffix = untracked.length > shown.length ? ` (+${untracked.length - shown.length} more)` : ''
|
|
148
|
-
return
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
175
|
+
return {
|
|
176
|
+
sourceFiles: [],
|
|
177
|
+
findings: [{
|
|
178
|
+
level: 'error',
|
|
179
|
+
rule: 'integrity',
|
|
180
|
+
msg: `project source of truth is untracked: ${shown.join(', ')}${suffix} — add it with \`git add .spec spexcode.json\` and commit it; generated harness files such as .codex/, .claude/, and AGENTS.md are machine-local`,
|
|
181
|
+
}],
|
|
182
|
+
}
|
|
153
183
|
}
|
|
154
184
|
const governed = trackedSourceFiles(root, cfg.governedRoots, cfg, tip)
|
|
155
185
|
const [hidx, didx] = await sourceIndexes(root, tip)
|
|
@@ -436,7 +466,7 @@ export async function specLint(root = repoRoot(), regs = extractors(root), optio
|
|
|
436
466
|
out.push({ level: 'warn', rule: 'related-drift', msg: `${rd.length} related file(s) across ${byNode.size} node(s) drifted ahead of their spec (SOFT — a dependency shifted, worth a glance; never blocks, no ack, no eval staleness). Most: ${worst}` })
|
|
437
467
|
}
|
|
438
468
|
|
|
439
|
-
return out
|
|
469
|
+
return { sourceFiles: governed.slice().sort(), findings: out }
|
|
440
470
|
}
|
|
441
471
|
|
|
442
472
|
export const DRIFT_GUIDANCE = `DRIFT — a governed file has moved ahead of its spec. A CHECKPOINT, not a chore: find WHERE the truth
|