spexcode 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +97 -149
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/harness.sh +26 -6
  4. package/spec-cli/src/anchors.ts +27 -5
  5. package/spec-cli/src/cli.ts +239 -75
  6. package/spec-cli/src/client.ts +113 -14
  7. package/spec-cli/src/codex-runtime-generations.ts +97 -7
  8. package/spec-cli/src/commit-surgery.ts +2 -1
  9. package/spec-cli/src/contract-filter.ts +58 -42
  10. package/spec-cli/src/delivery-queue.ts +62 -4
  11. package/spec-cli/src/doctor.ts +2 -1
  12. package/spec-cli/src/execution-trace.ts +444 -0
  13. package/spec-cli/src/file-write.ts +22 -0
  14. package/spec-cli/src/gateway-hub.ts +10 -8
  15. package/spec-cli/src/gateway.ts +99 -6
  16. package/spec-cli/src/git.ts +503 -105
  17. package/spec-cli/src/graph.ts +56 -6
  18. package/spec-cli/src/graphCache.ts +86 -17
  19. package/spec-cli/src/graphStream.ts +79 -11
  20. package/spec-cli/src/guide.ts +93 -4
  21. package/spec-cli/src/harness-select.ts +2 -2
  22. package/spec-cli/src/harness.ts +355 -75
  23. package/spec-cli/src/help.ts +30 -23
  24. package/spec-cli/src/host-resources.ts +62 -7
  25. package/spec-cli/src/identity-presets.js +16 -6
  26. package/spec-cli/src/index.ts +109 -14
  27. package/spec-cli/src/init.ts +8 -17
  28. package/spec-cli/src/layout.ts +151 -40
  29. package/spec-cli/src/lint.ts +37 -7
  30. package/spec-cli/src/materialize.ts +160 -112
  31. package/spec-cli/src/plugin-harness.ts +29 -18
  32. package/spec-cli/src/process-identity.ts +13 -0
  33. package/spec-cli/src/reviews.ts +103 -30
  34. package/spec-cli/src/session-execution.ts +68 -0
  35. package/spec-cli/src/session-files.ts +121 -0
  36. package/spec-cli/src/session-follow.ts +4 -4
  37. package/spec-cli/src/session-reparent.ts +33 -0
  38. package/spec-cli/src/session-timeline.ts +163 -14
  39. package/spec-cli/src/session-web.ts +135 -0
  40. package/spec-cli/src/sessions.ts +748 -111
  41. package/spec-cli/src/uninstall.ts +3 -2
  42. package/spec-cli/templates/hooks/post-checkout +3 -0
  43. package/spec-cli/templates/hooks/post-merge +1 -0
  44. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  46. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  47. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  48. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  49. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  50. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  51. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  53. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
  54. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  56. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
  57. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  58. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
  59. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
  60. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
  61. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  62. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
  63. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  64. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  65. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  66. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
  67. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  68. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
  69. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  70. package/spec-dashboard/dist/index.html +2 -2
  71. package/spec-dashboard/src/reviewFilters.js +5 -0
  72. package/spec-dashboard/src/session.js +5 -1
  73. package/spec-eval/src/cli.ts +13 -9
  74. package/spec-eval/src/evaltab.ts +32 -10
  75. package/spec-eval/src/freshness.ts +404 -81
  76. package/spec-eval/src/scenariofresh.ts +58 -8
  77. package/spec-eval/src/scenarios.ts +60 -12
  78. package/spec-eval/src/sessioneval.ts +234 -148
  79. package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
  80. package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
  81. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
  82. package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
  83. package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
  84. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  85. package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
  86. package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
  87. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
  88. 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,93 +64,136 @@ 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)
95
86
  }
96
87
 
97
- const SENTINEL_RE = /\n*<!-- spexcode:start -->[\s\S]*?<!-- spexcode:end -->\n*/
98
- export function stripSpexcodeBlock(text: string): string {
99
- const m = SENTINEL_RE.exec(text)
88
+ function managedBlockPattern(comment: readonly [string, string]): RegExp {
89
+ const [open, close] = comment
90
+ const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
91
+ return new RegExp(`\\n*${escape(`${open}spexcode:start${close}`)}[\\s\\S]*?${escape(`${open}spexcode:end${close}`)}\\n*`)
92
+ }
93
+ export function stripSpexcodeBlock(text: string, comment: readonly [string, string] = ['<!-- ', ' -->']): string {
94
+ const sentinel = managedBlockPattern(comment)
95
+ const m = sentinel.exec(text)
100
96
  if (!m) return text
101
97
  // mirror removeManagedBlock exactly: our block + its surrounding blanks collapse to one '\n', and only a
102
98
  // block sitting at the TOP of the file drops the leading newline (a host file beginning with its own
103
99
  // blank lines keeps them — clean(smudge(x)) == x).
104
- const replaced = text.replace(SENTINEL_RE, '\n')
100
+ const replaced = text.replace(sentinel, '\n')
105
101
  return m.index === 0 ? replaced.replace(/^\n+/, '') : replaced
106
102
  }
107
103
  function hostContentOf(file: string): string {
108
104
  if (!existsSync(file)) return ''
109
105
  return stripSpexcodeBlock(readFileSync(file, 'utf8'))
110
106
  }
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
107
  // the identity stamp on every generated skill/agent file — what lets the erase phase forget a product whose
119
108
  // NODE was renamed or deleted (the name-scoped sweep can only reconstruct paths the LIVE config still names).
120
109
  export const GENERATED_MARK = '<!-- spexcode:generated -->'
121
- function sweepGeneratedSkills(dir: string | null): void {
122
- if (!dir || !existsSync(dir)) return
110
+ function pruneGeneratedSkills(dir: string | null, keep: ReadonlySet<string>): boolean {
111
+ if (!dir || !existsSync(dir)) return false
112
+ let changed = false
123
113
  for (const e of readdirSync(dir, { withFileTypes: true })) {
124
114
  if (!e.isDirectory()) continue
125
115
  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 */ }
116
+ try {
117
+ if (!keep.has(e.name) && existsSync(f) && readFileSync(f, 'utf8').includes(GENERATED_MARK)) {
118
+ rmSync(join(dir, e.name), { recursive: true, force: true })
119
+ changed = true
120
+ }
121
+ } catch { /* unreadable → not provably ours */ }
127
122
  }
123
+ return changed
128
124
  }
129
- function sweepGeneratedAgents(dir: string | null): void {
130
- if (!dir || !existsSync(dir)) return
125
+ function pruneGeneratedAgents(dir: string | null, keep: ReadonlySet<string>): boolean {
126
+ if (!dir || !existsSync(dir)) return false
127
+ let changed = false
131
128
  for (const e of readdirSync(dir, { withFileTypes: true })) {
132
129
  if (!e.isFile() || !e.name.endsWith('.md')) continue
133
130
  const f = join(dir, e.name)
134
- try { if (readFileSync(f, 'utf8').includes(GENERATED_MARK)) rmSync(f, { force: true }) } catch { /* unreadable → not provably ours */ }
131
+ try {
132
+ if (!keep.has(e.name.slice(0, -3)) && readFileSync(f, 'utf8').includes(GENERATED_MARK)) {
133
+ rmSync(f, { force: true })
134
+ changed = true
135
+ }
136
+ } catch { /* unreadable → not provably ours */ }
137
+ }
138
+ return changed
139
+ }
140
+
141
+ type TreeTargets = {
142
+ contracts: ReadonlySet<string>
143
+ treeShims: ReadonlySet<string>
144
+ anchors: ReadonlySet<string>
145
+ skills: ReadonlyMap<string, ReadonlySet<string>>
146
+ agents: ReadonlyMap<string, ReadonlySet<string>>
147
+ }
148
+
149
+ function cleanupHarnessDirs(proj: string): void {
150
+ const roots = new Set([proj, mainCheckout(proj)])
151
+ const dirs = HARNESSES.flatMap((h) => {
152
+ const anchor = h.worktreeHookAnchor(proj)
153
+ return [h.skillDir(proj), h.agentDir(proj), dirname(h.shimFile(proj)), anchor ? dirname(anchor) : null]
154
+ }).filter((d): d is string => !!d)
155
+ for (const d of [...new Set([...dirs, ...dirs.map((dir) => dirname(dir))])]
156
+ .filter((dir) => !roots.has(dir)).sort((a, b) => b.length - a.length)) {
157
+ try { rmdirSync(d) } catch {}
135
158
  }
136
159
  }
137
160
 
161
+ function reconcileTree(proj: string, targets: TreeTargets, tracked: (file: string) => boolean): void {
162
+ let removed = false
163
+ const contractFiles = new Set(HARNESSES.flatMap((h) => h.contractFiles(proj)))
164
+ for (const file of contractFiles) {
165
+ if (targets.contracts.has(file) || !existsSync(file)) continue
166
+ const text = readFileSync(file, 'utf8')
167
+ if (!text.includes('<!-- spexcode:start -->')) continue
168
+ removeManagedBlock(file, ['<!-- ', ' -->'], !tracked(file))
169
+ removed = true
170
+ }
171
+ const treeShims = new Set(HARNESSES.filter((h) => h.shimScope === 'tree').map((h) => h.shimFile(proj)))
172
+ const anchors = new Set(HARNESSES.map((h) => h.worktreeHookAnchor(proj)).filter((path): path is string => !!path))
173
+ for (const file of [...treeShims, ...anchors]) {
174
+ if (targets.treeShims.has(file) || targets.anchors.has(file) || !existsSync(file)) continue
175
+ if (readFileSync(file, 'utf8').includes('dispatch.sh')) { rmSync(file, { force: true }); removed = true }
176
+ }
177
+ for (const dir of new Set(HARNESSES.map((h) => h.skillDir(proj)).filter((path): path is string => !!path)))
178
+ removed = pruneGeneratedSkills(dir, targets.skills.get(dir) ?? new Set()) || removed
179
+ for (const dir of new Set(HARNESSES.map((h) => h.agentDir(proj)).filter((path): path is string => !!path)))
180
+ removed = pruneGeneratedAgents(dir, targets.agents.get(dir) ?? new Set()) || removed
181
+ if (removed) cleanupHarnessDirs(proj)
182
+ }
183
+
138
184
  function eraseTree(proj: string, arts: HarnessArtifacts, preserveProject: boolean): void {
139
185
  for (const h of HARNESSES) {
140
186
  // h.clean = the adapter's surgical inverse: contract block (sentinels, deleteIfEmpty), the dispatch.sh-
141
187
  // stamped shim + worktree anchor, the trust block, and the arts-named skill/agent files.
142
188
  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))
189
+ for (const f of h.contractFiles(proj)) clearSkipWorktree(proj, f)
190
+ pruneGeneratedSkills(h.skillDir(proj), new Set())
191
+ pruneGeneratedAgents(h.agentDir(proj), new Set())
146
192
  }
147
193
  // same authorship rule as the contract files: deleteIfEmpty only when .gitignore is UNTRACKED (wholly-ours
148
194
  // generated file); a HOST-TRACKED .gitignore that carried nothing but our block is stripped, never deleted.
149
195
  removeManagedBlock(join(proj, '.gitignore'), ['# ', ''], !isTracked(proj, '.gitignore'))
150
- removeContractFilter(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')])
196
+ clearContractFilterPayload(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')])
151
197
  // the block-strip left tracked contract files stat-dirty (under a filter git NEVER content-verifies them,
152
198
  // and even unfiltered the phantom-`M` lingers) — settle the index stat, content-guarded so a user's real
153
199
  // unstaged edit is never staged ([[content-filter]] edge 2).
@@ -157,16 +203,7 @@ function eraseTree(proj: string, arts: HarnessArtifacts, preserveProject: boolea
157
203
  // because a harness may nest its shim a level below its home (opencode's .opencode/plugins/, pi's
158
204
  // .pi/extensions/) — but never the checkout roots themselves. rmdirSync is NON-recursive, so a dir holding
159
205
  // 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
- }
206
+ cleanupHarnessDirs(proj)
170
207
  }
171
208
 
172
209
  export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { skills: [], agents: [] }): void {
@@ -177,7 +214,7 @@ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { s
177
214
  git(['-C', tree, 'rev-parse', '--show-toplevel'])
178
215
  }
179
216
  for (const tree of trees) {
180
- // Only the caller's live spec may widen the legacy name sweep. Siblings are identity-stamp-only: the
217
+ // Only the caller's live spec may widen the name sweep. Siblings are identity-stamp-only: the
181
218
  // same name there may be user-owned or may not exist in this tree's divergent spec at all.
182
219
  eraseTree(tree, tree === current ? arts : { skills: [], agents: [] }, false)
183
220
  }
@@ -196,7 +233,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
196
233
  }
197
234
  // (1) hook manifest (persistent — the dispatcher reads it; regenerated only here, on change).
198
235
  const manifest = join(rt, 'hooks-manifest')
199
- writeFileSync(manifest, compileManifest())
236
+ writeFileIfChanged(manifest, compileManifest())
200
237
  record('hook manifest', manifest)
201
238
  // (2) the contract = the surface:system plugin bodies (in name order), written WHOLE into EACH harness's
202
239
  // contract file(s) + (3) each harness's thin shim → dispatch.sh + (4) its trust. All owned by the adapter.
@@ -213,14 +250,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
213
250
  const skillNodes = loadSkillConfig()
214
251
  const agentNodes = loadAgentConfig()
215
252
  const commandNodes = loadConfig()
216
- const arts: HarnessArtifacts = { skills: skillNodes.map((s) => s.name), agents: agentNodes.map((a) => a.name) }
217
253
 
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
254
  // a skill node → the agentskills.io SKILL.md primitive: `name`+`description` frontmatter (the load-trigger)
225
255
  // over the body instructions, closed by the GENERATED_MARK identity stamp (what the erase phase keys on).
226
256
  // One pure artifact builder shared by every harness — divergence is only its skillDir.
@@ -239,53 +269,80 @@ export function materialize(proj = process.cwd()): MaterializeResult {
239
269
  // because their residence is the live three-state kind detection below, not a static entry.
240
270
  const artifactPaths: string[] = []
241
271
  const machinePaths: string[] = []
242
- const contractPaths: string[] = []
272
+ const contractTargets = new Map<string, string>()
273
+ const treeShimTargets = new Map<string, string>()
274
+ const anchorTargets = new Map<string, string>()
275
+ const skillTargets = new Map<string, string>()
276
+ const agentTargets = new Map<string, string>()
277
+ const skillsByDir = new Map<string, Set<string>>()
278
+ const agentsByDir = new Map<string, Set<string>>()
279
+ const addTarget = (targets: Map<string, string>, path: string, content: string) => {
280
+ const prior = targets.get(path)
281
+ if (prior !== undefined && prior !== content) throw new Error(`conflicting materialize targets for ${path}`)
282
+ targets.set(path, content)
283
+ }
243
284
  for (const h of selected) {
244
- if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract); contractPaths.push(f); record('contract', f) }
285
+ if (contract) for (const f of h.contractFiles(proj)) addTarget(contractTargets, f, contract)
245
286
  const shim = h.shim(DISPATCH, SPEX)
246
287
  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)
288
+ addTarget(treeShimTargets, h.shimFile(proj), shim.content)
252
289
  }
253
290
  // a linked-worktree ANCHOR copy of the shim, when the harness needs one (codex: the shim lives at the main
254
291
  // checkout, so the worktree gets no `.codex/` unless we place one). One adapter line; null otherwise.
255
292
  const anchor = h.worktreeHookAnchor(proj)
256
- if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.content); machinePaths.push(anchor); record('shim', anchor) }
293
+ if (anchor) addTarget(anchorTargets, anchor, shim.content)
294
+ }
295
+ for (const sk of skillNodes) for (const h of selected) {
296
+ const dir = h.skillDir(proj); if (!dir) continue
297
+ addTarget(skillTargets, join(dir, sk.name, 'SKILL.md'), skillArtifact(sk))
298
+ const names = skillsByDir.get(dir) ?? new Set<string>(); names.add(sk.name); skillsByDir.set(dir, names)
299
+ }
300
+ for (const ag of agentNodes) for (const h of selected) {
301
+ const dir = h.agentDir(proj); if (!dir) continue
302
+ addTarget(agentTargets, join(dir, `${ag.name}.md`), agentArtifact(ag))
303
+ const names = agentsByDir.get(dir) ?? new Set<string>(); names.add(ag.name); agentsByDir.set(dir, names)
304
+ }
305
+ const tracked = new Map<string, boolean>()
306
+ const isTrackedHere = (file: string) => {
307
+ const known = tracked.get(file)
308
+ if (known !== undefined) return known
309
+ const value = isTracked(proj, file); tracked.set(file, value); return value
310
+ }
311
+ reconcileTree(proj, {
312
+ contracts: new Set(contractTargets.keys()), treeShims: new Set(treeShimTargets.keys()), anchors: new Set(anchorTargets.keys()),
313
+ skills: skillsByDir, agents: agentsByDir,
314
+ }, isTrackedHere)
315
+ const changedMaterialized = new Set<string>()
316
+ for (const [file, content] of contractTargets) {
317
+ if (writeManagedBlock(file, content)) changedMaterialized.add(file)
318
+ record('contract', file)
319
+ }
320
+ const contractPaths = [...contractTargets.keys()]
321
+ for (const [file, content] of treeShimTargets) {
322
+ mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
323
+ record('shim', file); machinePaths.push(file)
324
+ }
325
+ for (const [file, content] of anchorTargets) {
326
+ mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
327
+ record('shim', file); machinePaths.push(file)
257
328
  }
258
329
  const selectedByDispatch = new Map(selected.map((h) => [h.dispatchId, h]))
259
330
  for (const h of selectedByDispatch.values()) {
260
331
  const shim = h.shim(DISPATCH, SPEX)
261
332
  if (h.shimScope === 'project') {
262
333
  const file = h.shimFile(proj)
263
- mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, shim.content)
334
+ mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, shim.content)
264
335
  record('shim', file)
265
336
  }
266
337
  for (const file of h.writeTrust(proj, shim.cmd)) record('trust', file)
267
338
  }
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
- }
339
+ for (const [file, content] of skillTargets) {
340
+ mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
341
+ artifactPaths.push(file); record('skill', file)
279
342
  }
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
- }
343
+ for (const [file, content] of agentTargets) {
344
+ mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
345
+ artifactPaths.push(file); record('agent', file)
289
346
  }
290
347
  // (8) the PLUGIN target ([[plugin-harness]]): materialize the whole system into one self-contained Claude-plugin
291
348
  // bundle per selected folder. A plugin is EXCLUSIVE (`selected` is empty then). Pruning a DESELECTED
@@ -294,11 +351,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
294
351
  // global store records the folders emitted last run; any prev folder absent from the current set is
295
352
  // clean()ed, then the current folders are emitted and the ledger rewritten.
296
353
  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) : []
354
+ const prevFolders = existsSync(ledger) ? readFileSync(ledger, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean) : []
302
355
  const curFolders = plugins.map((p) => p.folder)
303
356
  for (const f of prevFolders) if (!curFolders.includes(f)) cleanPlugin(proj, f)
304
357
  if (plugins.length) {
@@ -315,7 +368,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
315
368
  record('plugin bundle', pluginBundleDir(proj, p.folder))
316
369
  }
317
370
  }
318
- writeFileSync(ledger, curFolders.join('\n'))
371
+ writeFileIfChanged(ledger, curFolders.join('\n'))
319
372
  // (9) ignore + mixed text. Only checkout-invariant residue and project-shared shims belong in the COMMON
320
373
  // info/exclude; selection-dependent paths live in this tree's filtered working .gitignore.
321
374
  const mc = mainCheckout(proj)
@@ -327,43 +380,38 @@ export function materialize(proj = process.cwd()): MaterializeResult {
327
380
  'spexcode.local.json', '.worktrees/', '.session',
328
381
  ]
329
382
  const entries = (list: string[]) => [...new Set(list)].sort().join('\n')
330
- const priorCommonEntries = managedExcludeEntries(infoExcludePath(proj))
331
-
332
383
  // Contract residence stays a live fact. Selection-dependent untracked products are ignored by this tree's
333
384
  // working .gitignore, whose own managed block is filtered when the host tracks/owns that file.
334
385
  const filterContracts: string[] = []
335
386
  const oursContracts: string[] = []
336
387
  for (const f of contractPaths) {
337
- if (isTracked(proj, f) || hostContentOf(f).trim()) filterContracts.push(f)
388
+ if (isTrackedHere(f) || hostContentOf(f).trim()) filterContracts.push(f)
338
389
  else oursContracts.push(f)
339
390
  }
340
391
  const localEntries = [...machinePaths, ...bundlePaths, ...artifactPaths, ...oursContracts]
341
392
  .map((p) => relative(proj, p)).filter((p) => !p.startsWith('..'))
342
393
  const ignoreFile = join(proj, '.gitignore')
343
- const ignoreTracked = isTracked(proj, ignoreFile)
344
- const ignoreHost = existsSync(ignoreFile) ? readFileSync(ignoreFile, 'utf8') : ''
394
+ const ignoreTracked = isTrackedHere(ignoreFile)
395
+ const ignoreHost = existsSync(ignoreFile) ? stripSpexcodeBlock(readFileSync(ignoreFile, 'utf8'), ['# ', '']) : ''
345
396
  if (!ignoreTracked && !ignoreHost.trim()) localEntries.push('.gitignore')
346
397
  const ignoreBody = entries(localEntries)
347
- writeManagedBlock(ignoreFile, ignoreBody, ['# ', ''])
398
+ if (writeManagedBlock(ignoreFile, ignoreBody, ['# ', ''])) changedMaterialized.add(ignoreFile)
348
399
 
349
400
  const payloads: ContractFilterPayload[] = filterContracts.map((file) => ({ file: relative(proj, file), content: contract }))
350
401
  if (ignoreTracked || ignoreHost.trim()) payloads.push({ file: '.gitignore', content: ignoreBody })
351
402
  const bindings: ContractFilterBinding[] = [
352
403
  ...[...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 })),
404
+ .map((file) => ({ file, start: '<!-- spexcode:start -->', end: '<!-- spexcode:end -->' })),
354
405
  { file: '.gitignore', start: '# spexcode:start', end: '# spexcode:end' },
355
406
  ]
356
- if (payloads.length) plantContractFilter(proj, payloads, bindings)
357
- // (5) finish diagnostics/migration, then atomically publish the allowlist LAST. Dispatch consumes only that
407
+ if (payloads.length) plantContractFilter(proj, payloads, bindings, [...changedMaterialized])
408
+ else if (contractFilterPlanted(proj)) removeContractFilter(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')])
409
+ // (5) finish diagnostics, then atomically publish the allowlist LAST. Dispatch consumes only that
358
410
  // final receipt; a killed writer leaves the preceding successful selection intact.
359
411
  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]), ['# ', ''])
412
+ writeFileIfChanged(join(rt, 'content-hash'), h)
413
+ writeFileIfChanged(join(runtimeRoot(proj), 'harness-selection-v1'), '')
414
+ writeManagedBlock(infoExcludePath(proj), entries(commonEntries), ['# ', ''])
367
415
  publishSelection(join(rt, 'harnesses'), selectionBody(selected, plugins.length > 0))
368
416
  return { contentHash: h, planted }
369
417
  }
@@ -1,31 +1,32 @@
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
- // Claude-plugin bundle dropped into the host-agent-scanned folder [[harness-select]] resolved (e.g. `.zcode` /
7
+ // Claude-plugin bundle dropped into the host-agent-scanned folder [[harness-select]] resolved (e.g. `.adopter-a` /
7
8
  // `.claude` → <folder>/plugins/spexcode/). It is the plugin-target counterpart of the native per-harness
8
9
  // write [[harness-adapter]] does — chosen INSTEAD of the natives (plugin exclusivity), so [[harness-delivery]]'s
9
10
  // materialize prunes every native first, then emits this. The bundle follows the de-facto Claude-plugin schema
10
11
  // (a `.claude-plugin/plugin.json` pointing at hooks/skills/commands/agents); the host's discovery order is
11
- // `.zcode-plugin > .claude-plugin > .codex-plugin` and z-code/Claude both read a `.claude-plugin` directly, so
12
- // the ONE `.claude-plugin` bundle reaches ZCode, Claude, and (future) Codex from a single emit.
12
+ // `.adopter-a-plugin > .claude-plugin > .codex-plugin` and adopter-a/Claude both read a `.claude-plugin` directly, so
13
+ // the ONE `.claude-plugin` bundle reaches AdopterA, Claude, and (future) Codex from a single emit.
13
14
  //
14
15
  // The contract is NOT delivered by an always-on CLAUDE.md block here (the bundle never touches the repo's own
15
16
  // files) — it maps to a SessionStart hook that emits hookSpecificOutput.additionalContext (the harness-neutral
16
- // injection Claude/z-code normalize, the superpowers pattern), so a plugin host gets the contract with no
17
+ // injection Claude/adopter-a normalize, the superpowers pattern), so a plugin host gets the contract with no
17
18
  // --append-system-prompt. The hooks reuse the SAME dispatch.sh wiring as the natives, located via the host's
18
19
  // ${CLAUDE_PLUGIN_ROOT} variable; dispatch.sh's first arg is the harness id `plugin`, so its shell mirror
19
- // (harness.sh) parses payloads as the claude family (z-code/Claude share Claude's tool names + file_path).
20
+ // (harness.sh) parses payloads as the claude family (adopter-a/Claude share Claude's tool names + file_path).
20
21
 
21
22
  const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
22
23
  const HOOKS_SRC = join(PKG, 'hooks') // the canonical dispatch.sh + harness.sh source
23
24
  const PLUGIN_NAME = 'spexcode'
24
25
  // the host substitutes ${CLAUDE_PLUGIN_ROOT} with the bundle's own absolute path before running a hook command
25
- // (the same variable z-code's hook-compat honours), so dispatch.sh/inject-contract.sh resolve regardless of
26
+ // (the same variable adopter-a's hook-compat honours), so dispatch.sh/inject-contract.sh resolve regardless of
26
27
  // where the host scanned the bundle from.
27
28
  const PLUGIN_ROOT = '${CLAUDE_PLUGIN_ROOT}'
28
- // the lifecycle events the bundle binds — the Claude/z-code superset; a host that fires fewer (Codex) simply
29
+ // the lifecycle events the bundle binds — the Claude/adopter-a superset; a host that fires fewer (Codex) simply
29
30
  // never invokes the extras, so binding all is harmless and one emit serves every host.
30
31
  const PLUGIN_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'StopFailure', 'Notification'] as const
31
32
 
@@ -50,7 +51,7 @@ export function pluginBundleDir(proj: string, folder: string): string {
50
51
  }
51
52
 
52
53
  // the de-facto Claude-plugin manifest: name (the bundle identity clean() gates on), version, description, and
53
- // the component pointers Claude/z-code discover (hooks.json + the skills/commands/agents dirs).
54
+ // the component pointers Claude/adopter-a discover (hooks.json + the skills/commands/agents dirs).
54
55
  function pluginManifest(version: string): string {
55
56
  return JSON.stringify({
56
57
  name: PLUGIN_NAME,
@@ -63,7 +64,7 @@ function pluginManifest(version: string): string {
63
64
  }, null, 2)
64
65
  }
65
66
 
66
- // hooks.json in the Claude/z-code-compatible shape { "hooks": { "<Event>": [{ "hooks": [command…] }] } }. Every
67
+ // hooks.json in the Claude/adopter-a-compatible shape { "hooks": { "<Event>": [{ "hooks": [command…] }] } }. Every
67
68
  // event → the SHARED dispatch.sh (`plugin` baked as its harness id, SPEX inherited by handlers); SessionStart
68
69
  // ALSO runs inject-contract.sh first, so the contract additionalContext lands alongside the normal dispatch.
69
70
  function pluginHooksJson(spex: string): string {
@@ -89,7 +90,7 @@ function contractContextJson(contract: string): string {
89
90
  // stand-in for --append-system-prompt). A bare cat — all the encoding happened at materialize time.
90
91
  const INJECT_SH = `#!/usr/bin/env bash
91
92
  # Emit the SpexCode contract as SessionStart additionalContext — the harness-neutral contract injection (the
92
- # superpowers pattern; Claude/z-code normalize hookSpecificOutput.additionalContext) that replaces a plugin
93
+ # superpowers pattern; Claude/adopter-a normalize hookSpecificOutput.additionalContext) that replaces a plugin
93
94
  # host's missing --append-system-prompt. The JSON was written at materialize time, so this is a trivial cat.
94
95
  here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
95
96
  [ -f "$here/contract-context.json" ] && cat "$here/contract-context.json"
@@ -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(