spexcode 0.2.4 → 0.2.6

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 (69) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +13 -10
  4. package/spec-cli/hooks/dispatch.sh +28 -40
  5. package/spec-cli/hooks/harness.sh +43 -7
  6. package/spec-cli/src/board.ts +2 -1
  7. package/spec-cli/src/boardCache.ts +27 -1
  8. package/spec-cli/src/boardStream.ts +5 -4
  9. package/spec-cli/src/cli.ts +93 -20
  10. package/spec-cli/src/commit-surgery.ts +81 -0
  11. package/spec-cli/src/contract-filter.ts +156 -0
  12. package/spec-cli/src/doctor.ts +11 -5
  13. package/spec-cli/src/git.ts +18 -4
  14. package/spec-cli/src/guide.ts +94 -38
  15. package/spec-cli/src/harness-select.ts +2 -2
  16. package/spec-cli/src/harness.ts +22 -8
  17. package/spec-cli/src/help.ts +28 -16
  18. package/spec-cli/src/index.ts +9 -6
  19. package/spec-cli/src/init.ts +17 -10
  20. package/spec-cli/src/issues.ts +7 -5
  21. package/spec-cli/src/layout.ts +31 -13
  22. package/spec-cli/src/lint.ts +19 -0
  23. package/spec-cli/src/localIssues.ts +16 -4
  24. package/spec-cli/src/materialize.ts +214 -144
  25. package/spec-cli/src/mentions.ts +5 -3
  26. package/spec-cli/src/plugin-harness.ts +10 -9
  27. package/spec-cli/src/ranker.ts +25 -8
  28. package/spec-cli/src/runtime-guard.ts +44 -0
  29. package/spec-cli/src/search.bench.mjs +15 -5
  30. package/spec-cli/src/sessions.ts +96 -22
  31. package/spec-cli/src/specs.ts +38 -18
  32. package/spec-cli/src/supervise.ts +2 -2
  33. package/spec-cli/src/tsx-bin.ts +6 -8
  34. package/spec-cli/src/uninstall.ts +18 -19
  35. package/spec-cli/src/worktree-sources.ts +52 -13
  36. package/spec-cli/templates/hooks/post-checkout +22 -0
  37. package/spec-cli/templates/hooks/post-merge +15 -9
  38. package/spec-cli/templates/hooks/pre-commit +10 -0
  39. package/spec-cli/templates/spec/project/.config/core/stop-gate/spec.md +1 -1
  40. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +26 -8
  41. package/spec-cli/templates/spec/project/.config/distill/digest.mjs +136 -0
  42. package/spec-cli/templates/spec/project/.config/distill/spec.md +74 -0
  43. package/spec-dashboard/dist/assets/Dashboard-BlRRsxE7.js +27 -0
  44. package/spec-dashboard/dist/assets/EvalsPage-BzVE38-Z.js +2 -0
  45. package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-DFuLVOeu.js} +1 -1
  46. package/spec-dashboard/dist/assets/IssuesPage-CzDaazhe.js +1 -0
  47. package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-CXQrQCNp.js} +1 -1
  48. package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-D1pUBl6q.js} +8 -8
  49. package/spec-dashboard/dist/assets/SessionWindow-Y25Bwg1e.js +9 -0
  50. package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-R610Vbzd.js} +1 -1
  51. package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BO0Zuweu.js} +2 -2
  52. package/spec-dashboard/dist/assets/index-uGs9v_9o.css +1 -0
  53. package/spec-dashboard/dist/index.html +2 -2
  54. package/spec-forge/src/cli.ts +2 -2
  55. package/spec-forge/src/drivers/gitlab.ts +168 -0
  56. package/spec-forge/src/drivers.ts +80 -2
  57. package/spec-forge/src/resident.ts +10 -5
  58. package/spec-yatsu/src/cli.ts +37 -16
  59. package/spec-yatsu/src/evaltab.ts +6 -3
  60. package/spec-yatsu/src/filing.ts +13 -8
  61. package/spec-yatsu/src/freshness.ts +97 -22
  62. package/spec-yatsu/src/proof.ts +14 -3
  63. package/spec-yatsu/src/scenariofresh.ts +38 -11
  64. package/spec-yatsu/src/yatsu.ts +52 -28
  65. package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +0 -27
  66. package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +0 -3
  67. package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +0 -1
  68. package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +0 -9
  69. package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +0 -1
@@ -1,27 +1,35 @@
1
- import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'node:fs'
1
+ import { writeFileSync, mkdirSync, readFileSync, existsSync, readdirSync, rmSync, rmdirSync } 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
5
  import { loadSystemConfig, loadSkillConfig, loadAgentConfig, loadConfig } from './specs.js'
6
6
  import { compileManifest } from './hooks.js'
7
- import { writeManagedBlock, removeManagedBlock, type HarnessArtifacts } from './harness.js'
7
+ import { writeManagedBlock, removeManagedBlock, HARNESSES, type HarnessArtifacts } from './harness.js'
8
8
  import { git } from './git.js'
9
- import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
9
+ import { runtimeRoot, treeSlotDir, mainCheckout, readConfig } from './layout.js'
10
10
  import { resolveHarnessTargets, partitionHarnesses } from './harness-select.js'
11
11
  import { emitPlugin, cleanPlugin, pluginBundleDir, pluginVersion } from './plugin-harness.js'
12
+ import { plantContractFilter, removeContractFilter, settleIndexStat } from './contract-filter.js'
12
13
 
13
- // @@@ materialize - the "pay-per-change" node step (≈0.85s) the cheap shell gate invokes ONLY when the
14
- // .config content-hash moved. It renders the spec tree's surface nodes into the flat artifacts each
15
- // consumer reads cheaply, so a USER-self-launched claude/codex (no SpexCode process in the launch) gets the
16
- // whole system via harness-auto-discovered files: (1) the hook MANIFEST (our dispatcher reads it),
17
- // (2) the CONTRACT the tracked docs guide (docs/AGENT_GUIDE.md) FOLLOWED BY the surface:system bodies —
18
- // written WHOLE into each harness's contract file(s), which are GENERATED, gitignored artifacts (like the
19
- // shims + skills): regenerated per clone/launch, never committed, so a self-launched agent still discovers
20
- // guide + contract while the repo tracks only the guide source, (3) the thin SHIMS (every event → dispatch.sh),
21
- // (4) the per-harness TRUST (Codex's deterministic trusted_hash; Claude none) so the self-launch is zero-prompt.
22
- // EVERY harness-specific fact is owned by the [[harness-adapter]] (harness.ts) — this file just loops over
23
- // HARNESSES, so adding a harness adds an adapter, not a branch here. All writes are idempotent + scoped. The
24
- // content-hash marker is stamped last.
14
+ // @@@ materialize - the materialize step (≈0.85s), anchored on GIT-NATIVE events only ([[commit-surgery]]):
15
+ // spex verbs (init/materialize), session-worktree creation, and the planted git hooks (pre-commit,
16
+ // post-checkout, post-merge) never a harness event; the harness is a READER of the materialized files, not
17
+ // a trigger. It turns the spec tree's surface nodes into the flat artifacts each consumer reads
18
+ // cheaply, so a USER-self-launched claude/codex (no SpexCode process in the launch) gets the whole system via
19
+ // harness-auto-discovered files: (1) the hook MANIFEST (our dispatcher reads it), (2) the CONTRACT the
20
+ // tracked docs guide (docs/AGENT_GUIDE.md) FOLLOWED BY the surface:system bodies written WHOLE into each
21
+ // harness's contract file(s), (3) the thin SHIMS (every event → dispatch.sh), (4) the per-harness TRUST
22
+ // (Codex's deterministic trusted_hash; Claude none). EVERY harness-specific fact is owned by the
23
+ // [[harness-adapter]] (harness.ts) — this file just loops over HARNESSES.
24
+ //
25
+ // THE FORGETTING LAW ([[harness-delivery]]): materialize(P₂) materialize(P₁) = materialize(P₂) — one pass
26
+ // under the current policy fully forgets every prior policy's artifacts; idempotence is the special case
27
+ // P₂ = P₁, and dematerialize (= materialize(∅), what `spex uninstall` builds on) is the empty policy. The
28
+ // implementation is ERASE-THEN-ASSERT over a CLOSED set of landing points: each is first erased
29
+ // unconditionally by its IDENTITY STAMP (sentinel blocks, the shim's dispatch.sh command line, the generated
30
+ // mark on skills/agents, the filter config namespace, the skip-worktree bit), then rewritten per the current
31
+ // policy (possibly to nothing). No ledger of past states, no pairwise migration branches — the erase IS the
32
+ // migration, whatever the previous state was.
25
33
 
26
34
  const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
27
35
  const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
@@ -29,13 +37,17 @@ const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
29
37
  // resolution AND the mid-merge guard (a conflicted source tree degrades to one line + exit 75, not an
30
38
  // esbuild stacktrace), so every hook-baked callback inherits both.
31
39
  const SPEX = join(PKG, 'bin', 'spex.mjs')
32
- // the manifest + content-hash marker render into the GLOBAL per-project store (layout.runtimeRoot), NOT the
33
- // worktree the worktree keeps zero SpexCode-rendered runtime; only the harness-discovered contract files +
34
- // shims (which the harness must find in-tree) are written under proj below.
40
+ // the manifest + content-hash marker + plugin-folder ledger land in the materialized TREE's own slot of the
41
+ // GLOBAL per-project store (layout.treeSlotDir trees/<enc-worktree>), NOT the worktree and NOT one shared
42
+ // per-project file: each is a pure function of ONE tree's .config, and the old single slot let the last-
43
+ // materialized tree's hook set reach every other tree's dispatch ([[hook-dispatch]]). The worktree keeps
44
+ // zero SpexCode-materialized runtime; only the harness-discovered contract files + shims (which the harness
45
+ // must find in-tree) are written under proj below.
35
46
 
36
- // the deterministic content fingerprint of the config roots. ONE definition `hp_config_hash` in the shell
37
- // mirror (harness.sh) — which the dispatch.sh gate ALSO calls, so the gate and this renderer can never disagree
38
- // on "changed" (they used to inline the identical find-pipeline in two places, each commenting the other "MUST match").
47
+ // the deterministic content fingerprint of the config roots + THE TOOLCHAIN ITSELF (`hp_config_hash` in the
48
+ // shell mirror, harness.sh). Stamped as a freshness record after every materialize; it folds in
49
+ // hp_toolchain_version (the toolchain-side content hash), so a stale stamp is diagnosable after a toolchain
50
+ // update ([[harness-delivery]]).
39
51
  export function contentHash(proj: string): string {
40
52
  try {
41
53
  const harnessSh = join(PKG, 'hooks', 'harness.sh')
@@ -43,15 +55,25 @@ export function contentHash(proj: string): string {
43
55
  } catch { return '' }
44
56
  }
45
57
 
46
- // @@@ private overlay ([[private-overlay]]) - the DEFAULT render commits .spec + spexcode.json and ignores its
47
- // generated wiring through a managed block in the TRACKED <repo>/.gitignore; that only truly hides a file that
48
- // is wholly ours, so on a host that ALREADY tracks CLAUDE.md/AGENTS.md/.gitignore the folded-in block still
49
- // lands in a tracked file and leaks. Private mode (spexcode.local.json `private:true`) closes that: it moves the
50
- // ignore block to the per-clone `.git/info/exclude` (never committed), widens it to hide .spec + spexcode.json
51
- // too, and `skip-worktree`s any TRACKED contract file so the block sits in the working copy (the harness still
52
- // discovers it) yet never stages. Every step has an inverse the DEFAULT mode re-asserts, so the toggle is
53
- // reversible and idempotent. It hides UNTRACKED paths; a pre-existing tracked .spec must be `git rm --cached`d
54
- // once (the render can't untrack for you reported, not silently ignored).
58
+ // @@@ footprint kinds ([[residence]]) - the vote axis is RETIRED: materialized artifacts carry no facts, so
59
+ // they are NEVER tracked there is exactly ONE residence behavior, not three. `.spec` + `spexcode.json` are ALWAYS
60
+ // tracked (git is the database no knob can untrack them); machine facts (shims, spexcode.local.json),
61
+ // run residue (.worktrees/), and wholly-ours artifacts are hidden by the per-clone .git/info/exclude (the
62
+ // host's tracked .gitignore is never touched); a contract file the host TRACKS — or one the user has begun
63
+ // writing THEIR OWN prose into is covered by the clean/smudge content filter ([[content-filter]]). An
64
+ // environment without the generator (a teammate's clone, CI, a cloud agent) runs `spex materialize` in its
65
+ // setup step there is no committed-artifact delivery mode.
66
+ export function retiredAxisNotice(cfg: { render?: string; private?: boolean }): void {
67
+ if (!cfg.render?.trim() && !cfg.private) return
68
+ const field = cfg.render?.trim() ? `"render": "${cfg.render.trim()}"` : '"private": true'
69
+ console.error(
70
+ `spexcode: the render vote is retired — ${field} is ignored. Materialized artifacts are never tracked:\n` +
71
+ ` ignore rules live in the per-clone .git/info/exclude, a host-tracked contract file is covered by the\n` +
72
+ ` clean/smudge filter, and a clone without spex runs \`spex materialize\` in its setup step. Remove the\n` +
73
+ ` field from spexcode.json / spexcode.local.json to retire this notice (see \`spex guide footprint\`).`,
74
+ )
75
+ }
76
+
55
77
  function gitCommonDirOf(proj: string): string {
56
78
  return git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
57
79
  }
@@ -61,186 +83,234 @@ function infoExcludePath(proj: string): string {
61
83
  function isTracked(proj: string, file: string): boolean {
62
84
  try { git(['-C', proj, 'ls-files', '--error-unmatch', file]); return true } catch { return false }
63
85
  }
64
- // mark/unmark a TRACKED file skip-worktree (a no-op on an untracked path — info/exclude hides those instead).
65
- // Best-effort: an index race or a non-repo must not fail the render.
66
- function setSkipWorktree(proj: string, file: string, on: boolean): void {
86
+
87
+ // @@@ contract kind detection ([[residence]]) - a contract file's residence is a LIVE CONTENT FACT, not
88
+ // an install-time choice, re-judged on every materialize: TRACKED → filter domain; untracked + wholly ours
89
+ // (nothing left after stripping our sentinel block) → exclude domain; untracked + HOST CONTENT present (the
90
+ // user began writing their own prose into it) → neither hidden nor tracked-for-them: the exclude entry is
91
+ // withheld (hiding user content would make their prose invisible to git — data-loss shaped) and the clean
92
+ // filter is pre-armed so their eventual, entirely-their-own `git add` strips our block automatically.
93
+ const SENTINEL_RE = /\n*<!-- spexcode:start -->[\s\S]*?<!-- spexcode:end -->\n*/
94
+ export function stripSpexcodeBlock(text: string): string {
95
+ const m = SENTINEL_RE.exec(text)
96
+ if (!m) return text
97
+ // mirror removeManagedBlock exactly: our block + its surrounding blanks collapse to one '\n', and only a
98
+ // block sitting at the TOP of the file drops the leading newline (a host file beginning with its own
99
+ // blank lines keeps them — clean(smudge(x)) == x).
100
+ const replaced = text.replace(SENTINEL_RE, '\n')
101
+ return m.index === 0 ? replaced.replace(/^\n+/, '') : replaced
102
+ }
103
+ function hostContentOf(file: string): string {
104
+ if (!existsSync(file)) return ''
105
+ return stripSpexcodeBlock(readFileSync(file, 'utf8'))
106
+ }
107
+ // clear a legacy skip-worktree bit (the retired private-overlay mechanism; erase-only now — nothing asserts
108
+ // it). Best-effort: an index race or a non-repo must not fail the materialize.
109
+ function clearSkipWorktree(proj: string, file: string): void {
67
110
  if (!isTracked(proj, file)) return
68
- try { git(['-C', proj, 'update-index', on ? '--skip-worktree' : '--no-skip-worktree', file]) } catch { /* best-effort */ }
111
+ try { git(['-C', proj, 'update-index', '--no-skip-worktree', file]) } catch { /* best-effort */ }
112
+ }
113
+
114
+ // the identity stamp on every generated skill/agent file — what lets the erase phase forget a product whose
115
+ // NODE was renamed or deleted (the name-scoped sweep can only reconstruct paths the LIVE config still names).
116
+ export const GENERATED_MARK = '<!-- spexcode:generated -->'
117
+ function sweepGeneratedSkills(dir: string | null): void {
118
+ if (!dir || !existsSync(dir)) return
119
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
120
+ if (!e.isDirectory()) continue
121
+ const f = join(dir, e.name, 'SKILL.md')
122
+ try { if (existsSync(f) && readFileSync(f, 'utf8').includes(GENERATED_MARK)) rmSync(join(dir, e.name), { recursive: true, force: true }) } catch { /* unreadable → not provably ours */ }
123
+ }
124
+ }
125
+ function sweepGeneratedAgents(dir: string | null): void {
126
+ if (!dir || !existsSync(dir)) return
127
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
128
+ if (!e.isFile() || !e.name.endsWith('.md')) continue
129
+ const f = join(dir, e.name)
130
+ try { if (readFileSync(f, 'utf8').includes(GENERATED_MARK)) rmSync(f, { force: true }) } catch { /* unreadable → not provably ours */ }
131
+ }
69
132
  }
70
133
 
71
- // the whole pay-per-change render. proj defaults to cwd. Returns the new content-hash it stamped.
134
+ // @@@ dematerialize - materialize(∅): the ERASE phase, also the whole of a backout ([[spex-uninstall]] adds
135
+ // only the global store + plugin sweep + optional git hooks on top). Every removal is gated on an identity
136
+ // stamp, so it deletes ONLY what a materialize wrote — never the user's prose, settings, or any .spec data. Order
137
+ // matters once: the managed blocks leave the WORKING contract files before the content filter's config goes
138
+ // (edge ③ in [[content-filter]] — a block outliving its clean filter surfaces as an uncommitted change).
139
+ // `arts` (live skill/agent node names) widens the sweep to pre-stamp legacy files; the GENERATED_MARK sweep
140
+ // covers everything materialized since, including products of renamed/deleted nodes.
141
+ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { skills: [], agents: [] }): void {
142
+ for (const h of HARNESSES) {
143
+ // h.clean = the adapter's surgical inverse: contract block (sentinels, deleteIfEmpty), the dispatch.sh-
144
+ // stamped shim + worktree anchor, the trust block, and the arts-named skill/agent files.
145
+ h.clean(proj, arts)
146
+ for (const f of h.contractFiles(proj)) clearSkipWorktree(proj, f) // legacy private-overlay bit — erase-only
147
+ sweepGeneratedSkills(h.skillDir(proj))
148
+ sweepGeneratedAgents(h.agentDir(proj))
149
+ }
150
+ // same authorship rule as the contract files: deleteIfEmpty only when .gitignore is UNTRACKED (wholly-ours
151
+ // generated file); a HOST-TRACKED .gitignore that carried nothing but our block is stripped, never deleted.
152
+ removeManagedBlock(join(proj, '.gitignore'), ['# ', ''], !isTracked(proj, '.gitignore'))
153
+ try { removeManagedBlock(infoExcludePath(proj), ['# ', ''], false) } catch { /* not a git repo */ }
154
+ removeContractFilter(proj) // AFTER the blocks left the working files
155
+ // the block-strip left tracked contract files stat-dirty (under a filter git NEVER content-verifies them,
156
+ // and even unfiltered the phantom-`M` lingers) — settle the index stat, content-guarded so a user's real
157
+ // unstaged edit is never staged ([[content-filter]] edge 2).
158
+ try { settleIndexStat(proj, HARNESSES.flatMap((h) => h.contractFiles(proj))) } catch { /* not a git repo */ }
159
+ // leaving nothing behind: drop the now-EMPTY dirs the assert phase mkdir'ed (.claude/.codex and their
160
+ // skills/agents subdirs — children listed before parents). rmdirSync is NON-recursive, so a dir holding
161
+ // any user file survives untouched; `.git/spexcode/` is deliberately NOT swept (shared per-clone home).
162
+ for (const h of HARNESSES) {
163
+ const anchor = h.worktreeHookAnchor(proj)
164
+ for (const d of [h.skillDir(proj), h.agentDir(proj), dirname(h.shimFile(proj)), anchor ? dirname(anchor) : null])
165
+ if (d) { try { rmdirSync(d) } catch { /* non-empty or absent — keep */ } }
166
+ }
167
+ }
168
+
169
+ // the whole pay-per-change materialize. proj defaults to cwd. Returns the new content-hash it stamped.
72
170
  export function materialize(proj = process.cwd()): string {
73
- const rt = runtimeRoot(proj) // global per-project store, not the worktree
171
+ const rt = treeSlotDir(proj) // this tree's slot in the global store, not the worktree
74
172
  mkdirSync(rt, { recursive: true })
75
173
  // (1) hook manifest (persistent — the dispatcher reads it; regenerated only here, on change).
76
174
  writeFileSync(join(rt, 'hooks-manifest'), compileManifest())
77
175
  // (2) the contract = the tracked docs guide (the hand-written agent/contributor notes — the ONE piece of
78
176
  // in-tree prose) FOLLOWED BY the surface:system bodies (in name order), written WHOLE into EACH harness's
79
177
  // contract file(s) + (3) each harness's thin shim → dispatch.sh + (4) its trust. All owned by the adapter.
80
- // The contract files are generated artifacts (gitignored below), so the guide is the single source a
81
- // self-launched agent reads from — assembling it in keeps guide + contract reaching the agent together.
82
178
  const guidePath = join(proj, 'docs', 'AGENT_GUIDE.md')
83
179
  const guide = existsSync(guidePath) ? readFileSync(guidePath, 'utf8').trim() : ''
84
180
  const systemBodies = loadSystemConfig().map((c) => c.body.trim()).filter(Boolean)
85
181
  const contract = [guide, ...systemBodies].filter(Boolean).join('\n\n')
86
182
  // WHICH harnesses to deliver into ([[harness-select]]): the spexcode.json `harnesses` set (default = every
87
183
  // native harness). resolveHarnessTargets FAILS LOUD on an illegal set (plugin+native, plugin w/o folder).
88
- // selected harnesses are write()n below; unselected ones are clean()ed (pruned) after — so dropping a harness
89
- // from the config removes its products on the next re-materialize. The plugin EMITTER is a later node.
90
184
  const cfg = readConfig(mainCheckout(proj))
91
185
  const targets = resolveHarnessTargets(cfg.harnesses)
92
- const priv = !!cfg.private // [[private-overlay]] — hide in .git/info/exclude + skip-worktree, not the tracked .gitignore
93
- const { selected, unselected, plugins } = partitionHarnesses(targets)
186
+ retiredAxisNotice(cfg) // [[residence]] — the vote axis is retired
187
+ const { selected, plugins } = partitionHarnesses(targets)
94
188
  const skillNodes = loadSkillConfig()
95
189
  const agentNodes = loadAgentConfig()
96
190
  const commandNodes = loadConfig()
191
+ const arts: HarnessArtifacts = { skills: skillNodes.map((s) => s.name), agents: agentNodes.map((a) => a.name) }
192
+
193
+ // ---- ERASE (the forgetting law): every landing point cleared by identity stamp, whatever policy — or
194
+ // legacy mode — wrote it last. Unselected harnesses need no separate prune branch: the erase already
195
+ // forgot them, and only the selected ones are asserted below.
196
+ dematerialize(proj, arts)
197
+
198
+ // ---- ASSERT: rewrite each landing point per the CURRENT policy.
97
199
  // a skill node → the agentskills.io SKILL.md primitive: `name`+`description` frontmatter (the load-trigger)
98
- // over the body instructions. One pure render shared by every harness divergence is only its skillDir.
99
- const renderSkill = (sk: { name: string; desc: string; body: string }) =>
100
- `---\nname: ${sk.name}\ndescription: ${JSON.stringify(sk.desc)}\n---\n\n${sk.body}\n`
200
+ // over the body instructions, closed by the GENERATED_MARK identity stamp (what the erase phase keys on).
201
+ // One pure artifact builder shared by every harness divergence is only its skillDir.
202
+ const skillArtifact = (sk: { name: string; desc: string; body: string }) =>
203
+ `---\nname: ${sk.name}\ndescription: ${JSON.stringify(sk.desc)}\n---\n\n${sk.body}\n\n${GENERATED_MARK}\n`
101
204
  // an agent node → a coding-agent sub-agent definition (the same primitive .claude/agents/*.md ships): the
102
205
  // node's `desc` is the on-demand load-trigger, its `tools` the harness tool allowlist, its body the agent's
103
- // system prompt. One pure render shared by every harness — divergence is only its agentDir.
104
- const renderAgent = (ag: { name: string; desc: string; tools: string[]; body: string }) =>
105
- `---\nname: ${ag.name}\ndescription: ${ag.desc}\ntools: ${ag.tools.join(', ')}\n---\n\n${ag.body}\n`
106
- // a command node → a host `/`-menu command file: the node's `desc` as the dropdown description over its body
107
- // (the preset prompt). Only the PLUGIN bundle ships these as files; the native path serves command presets via
108
- // the dashboard /api/slash-commands instead, so this render is plugin-only.
109
- const renderCommand = (cm: { desc: string; body: string }) =>
206
+ // system prompt. Same stamp, same reason.
207
+ const agentArtifact = (ag: { name: string; desc: string; tools: string[]; body: string }) =>
208
+ `---\nname: ${ag.name}\ndescription: ${ag.desc}\ntools: ${ag.tools.join(', ')}\n---\n\n${ag.body}\n\n${GENERATED_MARK}\n`
209
+ // a command node → a host `/`-menu command file: plugin-only (the native path serves command presets via
210
+ // the dashboard /api/slash-commands instead).
211
+ const commandArtifact = (cm: { desc: string; body: string }) =>
110
212
  (cm.desc ? `---\ndescription: ${JSON.stringify(cm.desc)}\n---\n\n` : '') + `${cm.body}\n`
111
- // shim ABSOLUTE paths; relativized into the managed .gitignore block below (checkout-invariant see there).
112
- const shimPaths: string[] = []
213
+ // materialized artifacts and machine facts both land in the same per-clone exclude; contract files are kept separate
214
+ // because their residence is the live three-state kind detection below, not a static entry.
215
+ const artifactPaths: string[] = []
216
+ const machinePaths: string[] = []
217
+ const contractPaths: string[] = []
113
218
  for (const h of selected) {
114
- if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract); shimPaths.push(f); setSkipWorktree(proj, f, priv) }
219
+ if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract); contractPaths.push(f) }
115
220
  const shimFile = h.shimFile(proj)
116
221
  mkdirSync(dirname(shimFile), { recursive: true })
117
222
  const shim = h.shim(DISPATCH, SPEX)
118
223
  writeFileSync(shimFile, shim.json)
119
224
  h.writeTrust(proj, shim.cmd)
120
- shimPaths.push(shimFile)
225
+ machinePaths.push(shimFile)
121
226
  // a linked-worktree ANCHOR copy of the shim, when the harness needs one (codex: the shim lives at the main
122
- // checkout, so the worktree gets no `.codex/` unless we place one and codex only discovers a worktree
123
- // thread's hooks if a `.codex/` under the worktree root anchors the project layer). One adapter line; null
124
- // for a harness whose shim already sits in the worktree (claude) or on the main checkout itself.
227
+ // checkout, so the worktree gets no `.codex/` unless we place one). One adapter line; null otherwise.
125
228
  const anchor = h.worktreeHookAnchor(proj)
126
- if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.json); shimPaths.push(anchor) }
229
+ if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.json); machinePaths.push(anchor) }
127
230
  }
128
- // (6) skills - each `surface: skill` node → a SKILL.md the harness auto-discovers, written into every
129
- // harness's own skillDir (Claude .claude/skills, Codex .codex/skills). Generated wiring, so the paths
130
- // join the same managed .gitignore block below. A harness with no skill primitive (skillDir null) is skipped.
231
+ // (6) skills + (7) sub-agents each surface node → the file the harness auto-discovers, one per selected
232
+ // harness that has the primitive (skillDir/agentDir null skips the divergence is the adapter's line).
131
233
  for (const sk of skillNodes) {
132
234
  for (const h of selected) {
133
235
  const dir = h.skillDir(proj); if (!dir) continue
134
236
  const f = join(dir, sk.name, 'SKILL.md')
135
237
  mkdirSync(dirname(f), { recursive: true })
136
- writeFileSync(f, renderSkill(sk))
137
- shimPaths.push(f) // reuse the same managed .gitignore block (absolute; relativized below)
238
+ writeFileSync(f, skillArtifact(sk))
239
+ artifactPaths.push(f)
138
240
  }
139
241
  }
140
- // (7) sub-agents - each `surface: agent` node → a <name>.md the harness auto-discovers, written into every
141
- // harness's own agentDir (Claude .claude/agents). The SAME pattern as skills: generated wiring, so the
142
- // paths join the same managed .gitignore block below. A harness with no agent primitive (agentDir null,
143
- // e.g. Codex) is skipped — no `if (codex)`, the divergence is the adapter's agentDir line.
144
242
  for (const ag of agentNodes) {
145
243
  for (const h of selected) {
146
244
  const dir = h.agentDir(proj); if (!dir) continue
147
245
  const f = join(dir, `${ag.name}.md`)
148
246
  mkdirSync(dirname(f), { recursive: true })
149
- writeFileSync(f, renderAgent(ag))
150
- shimPaths.push(f) // reuse the same managed .gitignore block (absolute; relativized below)
247
+ writeFileSync(f, agentArtifact(ag))
248
+ artifactPaths.push(f)
151
249
  }
152
250
  }
153
- // (8) PRUNE every UNSELECTED harness — clean() is the surgical inverse of the write above, removing ONLY this
154
- // harness's own managed block + generated shim + trust + named skill/agent files. So narrowing the
155
- // spexcode.json `harnesses` set (or switching to a plugin, which excludes all natives) removes the
156
- // dropped harness's products here, the user's own prose/data untouched. The names tell clean exactly
157
- // which on-demand artifacts were its to remove ([[harness-select]] / [[harness-adapter]]).
158
- const arts: HarnessArtifacts = { skills: skillNodes.map((s) => s.name), agents: agentNodes.map((a) => a.name) }
159
- for (const h of unselected) h.clean(proj, arts)
160
- // (8b) the PLUGIN target ([[plugin-harness]]): render the whole system into one self-contained Claude-plugin
161
- // bundle per selected folder. A plugin is EXCLUSIVE (so `selected` is already empty — every native was
162
- // pruned above). Pruning a DESELECTED plugin folder (plugin→native, or folder A→B) needs the PREVIOUS
163
- // folder set, which the live config no longer names — so a tiny ledger in the global store records the
164
- // folders emitted last run; any prev folder absent from the current set is clean()ed (the bundle's
165
- // inverse), then the current folders are emitted and the ledger rewritten. Bounded + surgical: cleanPlugin
166
- // is identity-gated on the bundle's own plugin.json.
251
+ // (8) the PLUGIN target ([[plugin-harness]]): materialize the whole system into one self-contained Claude-plugin
252
+ // bundle per selected folder. A plugin is EXCLUSIVE (`selected` is empty then). Pruning a DESELECTED
253
+ // folder needs the PREVIOUS folder set, which the live config no longer names the one landing point
254
+ // the identity-stamped erase cannot enumerate (a folder is an arbitrary path) so a tiny ledger in the
255
+ // global store records the folders emitted last run; any prev folder absent from the current set is
256
+ // clean()ed, then the current folders are emitted and the ledger rewritten.
167
257
  const ledger = join(rt, 'plugin-folders')
168
- const prevFolders = existsSync(ledger) ? readFileSync(ledger, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean) : []
258
+ // migration: a tree last materialized pre-slot left its ledger as the project-global file — read it once as
259
+ // the prev set so a deselected folder is still pruned; every write lands in the slot from here on.
260
+ const legacyLedger = join(runtimeRoot(proj), 'plugin-folders')
261
+ const ledgerSrc = existsSync(ledger) ? ledger : legacyLedger
262
+ const prevFolders = existsSync(ledgerSrc) ? readFileSync(ledgerSrc, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean) : []
169
263
  const curFolders = plugins.map((p) => p.folder)
170
264
  for (const f of prevFolders) if (!curFolders.includes(f)) cleanPlugin(proj, f)
171
265
  if (plugins.length) {
172
- const render = {
266
+ const bundle = {
173
267
  contract,
174
- skills: skillNodes.map((s) => ({ name: s.name, content: renderSkill(s) })),
175
- agents: agentNodes.map((a) => ({ name: a.name, content: renderAgent(a) })),
176
- commands: commandNodes.map((c) => ({ name: c.name, content: renderCommand(c) })),
268
+ skills: skillNodes.map((s) => ({ name: s.name, content: skillArtifact(s) })),
269
+ agents: agentNodes.map((a) => ({ name: a.name, content: agentArtifact(a) })),
270
+ commands: commandNodes.map((c) => ({ name: c.name, content: commandArtifact(c) })),
177
271
  spex: SPEX,
178
272
  version: pluginVersion(),
179
273
  }
180
- for (const p of plugins) emitPlugin(proj, p.folder, render)
274
+ for (const p of plugins) emitPlugin(proj, p.folder, bundle)
181
275
  }
182
276
  writeFileSync(ledger, curFolders.join('\n'))
183
- // (4b) every artifact this render writes IN-TREE is generated wiring, so gitignore it — regenerated per
184
- // clone/launch by this same gate, never committed. That now includes the CONTRACT files (CLAUDE.md/AGENTS.md):
185
- // their whole content is the generated guide+system block, so they are artifacts exactly like the shims +
186
- // skills + sub-agents the only tracked prose is the guide SOURCE (docs/AGENT_GUIDE.md), which this render
187
- // reads. Derived from the adapters' own contractFiles()/shimFile()/skillDir/agentDir, not hardcoded; written
188
- // as a managed `#` block so the user's own .gitignore is preserved.
189
- // The managed block must be CHECKOUT-INVARIANT: `.gitignore` is ONE tracked, committed file shared by the
190
- // main checkout and every worktree, so if the block differed by where materialize ran, whichever flavor got
191
- // committed would leave the OTHER checkout dirtying it on every render. The only artifact that varies is the
192
- // codex hooks shim, which materializes at the MAIN checkout (a linked worktree's codex reads the root's
193
- // hooks — see harness.ts): from main it is `.codex/hooks.json` (inside proj), from a worktree it is `../…`
194
- // (escapes proj). So an ignore entry is anchored to the checkout it LIVES under — proj-relative when inside
195
- // proj, else MAIN-checkout-relative — which resolves the codex shim to `.codex/hooks.json` from any checkout
196
- // (a pattern naming a main-only path is a harmless no-op in a worktree). Every checkout now emits the same
197
- // set → the committed `.gitignore` is stable, never re-dirtied. A path under neither root is not ignorable
198
- // here (dropped).
277
+ // (9) the ignore rules ALWAYS the per-clone .git/info/exclude ([[residence]]): the exclude is not a
278
+ // history guard (the pre-commit surgery owns history [[commit-surgery]]) but the ignored-bit
279
+ // DECLARATION every other git door consults (checkout may overwrite, clean -fd spares, status/add -A/
280
+ // stash stay silent). The host's tracked .gitignore is never touched.
281
+ // Entries must be CHECKOUT-INVARIANT: the exclude lives in the COMMON git dir shared by the main checkout
282
+ // and every worktree, so each entry is anchored to the checkout it LIVES under — proj-relative when inside
283
+ // proj, else MAIN-checkout-relative (the codex shim resolves to `.codex/hooks.json` from any checkout; a
284
+ // pattern naming a main-only path is a harmless no-op in a worktree). A path under neither root is dropped.
199
285
  const mc = mainCheckout(proj)
200
286
  const anchor = (abs: string): string | null => {
201
287
  const p = relative(proj, abs); if (!p.startsWith('..')) return p
202
288
  const m = relative(mc, abs); if (!m.startsWith('..')) return m
203
289
  return null
204
290
  }
205
- // spexcode.local.json the machine-local config overlay (host-specific values, e.g. an absolute worker
206
- // launcher path; see portable-layout)joins the SAME block on the same rationale: machine-specific, must
207
- // never be committed. Without it an adopter who follows our own guidance to put a host path there would
208
- // `git add -A` and leak it the exact thing the overlay exists to prevent.
209
- // each emitted plugin bundle is a generated, machine-local artifact too (its hooks.json bakes THIS install's
210
- // SPEX path), so its dir joins the same managed block — regenerated per clone/launch, never committed.
291
+ // machine facts + run residue, ignored under EVERY policy: the shims/anchors/bundles (bake this install's
292
+ // abs path), spexcode.local.json (the host overlay a `git add -A` must never leak it), and the session
293
+ // residue (`.worktrees/` where launches plant worktrees; `.session` is the legacy per-worktree state file
294
+ // an old backend wrote). Static strings stay checkout-invariant.
211
295
  const bundlePaths = curFolders.map((f) => pluginBundleDir(proj, f))
212
- // SpexCode's own SESSION artifacts also join the managed block: a launch creates a worktree under
213
- // `.worktrees/` (hardcoded in sessions.ts) plus a per-worktree `.session` state file the layout linker
214
- // reads. Neither is the adopter's code, both are per-clone/never-committed — but until now materialize
215
- // never ignored them, so an ADOPTED project leaked its session worktrees into `git status` (a `git add -A`
216
- // would commit them). Static strings, so they stay checkout-invariant. The dogfood hand-lists these; every
217
- // other repo gets them here, in the default `.gitignore` block AND (widened) the private `.git/info/exclude`.
218
- const sessionIgnores = ['.worktrees/', '.session']
219
- const ignorable = [...[...shimPaths, ...bundlePaths].map(anchor).filter((p): p is string => p !== null), 'spexcode.local.json', ...sessionIgnores]
220
- const gitignore = join(proj, '.gitignore')
221
- if (priv) {
222
- // PRIVATE ([[private-overlay]]): hide EVERYTHING — including the .spec tree + spexcode.json the DEFAULT
223
- // mode commits ("git is the database") — in the per-clone `.git/info/exclude` (never committed, never
224
- // shared), and strip any managed block a prior DEFAULT render left in the tracked .gitignore so switching
225
- // INTO private mode leaves the host's shared history clean.
226
- const entries = [...new Set([...ignorable, '.spec/', 'spexcode.json'])].sort().join('\n')
227
- writeManagedBlock(infoExcludePath(proj), entries, ['# ', ''])
228
- removeManagedBlock(gitignore, ['# ', ''], true)
229
- // the ONE manual step private mode can't do for you: .git/info/exclude hides UNTRACKED paths only, so a
230
- // .spec/spexcode.json ALREADY committed (a repo that adopted DEFAULT mode first) stays visible until
231
- // un-tracked. Surface it loudly rather than leaving a silent leak — the agent doing setup acts on this.
232
- const stillTracked = ['.spec', 'spexcode.json'].filter((p) => isTracked(proj, p))
233
- if (stillTracked.length) console.error(
234
- `spexcode private: ${stillTracked.join(' + ')} still git-tracked — private mode hides UNTRACKED paths only.\n` +
235
- ` → run once: git rm -r --cached ${stillTracked.join(' ')} (keeps the files on disk, stops tracking them; then commit on your branch)`,
236
- )
237
- } else {
238
- // DEFAULT: the checkout-invariant managed block in the TRACKED .gitignore (rationale above); strip any
239
- // private exclude block so switching OUT of private mode is equally clean.
240
- if (ignorable.length) writeManagedBlock(gitignore, [...new Set(ignorable)].sort().join('\n'), ['# ', ''])
241
- removeManagedBlock(infoExcludePath(proj), ['# ', ''], false)
296
+ const machineEntries = [
297
+ ...[...machinePaths, ...bundlePaths].map(anchor).filter((p): p is string => p !== null),
298
+ 'spexcode.local.json', '.worktrees/', '.session',
299
+ ]
300
+ // the contract three-state ([[residence]]): tracked filter; untracked+wholly-ours exclude;
301
+ // untracked+host-content NO exclude (never hide user prose) + the clean filter pre-armed, so the user's
302
+ // own eventual `git add` strips our block — tracking stays entirely their act.
303
+ const filterContracts: string[] = []
304
+ const oursContracts: string[] = []
305
+ for (const f of contractPaths) {
306
+ if (isTracked(proj, f) || hostContentOf(f).trim()) filterContracts.push(f)
307
+ else oursContracts.push(f)
242
308
  }
243
- // (5) stamp the content-hash marker LAST (so a crash mid-render leaves it stale → re-renders next gate).
309
+ if (contract && filterContracts.length) plantContractFilter(proj, filterContracts, contract)
310
+ const artifactEntries = [...artifactPaths, ...oursContracts].map(anchor).filter((p): p is string => p !== null)
311
+ const entries = (list: string[]) => [...new Set(list)].sort().join('\n')
312
+ writeManagedBlock(infoExcludePath(proj), entries([...machineEntries, ...artifactEntries]), ['# ', ''])
313
+ // (5) stamp the content-hash marker LAST (a diagnostic freshness record; a crash mid-materialize leaves it stale).
244
314
  const h = contentHash(proj)
245
315
  writeFileSync(join(rt, 'content-hash'), h)
246
316
  return h
@@ -6,9 +6,11 @@
6
6
  // separate, and sessions.ts is imported LAZILY so a mention-free post pays nothing.
7
7
 
8
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
9
+ // `@<token>` at a word boundary is an actor; `[[<id>]]` is a topic. Token chars are any unicode
10
+ // letter/number plus [_-] (a CJK session handle or node id is first-class same charset the launch-side
11
+ // MENTION and the dashboard's MENTION_RE use); a session id, a short label/prefix, or the literal `new`.
12
+ // Both forms are deduped in first-seen order.
13
+ const ACTOR_RE = /(?:^|\s)@([\p{L}\p{N}_-]+)/gu
12
14
  const NODE_RE = /\[\[([^\]\s]+)\]\]/g
13
15
 
14
16
  const uniq = (xs: string[]): string[] => [...new Set(xs)]
@@ -2,7 +2,7 @@ import { writeFileSync, mkdirSync, readFileSync, existsSync, rmSync, copyFileSyn
2
2
  import { join, dirname } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
 
5
- // @@@ plugin-harness - the PLUGIN BUNDLE emitter: render the whole SpexCode system into ONE self-contained
5
+ // @@@ plugin-harness - the PLUGIN BUNDLE emitter: materialize the whole SpexCode system into ONE self-contained
6
6
  // Claude-plugin bundle dropped into the host-agent-scanned folder [[harness-select]] resolved (e.g. `.zcode` /
7
7
  // `.claude` → <folder>/plugins/spexcode/). It is the plugin-target counterpart of the native per-harness
8
8
  // write [[harness-adapter]] does — chosen INSTEAD of the natives (plugin exclusivity), so [[harness-delivery]]'s
@@ -29,11 +29,12 @@ const PLUGIN_ROOT = '${CLAUDE_PLUGIN_ROOT}'
29
29
  // never invokes the extras, so binding all is harmless and one emit serves every host.
30
30
  const PLUGIN_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'StopFailure', 'Notification'] as const
31
31
 
32
- // the gathered surface artifacts this render writes into the bundle. contract = the assembled guide+system
32
+ // the gathered surface artifacts this materialize writes into the bundle. contract = the assembled guide+system
33
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
34
+ // agents/commands are the already-materialized file CONTENTS (reusing materialize's skillArtifact/agentArtifact),
35
+ // keyed
35
36
  // by node name; spex is the baked tsx+cli invocation the dispatcher gate calls; version stamps plugin.json.
36
- export type PluginRender = {
37
+ export type PluginBundle = {
37
38
  contract: string
38
39
  skills: { name: string; content: string }[]
39
40
  agents: { name: string; content: string }[]
@@ -63,7 +64,7 @@ function pluginManifest(version: string): string {
63
64
  }
64
65
 
65
66
  // 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
+ // event → the SHARED dispatch.sh (`plugin` baked as its harness id, SPEX inherited by handlers); SessionStart
67
68
  // ALSO runs inject-contract.sh first, so the contract additionalContext lands alongside the normal dispatch.
68
69
  function pluginHooksJson(spex: string): string {
69
70
  const dispatch = (e: string) => `SPEX='${spex}' bash "${PLUGIN_ROOT}/hooks/dispatch.sh" plugin ${e}`
@@ -78,7 +79,7 @@ function pluginHooksJson(spex: string): string {
78
79
  return JSON.stringify({ hooks }, null, 2)
79
80
  }
80
81
 
81
- // the SessionStart hook OUTPUT carrying the contract as additionalContext. JSON-encoded HERE, at render time,
82
+ // the SessionStart hook OUTPUT carrying the contract as additionalContext. JSON-encoded HERE, at materialize time,
82
83
  // so the runtime hook is a trivial `cat` — never a fragile shell escaping of arbitrary contract prose.
83
84
  function contractContextJson(contract: string): string {
84
85
  return JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: contract } }, null, 2)
@@ -89,14 +90,14 @@ function contractContextJson(contract: string): string {
89
90
  const INJECT_SH = `#!/usr/bin/env bash
90
91
  # Emit the SpexCode contract as SessionStart additionalContext — the harness-neutral contract injection (the
91
92
  # 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
+ # host's missing --append-system-prompt. The JSON was written at materialize time, so this is a trivial cat.
93
94
  here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
94
95
  [ -f "$here/contract-context.json" ] && cat "$here/contract-context.json"
95
96
  exit 0
96
97
  `
97
98
 
98
99
  // emit (or idempotently re-emit) the whole bundle into <folder>/plugins/spexcode.
99
- export function emitPlugin(proj: string, folder: string, r: PluginRender): void {
100
+ export function emitPlugin(proj: string, folder: string, r: PluginBundle): void {
100
101
  const bundle = pluginBundleDir(proj, folder)
101
102
  const meta = join(bundle, '.claude-plugin')
102
103
  const hooksDir = join(bundle, 'hooks')
@@ -110,7 +111,7 @@ export function emitPlugin(proj: string, folder: string, r: PluginRender): void
110
111
  writeFileSync(join(hooksDir, 'inject-contract.sh'), INJECT_SH)
111
112
  writeFileSync(join(hooksDir, 'contract-context.json'), contractContextJson(r.contract))
112
113
  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
+ // skills / agents / commands — the Claude-plugin layout, the SAME materialized contents as the native dirs.
114
115
  for (const s of r.skills) writeBundleFile(join(bundle, 'skills', s.name, 'SKILL.md'), s.content)
115
116
  for (const a of r.agents) writeBundleFile(join(bundle, 'agents', `${a.name}.md`), a.content)
116
117
  for (const c of r.commands) writeBundleFile(join(bundle, 'commands', `${c.name}.md`), c.content)