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.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/cli.ts +68 -47
  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/doctor.ts +2 -1
  8. package/spec-cli/src/file-write.ts +22 -0
  9. package/spec-cli/src/git.ts +30 -8
  10. package/spec-cli/src/graphStream.ts +79 -11
  11. package/spec-cli/src/harness.ts +11 -9
  12. package/spec-cli/src/help.ts +8 -13
  13. package/spec-cli/src/index.ts +8 -1
  14. package/spec-cli/src/init.ts +8 -17
  15. package/spec-cli/src/layout.ts +35 -7
  16. package/spec-cli/src/materialize.ts +150 -107
  17. package/spec-cli/src/plugin-harness.ts +19 -8
  18. package/spec-cli/src/process-identity.ts +13 -0
  19. package/spec-cli/src/reviews.ts +55 -11
  20. package/spec-cli/src/sessions.ts +166 -9
  21. package/spec-cli/src/uninstall.ts +2 -1
  22. package/spec-cli/templates/hooks/post-checkout +3 -0
  23. package/spec-cli/templates/hooks/post-merge +1 -0
  24. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  25. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  26. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  27. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-F9uaAVcH.js} +2 -2
  28. package/spec-dashboard/dist/assets/{Dashboard-CvAjfRC2.js → Dashboard-Ba_jhxp1.js} +3 -3
  29. package/spec-dashboard/dist/assets/{EvalsPage-Bz-nMKoS.js → EvalsPage-FixoOg_n.js} +2 -2
  30. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-CuKLFhH3.js} +1 -1
  31. package/spec-dashboard/dist/assets/{MobileApp-D9L1Va8Z.js → MobileApp-CHgEHORJ.js} +2 -2
  32. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-CQgYymmr.js} +1 -1
  33. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-hT7UTLvD.js} +1 -1
  34. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-CtXxakF9.js} +1 -1
  35. package/spec-dashboard/dist/assets/{SessionInterface-Dl9v0JFM.js → SessionInterface-Bpie-9fs.js} +12 -12
  36. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-CixDi4PI.js} +1 -1
  37. package/spec-dashboard/dist/assets/{Settings-BZ1lGRJs.js → Settings-C2MsucfE.js} +1 -1
  38. package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +13 -0
  39. package/spec-dashboard/dist/assets/{data-Bwd3kAVL.js → data-B-RQmit6.js} +1 -1
  40. package/spec-dashboard/dist/assets/{index-DAbQBBK_.css → index-CsI8DElI.css} +1 -1
  41. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DrVao0Ep.js} +2 -2
  42. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-BBH02b1v.js} +1 -1
  43. package/spec-dashboard/dist/index.html +2 -2
  44. package/spec-dashboard/src/reviewFilters.js +5 -0
  45. package/spec-dashboard/src/session.js +5 -1
  46. package/spec-eval/src/cli.ts +7 -8
  47. package/spec-eval/src/evaltab.ts +25 -5
  48. package/spec-eval/src/freshness.ts +52 -8
  49. package/spec-eval/src/scenariofresh.ts +58 -8
  50. package/spec-eval/src/scenarios.ts +60 -12
  51. package/spec-eval/src/sessioneval.ts +122 -30
  52. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  53. package/spec-eval/src/matrix.ts +0 -693
@@ -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(
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { listSessions } from './sessions.js'
3
3
  import { getBoard, getBoardForForgeRevision } from './graphCache.js'
4
- import { buildSessionEvals, type SessionEvals } from '../../spec-eval/src/sessioneval.js'
4
+ import { type SessionEvalOrderRow, buildSessionEvals, type SessionEvals } from '../../spec-eval/src/sessioneval.js'
5
5
  import { evalTimeline } from '../../spec-eval/src/evaltab.js'
6
6
  import { issuesEnabled as issuesEnabledForReview } from './localIssues.js'
7
7
  import { issueStores as issueStoresForReview } from './issues.js'
@@ -217,15 +217,26 @@ export function scopedEvalReviewItems(model: SessionEvals): ReviewItem[] {
217
217
 
218
218
  const evalItemKey = (item: any): string => `${String(item?.node ?? '')}\0${String(item?.scenario ?? '')}`
219
219
 
220
- function evalNeighbor(item: any): EvalNeighbor {
220
+ function evalNeighbor(item: any, stateOf: (row: { node: string; scenario: string }) => string): EvalNeighbor {
221
221
  return {
222
222
  node: String(item.node),
223
223
  scenario: String(item.scenario),
224
- state: String(item.state ?? evalReviewState(item)),
224
+ state: stateOf(item),
225
225
  }
226
226
  }
227
227
 
228
- export function boundedEvalNeighbors(items: ReviewItem[], node: string, scenario: string, want = 5) {
228
+ // `sequence` is the whole measured population in list order; `stateOf` answers only for rows whose freshness
229
+ // was actually computed. On a focused build those are two different sets — the sequence spans the scope, the
230
+ // states cover the selected row and its window — which is exactly why the state is looked up rather than
231
+ // carried: a row this response does not render contributes its POSITION and nothing else.
232
+ export function boundedEvalNeighbors(
233
+ sequence: { node: string; scenario: string }[],
234
+ node: string,
235
+ scenario: string,
236
+ stateOf: (row: { node: string; scenario: string }) => string,
237
+ want = 5,
238
+ ) {
239
+ const items = sequence
229
240
  const key = `${node}\0${scenario}`
230
241
  const index = items.findIndex((item) => evalItemKey(item) === key)
231
242
  if (index < 0) return { prev: [], next: [], total: items.length, index: null, order: 'default' as const }
@@ -235,8 +246,8 @@ export function boundedEvalNeighbors(items: ReviewItem[], node: string, scenario
235
246
  const nextN = Math.min(after, Math.max(Math.ceil(take / 2), take - before))
236
247
  const prevN = Math.min(before, take - nextN)
237
248
  return {
238
- prev: items.slice(index - prevN, index).reverse().map(evalNeighbor),
239
- next: items.slice(index + 1, index + 1 + nextN).map(evalNeighbor),
249
+ prev: items.slice(index - prevN, index).reverse().map((item) => evalNeighbor(item, stateOf)),
250
+ next: items.slice(index + 1, index + 1 + nextN).map((item) => evalNeighbor(item, stateOf)),
240
251
  total: items.length,
241
252
  index,
242
253
  order: 'default' as const,
@@ -248,12 +259,22 @@ export function projectEvalDetail(
248
259
  historySource: ReviewItem[],
249
260
  node: string,
250
261
  scenario: string,
251
- metadata: { scope?: string | null; summary?: SessionEvals['summary']; evalRevision?: SessionEvals['evalRevision'] } = {},
262
+ metadata: {
263
+ scope?: string | null
264
+ summary?: SessionEvals['summary']
265
+ evalRevision?: SessionEvals['evalRevision']
266
+ // the scope's whole measured sequence, when `items` deliberately holds only the rendered window
267
+ sequence?: { node: string; scenario: string }[]
268
+ } = {},
252
269
  ): EvalDetailReview {
253
270
  const results = items.filter((item: any) => item.filterKind === EVAL_FILTER_KIND.RESULT)
254
271
  const selected = results.find((item) => evalItemKey(item) === `${node}\0${scenario}`) ?? null
255
272
  const history = historySource.filter((reading: any) => String(reading.scenario) === scenario)
256
- const neighbors = boundedEvalNeighbors(results, node, scenario)
273
+ const stateByKey = new Map(results.map((item: any) => [evalItemKey(item), String(item.state ?? evalReviewState(item))]))
274
+ const sequence = metadata.sequence
275
+ ?? results.map((item: any) => ({ node: String(item.node), scenario: String(item.scenario) }))
276
+ const neighbors = boundedEvalNeighbors(sequence, node, scenario,
277
+ (row) => stateByKey.get(evalItemKey(row)) ?? 'empty')
257
278
  const scope = metadata.scope ?? null
258
279
  return {
259
280
  scope,
@@ -266,15 +287,38 @@ export function projectEvalDetail(
266
287
  }
267
288
  }
268
289
 
290
+ // the measured population in list order, from the freshness-free rows: a filed reading leads (newest first),
291
+ // and the tie-breaks are the identity ones — the SAME comparison `byNewest` applies, over the only fields it
292
+ // actually reads. Blind rows never enter, exactly as the detail's own `results` filter excludes them.
293
+ export function measuredSequence(order: SessionEvalOrderRow[]): { node: string; scenario: string }[] {
294
+ return order.filter((row) => row.ts)
295
+ .sort((a, b) => String(b.ts ?? '').localeCompare(String(a.ts ?? ''))
296
+ || a.node.localeCompare(b.node) || a.scenario.localeCompare(b.scenario))
297
+ .map((row) => ({ node: row.node, scenario: row.scenario }))
298
+ }
299
+
300
+ // the nodes whose verdicts the response will publish: the selected row's, plus a window wide enough to
301
+ // contain any neighbour boundedEvalNeighbors can choose (it takes at most five, split around the index).
302
+ export function focusNodes(order: SessionEvalOrderRow[], node: string, scenario: string): string[] {
303
+ const sequence = measuredSequence(order)
304
+ const index = sequence.findIndex((row) => row.node === node && row.scenario === scenario)
305
+ if (index < 0) return [node]
306
+ return [...new Set([node, ...sequence.slice(Math.max(0, index - 6), index + 7).map((row) => row.node)])]
307
+ }
308
+
269
309
  export async function evalDetailReview(node: string, scenario: string, scope?: string | null): Promise<EvalDetailReview | null> {
270
310
  if (scope) {
271
- const model = await buildSessionEvals(scope)
311
+ // A detail renders ONE row plus at most five neighbours, but owes the whole population's index/total.
312
+ // So name the window from the freshness-free sequence and let only those nodes pay the freshness pass;
313
+ // a build that finds a full cached model ignores the pick and answers from it instead.
314
+ const model = await buildSessionEvals(scope, (order) => focusNodes(order, node, scenario))
272
315
  if (!model) return null
273
316
  const sourceNode = model.nodes.find((candidate) => candidate.id === node)
274
317
  return projectEvalDetail(scopedEvalReviewItems(model), sourceNode?.evals ?? [], node, scenario, {
275
318
  scope,
276
319
  summary: model.summary,
277
320
  evalRevision: model.evalRevision,
321
+ ...(model.order ? { sequence: measuredSequence(model.order) } : {}),
278
322
  })
279
323
  }
280
324
  await getBoard()
@@ -335,13 +379,13 @@ export async function evalsReview(query: string | undefined, requestedPage: unkn
335
379
  const filtered = evalFilterModel(items, tokenFilterState(text, 'eval'), { sessions, defaultKind: 'all', defaultSection: '' })
336
380
  return {
337
381
  scope,
338
- gates: model.gates,
382
+ gates: model.gates ?? [],
339
383
  unknown: model.nodes.reduce((count, node) => count + (node.unknownCoverage?.length ?? 0), 0),
340
384
  summary: model.summary,
341
385
  evalRevision: model.evalRevision,
342
386
  impact: model.impact,
343
387
  ...paginateReview(items, filtered.shown, filtered, requestedPage, {
344
- domain: 'evals', scope, items, gates: model.gates, summary: model.summary,
388
+ domain: 'evals', scope, items, gates: model.gates ?? [], summary: model.summary,
345
389
  evalRevision: model.evalRevision, impact: model.impact, sessions: sessions.map((session) => session.id),
346
390
  }),
347
391
  }