spexcode 0.1.6 → 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.
- package/README.md +86 -25
- package/package.json +5 -6
- package/spec-cli/README.md +86 -0
- package/spec-cli/bin/spex.mjs +15 -3
- package/spec-cli/hooks/dispatch.sh +20 -8
- package/spec-cli/hooks/harness.sh +18 -11
- package/spec-cli/src/board.ts +47 -18
- package/spec-cli/src/boardCache.ts +70 -0
- package/spec-cli/src/boardDelta.ts +90 -0
- package/spec-cli/src/boardStream.ts +178 -0
- package/spec-cli/src/cli.ts +172 -119
- package/spec-cli/src/client.ts +6 -4
- package/spec-cli/src/gateway.ts +49 -13
- package/spec-cli/src/git.ts +105 -92
- package/spec-cli/src/guide.ts +175 -12
- package/spec-cli/src/harness-select.ts +63 -0
- package/spec-cli/src/harness.ts +506 -100
- package/spec-cli/src/help.ts +360 -0
- package/spec-cli/src/hooks.ts +0 -14
- package/spec-cli/src/index.ts +272 -32
- package/spec-cli/src/init.ts +41 -1
- package/spec-cli/src/issues.ts +250 -0
- package/spec-cli/src/layout.ts +70 -28
- package/spec-cli/src/lint.ts +12 -10
- package/spec-cli/src/listen.ts +28 -0
- package/spec-cli/src/localIssues.ts +683 -0
- package/spec-cli/src/materialize.ts +182 -27
- package/spec-cli/src/mentions.ts +192 -0
- package/spec-cli/src/plugin-harness.ts +145 -0
- package/spec-cli/src/pty-bridge.ts +378 -81
- package/spec-cli/src/self.ts +123 -20
- package/spec-cli/src/sessions.ts +461 -298
- package/spec-cli/src/specs.ts +55 -14
- package/spec-cli/src/supervise.ts +23 -3
- package/spec-cli/src/tsx-bin.ts +14 -5
- package/spec-cli/src/uninstall.ts +146 -0
- package/spec-cli/templates/hooks/post-merge +27 -0
- package/spec-cli/templates/hooks/pre-commit +51 -31
- package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
- package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
- package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
- package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
- package/spec-cli/templates/spec/project/.config/spec.md +3 -3
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
- package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +2 -2
- package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
- package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
- package/spec-dashboard/dist/index.html +17 -5
- package/spec-forge/src/cache.ts +16 -0
- package/spec-forge/src/drivers/github.ts +74 -4
- package/spec-forge/src/port.ts +25 -0
- package/spec-forge/src/resident.ts +40 -6
- package/spec-yatsu/src/cli.ts +227 -38
- package/spec-yatsu/src/evaltab.ts +169 -19
- package/spec-yatsu/src/filing.ts +48 -0
- package/spec-yatsu/src/freshness.ts +55 -20
- package/spec-yatsu/src/proof.ts +89 -3
- package/spec-yatsu/src/scenariofresh.ts +92 -0
- package/spec-yatsu/src/sidecar.ts +75 -11
- package/spec-yatsu/src/timeline.ts +47 -0
- package/spec-yatsu/src/yatsu.ts +47 -3
- package/spec-cli/src/relay.ts +0 -28
- package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
- package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
- 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 {
|
|
8
|
-
import {
|
|
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
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
|
45
|
-
//
|
|
46
|
-
|
|
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
|
|
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(
|
|
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
|
|
65
|
-
for (const h of
|
|
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(
|
|
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
|
-
|
|
85
|
-
|
|
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
|
+
}
|