spexcode 0.5.9 → 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.
Files changed (58) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/cli.ts +69 -70
  3. package/spec-cli/src/client.ts +1 -1
  4. package/spec-cli/src/codex-runtime-generations.ts +97 -7
  5. package/spec-cli/src/commit-surgery.ts +2 -1
  6. package/spec-cli/src/contract-filter.ts +58 -42
  7. package/spec-cli/src/delivery-queue.ts +107 -0
  8. package/spec-cli/src/doctor.ts +2 -1
  9. package/spec-cli/src/file-write.ts +22 -0
  10. package/spec-cli/src/git.ts +30 -8
  11. package/spec-cli/src/graphStream.ts +79 -11
  12. package/spec-cli/src/harness.ts +12 -10
  13. package/spec-cli/src/help.ts +9 -14
  14. package/spec-cli/src/index.ts +10 -2
  15. package/spec-cli/src/init.ts +8 -17
  16. package/spec-cli/src/layout.ts +35 -7
  17. package/spec-cli/src/materialize.ts +150 -107
  18. package/spec-cli/src/plugin-harness.ts +19 -8
  19. package/spec-cli/src/process-identity.ts +13 -0
  20. package/spec-cli/src/reviews.ts +55 -11
  21. package/spec-cli/src/session-cursors.ts +11 -17
  22. package/spec-cli/src/session-follow.ts +6 -6
  23. package/spec-cli/src/sessions.ts +224 -38
  24. package/spec-cli/src/uninstall.ts +2 -1
  25. package/spec-cli/templates/hooks/post-checkout +3 -0
  26. package/spec-cli/templates/hooks/post-merge +1 -0
  27. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  28. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +5 -41
  29. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +1 -1
  30. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  31. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  32. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-F9uaAVcH.js} +2 -2
  33. package/spec-dashboard/dist/assets/{Dashboard-CvAjfRC2.js → Dashboard-Ba_jhxp1.js} +3 -3
  34. package/spec-dashboard/dist/assets/{EvalsPage-Bz-nMKoS.js → EvalsPage-FixoOg_n.js} +2 -2
  35. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-CuKLFhH3.js} +1 -1
  36. package/spec-dashboard/dist/assets/{MobileApp-D9L1Va8Z.js → MobileApp-CHgEHORJ.js} +2 -2
  37. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-CQgYymmr.js} +1 -1
  38. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-hT7UTLvD.js} +1 -1
  39. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-CtXxakF9.js} +1 -1
  40. package/spec-dashboard/dist/assets/{SessionInterface-Dl9v0JFM.js → SessionInterface-Bpie-9fs.js} +12 -12
  41. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-CixDi4PI.js} +1 -1
  42. package/spec-dashboard/dist/assets/{Settings-BZ1lGRJs.js → Settings-C2MsucfE.js} +1 -1
  43. package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +13 -0
  44. package/spec-dashboard/dist/assets/{data-Bwd3kAVL.js → data-B-RQmit6.js} +1 -1
  45. package/spec-dashboard/dist/assets/{index-DAbQBBK_.css → index-CsI8DElI.css} +1 -1
  46. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DrVao0Ep.js} +2 -2
  47. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-BBH02b1v.js} +1 -1
  48. package/spec-dashboard/dist/index.html +2 -2
  49. package/spec-dashboard/src/reviewFilters.js +5 -0
  50. package/spec-dashboard/src/session.js +5 -1
  51. package/spec-eval/src/cli.ts +7 -8
  52. package/spec-eval/src/evaltab.ts +25 -5
  53. package/spec-eval/src/freshness.ts +52 -8
  54. package/spec-eval/src/scenariofresh.ts +58 -8
  55. package/spec-eval/src/scenarios.ts +60 -12
  56. package/spec-eval/src/sessioneval.ts +122 -30
  57. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  58. package/spec-eval/src/matrix.ts +0 -693
@@ -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, name: string): boolean {
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('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
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('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()
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('git', ['-C', targetDir, 'rev-parse', '--is-inside-work-tree'], { stdio: ['ignore', 'ignore', 'ignore'] })
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 (hookDigest(dest) === hookDigest(source)) continue
214
- if (isManagedHook(dest, e.name)) {
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 {
@@ -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 = git(['-C', wt, 'rev-parse', '--show-toplevel']).trim()
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
@@ -5,11 +5,12 @@ import { execFileSync } from 'node:child_process'
5
5
  import { loadSystemConfig, loadSkillConfig, loadAgentConfig, loadConfig } from './specs.js'
6
6
  import { compileManifest } from './hooks.js'
7
7
  import { writeManagedBlock, removeManagedBlock, HARNESSES, type HarnessArtifacts } from './harness.js'
8
- import { git } from './git.js'
9
- import { runtimeRoot, treeSlotDir, mainCheckout, readConfig, encodeProject } from './layout.js'
8
+ import { git, gitBinary } from './git.js'
9
+ import { runtimeRoot, treeSlotDir, mainCheckout, readConfig } 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, retireLegacyContractBlock, settleIndexStat, type ContractFilterBinding, type ContractFilterPayload } from './contract-filter.js'
12
+ import { clearContractFilterPayload, contractFilterPlanted, plantContractFilter, removeContractFilter, settleIndexStat, type ContractFilterBinding, type ContractFilterPayload } from './contract-filter.js'
13
+ import { writeFileIfChanged } from './file-write.js'
13
14
 
14
15
  export type MaterializedArtifact = {
15
16
  kind: 'hook manifest' | 'contract' | 'shim' | 'skill' | 'agent' | 'plugin bundle' | 'trust'
@@ -37,7 +38,9 @@ const SPEX = join(PKG, 'bin', 'spex.mjs')
37
38
  export function contentHash(proj: string): string {
38
39
  try {
39
40
  const harnessSh = join(PKG, 'hooks', 'harness.sh')
40
- return execFileSync('bash', ['-c', `cd "${proj}" && . "${harnessSh}" && hp_config_hash`]).toString().trim()
41
+ const gitDir = dirname(gitBinary(process.env))
42
+ const env = { ...process.env, PATH: `${gitDir}:${process.env.PATH || ''}` }
43
+ return execFileSync('bash', ['-c', `cd "${proj}" && . "${harnessSh}" && hp_config_hash`], { env }).toString().trim()
41
44
  } catch { return '' }
42
45
  }
43
46
 
@@ -61,34 +64,22 @@ function infoExcludePath(proj: string): string {
61
64
  function isTracked(proj: string, file: string): boolean {
62
65
  try { git(['-C', proj, 'ls-files', '--error-unmatch', file]); return true } catch { return false }
63
66
  }
67
+ function clearSkipWorktree(proj: string, file: string): void {
68
+ if (!isTracked(proj, file)) return
69
+ try { git(['-C', proj, 'update-index', '--no-skip-worktree', file]) } catch {}
70
+ }
64
71
 
65
72
  function registeredTrees(proj: string): string[] {
66
73
  const rows = git(['-C', mainCheckout(proj), 'worktree', 'list', '--porcelain', '-z']).split('\0')
67
74
  return rows.filter((row) => row.startsWith('worktree ')).map((row) => row.slice('worktree '.length))
68
75
  }
69
76
 
70
- const TREE_IGNORE_RECEIPT = 'tree-ignore-v1'
71
-
72
- function hasLegacyTreeIgnore(proj: string): boolean {
73
- return registeredTrees(proj).some((tree) => {
74
- const slot = join(runtimeRoot(proj), 'trees', encodeProject(tree))
75
- return existsSync(join(slot, 'content-hash')) && !existsSync(join(slot, TREE_IGNORE_RECEIPT))
76
- })
77
- }
78
-
79
- function managedExcludeEntries(file: string): string[] {
80
- if (!existsSync(file)) return []
81
- const lines = readFileSync(file, 'utf8').split('\n')
82
- const start = lines.indexOf('# spexcode:start')
83
- const end = lines.indexOf('# spexcode:end', start + 1)
84
- return start >= 0 && end > start ? lines.slice(start + 1, end).filter(Boolean) : []
85
- }
86
-
87
77
  function selectionBody(selected: typeof HARNESSES, plugin = false): string {
88
78
  return [...new Set([...selected.map((h) => h.dispatchId), ...(plugin ? ['plugin'] : [])])].sort().join('\n') + '\n'
89
79
  }
90
80
 
91
81
  function publishSelection(path: string, body: string): void {
82
+ try { if (readFileSync(path, 'utf8') === body) return } catch (error: any) { if (error?.code !== 'ENOENT') throw error }
92
83
  const prepared = `${path}.${process.pid}.tmp`
93
84
  writeFileSync(prepared, body)
94
85
  renameSync(prepared, path)
@@ -108,46 +99,96 @@ function hostContentOf(file: string): string {
108
99
  if (!existsSync(file)) return ''
109
100
  return stripSpexcodeBlock(readFileSync(file, 'utf8'))
110
101
  }
111
- // clear a legacy skip-worktree bit (the retired private-overlay mechanism; erase-only now — nothing asserts
112
- // it). Best-effort: an index race or a non-repo must not fail the materialize.
113
- function clearSkipWorktree(proj: string, file: string): void {
114
- if (!isTracked(proj, file)) return
115
- try { git(['-C', proj, 'update-index', '--no-skip-worktree', file]) } catch { /* best-effort */ }
116
- }
117
-
118
102
  // the identity stamp on every generated skill/agent file — what lets the erase phase forget a product whose
119
103
  // NODE was renamed or deleted (the name-scoped sweep can only reconstruct paths the LIVE config still names).
120
104
  export const GENERATED_MARK = '<!-- spexcode:generated -->'
121
- function sweepGeneratedSkills(dir: string | null): void {
122
- if (!dir || !existsSync(dir)) return
105
+ function pruneGeneratedSkills(dir: string | null, keep: ReadonlySet<string>): boolean {
106
+ if (!dir || !existsSync(dir)) return false
107
+ let changed = false
123
108
  for (const e of readdirSync(dir, { withFileTypes: true })) {
124
109
  if (!e.isDirectory()) continue
125
110
  const f = join(dir, e.name, 'SKILL.md')
126
- try { if (existsSync(f) && readFileSync(f, 'utf8').includes(GENERATED_MARK)) rmSync(join(dir, e.name), { recursive: true, force: true }) } catch { /* unreadable → not provably ours */ }
111
+ try {
112
+ if (!keep.has(e.name) && existsSync(f) && readFileSync(f, 'utf8').includes(GENERATED_MARK)) {
113
+ rmSync(join(dir, e.name), { recursive: true, force: true })
114
+ changed = true
115
+ }
116
+ } catch { /* unreadable → not provably ours */ }
127
117
  }
118
+ return changed
128
119
  }
129
- function sweepGeneratedAgents(dir: string | null): void {
130
- if (!dir || !existsSync(dir)) return
120
+ function pruneGeneratedAgents(dir: string | null, keep: ReadonlySet<string>): boolean {
121
+ if (!dir || !existsSync(dir)) return false
122
+ let changed = false
131
123
  for (const e of readdirSync(dir, { withFileTypes: true })) {
132
124
  if (!e.isFile() || !e.name.endsWith('.md')) continue
133
125
  const f = join(dir, e.name)
134
- try { if (readFileSync(f, 'utf8').includes(GENERATED_MARK)) rmSync(f, { force: true }) } catch { /* unreadable → not provably ours */ }
126
+ try {
127
+ if (!keep.has(e.name.slice(0, -3)) && readFileSync(f, 'utf8').includes(GENERATED_MARK)) {
128
+ rmSync(f, { force: true })
129
+ changed = true
130
+ }
131
+ } catch { /* unreadable → not provably ours */ }
132
+ }
133
+ return changed
134
+ }
135
+
136
+ type TreeTargets = {
137
+ contracts: ReadonlySet<string>
138
+ treeShims: ReadonlySet<string>
139
+ anchors: ReadonlySet<string>
140
+ skills: ReadonlyMap<string, ReadonlySet<string>>
141
+ agents: ReadonlyMap<string, ReadonlySet<string>>
142
+ }
143
+
144
+ function cleanupHarnessDirs(proj: string): void {
145
+ const roots = new Set([proj, mainCheckout(proj)])
146
+ const dirs = HARNESSES.flatMap((h) => {
147
+ const anchor = h.worktreeHookAnchor(proj)
148
+ return [h.skillDir(proj), h.agentDir(proj), dirname(h.shimFile(proj)), anchor ? dirname(anchor) : null]
149
+ }).filter((d): d is string => !!d)
150
+ for (const d of [...new Set([...dirs, ...dirs.map((dir) => dirname(dir))])]
151
+ .filter((dir) => !roots.has(dir)).sort((a, b) => b.length - a.length)) {
152
+ try { rmdirSync(d) } catch {}
135
153
  }
136
154
  }
137
155
 
156
+ function reconcileTree(proj: string, targets: TreeTargets, tracked: (file: string) => boolean): void {
157
+ let removed = false
158
+ const contractFiles = new Set(HARNESSES.flatMap((h) => h.contractFiles(proj)))
159
+ for (const file of contractFiles) {
160
+ if (targets.contracts.has(file) || !existsSync(file)) continue
161
+ const text = readFileSync(file, 'utf8')
162
+ if (!text.includes('<!-- spexcode:start -->')) continue
163
+ removeManagedBlock(file, ['<!-- ', ' -->'], !tracked(file))
164
+ removed = true
165
+ }
166
+ const treeShims = new Set(HARNESSES.filter((h) => h.shimScope === 'tree').map((h) => h.shimFile(proj)))
167
+ const anchors = new Set(HARNESSES.map((h) => h.worktreeHookAnchor(proj)).filter((path): path is string => !!path))
168
+ for (const file of [...treeShims, ...anchors]) {
169
+ if (targets.treeShims.has(file) || targets.anchors.has(file) || !existsSync(file)) continue
170
+ if (readFileSync(file, 'utf8').includes('dispatch.sh')) { rmSync(file, { force: true }); removed = true }
171
+ }
172
+ for (const dir of new Set(HARNESSES.map((h) => h.skillDir(proj)).filter((path): path is string => !!path)))
173
+ removed = pruneGeneratedSkills(dir, targets.skills.get(dir) ?? new Set()) || removed
174
+ for (const dir of new Set(HARNESSES.map((h) => h.agentDir(proj)).filter((path): path is string => !!path)))
175
+ removed = pruneGeneratedAgents(dir, targets.agents.get(dir) ?? new Set()) || removed
176
+ if (removed) cleanupHarnessDirs(proj)
177
+ }
178
+
138
179
  function eraseTree(proj: string, arts: HarnessArtifacts, preserveProject: boolean): void {
139
180
  for (const h of HARNESSES) {
140
181
  // h.clean = the adapter's surgical inverse: contract block (sentinels, deleteIfEmpty), the dispatch.sh-
141
182
  // stamped shim + worktree anchor, the trust block, and the arts-named skill/agent files.
142
183
  h.clean(proj, arts, preserveProject)
143
- for (const f of h.contractFiles(proj)) clearSkipWorktree(proj, f) // legacy private-overlay bit — erase-only
144
- sweepGeneratedSkills(h.skillDir(proj))
145
- sweepGeneratedAgents(h.agentDir(proj))
184
+ for (const f of h.contractFiles(proj)) clearSkipWorktree(proj, f)
185
+ pruneGeneratedSkills(h.skillDir(proj), new Set())
186
+ pruneGeneratedAgents(h.agentDir(proj), new Set())
146
187
  }
147
188
  // same authorship rule as the contract files: deleteIfEmpty only when .gitignore is UNTRACKED (wholly-ours
148
189
  // generated file); a HOST-TRACKED .gitignore that carried nothing but our block is stripped, never deleted.
149
190
  removeManagedBlock(join(proj, '.gitignore'), ['# ', ''], !isTracked(proj, '.gitignore'))
150
- removeContractFilter(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')])
191
+ clearContractFilterPayload(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')])
151
192
  // the block-strip left tracked contract files stat-dirty (under a filter git NEVER content-verifies them,
152
193
  // and even unfiltered the phantom-`M` lingers) — settle the index stat, content-guarded so a user's real
153
194
  // unstaged edit is never staged ([[content-filter]] edge 2).
@@ -157,16 +198,7 @@ function eraseTree(proj: string, arts: HarnessArtifacts, preserveProject: boolea
157
198
  // because a harness may nest its shim a level below its home (opencode's .opencode/plugins/, pi's
158
199
  // .pi/extensions/) — but never the checkout roots themselves. rmdirSync is NON-recursive, so a dir holding
159
200
  // any user file survives untouched; `.git/spexcode/` is deliberately NOT swept (shared per-clone home).
160
- for (const h of HARNESSES) {
161
- const anchor = h.worktreeHookAnchor(proj)
162
- const dirs = [h.skillDir(proj), h.agentDir(proj), dirname(h.shimFile(proj)), anchor ? dirname(anchor) : null]
163
- .filter((d): d is string => !!d)
164
- const roots = new Set([proj, mainCheckout(proj)])
165
- const sweep = [...new Set([...dirs, ...dirs.map((d) => dirname(d))])]
166
- .filter((d) => !roots.has(d))
167
- .sort((a, b) => b.length - a.length)
168
- for (const d of sweep) { try { rmdirSync(d) } catch { /* non-empty or absent — keep */ } }
169
- }
201
+ cleanupHarnessDirs(proj)
170
202
  }
171
203
 
172
204
  export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { skills: [], agents: [] }): void {
@@ -177,7 +209,7 @@ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { s
177
209
  git(['-C', tree, 'rev-parse', '--show-toplevel'])
178
210
  }
179
211
  for (const tree of trees) {
180
- // Only the caller's live spec may widen the legacy name sweep. Siblings are identity-stamp-only: the
212
+ // Only the caller's live spec may widen the name sweep. Siblings are identity-stamp-only: the
181
213
  // same name there may be user-owned or may not exist in this tree's divergent spec at all.
182
214
  eraseTree(tree, tree === current ? arts : { skills: [], agents: [] }, false)
183
215
  }
@@ -196,7 +228,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
196
228
  }
197
229
  // (1) hook manifest (persistent — the dispatcher reads it; regenerated only here, on change).
198
230
  const manifest = join(rt, 'hooks-manifest')
199
- writeFileSync(manifest, compileManifest())
231
+ writeFileIfChanged(manifest, compileManifest())
200
232
  record('hook manifest', manifest)
201
233
  // (2) the contract = the surface:system plugin bodies (in name order), written WHOLE into EACH harness's
202
234
  // contract file(s) + (3) each harness's thin shim → dispatch.sh + (4) its trust. All owned by the adapter.
@@ -213,14 +245,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
213
245
  const skillNodes = loadSkillConfig()
214
246
  const agentNodes = loadAgentConfig()
215
247
  const commandNodes = loadConfig()
216
- const arts: HarnessArtifacts = { skills: skillNodes.map((s) => s.name), agents: agentNodes.map((a) => a.name) }
217
248
 
218
- // ---- ERASE (the forgetting law): every landing point cleared by identity stamp, whatever policy — or
219
- // legacy mode — wrote it last. Unselected harnesses need no separate prune branch: the erase already
220
- // forgot them, and only the selected ones are asserted below.
221
- eraseTree(proj, arts, true)
222
-
223
- // ---- ASSERT: rewrite each landing point per the CURRENT policy.
224
249
  // a skill node → the agentskills.io SKILL.md primitive: `name`+`description` frontmatter (the load-trigger)
225
250
  // over the body instructions, closed by the GENERATED_MARK identity stamp (what the erase phase keys on).
226
251
  // One pure artifact builder shared by every harness — divergence is only its skillDir.
@@ -239,53 +264,80 @@ export function materialize(proj = process.cwd()): MaterializeResult {
239
264
  // because their residence is the live three-state kind detection below, not a static entry.
240
265
  const artifactPaths: string[] = []
241
266
  const machinePaths: string[] = []
242
- const contractPaths: string[] = []
267
+ const contractTargets = new Map<string, string>()
268
+ const treeShimTargets = new Map<string, string>()
269
+ const anchorTargets = new Map<string, string>()
270
+ const skillTargets = new Map<string, string>()
271
+ const agentTargets = new Map<string, string>()
272
+ const skillsByDir = new Map<string, Set<string>>()
273
+ const agentsByDir = new Map<string, Set<string>>()
274
+ const addTarget = (targets: Map<string, string>, path: string, content: string) => {
275
+ const prior = targets.get(path)
276
+ if (prior !== undefined && prior !== content) throw new Error(`conflicting materialize targets for ${path}`)
277
+ targets.set(path, content)
278
+ }
243
279
  for (const h of selected) {
244
- if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract); contractPaths.push(f); record('contract', f) }
280
+ if (contract) for (const f of h.contractFiles(proj)) addTarget(contractTargets, f, contract)
245
281
  const shim = h.shim(DISPATCH, SPEX)
246
282
  if (h.shimScope === 'tree') {
247
- const shimFile = h.shimFile(proj)
248
- mkdirSync(dirname(shimFile), { recursive: true })
249
- writeFileSync(shimFile, shim.content)
250
- record('shim', shimFile)
251
- machinePaths.push(shimFile)
283
+ addTarget(treeShimTargets, h.shimFile(proj), shim.content)
252
284
  }
253
285
  // a linked-worktree ANCHOR copy of the shim, when the harness needs one (codex: the shim lives at the main
254
286
  // checkout, so the worktree gets no `.codex/` unless we place one). One adapter line; null otherwise.
255
287
  const anchor = h.worktreeHookAnchor(proj)
256
- if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.content); machinePaths.push(anchor); record('shim', anchor) }
288
+ if (anchor) addTarget(anchorTargets, anchor, shim.content)
289
+ }
290
+ for (const sk of skillNodes) for (const h of selected) {
291
+ const dir = h.skillDir(proj); if (!dir) continue
292
+ addTarget(skillTargets, join(dir, sk.name, 'SKILL.md'), skillArtifact(sk))
293
+ const names = skillsByDir.get(dir) ?? new Set<string>(); names.add(sk.name); skillsByDir.set(dir, names)
294
+ }
295
+ for (const ag of agentNodes) for (const h of selected) {
296
+ const dir = h.agentDir(proj); if (!dir) continue
297
+ addTarget(agentTargets, join(dir, `${ag.name}.md`), agentArtifact(ag))
298
+ const names = agentsByDir.get(dir) ?? new Set<string>(); names.add(ag.name); agentsByDir.set(dir, names)
299
+ }
300
+ const tracked = new Map<string, boolean>()
301
+ const isTrackedHere = (file: string) => {
302
+ const known = tracked.get(file)
303
+ if (known !== undefined) return known
304
+ const value = isTracked(proj, file); tracked.set(file, value); return value
305
+ }
306
+ reconcileTree(proj, {
307
+ contracts: new Set(contractTargets.keys()), treeShims: new Set(treeShimTargets.keys()), anchors: new Set(anchorTargets.keys()),
308
+ skills: skillsByDir, agents: agentsByDir,
309
+ }, isTrackedHere)
310
+ const changedMaterialized = new Set<string>()
311
+ for (const [file, content] of contractTargets) {
312
+ if (writeManagedBlock(file, content)) changedMaterialized.add(file)
313
+ record('contract', file)
314
+ }
315
+ const contractPaths = [...contractTargets.keys()]
316
+ for (const [file, content] of treeShimTargets) {
317
+ mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
318
+ record('shim', file); machinePaths.push(file)
319
+ }
320
+ for (const [file, content] of anchorTargets) {
321
+ mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
322
+ record('shim', file); machinePaths.push(file)
257
323
  }
258
324
  const selectedByDispatch = new Map(selected.map((h) => [h.dispatchId, h]))
259
325
  for (const h of selectedByDispatch.values()) {
260
326
  const shim = h.shim(DISPATCH, SPEX)
261
327
  if (h.shimScope === 'project') {
262
328
  const file = h.shimFile(proj)
263
- mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, shim.content)
329
+ mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, shim.content)
264
330
  record('shim', file)
265
331
  }
266
332
  for (const file of h.writeTrust(proj, shim.cmd)) record('trust', file)
267
333
  }
268
- // (6) skills + (7) sub-agents — each surface node → the file the harness auto-discovers, one per selected
269
- // harness that has the primitive (skillDir/agentDir null skips — the divergence is the adapter's line).
270
- for (const sk of skillNodes) {
271
- for (const h of selected) {
272
- const dir = h.skillDir(proj); if (!dir) continue
273
- const f = join(dir, sk.name, 'SKILL.md')
274
- mkdirSync(dirname(f), { recursive: true })
275
- writeFileSync(f, skillArtifact(sk))
276
- artifactPaths.push(f)
277
- record('skill', f)
278
- }
334
+ for (const [file, content] of skillTargets) {
335
+ mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
336
+ artifactPaths.push(file); record('skill', file)
279
337
  }
280
- for (const ag of agentNodes) {
281
- for (const h of selected) {
282
- const dir = h.agentDir(proj); if (!dir) continue
283
- const f = join(dir, `${ag.name}.md`)
284
- mkdirSync(dirname(f), { recursive: true })
285
- writeFileSync(f, agentArtifact(ag))
286
- artifactPaths.push(f)
287
- record('agent', f)
288
- }
338
+ for (const [file, content] of agentTargets) {
339
+ mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
340
+ artifactPaths.push(file); record('agent', file)
289
341
  }
290
342
  // (8) the PLUGIN target ([[plugin-harness]]): materialize the whole system into one self-contained Claude-plugin
291
343
  // bundle per selected folder. A plugin is EXCLUSIVE (`selected` is empty then). Pruning a DESELECTED
@@ -294,11 +346,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
294
346
  // global store records the folders emitted last run; any prev folder absent from the current set is
295
347
  // clean()ed, then the current folders are emitted and the ledger rewritten.
296
348
  const ledger = join(rt, 'plugin-folders')
297
- // migration: a tree last materialized pre-slot left its ledger as the project-global file — read it once as
298
- // the prev set so a deselected folder is still pruned; every write lands in the slot from here on.
299
- const legacyLedger = join(runtimeRoot(proj), 'plugin-folders')
300
- const ledgerSrc = existsSync(ledger) ? ledger : legacyLedger
301
- const prevFolders = existsSync(ledgerSrc) ? readFileSync(ledgerSrc, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean) : []
349
+ const prevFolders = existsSync(ledger) ? readFileSync(ledger, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean) : []
302
350
  const curFolders = plugins.map((p) => p.folder)
303
351
  for (const f of prevFolders) if (!curFolders.includes(f)) cleanPlugin(proj, f)
304
352
  if (plugins.length) {
@@ -315,7 +363,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
315
363
  record('plugin bundle', pluginBundleDir(proj, p.folder))
316
364
  }
317
365
  }
318
- writeFileSync(ledger, curFolders.join('\n'))
366
+ writeFileIfChanged(ledger, curFolders.join('\n'))
319
367
  // (9) ignore + mixed text. Only checkout-invariant residue and project-shared shims belong in the COMMON
320
368
  // info/exclude; selection-dependent paths live in this tree's filtered working .gitignore.
321
369
  const mc = mainCheckout(proj)
@@ -327,43 +375,38 @@ export function materialize(proj = process.cwd()): MaterializeResult {
327
375
  'spexcode.local.json', '.worktrees/', '.session',
328
376
  ]
329
377
  const entries = (list: string[]) => [...new Set(list)].sort().join('\n')
330
- const priorCommonEntries = managedExcludeEntries(infoExcludePath(proj))
331
-
332
378
  // Contract residence stays a live fact. Selection-dependent untracked products are ignored by this tree's
333
379
  // working .gitignore, whose own managed block is filtered when the host tracks/owns that file.
334
380
  const filterContracts: string[] = []
335
381
  const oursContracts: string[] = []
336
382
  for (const f of contractPaths) {
337
- if (isTracked(proj, f) || hostContentOf(f).trim()) filterContracts.push(f)
383
+ if (isTrackedHere(f) || hostContentOf(f).trim()) filterContracts.push(f)
338
384
  else oursContracts.push(f)
339
385
  }
340
386
  const localEntries = [...machinePaths, ...bundlePaths, ...artifactPaths, ...oursContracts]
341
387
  .map((p) => relative(proj, p)).filter((p) => !p.startsWith('..'))
342
388
  const ignoreFile = join(proj, '.gitignore')
343
- const ignoreTracked = isTracked(proj, ignoreFile)
389
+ const ignoreTracked = isTrackedHere(ignoreFile)
344
390
  const ignoreHost = existsSync(ignoreFile) ? readFileSync(ignoreFile, 'utf8') : ''
345
391
  if (!ignoreTracked && !ignoreHost.trim()) localEntries.push('.gitignore')
346
392
  const ignoreBody = entries(localEntries)
347
- writeManagedBlock(ignoreFile, ignoreBody, ['# ', ''])
393
+ if (writeManagedBlock(ignoreFile, ignoreBody, ['# ', ''])) changedMaterialized.add(ignoreFile)
348
394
 
349
395
  const payloads: ContractFilterPayload[] = filterContracts.map((file) => ({ file: relative(proj, file), content: contract }))
350
396
  if (ignoreTracked || ignoreHost.trim()) payloads.push({ file: '.gitignore', content: ignoreBody })
351
397
  const bindings: ContractFilterBinding[] = [
352
398
  ...[...new Set(HARNESSES.flatMap((h) => h.contractFiles(proj).map((file) => relative(proj, file))))]
353
- .map((file) => ({ file, start: '<!-- spexcode:start -->', end: '<!-- spexcode:end -->', legacy: true })),
399
+ .map((file) => ({ file, start: '<!-- spexcode:start -->', end: '<!-- spexcode:end -->' })),
354
400
  { file: '.gitignore', start: '# spexcode:start', end: '# spexcode:end' },
355
401
  ]
356
- if (payloads.length) plantContractFilter(proj, payloads, bindings)
357
- // (5) finish diagnostics/migration, then atomically publish the allowlist LAST. Dispatch consumes only that
402
+ if (payloads.length) plantContractFilter(proj, payloads, bindings, [...changedMaterialized])
403
+ else if (contractFilterPlanted(proj)) removeContractFilter(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')])
404
+ // (5) finish diagnostics, then atomically publish the allowlist LAST. Dispatch consumes only that
358
405
  // final receipt; a killed writer leaves the preceding successful selection intact.
359
406
  const h = contentHash(proj)
360
- writeFileSync(join(rt, 'content-hash'), h)
361
- writeFileSync(join(rt, 'contract-filter-v2'), '')
362
- writeFileSync(join(rt, TREE_IGNORE_RECEIPT), '')
363
- writeFileSync(join(runtimeRoot(proj), 'harness-selection-v1'), '')
364
- retireLegacyContractBlock(proj)
365
- const legacyEntries = hasLegacyTreeIgnore(proj) ? priorCommonEntries : []
366
- writeManagedBlock(infoExcludePath(proj), entries([...commonEntries, ...legacyEntries]), ['# ', ''])
407
+ writeFileIfChanged(join(rt, 'content-hash'), h)
408
+ writeFileIfChanged(join(runtimeRoot(proj), 'harness-selection-v1'), '')
409
+ writeManagedBlock(infoExcludePath(proj), entries(commonEntries), ['# ', ''])
367
410
  publishSelection(join(rt, 'harnesses'), selectionBody(selected, plugins.length > 0))
368
411
  return { contentHash: h, planted }
369
412
  }
@@ -1,6 +1,7 @@
1
- import { writeFileSync, mkdirSync, readFileSync, existsSync, rmSync, copyFileSync } from 'node:fs'
1
+ import { mkdirSync, readFileSync, existsSync, readdirSync, rmSync } from 'node:fs'
2
2
  import { join, dirname } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
+ import { copyFileIfChanged, writeFileIfChanged } from './file-write.js'
4
5
 
5
6
  // @@@ plugin-harness - the PLUGIN BUNDLE emitter: materialize the whole SpexCode system into ONE self-contained
6
7
  // Claude-plugin bundle dropped into the host-agent-scanned folder [[harness-select]] resolved (e.g. `.zcode` /
@@ -103,23 +104,33 @@ export function emitPlugin(proj: string, folder: string, r: PluginBundle): void
103
104
  const hooksDir = join(bundle, 'hooks')
104
105
  mkdirSync(meta, { recursive: true })
105
106
  mkdirSync(hooksDir, { recursive: true })
106
- writeFileSync(join(meta, 'plugin.json'), pluginManifest(r.version))
107
+ writeFileIfChanged(join(meta, 'plugin.json'), pluginManifest(r.version))
107
108
  // hooks: the SHARED dispatcher + its shell mirror (copied verbatim — the exact native wiring), the contract
108
109
  // injector + its pre-encoded payload, and the event→dispatch binding.
109
- copyFileSync(join(HOOKS_SRC, 'dispatch.sh'), join(hooksDir, 'dispatch.sh'))
110
- copyFileSync(join(HOOKS_SRC, 'harness.sh'), join(hooksDir, 'harness.sh'))
111
- writeFileSync(join(hooksDir, 'inject-contract.sh'), INJECT_SH)
112
- writeFileSync(join(hooksDir, 'contract-context.json'), contractContextJson(r.contract))
113
- writeFileSync(join(hooksDir, 'hooks.json'), pluginHooksJson(r.spex))
110
+ copyFileIfChanged(join(HOOKS_SRC, 'dispatch.sh'), join(hooksDir, 'dispatch.sh'))
111
+ copyFileIfChanged(join(HOOKS_SRC, 'harness.sh'), join(hooksDir, 'harness.sh'))
112
+ writeFileIfChanged(join(hooksDir, 'inject-contract.sh'), INJECT_SH)
113
+ writeFileIfChanged(join(hooksDir, 'contract-context.json'), contractContextJson(r.contract))
114
+ writeFileIfChanged(join(hooksDir, 'hooks.json'), pluginHooksJson(r.spex))
114
115
  // skills / agents / commands — the Claude-plugin layout, the SAME materialized contents as the native dirs.
116
+ reconcileBundleDirectory(join(bundle, 'skills'), new Set(r.skills.map((s) => s.name)))
117
+ reconcileBundleDirectory(join(bundle, 'agents'), new Set(r.agents.map((a) => `${a.name}.md`)))
118
+ reconcileBundleDirectory(join(bundle, 'commands'), new Set(r.commands.map((c) => `${c.name}.md`)))
115
119
  for (const s of r.skills) writeBundleFile(join(bundle, 'skills', s.name, 'SKILL.md'), s.content)
116
120
  for (const a of r.agents) writeBundleFile(join(bundle, 'agents', `${a.name}.md`), a.content)
117
121
  for (const c of r.commands) writeBundleFile(join(bundle, 'commands', `${c.name}.md`), c.content)
118
122
  }
119
123
 
124
+ function reconcileBundleDirectory(dir: string, expectedNames: ReadonlySet<string>): void {
125
+ if (!existsSync(dir)) return
126
+ for (const entry of readdirSync(dir)) {
127
+ if (!expectedNames.has(entry)) rmSync(join(dir, entry), { recursive: true, force: true })
128
+ }
129
+ }
130
+
120
131
  function writeBundleFile(f: string, content: string): void {
121
132
  mkdirSync(dirname(f), { recursive: true })
122
- writeFileSync(f, content)
133
+ writeFileIfChanged(f, content)
123
134
  }
124
135
 
125
136
  // the INVERSE of emitPlugin — prune the bundle when its folder is DESELECTED ([[harness-delivery]] tracks the
@@ -142,6 +142,19 @@ export function verifyDetachedRuntime(pid: number, receiptFile: string, adapter:
142
142
  return live
143
143
  }
144
144
 
145
+ // The inverse question of verifyDetachedRuntime: not "is the recorded process running right now" but "is it
146
+ // provably GONE". The two are not complements — a missing receipt, an unreadable identity, or a PID we never
147
+ // recorded answers neither, and a caller that treats "unproven" as "dead" would retire a live runtime it can
148
+ // simply no longer address. Only a recorded start identity that the live PID no longer matches (including a
149
+ // PID that is not running at all) is proof of death.
150
+ export function detachedRuntimeIsGone(pid: number, receiptFile: string, adapter: ProcessAdapter = hostProcessAdapter): boolean {
151
+ let receipt: DetachedLaunchReceiptV4 | null
152
+ try { receipt = parseDetachedLaunchReceipt(readFileSync(receiptFile, 'utf8'), adapter.platform) }
153
+ catch { return false }
154
+ if (!receipt || receipt.pid !== pid) return false
155
+ return adapter.startToken(pid) !== receipt.startToken
156
+ }
157
+
145
158
  // A v3 scope is only a migration witness when every recorded and live Linux identity agrees. Readers never
146
159
  // call this: minting a v4 receipt belongs to the write admission path that needs the shared runtime.
147
160
  export function migrateLegacyDetachedRuntimeReceipt(