spexcode 0.5.3 → 0.5.5

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.
@@ -7,7 +7,7 @@ import { defaultHarness, HARNESSES, harnessById, sessionIdentityEnvVars, type Ha
7
7
  import { listSessionIds, readConfig, readJsonConfig, readPublicRecordEntry, readRawRecord, runtimeRoot, type PublicRecordEntry, type RawRecord } from './layout.js'
8
8
  import { repoRoot } from './git.js'
9
9
  import { endpointRecordPath } from './host.js'
10
- import { parseProcStat, processStartToken, processTopology, type ProcessIdentity } from './process-identity.js'
10
+ import { detachedRuntimeGenerationToken, parseProcStat, processStartToken, verifyDetachedRuntime, type ProcessIdentity } from './process-identity.js'
11
11
  import { readBackendInstanceRecords, type BackendInstanceRecord } from './runtime-ownership.js'
12
12
 
13
13
  type Proc = ProcessIdentity & {
@@ -384,6 +384,7 @@ const sessionStopBlocker = async (
384
384
  harnessId: string | null,
385
385
  recs = rawRecords(),
386
386
  knownProbes?: Map<string, SharedRuntimeProbe>,
387
+ opts: { coldReceipt?: unknown } = {},
387
388
  ): Promise<string | null> => {
388
389
  const allowed = harnessId
389
390
  ? new Set((harnessById(harnessId).sharedRuntimes?.(runtimeRoot()) ?? []).map((descriptor) => descriptor.key))
@@ -392,7 +393,6 @@ const sessionStopBlocker = async (
392
393
  if (allowed && !allowed.has(key)) continue
393
394
  const descriptor = entry.descriptor
394
395
  const pid = runtimePid(descriptor.pidFile)
395
- const startToken = pid ? processStartToken(pid) : null
396
396
  const ownerCounts = new Map<string, number>()
397
397
  for (const rec of entry.recs) if (rec.harness_session_id) ownerCounts.set(rec.harness_session_id, (ownerCounts.get(rec.harness_session_id) ?? 0) + 1)
398
398
  const targetThread = entry.recs.find((rec) => rec.session_id === id)?.harness_session_id
@@ -401,24 +401,16 @@ const sessionStopBlocker = async (
401
401
  if (!knownProbes && descriptor.mutationGuard) {
402
402
  if (!targetThread) return `${descriptor.label} target has no exact governed thread identity`
403
403
  if (!pid) return `${descriptor.label} target-scoped mutation guard has no readable owner PID`
404
- if (!startToken) return `${descriptor.label} PID ${pid} target-scoped mutation guard has no readable process-start identity`
405
- const topologyBefore = processTopology(pid)
406
- let stampBefore = ''
407
- try { stampBefore = readFileSync(descriptor.isolationFile, 'utf8').trim() } catch { /* legacy unsafe runtime */ }
408
- if (!topologyBefore || topologyBefore.startToken !== startToken || topologyBefore.processGroupId !== pid || topologyBefore.sessionId !== pid ||
409
- stampBefore !== `detached-v3 ${pid} ${startToken} ${pid} ${pid}`)
410
- return `${descriptor.label} PID ${pid}@${startToken} has no matching live detached process-boundary record`
404
+ const identityBefore = verifyDetachedRuntime(pid, descriptor.receiptFile)
405
+ if (!identityBefore.ok)
406
+ return `${descriptor.label} PID ${pid} has no matching live detached process-boundary record: ${identityBefore.reason}`
411
407
  let guard
412
- try { guard = await descriptor.mutationGuard(targetThread) }
408
+ try { guard = await descriptor.mutationGuard(targetThread, opts) }
413
409
  catch (error) { return `${descriptor.label} target-scoped mutation guard failed: ${(error as Error).message}` }
414
- const startAfter = processStartToken(pid)
415
- const topologyAfter = processTopology(pid)
416
- let stampAfter = ''
417
- try { stampAfter = readFileSync(descriptor.isolationFile, 'utf8').trim() } catch { /* changed/missing identity */ }
418
- if (startAfter !== startToken || !topologyAfter || topologyAfter.startToken !== startToken ||
419
- topologyAfter.processGroupId !== pid || topologyAfter.sessionId !== pid || stampAfter !== stampBefore)
420
- return `${descriptor.label} PID/start/isolation identity changed during target-scoped mutation guard`
421
- if (guard.descendantIds.length)
410
+ const identityAfter = verifyDetachedRuntime(pid, descriptor.receiptFile)
411
+ if (!identityAfter.ok || detachedRuntimeGenerationToken(identityAfter.identity) !== detachedRuntimeGenerationToken(identityBefore.identity))
412
+ return `${descriptor.label} PID/start/detached-receipt identity changed during target-scoped mutation guard${identityAfter.ok ? '' : `: ${identityAfter.reason}`}`
413
+ if (guard.descendantIds.length && guard.coldTeardownAuthorized !== true)
422
414
  return `${descriptor.label} target thread ${targetThread} has owned descendants (${guard.descendantIds.join(', ')})`
423
415
  if (!guard.healthy)
424
416
  return `${descriptor.label} target thread ${targetThread} is unknown: ${guard.error || 'target-scoped mutation guard failed'}`
@@ -433,17 +425,13 @@ const sessionStopBlocker = async (
433
425
  ? `${siblings.length} live sibling thread(s)`
434
426
  : probe.healthy ? `${probe.references.length} live thread reference(s)` : 'an unproven live reference set'
435
427
  if (!pid) return `${descriptor.label} has ${liveReason} but no readable owner PID`
436
- if (!startToken) return `${descriptor.label} PID ${pid} has ${liveReason} but no readable process-start identity`
437
- const topology = processTopology(pid)
438
- let stamp = ''
439
- try { stamp = readFileSync(descriptor.isolationFile, 'utf8').trim() } catch { /* legacy unsafe runtime */ }
440
- if (!topology || topology.startToken !== startToken || topology.processGroupId !== pid || topology.sessionId !== pid ||
441
- stamp !== `detached-v3 ${pid} ${startToken} ${pid} ${pid}`) {
428
+ const identity = verifyDetachedRuntime(pid, descriptor.receiptFile)
429
+ if (!identity.ok) {
442
430
  const refs = probe.references.map((reference) => reference.referenceId).join(', ') || 'no loaded threads'
443
- return `${descriptor.label} PID ${pid}@${startToken} serves ${refs} and has no matching live detached process-boundary record`
431
+ return `${descriptor.label} PID ${pid} serves ${refs} and has no matching live detached process-boundary record: ${identity.reason}`
444
432
  }
445
433
  if (!probe.healthy)
446
- return `${descriptor.label} PID ${pid}@${startToken} has an unproven live reference set: ${probe.error || 'unknown probe failure'}`
434
+ return `${descriptor.label} PID ${pid}@${identity.identity.startToken} has an unproven live reference set: ${probe.error || 'unknown probe failure'}`
447
435
  }
448
436
  return null
449
437
  }
@@ -658,9 +646,13 @@ export async function collectResourceReport(opts: { procRoot?: string; persist?:
658
646
  return report
659
647
  }
660
648
 
661
- export async function assertSessionStopSafe(id: string, rec: (HarnessLivenessRecord & { harness?: string }) | null): Promise<void> {
649
+ export async function assertSessionStopSafe(
650
+ id: string,
651
+ rec: (HarnessLivenessRecord & { harness?: string }) | null,
652
+ opts: { coldReceipt?: unknown } = {},
653
+ ): Promise<void> {
662
654
  if (!rec) throw new ResourceConflict(`refusing to stop ${id}: no readable session record proves the adapter or leaf owner`)
663
- const blocker = await sessionStopBlocker(id, rec.harness || null)
655
+ const blocker = await sessionStopBlocker(id, rec.harness || null, rawRecords(), undefined, opts)
664
656
  if (blocker) throw new ResourceConflict(`refusing to stop ${id}: ${blocker}`)
665
657
  }
666
658
 
@@ -15,7 +15,7 @@ import { resolveLayout, mainBranch } from './layout.js'
15
15
  import { getBoardJson } from './graphCache.js'
16
16
  import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
17
17
  import { gitA, gitTry, repoRoot } from './git.js'
18
- import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, archiveSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
18
+ import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, archiveSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
19
19
  import { superviseTimeline, readTimeline } from './session-timeline.js'
20
20
  import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js'
21
21
  import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
@@ -684,6 +684,7 @@ installConnectionReaper(server as unknown as HttpServer)
684
684
  injectWebSocket(server)
685
685
  superviseBridges() // restore visible helpers after failure; their viewer subscriptions survive replacement
686
686
  superviseQueue() // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
687
+ superviseTurnFailures() // reconcile adapter-owned native failure subscriptions across backend replacement
687
688
  superviseTimeline() // record authored-lifecycle transitions to each session's durable timeline ([[session-timeline]])
688
689
  console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`)
689
690
 
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, copyFileSync, readFileSync, readdirSync, renameSync, rmSync, statSync, chmodSync, writeFileSync } from 'node:fs'
2
2
  import { createHash } from 'node:crypto'
3
- import { join, resolve, relative } from 'node:path'
3
+ import { join, resolve, relative, dirname } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { execFileSync } from 'node:child_process'
6
6
  import { readConfig, readJsonConfig } from './layout.js'
@@ -81,6 +81,20 @@ function resolveHooksDir(dir: string): string | null {
81
81
  }
82
82
  }
83
83
 
84
+ // Detect once, at adoption, while the root checkout still names the branch being adopted. Steady-state
85
+ // layout never re-derives trunk identity from a mutable checkout (an ordinary `git switch node/x` must not
86
+ // turn that feature branch into trunk).
87
+ function adoptionMainBranch(dir: string): string {
88
+ try {
89
+ const common = execFileSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { encoding: 'utf8' }).trim()
90
+ const branch = execFileSync('git', ['-C', dirname(common), 'symbolic-ref', '--short', 'HEAD'], { encoding: 'utf8' }).trim()
91
+ if (branch) return branch
92
+ } catch { /* render the one product-level repair below */ }
93
+ 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`')
94
+ error.name = 'ConfigError'
95
+ throw error
96
+ }
97
+
84
98
  export async function specInit(targetArg: string | undefined, presetArg?: string, harnessArg?: string): Promise<void> {
85
99
  const targetDir = resolve(targetArg ?? process.cwd())
86
100
 
@@ -153,18 +167,23 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
153
167
  const cfgDest = join(targetDir, 'spexcode.json')
154
168
  const nativeChosen = (chosenHarnesses as unknown[]).filter((m): m is string => typeof m === 'string')
155
169
  if (existsSync(cfgDest)) {
156
- if (flagRaw) {
157
- // an explicit --harness on a re-init is a deliberate command: restamp THAT field, touch nothing else.
158
- const cfg = (readJsonConfig(cfgDest) ?? {}) as Record<string, unknown>
159
- cfg.harnesses = flagRaw
170
+ const cfg = (readJsonConfig(cfgDest) ?? {}) as Record<string, unknown>
171
+ const stampedBranch = typeof cfg.mainBranch !== 'string' || !cfg.mainBranch.trim()
172
+ if (stampedBranch) cfg.mainBranch = adoptionMainBranch(targetDir)
173
+ if (flagRaw) cfg.harnesses = flagRaw
174
+ if (flagRaw || stampedBranch) {
160
175
  writeFileSync(cfgDest, JSON.stringify(cfg, null, 2) + '\n')
161
- console.log(`✓ stamped "harnesses": ${JSON.stringify(flagRaw)} into the existing spexcode.json (other fields untouched)`)
176
+ console.log(`✓ stamped ${[
177
+ flagRaw ? `"harnesses": ${JSON.stringify(flagRaw)}` : '',
178
+ stampedBranch ? `"mainBranch": ${JSON.stringify(cfg.mainBranch)}` : '',
179
+ ].filter(Boolean).join(' and ')} into the existing spexcode.json (other fields untouched)`)
162
180
  } else {
163
181
  console.warn(`• spexcode.json already exists at ${cfgDest} — left untouched (harnesses: ${JSON.stringify(chosenHarnesses)}).`)
164
182
  }
165
183
  } else {
166
184
  const cfg = (readJsonConfig(join(TEMPLATES, 'spexcode.json')) ?? {}) as Record<string, any>
167
185
  cfg.harnesses = chosenHarnesses
186
+ cfg.mainBranch = adoptionMainBranch(targetDir)
168
187
  if (nativeChosen.length && cfg.sessions?.launchers) {
169
188
  cfg.sessions.launchers = Object.fromEntries(
170
189
  Object.entries(cfg.sessions.launchers as Record<string, { harness?: string }>).filter(([, l]) => nativeChosen.includes(l.harness ?? 'claude')))
@@ -173,7 +192,7 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
173
192
  }
174
193
  writeFileSync(cfgDest, JSON.stringify(cfg, null, 2) + '\n')
175
194
  const roots = JSON.stringify(readJsonConfig(cfgDest)?.lint?.governedRoots ?? null)
176
- console.log(`✓ planted spexcode.json — harnesses ${JSON.stringify(chosenHarnesses)}, launchers ${JSON.stringify(Object.keys(cfg.sessions?.launchers ?? {}))}; lint.governedRoots starts as ${roots} (the whole git-tracked tree, tests excluded)`)
195
+ console.log(`✓ planted spexcode.json — mainBranch ${JSON.stringify(cfg.mainBranch)}, harnesses ${JSON.stringify(chosenHarnesses)}, launchers ${JSON.stringify(Object.keys(cfg.sessions?.launchers ?? {}))}; lint.governedRoots starts as ${roots} (the whole git-tracked tree, tests excluded)`)
177
196
  }
178
197
 
179
198
  // 2. install the git hooks. Unknown existing hooks are user-owned and stay byte-identical. An exact
@@ -9,7 +9,7 @@ export { encodeProject, spexcodeHome } from './project-store.js'
9
9
 
10
10
  export type Config = {
11
11
  main?: string // path to the source-of-truth checkout (default: the `main` worktree)
12
- mainBranch?: string // source-of-truth BRANCH worktrees fork from (default: auto-detected — see mainBranch())
12
+ mainBranch?: string // stable source-of-truth branch stamped by init (default: "main")
13
13
  branchPrefix?: string // how a branch names its node (default: "node/")
14
14
  preset?: string // the SELECTED init preset — which cumulative .plugins tier `spex init` seeds (default 'default'; seed-time only, no launcher gate; read by init.ts; see [[init-preset]])
15
15
  // RETIRED ([[residence]]) — the old three-word footprint vote. Materialized artifacts carry no facts and are never
@@ -115,16 +115,8 @@ export function gitCommonDir(): string {
115
115
 
116
116
  export function mainBranch(): string {
117
117
  let checkout: string
118
- try {
119
- checkout = mainCheckout()
120
- } catch { return 'main' }
121
- const override = readConfig(checkout).mainBranch?.trim()
122
- if (override) return override
123
- try {
124
- const cur = git(['-C', checkout, 'symbolic-ref', '--short', 'HEAD']).trim()
125
- if (cur) return cur
126
- } catch { /* detached/bare checkout: use the documented conventional default */ }
127
- return 'main'
118
+ try { checkout = mainCheckout() } catch { return 'main' }
119
+ return readConfig(checkout).mainBranch?.trim() || 'main'
128
120
  }
129
121
 
130
122
  // the MAIN checkout (the root working tree) for a project — the SAME answer from main OR any linked worktree
@@ -1,4 +1,4 @@
1
- import { writeFileSync, mkdirSync, readFileSync, existsSync, readdirSync, rmSync, rmdirSync } from 'node:fs'
1
+ import { writeFileSync, mkdirSync, readFileSync, existsSync, readdirSync, renameSync, rmSync, rmdirSync } from 'node:fs'
2
2
  import { join, dirname, relative } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { execFileSync } from 'node:child_process'
@@ -6,10 +6,10 @@ import { loadSystemConfig, loadSkillConfig, loadAgentConfig, loadConfig } from '
6
6
  import { compileManifest } from './hooks.js'
7
7
  import { writeManagedBlock, removeManagedBlock, HARNESSES, type HarnessArtifacts } from './harness.js'
8
8
  import { git } from './git.js'
9
- import { runtimeRoot, treeSlotDir, mainCheckout, readConfig } from './layout.js'
9
+ import { runtimeRoot, treeSlotDir, mainCheckout, readConfig, encodeProject } from './layout.js'
10
10
  import { resolveHarnessTargets, partitionHarnesses } from './harness-select.js'
11
11
  import { emitPlugin, cleanPlugin, pluginBundleDir, pluginVersion } from './plugin-harness.js'
12
- import { plantContractFilter, removeContractFilter, settleIndexStat } from './contract-filter.js'
12
+ import { plantContractFilter, removeContractFilter, retireLegacyContractBlock, settleIndexStat, type ContractFilterBinding, type ContractFilterPayload } from './contract-filter.js'
13
13
 
14
14
  export type MaterializedArtifact = {
15
15
  kind: 'hook manifest' | 'contract' | 'shim' | 'skill' | 'agent' | 'plugin bundle' | 'trust'
@@ -34,8 +34,9 @@ export type MaterializeResult = { contentHash: string; planted: MaterializedArti
34
34
  // implementation is ERASE-THEN-ASSERT over a CLOSED set of landing points: each is first erased
35
35
  // unconditionally by its IDENTITY STAMP (sentinel blocks, the shim's dispatch.sh command line, the generated
36
36
  // mark on skills/agents, the filter config namespace, the skip-worktree bit), then rewritten per the current
37
- // policy (possibly to nothing). No ledger of past states, no pairwise migration branches the erase IS the
38
- // migration, whatever the previous state was.
37
+ // policy (possibly to nothing). There are no policy-pair branches. The one cross-tree migration receipt below
38
+ // preserves old common ignore entries until every registered tree owns its local projection; it never reads or
39
+ // reconstructs a sibling policy.
39
40
 
40
41
  const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
41
42
  const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
@@ -64,8 +65,8 @@ export function contentHash(proj: string): string {
64
65
  // @@@ footprint kinds ([[residence]]) - the vote axis is RETIRED: materialized artifacts carry no facts, so
65
66
  // they are NEVER tracked — there is exactly ONE residence behavior, not three. `.spec` + `spexcode.json` are ALWAYS
66
67
  // tracked (git is the database — no knob can untrack them); machine facts (shims, spexcode.local.json),
67
- // run residue (.worktrees/), and wholly-ours artifacts are hidden by the per-clone .git/info/exclude (the
68
- // host's tracked .gitignore is never touched); a contract file the host TRACKS — or one the user has begun
68
+ // run residue (.worktrees/) stays in the common exclude; tree-selected artifacts are hidden by a managed
69
+ // working .gitignore block whose tracked bytes stay pristine through the content filter. A contract file the host TRACKS — or one the user has begun
69
70
  // writing THEIR OWN prose into — is covered by the clean/smudge content filter ([[content-filter]]). An
70
71
  // environment without the generator (a teammate's clone, CI, a cloud agent) runs `spex materialize` in its
71
72
  // setup step — there is no committed-artifact delivery mode.
@@ -74,7 +75,7 @@ export function retiredAxisNotice(cfg: { render?: string; private?: boolean }):
74
75
  const field = cfg.render?.trim() ? `"render": "${cfg.render.trim()}"` : '"private": true'
75
76
  console.error(
76
77
  `spexcode: the render vote is retired — ${field} is ignored. Materialized artifacts are never tracked:\n` +
77
- ` ignore rules live in the per-clone .git/info/exclude, a host-tracked contract file is covered by the\n` +
78
+ ` tree-local ignore rules live in a filtered working .gitignore, and a host-tracked contract is covered by the\n` +
78
79
  ` clean/smudge filter, and a clone without spex runs \`spex materialize\` in its setup step. Remove the\n` +
79
80
  ` field from spexcode.json / spexcode.local.json to retire this notice (see \`spex guide footprint\`).`,
80
81
  )
@@ -90,6 +91,38 @@ function isTracked(proj: string, file: string): boolean {
90
91
  try { git(['-C', proj, 'ls-files', '--error-unmatch', file]); return true } catch { return false }
91
92
  }
92
93
 
94
+ function registeredTrees(proj: string): string[] {
95
+ const rows = git(['-C', mainCheckout(proj), 'worktree', 'list', '--porcelain', '-z']).split('\0')
96
+ return rows.filter((row) => row.startsWith('worktree ')).map((row) => row.slice('worktree '.length))
97
+ }
98
+
99
+ const TREE_IGNORE_RECEIPT = 'tree-ignore-v1'
100
+
101
+ function hasLegacyTreeIgnore(proj: string): boolean {
102
+ return registeredTrees(proj).some((tree) => {
103
+ const slot = join(runtimeRoot(proj), 'trees', encodeProject(tree))
104
+ return existsSync(join(slot, 'content-hash')) && !existsSync(join(slot, TREE_IGNORE_RECEIPT))
105
+ })
106
+ }
107
+
108
+ function managedExcludeEntries(file: string): string[] {
109
+ if (!existsSync(file)) return []
110
+ const lines = readFileSync(file, 'utf8').split('\n')
111
+ const start = lines.indexOf('# spexcode:start')
112
+ const end = lines.indexOf('# spexcode:end', start + 1)
113
+ return start >= 0 && end > start ? lines.slice(start + 1, end).filter(Boolean) : []
114
+ }
115
+
116
+ function selectionBody(selected: typeof HARNESSES, plugin = false): string {
117
+ return [...new Set([...selected.map((h) => h.dispatchId), ...(plugin ? ['plugin'] : [])])].sort().join('\n') + '\n'
118
+ }
119
+
120
+ function publishSelection(path: string, body: string): void {
121
+ const prepared = `${path}.${process.pid}.tmp`
122
+ writeFileSync(prepared, body)
123
+ renameSync(prepared, path)
124
+ }
125
+
93
126
  // @@@ contract kind detection ([[residence]]) - a contract file's residence is a LIVE CONTENT FACT, not
94
127
  // an install-time choice, re-judged on every materialize: TRACKED → filter domain; untracked + wholly ours
95
128
  // (nothing left after stripping our sentinel block) → exclude domain; untracked + HOST CONTENT present (the
@@ -144,11 +177,11 @@ function sweepGeneratedAgents(dir: string | null): void {
144
177
  // (edge ③ in [[content-filter]] — a block outliving its clean filter surfaces as an uncommitted change).
145
178
  // `arts` (live skill/agent node names) widens the sweep to pre-stamp legacy files; the GENERATED_MARK sweep
146
179
  // covers everything materialized since, including products of renamed/deleted nodes.
147
- export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { skills: [], agents: [] }): void {
180
+ function eraseTree(proj: string, arts: HarnessArtifacts, preserveProject: boolean): void {
148
181
  for (const h of HARNESSES) {
149
182
  // h.clean = the adapter's surgical inverse: contract block (sentinels, deleteIfEmpty), the dispatch.sh-
150
183
  // stamped shim + worktree anchor, the trust block, and the arts-named skill/agent files.
151
- h.clean(proj, arts)
184
+ h.clean(proj, arts, preserveProject)
152
185
  for (const f of h.contractFiles(proj)) clearSkipWorktree(proj, f) // legacy private-overlay bit — erase-only
153
186
  sweepGeneratedSkills(h.skillDir(proj))
154
187
  sweepGeneratedAgents(h.agentDir(proj))
@@ -156,8 +189,7 @@ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { s
156
189
  // same authorship rule as the contract files: deleteIfEmpty only when .gitignore is UNTRACKED (wholly-ours
157
190
  // generated file); a HOST-TRACKED .gitignore that carried nothing but our block is stripped, never deleted.
158
191
  removeManagedBlock(join(proj, '.gitignore'), ['# ', ''], !isTracked(proj, '.gitignore'))
159
- try { removeManagedBlock(infoExcludePath(proj), ['# ', ''], false) } catch { /* not a git repo */ }
160
- removeContractFilter(proj) // AFTER the blocks left the working files
192
+ removeContractFilter(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')])
161
193
  // the block-strip left tracked contract files stat-dirty (under a filter git NEVER content-verifies them,
162
194
  // and even unfiltered the phantom-`M` lingers) — settle the index stat, content-guarded so a user's real
163
195
  // unstaged edit is never staged ([[content-filter]] edge 2).
@@ -179,6 +211,22 @@ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { s
179
211
  }
180
212
  }
181
213
 
214
+ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { skills: [], agents: [] }): void {
215
+ const trees = registeredTrees(proj)
216
+ const current = git(['-C', proj, 'rev-parse', '--show-toplevel']).trim()
217
+ for (const tree of trees) {
218
+ if (!existsSync(tree)) throw new Error(`cannot dematerialize project while registered worktree ${tree} is inaccessible — repair or remove/prune it first`)
219
+ git(['-C', tree, 'rev-parse', '--show-toplevel'])
220
+ }
221
+ for (const tree of trees) {
222
+ // Only the caller's live spec may widen the legacy name sweep. Siblings are identity-stamp-only: the
223
+ // same name there may be user-owned or may not exist in this tree's divergent spec at all.
224
+ eraseTree(tree, tree === current ? arts : { skills: [], agents: [] }, false)
225
+ }
226
+ try { removeManagedBlock(infoExcludePath(proj), ['# ', ''], false) } catch { /* not a git repo */ }
227
+ removeContractFilter(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')], true)
228
+ }
229
+
182
230
  // the whole pay-per-change materialize. proj defaults to cwd. Its receipt is populated at each successful
183
231
  // write so callers report the actual selected footprint instead of maintaining a second artifact inventory.
184
232
  export function materialize(proj = process.cwd()): MaterializeResult {
@@ -198,9 +246,9 @@ export function materialize(proj = process.cwd()): MaterializeResult {
198
246
  // own hand-written prose is not folded in — repo-local notes belong in the harness file's own
199
247
  // block-outside region (untracked, per-clone), and anything that must reach EVERY agent is a plugin node.
200
248
  const contract = loadSystemConfig().map((c) => c.body.trim()).filter(Boolean).join('\n\n')
201
- // WHICH harnesses to deliver into ([[harness-select]]): the spexcode.json `harnesses` set (default = every
202
- // native harness). resolveHarnessTargets FAILS LOUD on an illegal set (plugin+native, plugin w/o folder).
203
- const cfg = readConfig(mainCheckout(proj))
249
+ // WHICH harnesses to deliver into ([[harness-select]]): this tree's explicit spexcode.json `harnesses` set.
250
+ // resolveHarnessTargets FAILS LOUD on an illegal set (plugin+native, plugin w/o folder).
251
+ const cfg = readConfig(proj)
204
252
  const targets = resolveHarnessTargets(cfg.harnesses)
205
253
  retiredAxisNotice(cfg) // [[residence]] — the vote axis is retired
206
254
  const { selected, plugins } = partitionHarnesses(targets)
@@ -212,7 +260,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
212
260
  // ---- ERASE (the forgetting law): every landing point cleared by identity stamp, whatever policy — or
213
261
  // legacy mode — wrote it last. Unselected harnesses need no separate prune branch: the erase already
214
262
  // forgot them, and only the selected ones are asserted below.
215
- dematerialize(proj, arts)
263
+ eraseTree(proj, arts, true)
216
264
 
217
265
  // ---- ASSERT: rewrite each landing point per the CURRENT policy.
218
266
  // a skill node → the agentskills.io SKILL.md primitive: `name`+`description` frontmatter (the load-trigger)
@@ -236,18 +284,29 @@ export function materialize(proj = process.cwd()): MaterializeResult {
236
284
  const contractPaths: string[] = []
237
285
  for (const h of selected) {
238
286
  if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract); contractPaths.push(f); record('contract', f) }
239
- const shimFile = h.shimFile(proj)
240
- mkdirSync(dirname(shimFile), { recursive: true })
241
287
  const shim = h.shim(DISPATCH, SPEX)
242
- writeFileSync(shimFile, shim.content)
243
- record('shim', shimFile)
244
- for (const f of h.writeTrust(proj, shim.cmd)) record('trust', f)
245
- machinePaths.push(shimFile)
288
+ if (h.shimScope === 'tree') {
289
+ const shimFile = h.shimFile(proj)
290
+ mkdirSync(dirname(shimFile), { recursive: true })
291
+ writeFileSync(shimFile, shim.content)
292
+ record('shim', shimFile)
293
+ machinePaths.push(shimFile)
294
+ }
246
295
  // a linked-worktree ANCHOR copy of the shim, when the harness needs one (codex: the shim lives at the main
247
296
  // checkout, so the worktree gets no `.codex/` unless we place one). One adapter line; null otherwise.
248
297
  const anchor = h.worktreeHookAnchor(proj)
249
298
  if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.content); machinePaths.push(anchor); record('shim', anchor) }
250
299
  }
300
+ const selectedByDispatch = new Map(selected.map((h) => [h.dispatchId, h]))
301
+ for (const h of selectedByDispatch.values()) {
302
+ const shim = h.shim(DISPATCH, SPEX)
303
+ if (h.shimScope === 'project') {
304
+ const file = h.shimFile(proj)
305
+ mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, shim.content)
306
+ record('shim', file)
307
+ }
308
+ for (const file of h.writeTrust(proj, shim.cmd)) record('trust', file)
309
+ }
251
310
  // (6) skills + (7) sub-agents — each surface node → the file the harness auto-discovers, one per selected
252
311
  // harness that has the primitive (skillDir/agentDir null skips — the divergence is the adapter's line).
253
312
  for (const sk of skillNodes) {
@@ -299,44 +358,54 @@ export function materialize(proj = process.cwd()): MaterializeResult {
299
358
  }
300
359
  }
301
360
  writeFileSync(ledger, curFolders.join('\n'))
302
- // (9) the ignore rules ALWAYS the per-clone .git/info/exclude ([[residence]]): the exclude is not a
303
- // history guard (the pre-commit surgery owns history [[commit-surgery]]) but the ignored-bit
304
- // DECLARATION every other git door consults (checkout may overwrite, clean -fd spares, status/add -A/
305
- // stash stay silent). The host's tracked .gitignore is never touched.
306
- // Entries must be CHECKOUT-INVARIANT: the exclude lives in the COMMON git dir shared by the main checkout
307
- // and every worktree, so each entry is anchored to the checkout it LIVES under — proj-relative when inside
308
- // proj, else MAIN-checkout-relative (the codex shim resolves to `.codex/hooks.json` from any checkout; a
309
- // pattern naming a main-only path is a harmless no-op in a worktree). A path under neither root is dropped.
361
+ // (9) ignore + mixed text. Only checkout-invariant residue and project-shared shims belong in the COMMON
362
+ // info/exclude; selection-dependent paths live in this tree's filtered working .gitignore.
310
363
  const mc = mainCheckout(proj)
311
- const anchor = (abs: string): string | null => {
312
- const p = relative(proj, abs); if (!p.startsWith('..')) return p
313
- const m = relative(mc, abs); if (!m.startsWith('..')) return m
314
- return null
315
- }
316
- // machine facts + run residue, ignored under EVERY policy: the shims/anchors/bundles (bake this install's
317
- // abs path), spexcode.local.json (the host overlay — a `git add -A` must never leak it), and the session
318
- // residue (`.worktrees/` where launches plant worktrees; `.session` is the legacy per-worktree state file
319
- // an old backend wrote). Static strings stay checkout-invariant.
320
364
  const bundlePaths = curFolders.map((f) => pluginBundleDir(proj, f))
321
- const machineEntries = [
322
- ...[...machinePaths, ...bundlePaths].map(anchor).filter((p): p is string => p !== null),
365
+ const commonEntries = [
366
+ ...[...new Set(HARNESSES.filter((h) => h.shimScope === 'project' && existsSync(h.shimFile(proj)) &&
367
+ readFileSync(h.shimFile(proj), 'utf8').includes('dispatch.sh')).map((h) => relative(mc, h.shimFile(proj))))]
368
+ .filter((p) => !p.startsWith('..')),
323
369
  'spexcode.local.json', '.worktrees/', '.session',
324
370
  ]
325
- // the contract three-state ([[residence]]): tracked filter; untracked+wholly-ours → exclude;
326
- // untracked+host-content NO exclude (never hide user prose) + the clean filter pre-armed, so the user's
327
- // own eventual `git add` strips our block — tracking stays entirely their act.
371
+ const entries = (list: string[]) => [...new Set(list)].sort().join('\n')
372
+ const priorCommonEntries = managedExcludeEntries(infoExcludePath(proj))
373
+
374
+ // Contract residence stays a live fact. Selection-dependent untracked products are ignored by this tree's
375
+ // working .gitignore, whose own managed block is filtered when the host tracks/owns that file.
328
376
  const filterContracts: string[] = []
329
377
  const oursContracts: string[] = []
330
378
  for (const f of contractPaths) {
331
379
  if (isTracked(proj, f) || hostContentOf(f).trim()) filterContracts.push(f)
332
380
  else oursContracts.push(f)
333
381
  }
334
- if (contract && filterContracts.length) plantContractFilter(proj, filterContracts, contract)
335
- const artifactEntries = [...artifactPaths, ...oursContracts].map(anchor).filter((p): p is string => p !== null)
336
- const entries = (list: string[]) => [...new Set(list)].sort().join('\n')
337
- writeManagedBlock(infoExcludePath(proj), entries([...machineEntries, ...artifactEntries]), ['# ', ''])
338
- // (5) stamp the content-hash marker LAST (a diagnostic freshness record; a crash mid-materialize leaves it stale).
382
+ const localEntries = [...machinePaths, ...bundlePaths, ...artifactPaths, ...oursContracts]
383
+ .map((p) => relative(proj, p)).filter((p) => !p.startsWith('..'))
384
+ const ignoreFile = join(proj, '.gitignore')
385
+ const ignoreTracked = isTracked(proj, ignoreFile)
386
+ const ignoreHost = existsSync(ignoreFile) ? readFileSync(ignoreFile, 'utf8') : ''
387
+ if (!ignoreTracked && !ignoreHost.trim()) localEntries.push('.gitignore')
388
+ const ignoreBody = entries(localEntries)
389
+ writeManagedBlock(ignoreFile, ignoreBody, ['# ', ''])
390
+
391
+ const payloads: ContractFilterPayload[] = filterContracts.map((file) => ({ file: relative(proj, file), content: contract }))
392
+ if (ignoreTracked || ignoreHost.trim()) payloads.push({ file: '.gitignore', content: ignoreBody })
393
+ const bindings: ContractFilterBinding[] = [
394
+ ...[...new Set(HARNESSES.flatMap((h) => h.contractFiles(proj).map((file) => relative(proj, file))))]
395
+ .map((file) => ({ file, start: '<!-- spexcode:start -->', end: '<!-- spexcode:end -->', legacy: true })),
396
+ { file: '.gitignore', start: '# spexcode:start', end: '# spexcode:end' },
397
+ ]
398
+ if (payloads.length) plantContractFilter(proj, payloads, bindings)
399
+ // (5) finish diagnostics/migration, then atomically publish the allowlist LAST. Dispatch consumes only that
400
+ // final receipt; a killed writer leaves the preceding successful selection intact.
339
401
  const h = contentHash(proj)
340
402
  writeFileSync(join(rt, 'content-hash'), h)
403
+ writeFileSync(join(rt, 'contract-filter-v2'), '')
404
+ writeFileSync(join(rt, TREE_IGNORE_RECEIPT), '')
405
+ writeFileSync(join(runtimeRoot(proj), 'harness-selection-v1'), '')
406
+ retireLegacyContractBlock(proj)
407
+ const legacyEntries = hasLegacyTreeIgnore(proj) ? priorCommonEntries : []
408
+ writeManagedBlock(infoExcludePath(proj), entries([...commonEntries, ...legacyEntries]), ['# ', ''])
409
+ publishSelection(join(rt, 'harnesses'), selectionBody(selected, plugins.length > 0))
341
410
  return { contentHash: h, planted }
342
411
  }