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/spec-cli/src/git.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync, execFile, spawn } from 'node:child_process'
|
|
2
2
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
3
|
-
import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSync, rmSync, renameSync, openSync, closeSync } from 'node:fs'
|
|
4
|
-
import { join, isAbsolute, resolve } from 'node:path'
|
|
3
|
+
import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSync, rmSync, renameSync, openSync, closeSync, accessSync, constants } from 'node:fs'
|
|
4
|
+
import { join, isAbsolute, resolve, delimiter } from 'node:path'
|
|
5
5
|
import { createHash, randomBytes } from 'node:crypto'
|
|
6
6
|
import { projectRuntimeRoot } from './project-store.js'
|
|
7
7
|
import { rootSlots, touchRoot as touchRootLru } from './root-lru.js'
|
|
@@ -15,6 +15,24 @@ const US = '\x1f', RS = '\x1e'
|
|
|
15
15
|
// than materializing one process per worktree/eval. Calls outside that build context remain unconstrained.
|
|
16
16
|
const GIT_TIMEOUT_MS = Number(process.env.SPEXCODE_GIT_TIMEOUT_MS || 120000)
|
|
17
17
|
export const BOARD_GIT_CONCURRENCY = 4
|
|
18
|
+
const gitByPath = new Map<string, string>()
|
|
19
|
+
|
|
20
|
+
export function gitBinary(env: NodeJS.ProcessEnv = process.env): string {
|
|
21
|
+
const path = env.PATH || ''
|
|
22
|
+
const known = gitByPath.get(path)
|
|
23
|
+
if (known) {
|
|
24
|
+
try { accessSync(known, constants.X_OK); return known } catch {}
|
|
25
|
+
}
|
|
26
|
+
for (const dir of path.split(delimiter)) {
|
|
27
|
+
const candidate = resolve(dir || '.', 'git')
|
|
28
|
+
try {
|
|
29
|
+
accessSync(candidate, constants.X_OK)
|
|
30
|
+
gitByPath.set(path, candidate)
|
|
31
|
+
return candidate
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
throw new Error('git executable not found on PATH')
|
|
35
|
+
}
|
|
18
36
|
type GitPermitPool = { acquire: (signal: AbortSignal) => Promise<() => void> }
|
|
19
37
|
type GitBuildContext = { signal: AbortSignal; permits: GitPermitPool }
|
|
20
38
|
const gitBuild = new AsyncLocalStorage<GitBuildContext>()
|
|
@@ -115,7 +133,7 @@ export function git(args: string[]): string {
|
|
|
115
133
|
const env = { ...process.env }
|
|
116
134
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
117
135
|
try {
|
|
118
|
-
return execFileSync(
|
|
136
|
+
return execFileSync(gitBinary(env), withBuildLimits(args), { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'], timeout: GIT_TIMEOUT_MS, killSignal: 'SIGKILL' })
|
|
119
137
|
} catch (e: any) { warnIfTimedOut(e, args); throw e }
|
|
120
138
|
}
|
|
121
139
|
|
|
@@ -123,7 +141,7 @@ function gitBuffer(args: string[], input?: string): Buffer {
|
|
|
123
141
|
const env = { ...process.env }
|
|
124
142
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
125
143
|
try {
|
|
126
|
-
return execFileSync(
|
|
144
|
+
return execFileSync(gitBinary(env), withBuildLimits(args), {
|
|
127
145
|
input,
|
|
128
146
|
env,
|
|
129
147
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -267,7 +285,7 @@ const GIT_MAX_BUFFER = 1 << 24
|
|
|
267
285
|
function execGit(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal, maxBuffer = GIT_MAX_BUFFER, input?: string): Promise<GitExec> {
|
|
268
286
|
return new Promise((resolve, reject) => {
|
|
269
287
|
if (signal?.aborted) { reject(gitAbortError()); return }
|
|
270
|
-
const child = spawn(
|
|
288
|
+
const child = spawn(gitBinary(env), args, { env, detached: true, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
|
|
271
289
|
const stdout: Buffer[] = [], stderr: Buffer[] = []
|
|
272
290
|
let stdoutBytes = 0, stderrBytes = 0, aborted = false, timedOut = false, overflow = false
|
|
273
291
|
let spawnError: Error | null = null
|
|
@@ -338,7 +356,7 @@ async function execGitForCaller(args: string[], env: NodeJS.ProcessEnv, maxBuffe
|
|
|
338
356
|
function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<GitExec> {
|
|
339
357
|
return new Promise((resolve, reject) => {
|
|
340
358
|
if (signal?.aborted) { reject(gitAbortError()); return }
|
|
341
|
-
const child = spawn(
|
|
359
|
+
const child = spawn(gitBinary(env), args, { env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
342
360
|
const stdout: Buffer[] = [], stderr: Buffer[] = []
|
|
343
361
|
let settled = false, aborted = false, timedOut = false
|
|
344
362
|
const killTree = () => {
|
|
@@ -826,8 +844,12 @@ async function identityRawEventStream(root: string, tip: string, request: EventS
|
|
|
826
844
|
return value as IdentityRawRecord[]
|
|
827
845
|
}
|
|
828
846
|
export type GitTryFailure = 'exit' | 'spawn' | 'timeout'
|
|
829
|
-
export async function gitTry(args: string[], options: { indexFile?: string } = {}): Promise<{ ok: boolean; stdout: string; stderr: string; failure?: GitTryFailure }> {
|
|
847
|
+
export async function gitTry(args: string[], options: { indexFile?: string; extraEnv?: Record<string, string | undefined> } = {}): Promise<{ ok: boolean; stdout: string; stderr: string; failure?: GitTryFailure }> {
|
|
830
848
|
const env = { ...process.env }
|
|
849
|
+
for (const [key, value] of Object.entries(options.extraEnv ?? {})) {
|
|
850
|
+
if (value === undefined) delete env[key]
|
|
851
|
+
else env[key] = value
|
|
852
|
+
}
|
|
831
853
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
832
854
|
if (options.indexFile) env.GIT_INDEX_FILE = options.indexFile
|
|
833
855
|
const context = inheritedContext()
|
|
@@ -2091,7 +2113,7 @@ export function mergeConflicts(wtPath: string, mainRef = 'main'): Promise<boolea
|
|
|
2091
2113
|
return new Promise((resolve) => {
|
|
2092
2114
|
const env = { ...process.env }
|
|
2093
2115
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
2094
|
-
execFile(
|
|
2116
|
+
execFile(gitBinary(env), ['-C', wtPath, 'merge-tree', '--write-tree', '--no-messages', mainRef, 'HEAD'],
|
|
2095
2117
|
{ encoding: 'utf8', env, maxBuffer: 1 << 24 },
|
|
2096
2118
|
// execFile sets err.code to the numeric EXIT code on a non-zero exit (1 = conflicts), or a string
|
|
2097
2119
|
// errno (e.g. 'ENOENT') if git can't be spawned — only the exit-1 case is a real conflict verdict.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { streamSSE } from 'hono/streaming'
|
|
2
2
|
import type { Context } from 'hono'
|
|
3
3
|
import { watch, mkdirSync, readdirSync, readFileSync, type FSWatcher } from 'node:fs'
|
|
4
|
-
import { join, dirname, relative, resolve } from 'node:path'
|
|
5
|
-
import { sessionsRoot, gitCommonDir } from './layout.js'
|
|
6
|
-
import { hotSignature, warmSignature, listSessions } from './sessions.js'
|
|
4
|
+
import { join, dirname, relative, resolve, basename } from 'node:path'
|
|
5
|
+
import { sessionsRoot, gitCommonDir, sessionBranchIndex, mainBranch } from './layout.js'
|
|
6
|
+
import { hotSignature, warmSignature, listSessions, pendingSessionCreateWorktreePaths } from './sessions.js'
|
|
7
7
|
import { getBoard, getBoardForSessionRefresh, invalidateBoard, patrolBoard } from './graphCache.js'
|
|
8
8
|
import { unitize, tagOf, diffUnits, type Units } from './graphDelta.js'
|
|
9
9
|
import {
|
|
@@ -217,6 +217,16 @@ export const addPendingGraphChange = (pending: PendingGraphChanges, scope: Scope
|
|
|
217
217
|
sessions: pending.sessions || scope === 'sessions',
|
|
218
218
|
})
|
|
219
219
|
|
|
220
|
+
// Git creates the registry entry before the session transaction publishes its durable record. A registry
|
|
221
|
+
// event for that private candidate must not start a full board build while the `git worktree add` child is
|
|
222
|
+
// still running; the create route flushes the deferred event after publication (or cleanup).
|
|
223
|
+
export function isSessionCreateCandidateRegistryEvent(relativePath: string, candidatePaths: Iterable<string>): boolean {
|
|
224
|
+
const entry = relativePath.split(/[\\/]+/).filter(Boolean)[0] ?? ''
|
|
225
|
+
if (!entry) return false
|
|
226
|
+
for (const path of candidatePaths) if (basename(resolve(path)) === entry) return true
|
|
227
|
+
return false
|
|
228
|
+
}
|
|
229
|
+
|
|
220
230
|
// under SPEXCODE_BOARD_DEBUG=1, every broadcast logs its changed unit keys + trigger tags + build ms.
|
|
221
231
|
const DEBUG = process.env.SPEXCODE_BOARD_DEBUG === '1'
|
|
222
232
|
function traceLatency(stage: 'sessions-signal' | 'session-projection-complete' | 'broadcast', detail: Record<string, unknown> = {}): void {
|
|
@@ -466,7 +476,7 @@ function ensureWatcher(root: string): void {
|
|
|
466
476
|
root,
|
|
467
477
|
source: 'store',
|
|
468
478
|
scope: 'sessions',
|
|
469
|
-
onInput: () => fireChanged('sessions'),
|
|
479
|
+
onInput: () => { dropBranchIndex(); fireChanged('sessions') }, // a created/renamed session may own a new branch
|
|
470
480
|
onFailure: (error) => {
|
|
471
481
|
if (storeWatcher === registry) storeWatcher = null
|
|
472
482
|
noteSourceFailure('store', error)
|
|
@@ -492,9 +502,34 @@ type RegistryGroup = {
|
|
|
492
502
|
let refsWatchers: RegistryGroup | null = null
|
|
493
503
|
const REFS_OBSERVER = 'graph:refs'
|
|
494
504
|
|
|
505
|
+
// @@@ the moved ref NAMES its scope - the watcher has always known which ref moved and threw it away, so
|
|
506
|
+
// every ref movement anywhere invalidated every session's evaluation. On a host carrying dozens of branches
|
|
507
|
+
// and a bot that commits continuously that means nothing is ever warm: observed on z-code as an input
|
|
508
|
+
// generation of 1208 against a last-known 254. A session's fingerprint reads exactly three refs — its own
|
|
509
|
+
// tip, the base tip, and their merge-base — so a ref that is neither cannot move it. `packed-refs` and
|
|
510
|
+
// `HEAD` stay broad on purpose: a packed update rewrites many refs behind ONE event, so it names nothing.
|
|
511
|
+
// the index is read from the session store, so it is only ever stale when that store moved — and the store
|
|
512
|
+
// has its own watcher, which drops it. A ref burst therefore costs one map lookup, not 76 record reads.
|
|
513
|
+
let branchIndexMemo: Map<string, string> | null = null
|
|
514
|
+
function branchIndex(): Map<string, string> {
|
|
515
|
+
return (branchIndexMemo ??= sessionBranchIndex())
|
|
516
|
+
}
|
|
517
|
+
function dropBranchIndex(): void { branchIndexMemo = null }
|
|
518
|
+
|
|
519
|
+
export function evalTargetForRef(ref: string | undefined, base: string, branches: Map<string, string>): EvalTarget | undefined {
|
|
520
|
+
if (ref === undefined) return 'all' // an unnamed movement must assume the worst
|
|
521
|
+
const rel = ref.replace(/\\/g, '/')
|
|
522
|
+
if (!rel.startsWith('heads/')) return undefined // tags and remotes feed no session fingerprint
|
|
523
|
+
const branch = rel.slice('heads/'.length)
|
|
524
|
+
if (!branch) return 'all'
|
|
525
|
+
if (branch === base) return 'all' // every merge-base may have moved
|
|
526
|
+
const id = branches.get(branch)
|
|
527
|
+
return id ? { id } : undefined // a branch no session owns moves no session's evaluation
|
|
528
|
+
}
|
|
529
|
+
|
|
495
530
|
export function watchSessionEvalRefs(
|
|
496
531
|
common: string,
|
|
497
|
-
onInput: () => void,
|
|
532
|
+
onInput: (ref?: string) => void,
|
|
498
533
|
onFailure: (error: Error) => void,
|
|
499
534
|
): RegistryGroup {
|
|
500
535
|
let attached: TreeWatcherRegistry[] = []
|
|
@@ -517,7 +552,7 @@ export function watchSessionEvalRefs(
|
|
|
517
552
|
root: join(common, 'refs'),
|
|
518
553
|
source: 'refs',
|
|
519
554
|
scope: 'full',
|
|
520
|
-
onInput: () => onInput(),
|
|
555
|
+
onInput: (_event, rel) => onInput(rel),
|
|
521
556
|
onFailure: fail,
|
|
522
557
|
})
|
|
523
558
|
attached.push(refs)
|
|
@@ -528,7 +563,7 @@ export function watchSessionEvalRefs(
|
|
|
528
563
|
source: 'refs-common',
|
|
529
564
|
scope: 'full',
|
|
530
565
|
recursive: false,
|
|
531
|
-
onInput: (_event, file) => { if (file === 'packed-refs' || file === 'HEAD') onInput() },
|
|
566
|
+
onInput: (_event, file) => { if (file === 'packed-refs' || file === 'HEAD') onInput() }, // names nothing -> 'all'
|
|
532
567
|
onFailure: fail,
|
|
533
568
|
})
|
|
534
569
|
attached.push(commonFiles)
|
|
@@ -553,7 +588,7 @@ function ensureRefsWatcher(common = activeCommonRoot): void {
|
|
|
553
588
|
}
|
|
554
589
|
if (!mayAttach('refs')) return
|
|
555
590
|
try {
|
|
556
|
-
refsWatchers = watchSessionEvalRefs(common, () => fireChanged('full',
|
|
591
|
+
refsWatchers = watchSessionEvalRefs(common, (ref) => fireChanged('full', evalTargetForRef(ref, mainBranch(), branchIndex())), refsWatcherFailed)
|
|
557
592
|
noteSourceHealthy('refs')
|
|
558
593
|
if (releaseSessionEvalProjectionObserver(REFS_OBSERVER)) fireChanged('full')
|
|
559
594
|
} catch (error) {
|
|
@@ -745,10 +780,37 @@ function reconcileWorktrees(forceSessionId?: string): Promise<void> {
|
|
|
745
780
|
return flight
|
|
746
781
|
}
|
|
747
782
|
const WORKTREE_REGISTRY_OBSERVER = 'graph:worktree-registry'
|
|
783
|
+
let deferredRegistryChange = false
|
|
784
|
+
let deferredRegistryTimer: ReturnType<typeof setTimeout> | null = null
|
|
785
|
+
|
|
786
|
+
function flushDeferredRegistryChange(): void {
|
|
787
|
+
if (!deferredRegistryChange) return
|
|
788
|
+
deferredRegistryChange = false
|
|
789
|
+
if (deferredRegistryTimer) { clearTimeout(deferredRegistryTimer); deferredRegistryTimer = null }
|
|
790
|
+
void reconcileWorktrees()
|
|
791
|
+
fireChanged('full', 'all')
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// A crashed or disconnected create request must not leave the registry event deferred forever. The normal
|
|
795
|
+
// route flush is immediate; this bounded fallback preserves the watcher contract if the process never reaches
|
|
796
|
+
// its response boundary.
|
|
797
|
+
function deferRegistryChange(): void {
|
|
798
|
+
deferredRegistryChange = true
|
|
799
|
+
if (deferredRegistryTimer) return
|
|
800
|
+
deferredRegistryTimer = setTimeout(() => {
|
|
801
|
+
deferredRegistryTimer = null
|
|
802
|
+
flushDeferredRegistryChange()
|
|
803
|
+
}, 10_000)
|
|
804
|
+
deferredRegistryTimer.unref?.()
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
export function flushDeferredWorktreeRegistryChange(): void {
|
|
808
|
+
flushDeferredRegistryChange()
|
|
809
|
+
}
|
|
748
810
|
|
|
749
811
|
export function watchSessionEvalRegistry(
|
|
750
812
|
dir: string,
|
|
751
|
-
onInput: () => void,
|
|
813
|
+
onInput: (event: 'rename' | 'change', relativePath: string) => void,
|
|
752
814
|
onFailure: (error: Error) => void,
|
|
753
815
|
): TreeWatcherRegistry {
|
|
754
816
|
let ready = false
|
|
@@ -758,7 +820,7 @@ export function watchSessionEvalRegistry(
|
|
|
758
820
|
source: 'worktree-registry',
|
|
759
821
|
scope: 'full',
|
|
760
822
|
recursive: false,
|
|
761
|
-
onInput: () => onInput(),
|
|
823
|
+
onInput: (_event, relativePath) => onInput(_event, relativePath),
|
|
762
824
|
onFailure: (error) => {
|
|
763
825
|
if (ready) onFailure(error)
|
|
764
826
|
else attachError = error
|
|
@@ -807,7 +869,11 @@ async function ensureWorktreeRegistry(forceSessionId?: string): Promise<void> {
|
|
|
807
869
|
catch (error) { console.error(`spec-cli: graph watcher 'worktree-registry' could not create ${dir}: ${error instanceof Error ? error.message : String(error)}`) }
|
|
808
870
|
// a registry add/remove is itself a 'full' change (a new/gone worktree reshapes the overlay); also
|
|
809
871
|
// reconcile the per-worktree `.spec` watchers on every registry event.
|
|
810
|
-
registryWatcher = watchSessionEvalRegistry(dir, () => {
|
|
872
|
+
registryWatcher = watchSessionEvalRegistry(dir, (_event, relativePath) => {
|
|
873
|
+
if (isSessionCreateCandidateRegistryEvent(relativePath, pendingSessionCreateWorktreePaths())) {
|
|
874
|
+
deferRegistryChange()
|
|
875
|
+
return
|
|
876
|
+
}
|
|
811
877
|
void reconcileWorktrees()
|
|
812
878
|
fireChanged('full', 'all')
|
|
813
879
|
}, registryWatcherFailed)
|
|
@@ -841,6 +907,8 @@ export async function ensureBoardFileWatchers(forceSessionId?: string): Promise<
|
|
|
841
907
|
export function closeBoardFileWatchers(): void {
|
|
842
908
|
watcherEra++
|
|
843
909
|
if (repairTimer) { clearTimeout(repairTimer); repairTimer = null }
|
|
910
|
+
if (deferredRegistryTimer) { clearTimeout(deferredRegistryTimer); deferredRegistryTimer = null }
|
|
911
|
+
deferredRegistryChange = false
|
|
844
912
|
heldSources.clear()
|
|
845
913
|
repairStep = 0
|
|
846
914
|
repairing = false
|
package/spec-cli/src/harness.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { git } from './git.js'
|
|
|
18
18
|
import { shQuote } from './sh.js'
|
|
19
19
|
import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime, type VerifiedDetachedRuntime } from './process-identity.js'
|
|
20
20
|
import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, resolveCodexGenerationForSession, type CodexGenerationEndpoint } from './codex-runtime-generations.js'
|
|
21
|
+
import { writeFileIfChanged } from './file-write.js'
|
|
21
22
|
|
|
22
23
|
// @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
|
|
23
24
|
// Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
|
|
@@ -702,10 +703,12 @@ export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: s
|
|
|
702
703
|
// thread/start bypass, so the worktree's hooks stay untrusted and NO lifecycle hooks fire.
|
|
703
704
|
`export SPEXCODE_CODEX_CMD=${shQuote(codexCmd)}`,
|
|
704
705
|
// The runtime command is the single generation-ledger boundary. A new turn receives canonical `current`;
|
|
705
|
-
// resume resolves its existing session/thread binding
|
|
706
|
-
// replacement
|
|
706
|
+
// resume resolves its existing session/thread binding, so a LIVE root never has its conversation moved to a
|
|
707
|
+
// replacement. Both spellings carry the server command because either may be the launch that has to start a
|
|
708
|
+
// root: after a host restart the bound generation is a corpse, and resume rebuilds one to load the same
|
|
709
|
+
// on-disk rollout. It prints only shell assignments for the exact proven endpoint.
|
|
707
710
|
'if [ "$1" = "--resume" ]; then',
|
|
708
|
-
` eval "$( ${SPEX} internal codex-generation-session "$dir" "$SPEXCODE_SESSION_ID" "$2" )" || exit 1`,
|
|
711
|
+
` eval "$( ${SPEX} internal codex-generation-session "$dir" "$SPEXCODE_SESSION_ID" "$2" ${shQuote(server)} )" || exit 1`,
|
|
709
712
|
'else',
|
|
710
713
|
` eval "$( ${SPEX} internal codex-generation-current "$dir" ${shQuote(server)} )" || exit 1`,
|
|
711
714
|
'fi',
|
|
@@ -1797,17 +1800,16 @@ async function deliverViaCodexAppServer(rec: HarnessDeliveryRecord, text: string
|
|
|
1797
1800
|
// idempotent replace of the content between sentinels; the user's own content above/below is preserved. The
|
|
1798
1801
|
// comment STYLE is a parameter so ONE primitive serves every managed file — HTML for the md contracts
|
|
1799
1802
|
// (CLAUDE.md/AGENTS.md), `#` for .gitignore — instead of a per-file-type writer. Default = HTML (the md case).
|
|
1800
|
-
export function writeManagedBlock(file: string, body: string, comment: readonly [string, string] = ['<!-- ', ' -->']):
|
|
1803
|
+
export function writeManagedBlock(file: string, body: string, comment: readonly [string, string] = ['<!-- ', ' -->']): boolean {
|
|
1801
1804
|
const [open, close] = comment
|
|
1802
1805
|
const START = `${open}spexcode:start${close}`
|
|
1803
1806
|
const END = `${open}spexcode:end${close}`
|
|
1804
1807
|
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
1805
1808
|
const block = `${START}\n${body}\n${END}`
|
|
1806
|
-
|
|
1809
|
+
const cur = existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
1807
1810
|
const re = new RegExp(`${esc(START)}[\\s\\S]*?${esc(END)}`)
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
writeFileSync(file, cur)
|
|
1811
|
+
const next = re.test(cur) ? cur.replace(re, block) : cur.trim() ? `${cur.replace(/\n*$/, '')}\n\n${block}\n` : `${block}\n`
|
|
1812
|
+
return writeFileIfChanged(file, next)
|
|
1811
1813
|
}
|
|
1812
1814
|
|
|
1813
1815
|
// the INVERSE of writeManagedBlock: strip the spexcode sentinel block (with the blank space around it),
|
|
@@ -1916,7 +1918,7 @@ export function writeCodexTrust(proj: string, events: readonly string[], cmdFor:
|
|
|
1916
1918
|
const blk = `# spexcode:trust:${proj} (managed — do not edit)\n${lines.join('\n')}\n# spexcode:trust:end:${proj}`
|
|
1917
1919
|
const cleaned = stripCodexTrustFor(existsSync(file) ? readFileSync(file, 'utf8') : '', proj, hooksJson)
|
|
1918
1920
|
if (!existsSync(home)) mkdirSync(home, { recursive: true })
|
|
1919
|
-
|
|
1921
|
+
writeFileIfChanged(file, cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`)
|
|
1920
1922
|
return file
|
|
1921
1923
|
}
|
|
1922
1924
|
|
package/spec-cli/src/help.ts
CHANGED
|
@@ -44,8 +44,8 @@ session to that node. --prompt-file <path>|- carries a long prompt without shell
|
|
|
44
44
|
ls: ['spex session ls [SEL…] [--status a,b] [--all] [--json]',
|
|
45
45
|
'One-shot table of living sessions. Shelved sessions ([[archive]]) are hidden; --all includes them, and naming one explicitly always shows it.', ['selector']],
|
|
46
46
|
resources: ['spex session resources [--json]', 'Read-only host/process ownership, budgets, shared refs, and findings.'],
|
|
47
|
-
watch: ['spex session watch [SEL…] [--as NAME] [--idle] [--interval N=1]',
|
|
48
|
-
'
|
|
47
|
+
watch: [['spex session watch <SEL…>', 'spex session watch list', 'spex session watch cancel <SEL…>', 'spex session watch stream [SEL…] [--as NAME] [--idle] [--interval N=1]'],
|
|
48
|
+
'With a governed caller, watch registers durable send-backed state delivery and exits. list/cancel manage those relations. Without a governed caller it names the background `session wait` fallback. stream is the human-only continuous log view and blocks until killed.', ['selector']],
|
|
49
49
|
wait: ['spex session wait [SEL…] [--timeout S=1200] [--interval S=1] [--idle]',
|
|
50
50
|
`EDGE-TRIGGERED wait: follows the selected sessions' logs AND your own log, and exits 0 on
|
|
51
51
|
the FIRST thing worth waking for — a followed session TRANSITIONING from a non-actionable
|
|
@@ -79,7 +79,7 @@ you are running in, mid-turn. Your own ending is a declaration: \`done --propose
|
|
|
79
79
|
done: ['spex session done --propose merge|nothing|close [--note T]',
|
|
80
80
|
'`merge` declares review: committed work ready for human review, and it is the ONLY declaration that offers a clickable merge. `nothing` declares done: committed work, but no merge proposal. `close` declares close-pending: PROPOSE discarding this worktree — the human closes it. This declaration is how a session ends itself; never run `session close` on your own id.'],
|
|
81
81
|
park: ['spex session park --note <what-you-await>',
|
|
82
|
-
'Declare parked only when a real background task will wake your own session. It self-resumes; waiting for a human is asking, not parked.'],
|
|
82
|
+
'Declare parked only when a managed watch delivery or real background task will wake your own session. It self-resumes; waiting for a human is asking, not parked.'],
|
|
83
83
|
ask: ['spex session ask --note <your-question>',
|
|
84
84
|
'Declare asking when the session needs a human reply or direction. It resumes only when the human replies; a background wake-up is parked instead.'],
|
|
85
85
|
attach: ['spex session attach <SEL>', `Attaches the current terminal to the worker's tmux (detach: C-b d) and blocks until detached.
|
|
@@ -273,7 +273,7 @@ edit the spec instead — same commit as the code.`,
|
|
|
273
273
|
see: 'spex eval ls --session <SEL> (the session’s measured loss) · spex help eval',
|
|
274
274
|
},
|
|
275
275
|
eval: {
|
|
276
|
-
line: 'eval <verb> the measurement system: add · ls · scenario ls/write ·
|
|
276
|
+
line: 'eval <verb> the measurement system: add · ls · scenario ls/write · lint · ok · retract · clean',
|
|
277
277
|
body: `Usage: spex eval add [<node>|.] [--scenario <name>] (--pass|--fail) [--note <text>]
|
|
278
278
|
[--image <png> …repeatable] [--result <path|->] [--video <webm|mp4>] [--timeline <json>]
|
|
279
279
|
spex eval ls [<node>|.] [--json] a node's eval timeline, newest first
|
|
@@ -281,7 +281,6 @@ edit the spec instead — same commit as the code.`,
|
|
|
281
281
|
spex eval ls --session <SEL> --export [--open | --out <path>]
|
|
282
282
|
spex eval scenario ls [<node>|.] [--unmeasured] [--json] declared scenarios; JSON = canonical index
|
|
283
283
|
spex eval scenario write --mutation <json> < eval.md propose one canonical metadata mutation
|
|
284
|
-
spex eval matrix <launcher> [--node <id>] [--rows k1,k2] the harness live-behavior matrix
|
|
285
284
|
spex eval lint [--changed] measurement-layer findings (advisory, always exit 0)
|
|
286
285
|
spex eval ok <node> [--scenario <name>] the HUMAN sign-off on the scenario's latest measurement
|
|
287
286
|
spex eval retract [<node>|.] [--scenario <name>] [--last | --ts <iso>] [--note <why>]
|
|
@@ -305,12 +304,6 @@ scenario write — the fixed-tree declaration writer for an external measurement
|
|
|
305
304
|
eval.md, --mutation is one closed JSON insert/delete request for one scenario's test metadata, and stdout is only
|
|
306
305
|
the proposed eval.md bytes. It reads no worktree or runner and fails without stdout on malformed or ambiguous input.
|
|
307
306
|
|
|
308
|
-
matrix — run the eight-row harness live-behavior matrix against a REAL dispatched session of the named
|
|
309
|
-
launcher (the harness-adapter acceptance rule, defined once in spec-eval/src/matrix.ts): it syncs the
|
|
310
|
-
rows into the \`<harness>-harness\` node's eval.md scenarios, drives one worker through undeclared-stop ·
|
|
311
|
-
pretooluse-block · ask-note · deliver-steer · resume · liveness · commit-gate · close-residue, and files
|
|
312
|
-
a per-row measurement with its evidence transcript. A new harness needs only its launcher + spec node.
|
|
313
|
-
|
|
314
307
|
lint — the measurement layer's findings: malformed eval.md (eval-schema) · unmeasured (eval-missing) ·
|
|
315
308
|
stale (eval-drift) · orphaned remark tracks (eval-dangling) · governed source with no eval.md
|
|
316
309
|
(eval-coverage — the same name and shape as spec lint's coverage, one rule per layer) · over-owned
|
|
@@ -439,10 +432,12 @@ export function commandHelp(name: string, verb?: string): string | null {
|
|
|
439
432
|
return `${header}${e.body}${e.see ? `\n\nsee also: ${e.see}` : ''}\n\nmap: spex help · skills: spex guide`
|
|
440
433
|
}
|
|
441
434
|
|
|
442
|
-
export function sessionLaunchReceipt(id: string): string {
|
|
435
|
+
export function sessionLaunchReceipt(id: string, managedWatch = false): string {
|
|
443
436
|
return `spex: launched session ${id}
|
|
444
437
|
current result: the session JSON is on stdout now; \`spex session ls ${id}\` is the later one-shot snapshot
|
|
445
|
-
next lifecycle change:
|
|
438
|
+
next lifecycle change: ${managedWatch
|
|
439
|
+
? `managed watch registered — this parent receives ${id}'s state changes through its normal send queue; \`spex session watch cancel ${id}\` ends it`
|
|
440
|
+
: `background \`spex session wait ${id}\` (edge-triggered; exits on the next non-actionable→actionable transition); \`spex session watch ${id}\` registers send-backed delivery when the caller is governed`}; \`spex session watch stream ${id}\` NEVER EXITS
|
|
446
441
|
response channel: \`spex session send ${id} "<msg>"\`; \`send --keys\` is an UNSTABLE LAST RESORT after a plain send cannot land`
|
|
447
442
|
}
|
|
448
443
|
|
package/spec-cli/src/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
|
|
|
15
15
|
import { summarizeLoopIn } from './mentions.js'
|
|
16
16
|
import { resolveLayout, mainBranch } from './layout.js'
|
|
17
17
|
import { getBoardJson } from './graphCache.js'
|
|
18
|
-
import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
|
|
18
|
+
import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged, flushDeferredWorktreeRegistryChange } from './graphStream.js'
|
|
19
19
|
import { gitA, gitTry, repoRoot } from './git.js'
|
|
20
20
|
import { cockpitReview } from './cockpit.js'
|
|
21
21
|
import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, archiveSession, resumeSession, mergeSession, captureSessionResult, sessionPrompt, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, superviseDelivery, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
|
|
@@ -455,12 +455,19 @@ app.post('/api/sessions', async (c) => {
|
|
|
455
455
|
try {
|
|
456
456
|
const body = await c.req.json().catch(() => null)
|
|
457
457
|
const result = await sessionCreateRequest(body, { requestKey, signal: controller.signal })
|
|
458
|
+
// The durable row is now public. Nudge the cheap session projection explicitly so a dashboard does not
|
|
459
|
+
// wait for the best-effort store watcher; any held candidate worktree event remains a separate full claim.
|
|
460
|
+
if (result.status === 201) notifyBoardChanged('sessions')
|
|
461
|
+
// A candidate registry event is intentionally held while Git creates the private worktree. Once the
|
|
462
|
+
// transaction has published or cleaned up its record, release the one deferred full refresh.
|
|
463
|
+
flushDeferredWorktreeRegistryChange()
|
|
458
464
|
if (result.status === 201) {
|
|
459
465
|
c.header('Idempotency-Key', requestKey)
|
|
460
466
|
return c.json(result.session, 201)
|
|
461
467
|
}
|
|
462
468
|
return c.json({ error: result.error, ...(result.code ? { code: result.code } : {}), ...(result.phase ? { phase: result.phase } : {}) }, result.status as any)
|
|
463
469
|
} finally {
|
|
470
|
+
flushDeferredWorktreeRegistryChange()
|
|
464
471
|
rawSignal.removeEventListener('abort', cancelFromRequest)
|
|
465
472
|
outgoing?.off('close', cancel)
|
|
466
473
|
}
|
package/spec-cli/src/init.ts
CHANGED
|
@@ -1,29 +1,20 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, copyFileSync, readFileSync, readdirSync, renameSync, rmSync, statSync, chmodSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { createHash } from 'node:crypto'
|
|
3
2
|
import { join, resolve, relative, dirname } from 'node:path'
|
|
4
3
|
import { fileURLToPath } from 'node:url'
|
|
5
4
|
import { execFileSync } from 'node:child_process'
|
|
6
5
|
import { readConfig, readJsonConfig } from './layout.js'
|
|
7
6
|
import { resolveHarnessTargets, parseHarnessFlag, NATIVE_HARNESS_IDS } from './harness-select.js'
|
|
7
|
+
import { gitBinary } from './git.js'
|
|
8
8
|
|
|
9
9
|
// this file lives at <pkgRoot>/src/init.ts, so `..` is the package root — the same derivation the
|
|
10
10
|
// launch paths use, never a hardcoded repo path (so a relocated/installed package still finds its data).
|
|
11
11
|
const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
12
12
|
const TEMPLATES = join(pkgRoot, 'templates')
|
|
13
13
|
const MANAGED_HOOK_HEADER = '# spexcode-managed-hook-v1'
|
|
14
|
-
const LEGACY_MANAGED_HOOKS: Record<string, Set<string>> = {
|
|
15
|
-
'pre-commit': new Set(['fc33a6ff2b444fa47210a2594b6a714613d9c62fd8ec77a9a13aec81143de8a7']),
|
|
16
|
-
'prepare-commit-msg': new Set(['08b26ad8ffb305a64b7cddc868231854c84d539b8e5e9324702bd2c293044183']),
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function hookDigest(path: string): string {
|
|
20
|
-
return createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
21
|
-
}
|
|
22
14
|
|
|
23
|
-
function isManagedHook(path: string
|
|
15
|
+
function isManagedHook(path: string): boolean {
|
|
24
16
|
const source = readFileSync(path, 'utf8')
|
|
25
17
|
return source.split('\n').slice(0, 4).includes(MANAGED_HOOK_HEADER)
|
|
26
|
-
|| LEGACY_MANAGED_HOOKS[name]?.has(createHash('sha256').update(source).digest('hex')) === true
|
|
27
18
|
}
|
|
28
19
|
|
|
29
20
|
function replaceHook(source: string, dest: string): void {
|
|
@@ -71,7 +62,7 @@ function copyTreeNoClobber(srcDir: string, destDir: string, base: string): strin
|
|
|
71
62
|
// hooks/ (shared across all worktrees). Returns null (with a loud reason) when <dir> isn't a git repo.
|
|
72
63
|
function resolveHooksDir(dir: string): string | null {
|
|
73
64
|
try {
|
|
74
|
-
const common = execFileSync(
|
|
65
|
+
const common = execFileSync(gitBinary(process.env), ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
|
75
66
|
encoding: 'utf8',
|
|
76
67
|
stdio: ['ignore', 'pipe', 'ignore'], // swallow git's own "fatal: not a git repository" — our warning is the signal
|
|
77
68
|
}).trim()
|
|
@@ -86,8 +77,8 @@ function resolveHooksDir(dir: string): string | null {
|
|
|
86
77
|
// turn that feature branch into trunk).
|
|
87
78
|
function adoptionMainBranch(dir: string): string {
|
|
88
79
|
try {
|
|
89
|
-
const common = execFileSync(
|
|
90
|
-
const branch = execFileSync(
|
|
80
|
+
const common = execFileSync(gitBinary(process.env), ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { encoding: 'utf8' }).trim()
|
|
81
|
+
const branch = execFileSync(gitBinary(process.env), ['-C', dirname(common), 'symbolic-ref', '--short', 'HEAD'], { encoding: 'utf8' }).trim()
|
|
91
82
|
if (branch) return branch
|
|
92
83
|
} catch { /* render the one product-level repair below */ }
|
|
93
84
|
const error = new Error('cannot determine the source-of-truth branch at adoption — check out the trunk in the root checkout, or set "mainBranch" in spexcode.json before re-running `spex init`')
|
|
@@ -113,7 +104,7 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
113
104
|
// NOT `git init` for them: creating a repo is a side effect beyond init's remit (a subdir, a dir not
|
|
114
105
|
// meant as a repo root), and `git init` is one deliberate command.
|
|
115
106
|
try {
|
|
116
|
-
execFileSync(
|
|
107
|
+
execFileSync(gitBinary(process.env), ['-C', targetDir, 'rev-parse', '--is-inside-work-tree'], { stdio: ['ignore', 'ignore', 'ignore'] })
|
|
117
108
|
} catch {
|
|
118
109
|
console.error(`spex init: ${targetDir} is not a git repository. SpexCode is git-backed (git is the version database; the hooks live in .git). Run \`git init\` there first, then \`spex init\`.`)
|
|
119
110
|
process.exit(1)
|
|
@@ -210,8 +201,8 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
210
201
|
const source = join(hooksSrc, e.name)
|
|
211
202
|
const dest = join(hooksDir, e.name)
|
|
212
203
|
if (existsSync(dest)) {
|
|
213
|
-
if (
|
|
214
|
-
if (isManagedHook(dest
|
|
204
|
+
if (readFileSync(dest).equals(readFileSync(source))) continue
|
|
205
|
+
if (isManagedHook(dest)) {
|
|
215
206
|
replaceHook(source, dest)
|
|
216
207
|
refreshed.push(e.name)
|
|
217
208
|
} else {
|
package/spec-cli/src/layout.ts
CHANGED
|
@@ -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 —
|
|
@@ -448,6 +462,20 @@ export function listSessionIds(): string[] {
|
|
|
448
462
|
return ents.filter((d) => d.isDirectory()).map((d) => d.name)
|
|
449
463
|
}
|
|
450
464
|
|
|
465
|
+
// @@@ branch -> session, so a moved ref can DERIVE its invalidation scope - a session's evaluation depends
|
|
466
|
+
// on its own branch tip, the base branch tip and their merge-base; no other ref participates. Without this
|
|
467
|
+
// map a ref watcher knows only that "something under refs/ moved" and the honest fallback is to invalidate
|
|
468
|
+
// every session ([[taste]] 19). One record read per session, and only when the session store itself moved.
|
|
469
|
+
export function sessionBranchIndex(): Map<string, string> {
|
|
470
|
+
const index = new Map<string, string>()
|
|
471
|
+
for (const id of listSessionIds()) {
|
|
472
|
+
let branch: unknown
|
|
473
|
+
try { branch = readRawRecord(id)?.branch } catch { continue } // a corrupt record narrows nothing
|
|
474
|
+
if (typeof branch === 'string' && branch) index.set(branch, id)
|
|
475
|
+
}
|
|
476
|
+
return index
|
|
477
|
+
}
|
|
478
|
+
|
|
451
479
|
// memo the overlay (4 git diffs/worktree, all .spec-scoped) keyed on fork-point merge-base + HEAD + spec
|
|
452
480
|
// sig + MAIN'S TIP ([[worktree-linker]]): the main-tip component is what lets a merge landing identical
|
|
453
481
|
// content dissolve a worktree's now-moot ops — the recompute it triggers is cheap because every diff is
|