spexcode 0.1.5 → 0.2.0

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 (70) hide show
  1. package/README.md +86 -25
  2. package/package.json +5 -6
  3. package/spec-cli/README.md +86 -0
  4. package/spec-cli/bin/spex.mjs +15 -3
  5. package/spec-cli/hooks/dispatch.sh +20 -8
  6. package/spec-cli/hooks/harness.sh +18 -11
  7. package/spec-cli/src/board.ts +47 -18
  8. package/spec-cli/src/boardCache.ts +70 -0
  9. package/spec-cli/src/boardDelta.ts +90 -0
  10. package/spec-cli/src/boardStream.ts +178 -0
  11. package/spec-cli/src/cli.ts +172 -119
  12. package/spec-cli/src/client.ts +6 -4
  13. package/spec-cli/src/gateway.ts +60 -19
  14. package/spec-cli/src/git.ts +105 -92
  15. package/spec-cli/src/guide.ts +175 -12
  16. package/spec-cli/src/harness-select.ts +63 -0
  17. package/spec-cli/src/harness.ts +506 -100
  18. package/spec-cli/src/help.ts +360 -0
  19. package/spec-cli/src/hooks.ts +0 -14
  20. package/spec-cli/src/index.ts +272 -32
  21. package/spec-cli/src/init.ts +41 -1
  22. package/spec-cli/src/issues.ts +250 -0
  23. package/spec-cli/src/layout.ts +70 -28
  24. package/spec-cli/src/lint.ts +12 -10
  25. package/spec-cli/src/listen.ts +28 -0
  26. package/spec-cli/src/localIssues.ts +683 -0
  27. package/spec-cli/src/materialize.ts +182 -27
  28. package/spec-cli/src/mentions.ts +192 -0
  29. package/spec-cli/src/plugin-harness.ts +145 -0
  30. package/spec-cli/src/pty-bridge.ts +378 -81
  31. package/spec-cli/src/self.ts +123 -20
  32. package/spec-cli/src/sessions.ts +461 -298
  33. package/spec-cli/src/specs.ts +55 -14
  34. package/spec-cli/src/supervise.ts +23 -3
  35. package/spec-cli/src/tsx-bin.ts +14 -5
  36. package/spec-cli/src/uninstall.ts +146 -0
  37. package/spec-cli/templates/hooks/post-merge +27 -0
  38. package/spec-cli/templates/hooks/pre-commit +51 -31
  39. package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
  40. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
  41. package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
  42. package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
  43. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
  44. package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
  46. package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
  47. package/spec-cli/templates/spec/project/.config/spec.md +3 -3
  48. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
  49. package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
  50. package/spec-cli/templates/spec/project/spec.md +2 -2
  51. package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
  52. package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
  53. package/spec-dashboard/dist/index.html +17 -5
  54. package/spec-forge/src/cache.ts +16 -0
  55. package/spec-forge/src/drivers/github.ts +74 -4
  56. package/spec-forge/src/port.ts +25 -0
  57. package/spec-forge/src/resident.ts +40 -6
  58. package/spec-yatsu/src/cli.ts +227 -38
  59. package/spec-yatsu/src/evaltab.ts +169 -19
  60. package/spec-yatsu/src/filing.ts +48 -0
  61. package/spec-yatsu/src/freshness.ts +55 -20
  62. package/spec-yatsu/src/proof.ts +89 -3
  63. package/spec-yatsu/src/scenariofresh.ts +92 -0
  64. package/spec-yatsu/src/sidecar.ts +75 -11
  65. package/spec-yatsu/src/timeline.ts +47 -0
  66. package/spec-yatsu/src/yatsu.ts +47 -3
  67. package/spec-cli/src/relay.ts +0 -28
  68. package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
  69. package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
  70. package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
@@ -1,22 +1,28 @@
1
- import { writeFileSync, mkdirSync } from 'node:fs'
1
+ import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'node:fs'
2
2
  import { join, dirname, relative } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { execFileSync } from 'node:child_process'
5
- import { loadSystemConfig, loadSkillConfig } from './specs.js'
5
+ import { loadSystemConfig, loadSkillConfig, loadAgentConfig, loadConfig } from './specs.js'
6
6
  import { compileManifest } from './hooks.js'
7
- import { HARNESSES, writeManagedBlock } from './harness.js'
8
- import { runtimeRoot } from './layout.js'
7
+ import { writeManagedBlock, removeManagedBlock, type HarnessArtifacts } from './harness.js'
8
+ import { git } from './git.js'
9
+ import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
10
+ import { resolveHarnessTargets, partitionHarnesses } from './harness-select.js'
11
+ import { emitPlugin, cleanPlugin, pluginBundleDir, pluginVersion } from './plugin-harness.js'
9
12
  import { tsxBin } from './tsx-bin.js'
10
13
 
11
14
  // @@@ materialize - the "pay-per-change" node step (≈0.85s) the cheap shell gate invokes ONLY when the
12
15
  // .config content-hash moved. It renders the spec tree's surface nodes into the flat artifacts each
13
16
  // consumer reads cheaply, so a USER-self-launched claude/codex (no SpexCode process in the launch) gets the
14
17
  // whole system via harness-auto-discovered files: (1) the hook MANIFEST (our dispatcher reads it),
15
- // (2) the CONTRACT as a managed <spexcode> block in each harness's contract file(s) user content
16
- // preserved, (3) the thin SHIMS (every event dispatch.sh), (4) the per-harness TRUST (Codex's deterministic
17
- // trusted_hash; Claude none) so the self-launch is zero-prompt. EVERY harness-specific fact is owned by the
18
- // [[harness-adapter]] (harness.ts) this file just loops over HARNESSES, so adding a harness adds an adapter,
19
- // not a branch here. All writes are idempotent + scoped. The content-hash marker is stamped last.
18
+ // (2) the CONTRACT the tracked docs guide (docs/AGENT_GUIDE.md) FOLLOWED BY the surface:system bodies
19
+ // written WHOLE into each harness's contract file(s), which are GENERATED, gitignored artifacts (like the
20
+ // shims + skills): regenerated per clone/launch, never committed, so a self-launched agent still discovers
21
+ // guide + contract while the repo tracks only the guide source, (3) the thin SHIMS (every event → dispatch.sh),
22
+ // (4) the per-harness TRUST (Codex's deterministic trusted_hash; Claude none) so the self-launch is zero-prompt.
23
+ // EVERY harness-specific fact is owned by the [[harness-adapter]] (harness.ts) — this file just loops over
24
+ // HARNESSES, so adding a harness adds an adapter, not a branch here. All writes are idempotent + scoped. The
25
+ // content-hash marker is stamped last.
20
26
 
21
27
  const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
22
28
  const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
@@ -35,54 +41,203 @@ export function contentHash(proj: string): string {
35
41
  } catch { return '' }
36
42
  }
37
43
 
44
+ // @@@ private overlay ([[private-overlay]]) - the DEFAULT render commits .spec + spexcode.json and ignores its
45
+ // generated wiring through a managed block in the TRACKED <repo>/.gitignore; that only truly hides a file that
46
+ // is wholly ours, so on a host that ALREADY tracks CLAUDE.md/AGENTS.md/.gitignore the folded-in block still
47
+ // lands in a tracked file and leaks. Private mode (spexcode.local.json `private:true`) closes that: it moves the
48
+ // ignore block to the per-clone `.git/info/exclude` (never committed), widens it to hide .spec + spexcode.json
49
+ // too, and `skip-worktree`s any TRACKED contract file so the block sits in the working copy (the harness still
50
+ // discovers it) yet never stages. Every step has an inverse the DEFAULT mode re-asserts, so the toggle is
51
+ // reversible and idempotent. It hides UNTRACKED paths; a pre-existing tracked .spec must be `git rm --cached`d
52
+ // once (the render can't untrack for you — reported, not silently ignored).
53
+ function gitCommonDirOf(proj: string): string {
54
+ return git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
55
+ }
56
+ function infoExcludePath(proj: string): string {
57
+ return join(gitCommonDirOf(proj), 'info', 'exclude')
58
+ }
59
+ function isTracked(proj: string, file: string): boolean {
60
+ try { git(['-C', proj, 'ls-files', '--error-unmatch', file]); return true } catch { return false }
61
+ }
62
+ // mark/unmark a TRACKED file skip-worktree (a no-op on an untracked path — info/exclude hides those instead).
63
+ // Best-effort: an index race or a non-repo must not fail the render.
64
+ function setSkipWorktree(proj: string, file: string, on: boolean): void {
65
+ if (!isTracked(proj, file)) return
66
+ try { git(['-C', proj, 'update-index', on ? '--skip-worktree' : '--no-skip-worktree', file]) } catch { /* best-effort */ }
67
+ }
68
+
38
69
  // the whole pay-per-change render. proj defaults to cwd. Returns the new content-hash it stamped.
39
70
  export function materialize(proj = process.cwd()): string {
40
71
  const rt = runtimeRoot(proj) // global per-project store, not the worktree
41
72
  mkdirSync(rt, { recursive: true })
42
73
  // (1) hook manifest (persistent — the dispatcher reads it; regenerated only here, on change).
43
74
  writeFileSync(join(rt, 'hooks-manifest'), compileManifest())
44
- // (2) the contract = the surface:system bodies, in name order, written into EACH harness's contract file(s)
45
- // + (3) each harness's thin shim dispatch.sh + (4) its trust. All owned by the adapter.
46
- const contract = loadSystemConfig().map((c) => c.body.trim()).filter(Boolean).join('\n\n')
75
+ // (2) the contract = the tracked docs guide (the hand-written agent/contributor notes the ONE piece of
76
+ // in-tree prose) FOLLOWED BY the surface:system bodies (in name order), written WHOLE into EACH harness's
77
+ // contract file(s) + (3) each harness's thin shim → dispatch.sh + (4) its trust. All owned by the adapter.
78
+ // The contract files are generated artifacts (gitignored below), so the guide is the single source a
79
+ // self-launched agent reads from — assembling it in keeps guide + contract reaching the agent together.
80
+ const guidePath = join(proj, 'docs', 'AGENT_GUIDE.md')
81
+ const guide = existsSync(guidePath) ? readFileSync(guidePath, 'utf8').trim() : ''
82
+ const systemBodies = loadSystemConfig().map((c) => c.body.trim()).filter(Boolean)
83
+ const contract = [guide, ...systemBodies].filter(Boolean).join('\n\n')
84
+ // WHICH harnesses to deliver into ([[harness-select]]): the spexcode.json `harnesses` set (default = every
85
+ // native harness). resolveHarnessTargets FAILS LOUD on an illegal set (plugin+native, plugin w/o folder).
86
+ // selected harnesses are write()n below; unselected ones are clean()ed (pruned) after — so dropping a harness
87
+ // from the config removes its products on the next re-materialize. The plugin EMITTER is a later node.
88
+ const cfg = readConfig(mainCheckout(proj))
89
+ const targets = resolveHarnessTargets(cfg.harnesses)
90
+ const priv = !!cfg.private // [[private-overlay]] — hide in .git/info/exclude + skip-worktree, not the tracked .gitignore
91
+ const { selected, unselected, plugins } = partitionHarnesses(targets)
92
+ const skillNodes = loadSkillConfig()
93
+ const agentNodes = loadAgentConfig()
94
+ const commandNodes = loadConfig()
47
95
  // a skill node → the agentskills.io SKILL.md primitive: `name`+`description` frontmatter (the load-trigger)
48
96
  // over the body instructions. One pure render shared by every harness — divergence is only its skillDir.
49
97
  const renderSkill = (sk: { name: string; desc: string; body: string }) =>
50
98
  `---\nname: ${sk.name}\ndescription: ${JSON.stringify(sk.desc)}\n---\n\n${sk.body}\n`
99
+ // an agent node → a coding-agent sub-agent definition (the same primitive .claude/agents/*.md ships): the
100
+ // node's `desc` is the on-demand load-trigger, its `tools` the harness tool allowlist, its body the agent's
101
+ // system prompt. One pure render shared by every harness — divergence is only its agentDir.
102
+ const renderAgent = (ag: { name: string; desc: string; tools: string[]; body: string }) =>
103
+ `---\nname: ${ag.name}\ndescription: ${ag.desc}\ntools: ${ag.tools.join(', ')}\n---\n\n${ag.body}\n`
104
+ // a command node → a host `/`-menu command file: the node's `desc` as the dropdown description over its body
105
+ // (the preset prompt). Only the PLUGIN bundle ships these as files; the native path serves command presets via
106
+ // the dashboard /api/slash-commands instead, so this render is plugin-only.
107
+ const renderCommand = (cm: { desc: string; body: string }) =>
108
+ (cm.desc ? `---\ndescription: ${JSON.stringify(cm.desc)}\n---\n\n` : '') + `${cm.body}\n`
109
+ // shim ABSOLUTE paths; relativized into the managed .gitignore block below (checkout-invariant — see there).
51
110
  const shimPaths: string[] = []
52
- for (const h of HARNESSES) {
53
- if (contract) for (const f of h.contractFiles(proj)) writeManagedBlock(f, contract)
111
+ for (const h of selected) {
112
+ if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract); shimPaths.push(f); setSkipWorktree(proj, f, priv) }
54
113
  const shimFile = h.shimFile(proj)
55
114
  mkdirSync(dirname(shimFile), { recursive: true })
56
115
  const shim = h.shim(DISPATCH, SPEX)
57
116
  writeFileSync(shimFile, shim.json)
58
117
  h.writeTrust(proj, shim.cmd)
59
- shimPaths.push(relative(proj, shimFile))
118
+ shimPaths.push(shimFile)
119
+ // a linked-worktree ANCHOR copy of the shim, when the harness needs one (codex: the shim lives at the main
120
+ // checkout, so the worktree gets no `.codex/` unless we place one — and codex only discovers a worktree
121
+ // thread's hooks if a `.codex/` under the worktree root anchors the project layer). One adapter line; null
122
+ // for a harness whose shim already sits in the worktree (claude) or on the main checkout itself.
123
+ const anchor = h.worktreeHookAnchor(proj)
124
+ if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.json); shimPaths.push(anchor) }
60
125
  }
61
126
  // (6) skills - each `surface: skill` node → a SKILL.md the harness auto-discovers, written into every
62
127
  // harness's own skillDir (Claude .claude/skills, Codex .codex/skills). Generated wiring, so the paths
63
128
  // join the same managed .gitignore block below. A harness with no skill primitive (skillDir null) is skipped.
64
- for (const sk of loadSkillConfig()) {
65
- for (const h of HARNESSES) {
129
+ for (const sk of skillNodes) {
130
+ for (const h of selected) {
66
131
  const dir = h.skillDir(proj); if (!dir) continue
67
132
  const f = join(dir, sk.name, 'SKILL.md')
68
133
  mkdirSync(dirname(f), { recursive: true })
69
134
  writeFileSync(f, renderSkill(sk))
70
- shimPaths.push(relative(proj, f)) // reuse the same managed .gitignore block
135
+ shimPaths.push(f) // reuse the same managed .gitignore block (absolute; relativized below)
136
+ }
137
+ }
138
+ // (7) sub-agents - each `surface: agent` node → a <name>.md the harness auto-discovers, written into every
139
+ // harness's own agentDir (Claude .claude/agents). The SAME pattern as skills: generated wiring, so the
140
+ // paths join the same managed .gitignore block below. A harness with no agent primitive (agentDir null,
141
+ // e.g. Codex) is skipped — no `if (codex)`, the divergence is the adapter's agentDir line.
142
+ for (const ag of agentNodes) {
143
+ for (const h of selected) {
144
+ const dir = h.agentDir(proj); if (!dir) continue
145
+ const f = join(dir, `${ag.name}.md`)
146
+ mkdirSync(dirname(f), { recursive: true })
147
+ writeFileSync(f, renderAgent(ag))
148
+ shimPaths.push(f) // reuse the same managed .gitignore block (absolute; relativized below)
149
+ }
150
+ }
151
+ // (8) PRUNE every UNSELECTED harness — clean() is the surgical inverse of the write above, removing ONLY this
152
+ // harness's own managed block + generated shim + trust + named skill/agent files. So narrowing the
153
+ // spexcode.json `harnesses` set (or switching to a plugin, which excludes all natives) removes the
154
+ // dropped harness's products here, the user's own prose/data untouched. The names tell clean exactly
155
+ // which on-demand artifacts were its to remove ([[harness-select]] / [[harness-adapter]]).
156
+ const arts: HarnessArtifacts = { skills: skillNodes.map((s) => s.name), agents: agentNodes.map((a) => a.name) }
157
+ for (const h of unselected) h.clean(proj, arts)
158
+ // (8b) the PLUGIN target ([[plugin-harness]]): render the whole system into one self-contained Claude-plugin
159
+ // bundle per selected folder. A plugin is EXCLUSIVE (so `selected` is already empty — every native was
160
+ // pruned above). Pruning a DESELECTED plugin folder (plugin→native, or folder A→B) needs the PREVIOUS
161
+ // folder set, which the live config no longer names — so a tiny ledger in the global store records the
162
+ // folders emitted last run; any prev folder absent from the current set is clean()ed (the bundle's
163
+ // inverse), then the current folders are emitted and the ledger rewritten. Bounded + surgical: cleanPlugin
164
+ // is identity-gated on the bundle's own plugin.json.
165
+ const ledger = join(rt, 'plugin-folders')
166
+ const prevFolders = existsSync(ledger) ? readFileSync(ledger, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean) : []
167
+ const curFolders = plugins.map((p) => p.folder)
168
+ for (const f of prevFolders) if (!curFolders.includes(f)) cleanPlugin(proj, f)
169
+ if (plugins.length) {
170
+ const render = {
171
+ contract,
172
+ skills: skillNodes.map((s) => ({ name: s.name, content: renderSkill(s) })),
173
+ agents: agentNodes.map((a) => ({ name: a.name, content: renderAgent(a) })),
174
+ commands: commandNodes.map((c) => ({ name: c.name, content: renderCommand(c) })),
175
+ spex: SPEX,
176
+ version: pluginVersion(),
71
177
  }
178
+ for (const p of plugins) emitPlugin(proj, p.folder, render)
179
+ }
180
+ writeFileSync(ledger, curFolders.join('\n'))
181
+ // (4b) every artifact this render writes IN-TREE is generated wiring, so gitignore it — regenerated per
182
+ // clone/launch by this same gate, never committed. That now includes the CONTRACT files (CLAUDE.md/AGENTS.md):
183
+ // their whole content is the generated guide+system block, so they are artifacts exactly like the shims +
184
+ // skills + sub-agents — the only tracked prose is the guide SOURCE (docs/AGENT_GUIDE.md), which this render
185
+ // reads. Derived from the adapters' own contractFiles()/shimFile()/skillDir/agentDir, not hardcoded; written
186
+ // as a managed `#` block so the user's own .gitignore is preserved.
187
+ // The managed block must be CHECKOUT-INVARIANT: `.gitignore` is ONE tracked, committed file shared by the
188
+ // main checkout and every worktree, so if the block differed by where materialize ran, whichever flavor got
189
+ // committed would leave the OTHER checkout dirtying it on every render. The only artifact that varies is the
190
+ // codex hooks shim, which materializes at the MAIN checkout (a linked worktree's codex reads the root's
191
+ // hooks — see harness.ts): from main it is `.codex/hooks.json` (inside proj), from a worktree it is `../…`
192
+ // (escapes proj). So an ignore entry is anchored to the checkout it LIVES under — proj-relative when inside
193
+ // proj, else MAIN-checkout-relative — which resolves the codex shim to `.codex/hooks.json` from any checkout
194
+ // (a pattern naming a main-only path is a harmless no-op in a worktree). Every checkout now emits the same
195
+ // set → the committed `.gitignore` is stable, never re-dirtied. A path under neither root is not ignorable
196
+ // here (dropped).
197
+ const mc = mainCheckout(proj)
198
+ const anchor = (abs: string): string | null => {
199
+ const p = relative(proj, abs); if (!p.startsWith('..')) return p
200
+ const m = relative(mc, abs); if (!m.startsWith('..')) return m
201
+ return null
72
202
  }
73
- // (4b) the shims are machine-specific generated wiring (they bake this machine's absolute install path), so
74
- // gitignore them — regenerated per-machine by this same gate. Derived from the adapters' shimFile(), not
75
- // hardcoded; written as a managed `#` block so the user's own .gitignore is preserved. Keeps the worktree
76
- // free of tracked machine-specific files (the contract md files stay tracked — they carry the user's prose).
77
- // only ignore paths that live INSIDE proj. The codex hooks shim now materializes at the MAIN checkout (codex
78
- // reads a linked worktree's hooks from the root checkout — see harness.ts); from a linked worktree that path
79
- // escapes proj (`../…`) and is gitignored by the main checkout's OWN materialize, not the worktree's.
80
203
  // spexcode.local.json — the machine-local config overlay (host-specific values, e.g. an absolute worker
81
204
  // launcher path; see portable-layout) — joins the SAME block on the same rationale: machine-specific, must
82
205
  // never be committed. Without it an adopter who follows our own guidance to put a host path there would
83
206
  // `git add -A` and leak it — the exact thing the overlay exists to prevent.
84
- const ignorable = [...shimPaths.filter((p) => !p.startsWith('..')), 'spexcode.local.json']
85
- if (ignorable.length) writeManagedBlock(join(proj, '.gitignore'), ignorable.sort().join('\n'), ['# ', ''])
207
+ // each emitted plugin bundle is a generated, machine-local artifact too (its hooks.json bakes THIS install's
208
+ // SPEX path), so its dir joins the same managed block — regenerated per clone/launch, never committed.
209
+ const bundlePaths = curFolders.map((f) => pluginBundleDir(proj, f))
210
+ // SpexCode's own SESSION artifacts also join the managed block: a launch creates a worktree under
211
+ // `.worktrees/` (hardcoded in sessions.ts) plus a per-worktree `.session` state file the layout linker
212
+ // reads. Neither is the adopter's code, both are per-clone/never-committed — but until now materialize
213
+ // never ignored them, so an ADOPTED project leaked its session worktrees into `git status` (a `git add -A`
214
+ // would commit them). Static strings, so they stay checkout-invariant. The dogfood hand-lists these; every
215
+ // other repo gets them here, in the default `.gitignore` block AND (widened) the private `.git/info/exclude`.
216
+ const sessionIgnores = ['.worktrees/', '.session']
217
+ const ignorable = [...[...shimPaths, ...bundlePaths].map(anchor).filter((p): p is string => p !== null), 'spexcode.local.json', ...sessionIgnores]
218
+ const gitignore = join(proj, '.gitignore')
219
+ if (priv) {
220
+ // PRIVATE ([[private-overlay]]): hide EVERYTHING — including the .spec tree + spexcode.json the DEFAULT
221
+ // mode commits ("git is the database") — in the per-clone `.git/info/exclude` (never committed, never
222
+ // shared), and strip any managed block a prior DEFAULT render left in the tracked .gitignore so switching
223
+ // INTO private mode leaves the host's shared history clean.
224
+ const entries = [...new Set([...ignorable, '.spec/', 'spexcode.json'])].sort().join('\n')
225
+ writeManagedBlock(infoExcludePath(proj), entries, ['# ', ''])
226
+ removeManagedBlock(gitignore, ['# ', ''], true)
227
+ // the ONE manual step private mode can't do for you: .git/info/exclude hides UNTRACKED paths only, so a
228
+ // .spec/spexcode.json ALREADY committed (a repo that adopted DEFAULT mode first) stays visible until
229
+ // un-tracked. Surface it loudly rather than leaving a silent leak — the agent doing setup acts on this.
230
+ const stillTracked = ['.spec', 'spexcode.json'].filter((p) => isTracked(proj, p))
231
+ if (stillTracked.length) console.error(
232
+ `spexcode private: ${stillTracked.join(' + ')} still git-tracked — private mode hides UNTRACKED paths only.\n` +
233
+ ` → run once: git rm -r --cached ${stillTracked.join(' ')} (keeps the files on disk, stops tracking them; then commit on your branch)`,
234
+ )
235
+ } else {
236
+ // DEFAULT: the checkout-invariant managed block in the TRACKED .gitignore (rationale above); strip any
237
+ // private exclude block so switching OUT of private mode is equally clean.
238
+ if (ignorable.length) writeManagedBlock(gitignore, [...new Set(ignorable)].sort().join('\n'), ['# ', ''])
239
+ removeManagedBlock(infoExcludePath(proj), ['# ', ''], false)
240
+ }
86
241
  // (5) stamp the content-hash marker LAST (so a crash mid-render leaves it stale → re-renders next gate).
87
242
  const h = contentHash(proj)
88
243
  writeFileSync(join(rt, 'content-hash'), h)
@@ -0,0 +1,192 @@
1
+ // @@@ mentions - the two universal in-text reference primitives ([[mentions]]): `[[node]]` (a TOPIC — a spec
2
+ // node) and `@session` (an ACTOR — a live session, or `@new` for a fresh worker). The same parser resolves
3
+ // them in ANY input box; the resolve+dispatch live HERE (CLI-first) so the issues page, the composer, and an agent's
4
+ // own prompt share one implementation. An `@` "just auto-sends a prompt": resolve it against the live board
5
+ // sessions and dispatch via [[dispatch]]'s sendKeys / [[launch]]'s newSession — storage and delivery stay
6
+ // separate, and sessions.ts is imported LAZILY so a mention-free post pays nothing.
7
+
8
+ // ── parse (pure) ──────────────────────────────────────────────────────────────────────────────────────
9
+ // `@<token>` at a word boundary is an actor; `[[<id>]]` is a topic. Tokens are [A-Za-z0-9_-]; a session id,
10
+ // a short label/prefix, or the literal `new`. Both forms are deduped in first-seen order.
11
+ const ACTOR_RE = /(?:^|\s)@([A-Za-z0-9_-]+)/g
12
+ const NODE_RE = /\[\[([^\]\s]+)\]\]/g
13
+
14
+ const uniq = (xs: string[]): string[] => [...new Set(xs)]
15
+
16
+ export function parseMentions(text: string): { actors: string[]; nodes: string[] } {
17
+ const actors: string[] = []
18
+ const nodes: string[] = []
19
+ for (const m of text.matchAll(ACTOR_RE)) actors.push(m[1])
20
+ for (const m of text.matchAll(NODE_RE)) nodes.push(m[1])
21
+ return { actors: uniq(actors), nodes: uniq(nodes) }
22
+ }
23
+
24
+ // ── resolve actors against live sessions (pure) ───────────────────────────────────────────────────────
25
+ // A minimal shape of what dispatchMentions needs from a Session (so this stays testable without sessions.ts).
26
+ export type ActorSession = { id: string; node: string | null; name: string | null; title: string | null; liveness: string }
27
+ export type Resolved =
28
+ | { token: string; kind: 'new' }
29
+ | { token: string; kind: 'session'; session: ActorSession }
30
+ | { token: string; kind: 'unresolved' }
31
+
32
+ // Resolve each actor token to a `new` sentinel, an ONLINE session (never a dead one — you don't summon a
33
+ // closed agent; `@new` acts on its behalf), or unresolved. Match order: literal `new` → exact id → id prefix
34
+ // → name/title (case-insensitive) → name/title prefix. First online match wins.
35
+ export function resolveActors(tokens: string[], sessions: ActorSession[]): Resolved[] {
36
+ const online = sessions.filter((s) => s.liveness === 'online')
37
+ return tokens.map((token): Resolved => {
38
+ if (token === 'new') return { token, kind: 'new' }
39
+ const t = token.toLowerCase()
40
+ const label = (s: ActorSession) => (s.name || s.title || '').toLowerCase()
41
+ const hit =
42
+ online.find((s) => s.id === token) ||
43
+ online.find((s) => s.id.startsWith(token)) ||
44
+ online.find((s) => label(s) === t) ||
45
+ online.find((s) => label(s).startsWith(t) && t.length >= 2)
46
+ return hit ? { token, kind: 'session', session: hit } : { token, kind: 'unresolved' }
47
+ })
48
+ }
49
+
50
+ // ── dispatch (integration) ─────────────────────────────────────────────────────────────────────────────
51
+ export type DispatchOutcome = { token: string; result: 'sent' | 'spawned' | 'offline' | 'unresolved' | 'failed'; detail?: string; note?: string }
52
+
53
+ // The prompt an @-mentioned actor receives: the comment verbatim + a pointer back to the thread. It is a
54
+ // nudge to look, never a rigid command — what to do is up to the comment's words.
55
+ function mentionPrompt(threadId: string, node: string | null, author: string, text: string): string {
56
+ const re = node ? ` (re: ${node})` : ''
57
+ return `You were @-mentioned in issue thread "${threadId}"${re} by ${author}:\n\n ${text.trim()}\n\n` +
58
+ `Read the thread and act as the comment asks (often just a look): \`spex issues --all\` lists them; ` +
59
+ `reply with \`spex issues reply ${threadId} --body -\`.`
60
+ }
61
+ // A non-open thread is settled work: a fresh worker spawned onto it must not re-implement what already
62
+ // landed, so the prompt leads with the status and a verify-on-main-first instruction.
63
+ export function newWorkerPrompt(threadId: string, node: string | null, author: string, text: string, status?: string | null): string {
64
+ const on = node ? ` on node ${node}` : ''
65
+ const settled = status && status !== 'open'
66
+ ? `NOTE: this thread is already resolved (status: ${status}) — the work it describes has likely LANDED. ` +
67
+ `Verify the current state on main FIRST; if main already satisfies the thread, reply with that finding ` +
68
+ `instead of re-implementing.\n\n`
69
+ : ''
70
+ return `Issue thread "${threadId}"${on} @-mentioned @new (by ${author}) for a fresh look:\n\n ${text.trim()}\n\n` +
71
+ settled +
72
+ `Read the thread (\`spex issues --all\`, find ${threadId}) and act on it${node ? `; the relevant node is ${node}` : ''}.`
73
+ }
74
+
75
+ // Parse a committed issue post's text for `@` actors and deliver to each. Best-effort and LOUD: the thread is
76
+ // already committed, so a failed dispatch never fails the post — it is reported. sessions.ts is imported
77
+ // lazily; with no actor mentions this returns [] without loading it or hitting the backend.
78
+ export async function dispatchMentions(
79
+ text: string,
80
+ ctx: { threadId: string; node: string | null; author: string; status?: string | null },
81
+ ): Promise<DispatchOutcome[]> {
82
+ const { actors } = parseMentions(text)
83
+ if (!actors.length) return []
84
+ const { sendKeys, listSessions, newSession } = await import('./sessions.js')
85
+ const sessions = await listSessions()
86
+ const resolved = resolveActors(actors, sessions as unknown as ActorSession[])
87
+ const out: DispatchOutcome[] = []
88
+ for (const r of resolved) {
89
+ if (r.kind === 'unresolved') { out.push({ token: r.token, result: 'unresolved' }); continue }
90
+ if (r.kind === 'new') {
91
+ // the drain guard ([[mentions]]): @new on a settled thread still spawns (the summons may be a
92
+ // deliberate audit/re-measure), but the worker prompt carries the status and the outcome line warns.
93
+ const settled = ctx.status && ctx.status !== 'open' ? ctx.status : undefined
94
+ try {
95
+ const s = await newSession(ctx.node, newWorkerPrompt(ctx.threadId, ctx.node, ctx.author, text, ctx.status))
96
+ out.push({ token: r.token, result: 'spawned', detail: s.id, ...(settled ? { note: `thread ${settled}` } : {}) })
97
+ } catch (e) { out.push({ token: r.token, result: 'failed', detail: e instanceof Error ? e.message : String(e) }) }
98
+ continue
99
+ }
100
+ const res = await sendKeys(r.session.id, mentionPrompt(ctx.threadId, ctx.node, ctx.author, text), 'issues')
101
+ out.push(res.ok ? { token: r.token, result: 'sent', detail: r.session.id }
102
+ : { token: r.token, result: 'offline', detail: res.error })
103
+ }
104
+ return out
105
+ }
106
+
107
+ // ── implicit originator loop-in + the dispatch fallback chain ([[mentions]] / [[remark-substrate]] R3) ────
108
+ // A committed reply is ALSO auto-delivered as a COURTESY — never an assignment — to a FALLBACK CHAIN of
109
+ // candidates, in order, stopping at the FIRST one that can be reached: for a remark this is the reading's
110
+ // filer session, then the node's governing session, then nobody (it still surfaces on the board via the
111
+ // teeth). This is a NOTIFICATION chain only — it resolves NOTHING (resolve stays a deliberate `spex resolve`,
112
+ // R3); it just reaches an agent who can act. It is the same delivery pipe as dispatchMentions (one
113
+ // online-resolution + one sendKeys), with the same cuts that keep courtesy ≠ assignment: deliver ONLY to an
114
+ // ONLINE session (an unreachable link is skipped for the next, NEVER spawns a worker, NEVER drains — only an
115
+ // explicit @new spawns); SKIP a candidate that is the replier (no self-notify); a candidate already reached by
116
+ // an explicit @-target of this same text counts as delivered, so the chain STOPS (no double-delivery, no
117
+ // needless escalation). Store-agnostic: a forge issue's author is a github login, resolves to no live session,
118
+ // and the chain runs dry silently — exactly right.
119
+ export type LoopIn = { originator: string } // the candidate we actually reached (a filer OR a governing-session fallback)
120
+
121
+ // The courtesy prompt — framed as a heads-up, never a command (that is what an @-mention's mentionPrompt is).
122
+ function originatorPrompt(threadId: string, node: string | null, replier: string, text: string): string {
123
+ const re = node ? ` (re: ${node})` : ''
124
+ return `A new reply landed on a thread you originated — "${threadId}"${re}, from ${replier}:\n\n ${text.trim()}\n\n` +
125
+ `This is a courtesy heads-up (you started this thread), not an assignment. Look if it concerns you; ` +
126
+ `\`spex issues --all\` lists them, reply with \`spex issues reply ${threadId} --body -\`.`
127
+ }
128
+
129
+ // The pure fallback decision (testable without sessions.ts): walk the ordered chain (nulls/dupes/the-replier
130
+ // pruned) and return the FIRST link that resolves to an online session — that is who the courtesy goes to. A
131
+ // link already reached by an explicit @-target of this same text short-circuits to `reached` (stop, no
132
+ // double-delivery — the actor already has it); an offline/absent link falls through to the next. `none` means
133
+ // the chain ran dry (nobody online). This is the whole fallback logic; delivery is a thin sendKeys around it.
134
+ export type LoopInPick =
135
+ | { kind: 'deliver'; originator: string; session: ActorSession }
136
+ | { kind: 'reached' }
137
+ | { kind: 'none' }
138
+ export function pickLoopIn(
139
+ chain: (string | null)[],
140
+ replier: string,
141
+ sessions: ActorSession[],
142
+ alreadyDelivered?: Set<string>,
143
+ ): LoopInPick {
144
+ const seen = new Set<string>()
145
+ const candidates = chain.filter((c): c is string => !!c && c !== replier && !seen.has(c) && (seen.add(c), true))
146
+ for (const originator of candidates) {
147
+ const [resolved] = resolveActors([originator], sessions)
148
+ if (resolved.kind !== 'session') continue // offline / no live session → try the next fallback link
149
+ if (alreadyDelivered?.has(resolved.session.id)) return { kind: 'reached' } // already an explicit @-target: stop
150
+ return { kind: 'deliver', originator, session: resolved.session }
151
+ }
152
+ return { kind: 'none' }
153
+ }
154
+
155
+ // `chain` is the ordered fallback list. We deliver the courtesy to the first online link and STOP; an
156
+ // offline/failed link falls through to the next. NOTIFICATION ONLY — this never touches a `resolved` bit
157
+ // (resolve is a deliberate `spex resolve`), never spawns (only `@new` spawns).
158
+ export async function notifyOriginator(
159
+ chain: (string | null)[],
160
+ replier: string,
161
+ text: string,
162
+ ctx: { threadId: string; node: string | null; alreadyDelivered?: Set<string> },
163
+ ): Promise<LoopIn | null> {
164
+ const seen = new Set<string>()
165
+ if (!chain.some((c) => c && c !== replier && !seen.has(c) && (seen.add(c), true))) return null // nothing to do → no session load
166
+ const { sendKeys, listSessions } = await import('./sessions.js')
167
+ const pick = pickLoopIn(chain, replier, await listSessions() as unknown as ActorSession[], ctx.alreadyDelivered)
168
+ if (pick.kind !== 'deliver') return null // reached via @ / nobody online → silent
169
+ const res = await sendKeys(pick.session.id, originatorPrompt(ctx.threadId, ctx.node, replier, text), 'issues')
170
+ return res.ok ? { originator: pick.originator } : null // a failed send behaves like offline: silent
171
+ }
172
+
173
+ // The set of session ids a dispatch ALREADY reached (sent or spawned) — what the loop-in skips to avoid a
174
+ // double-delivery to an originator who was also an explicit @-target.
175
+ export function deliveredIds(outcomes: DispatchOutcome[]): Set<string> {
176
+ return new Set(outcomes.filter((o) => (o.result === 'sent' || o.result === 'spawned') && o.detail).map((o) => o.detail!))
177
+ }
178
+
179
+ // A one-line human summary of what a dispatch did, for the CLI to echo after a post. The optional loop-in is
180
+ // noted DISTINCT from the @-dispatch — a courtesy copy, not an assignment.
181
+ export function summarize(outcomes: DispatchOutcome[], loopIn?: LoopIn | null): string {
182
+ const parts: string[] = []
183
+ if (outcomes.length) parts.push('@ ' + outcomes.map((o) => {
184
+ if (o.result === 'sent') return `${o.token}→sent`
185
+ if (o.result === 'spawned') return `new→${o.detail}${o.note ? ` ⚠ ${o.note} — likely already landed` : ''}`
186
+ if (o.result === 'offline') return `${o.token}→offline (stored)`
187
+ if (o.result === 'unresolved') return `${o.token}→? (no live session; stored)`
188
+ return `${o.token}→failed (${o.detail})`
189
+ }).join(' · '))
190
+ if (loopIn) parts.push(`↩ looped in originator @${loopIn.originator} (online)`)
191
+ return parts.join(' · ')
192
+ }
@@ -0,0 +1,145 @@
1
+ import { writeFileSync, mkdirSync, readFileSync, existsSync, rmSync, copyFileSync } from 'node:fs'
2
+ import { join, dirname } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ // @@@ plugin-harness - the PLUGIN BUNDLE emitter: render 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` → <folder>/plugins/spexcode/). It is the plugin-target counterpart of the native per-harness
8
+ // write [[harness-adapter]] does — chosen INSTEAD of the natives (plugin exclusivity), so [[harness-delivery]]'s
9
+ // materialize prunes every native first, then emits this. The bundle follows the de-facto Claude-plugin schema
10
+ // (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.
13
+ //
14
+ // The contract is NOT delivered by an always-on CLAUDE.md block here (the bundle never touches the repo's own
15
+ // 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
+ // --append-system-prompt. The hooks reuse the SAME dispatch.sh wiring as the natives, located via the host's
18
+ // ${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
+
21
+ const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
22
+ const HOOKS_SRC = join(PKG, 'hooks') // the canonical dispatch.sh + harness.sh source
23
+ const PLUGIN_NAME = 'spexcode'
24
+ // 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
+ // where the host scanned the bundle from.
27
+ 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
+ // never invokes the extras, so binding all is harmless and one emit serves every host.
30
+ const PLUGIN_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'StopFailure', 'Notification'] as const
31
+
32
+ // the gathered surface artifacts this render writes into the bundle. contract = the assembled guide+system
33
+ // block (the SAME string the native path folds into CLAUDE.md, here delivered as additionalContext); skills/
34
+ // agents/commands are the already-rendered file CONTENTS (reusing materialize's renderSkill/renderAgent), keyed
35
+ // by node name; spex is the baked tsx+cli invocation the dispatcher gate calls; version stamps plugin.json.
36
+ export type PluginRender = {
37
+ contract: string
38
+ skills: { name: string; content: string }[]
39
+ agents: { name: string; content: string }[]
40
+ commands: { name: string; content: string }[]
41
+ spex: string
42
+ version: string
43
+ }
44
+
45
+ // the bundle dir for a plugin folder: <proj>/<folder>/plugins/spexcode — `<folder>` is the host's plugins-scan
46
+ // root ([[harness-select]] requires it explicit), `plugins/<name>` the conventional bundle location under it.
47
+ export function pluginBundleDir(proj: string, folder: string): string {
48
+ return join(proj, folder, 'plugins', PLUGIN_NAME)
49
+ }
50
+
51
+ // the de-facto Claude-plugin manifest: name (the bundle identity clean() gates on), version, description, and
52
+ // the component pointers Claude/z-code discover (hooks.json + the skills/commands/agents dirs).
53
+ function pluginManifest(version: string): string {
54
+ return JSON.stringify({
55
+ name: PLUGIN_NAME,
56
+ version,
57
+ description: 'SpexCode — spec-driven dev-flow contract, hooks, skills, commands & agents as one self-contained plugin.',
58
+ hooks: './hooks/hooks.json',
59
+ commands: './commands',
60
+ agents: './agents',
61
+ skills: './skills',
62
+ }, null, 2)
63
+ }
64
+
65
+ // hooks.json in the Claude/z-code-compatible shape { "hooks": { "<Event>": [{ "hooks": [command…] }] } }. Every
66
+ // event → the SHARED dispatch.sh (`plugin` baked as its harness id, SPEX inherited for the gate); SessionStart
67
+ // ALSO runs inject-contract.sh first, so the contract additionalContext lands alongside the normal dispatch.
68
+ function pluginHooksJson(spex: string): string {
69
+ const dispatch = (e: string) => `SPEX='${spex}' bash "${PLUGIN_ROOT}/hooks/dispatch.sh" plugin ${e}`
70
+ const inject = `bash "${PLUGIN_ROOT}/hooks/inject-contract.sh"`
71
+ const hooks: Record<string, unknown> = {}
72
+ for (const e of PLUGIN_EVENTS) {
73
+ const cmds = e === 'SessionStart'
74
+ ? [{ type: 'command', command: inject }, { type: 'command', command: dispatch(e) }]
75
+ : [{ type: 'command', command: dispatch(e) }]
76
+ hooks[e] = [{ hooks: cmds }]
77
+ }
78
+ return JSON.stringify({ hooks }, null, 2)
79
+ }
80
+
81
+ // the SessionStart hook OUTPUT carrying the contract as additionalContext. JSON-encoded HERE, at render time,
82
+ // so the runtime hook is a trivial `cat` — never a fragile shell escaping of arbitrary contract prose.
83
+ function contractContextJson(contract: string): string {
84
+ return JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: contract } }, null, 2)
85
+ }
86
+
87
+ // the SessionStart contract injector: print the pre-encoded additionalContext payload (the harness-neutral
88
+ // stand-in for --append-system-prompt). A bare cat — all the encoding happened at materialize time.
89
+ const INJECT_SH = `#!/usr/bin/env bash
90
+ # Emit the SpexCode contract as SessionStart additionalContext — the harness-neutral contract injection (the
91
+ # superpowers pattern; Claude/z-code normalize hookSpecificOutput.additionalContext) that replaces a plugin
92
+ # host's missing --append-system-prompt. The JSON was rendered at materialize time, so this is a trivial cat.
93
+ here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
94
+ [ -f "$here/contract-context.json" ] && cat "$here/contract-context.json"
95
+ exit 0
96
+ `
97
+
98
+ // emit (or idempotently re-emit) the whole bundle into <folder>/plugins/spexcode.
99
+ export function emitPlugin(proj: string, folder: string, r: PluginRender): void {
100
+ const bundle = pluginBundleDir(proj, folder)
101
+ const meta = join(bundle, '.claude-plugin')
102
+ const hooksDir = join(bundle, 'hooks')
103
+ mkdirSync(meta, { recursive: true })
104
+ mkdirSync(hooksDir, { recursive: true })
105
+ writeFileSync(join(meta, 'plugin.json'), pluginManifest(r.version))
106
+ // hooks: the SHARED dispatcher + its shell mirror (copied verbatim — the exact native wiring), the contract
107
+ // injector + its pre-encoded payload, and the event→dispatch binding.
108
+ copyFileSync(join(HOOKS_SRC, 'dispatch.sh'), join(hooksDir, 'dispatch.sh'))
109
+ copyFileSync(join(HOOKS_SRC, 'harness.sh'), join(hooksDir, 'harness.sh'))
110
+ writeFileSync(join(hooksDir, 'inject-contract.sh'), INJECT_SH)
111
+ writeFileSync(join(hooksDir, 'contract-context.json'), contractContextJson(r.contract))
112
+ writeFileSync(join(hooksDir, 'hooks.json'), pluginHooksJson(r.spex))
113
+ // skills / agents / commands — the Claude-plugin layout, the SAME rendered contents as the native dirs.
114
+ for (const s of r.skills) writeBundleFile(join(bundle, 'skills', s.name, 'SKILL.md'), s.content)
115
+ for (const a of r.agents) writeBundleFile(join(bundle, 'agents', `${a.name}.md`), a.content)
116
+ for (const c of r.commands) writeBundleFile(join(bundle, 'commands', `${c.name}.md`), c.content)
117
+ }
118
+
119
+ function writeBundleFile(f: string, content: string): void {
120
+ mkdirSync(dirname(f), { recursive: true })
121
+ writeFileSync(f, content)
122
+ }
123
+
124
+ // the INVERSE of emitPlugin — prune the bundle when its folder is DESELECTED ([[harness-delivery]] tracks the
125
+ // previously-emitted folders and cleans any the current set dropped, e.g. switching plugin→native or folder A→B).
126
+ // Identity-gated on the bundle's own plugin.json `name`, so it removes ONLY a spexcode bundle, never a folder
127
+ // the user populated with another plugin.
128
+ export function cleanPlugin(proj: string, folder: string): void {
129
+ const bundle = pluginBundleDir(proj, folder)
130
+ const manifest = join(bundle, '.claude-plugin', 'plugin.json')
131
+ if (!existsSync(manifest)) return
132
+ try {
133
+ if (String(JSON.parse(readFileSync(manifest, 'utf8'))?.name) !== PLUGIN_NAME) return
134
+ } catch { return }
135
+ rmSync(bundle, { recursive: true, force: true })
136
+ }
137
+
138
+ // the bundle version stamped into plugin.json — the monorepo root `spexcode` package version (the published
139
+ // artifact's version), falling back to the spec-cli package, then 0.0.0.
140
+ export function pluginVersion(): string {
141
+ for (const p of [join(PKG, '..', 'package.json'), join(PKG, 'package.json')]) {
142
+ try { const v = JSON.parse(readFileSync(p, 'utf8'))?.version; if (v) return String(v) } catch { /* keep trying */ }
143
+ }
144
+ return '0.0.0'
145
+ }