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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +13 -10
- package/spec-cli/hooks/dispatch.sh +28 -40
- package/spec-cli/hooks/harness.sh +43 -7
- package/spec-cli/src/board.ts +2 -1
- package/spec-cli/src/boardCache.ts +27 -1
- package/spec-cli/src/boardStream.ts +5 -4
- package/spec-cli/src/cli.ts +93 -20
- package/spec-cli/src/commit-surgery.ts +81 -0
- package/spec-cli/src/contract-filter.ts +156 -0
- package/spec-cli/src/doctor.ts +11 -5
- package/spec-cli/src/git.ts +18 -4
- package/spec-cli/src/guide.ts +94 -38
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +22 -8
- package/spec-cli/src/help.ts +28 -16
- package/spec-cli/src/index.ts +9 -6
- package/spec-cli/src/init.ts +17 -10
- package/spec-cli/src/issues.ts +7 -5
- package/spec-cli/src/layout.ts +31 -13
- package/spec-cli/src/lint.ts +19 -0
- package/spec-cli/src/localIssues.ts +16 -4
- package/spec-cli/src/materialize.ts +214 -144
- package/spec-cli/src/mentions.ts +5 -3
- package/spec-cli/src/plugin-harness.ts +10 -9
- package/spec-cli/src/ranker.ts +25 -8
- package/spec-cli/src/runtime-guard.ts +44 -0
- package/spec-cli/src/search.bench.mjs +15 -5
- package/spec-cli/src/sessions.ts +96 -22
- package/spec-cli/src/specs.ts +38 -18
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tsx-bin.ts +6 -8
- package/spec-cli/src/uninstall.ts +18 -19
- package/spec-cli/src/worktree-sources.ts +52 -13
- package/spec-cli/templates/hooks/post-checkout +22 -0
- package/spec-cli/templates/hooks/post-merge +15 -9
- package/spec-cli/templates/hooks/pre-commit +10 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +26 -8
- package/spec-cli/templates/spec/project/.config/distill/digest.mjs +136 -0
- package/spec-cli/templates/spec/project/.config/distill/spec.md +74 -0
- package/spec-dashboard/dist/assets/Dashboard-BlRRsxE7.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BzVE38-Z.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-DFuLVOeu.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CzDaazhe.js +1 -0
- package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-CXQrQCNp.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-D1pUBl6q.js} +8 -8
- package/spec-dashboard/dist/assets/SessionWindow-Y25Bwg1e.js +9 -0
- package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-R610Vbzd.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BO0Zuweu.js} +2 -2
- package/spec-dashboard/dist/assets/index-uGs9v_9o.css +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-forge/src/cli.ts +2 -2
- package/spec-forge/src/drivers/gitlab.ts +168 -0
- package/spec-forge/src/drivers.ts +80 -2
- package/spec-forge/src/resident.ts +10 -5
- package/spec-yatsu/src/cli.ts +37 -16
- package/spec-yatsu/src/evaltab.ts +6 -3
- package/spec-yatsu/src/filing.ts +13 -8
- package/spec-yatsu/src/freshness.ts +97 -22
- package/spec-yatsu/src/proof.ts +14 -3
- package/spec-yatsu/src/scenariofresh.ts +38 -11
- package/spec-yatsu/src/yatsu.ts +52 -28
- package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +0 -3
- package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +0 -1
- package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +0 -9
- 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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// (
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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
|
|
37
|
-
// mirror
|
|
38
|
-
//
|
|
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
|
-
// @@@
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
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
|
-
|
|
65
|
-
//
|
|
66
|
-
|
|
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',
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
|
|
93
|
-
const { selected,
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
104
|
-
const
|
|
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
|
|
107
|
-
//
|
|
108
|
-
|
|
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
|
-
//
|
|
112
|
-
|
|
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);
|
|
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
|
-
|
|
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
|
|
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);
|
|
229
|
+
if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.json); machinePaths.push(anchor) }
|
|
127
230
|
}
|
|
128
|
-
// (6) skills - each
|
|
129
|
-
// harness
|
|
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,
|
|
137
|
-
|
|
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,
|
|
150
|
-
|
|
247
|
+
writeFileSync(f, agentArtifact(ag))
|
|
248
|
+
artifactPaths.push(f)
|
|
151
249
|
}
|
|
152
250
|
}
|
|
153
|
-
// (8)
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
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
|
-
|
|
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
|
|
266
|
+
const bundle = {
|
|
173
267
|
contract,
|
|
174
|
-
skills: skillNodes.map((s) => ({ name: s.name, content:
|
|
175
|
-
agents: agentNodes.map((a) => ({ name: a.name, content:
|
|
176
|
-
commands: commandNodes.map((c) => ({ name: c.name, content:
|
|
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,
|
|
274
|
+
for (const p of plugins) emitPlugin(proj, p.folder, bundle)
|
|
181
275
|
}
|
|
182
276
|
writeFileSync(ledger, curFolders.join('\n'))
|
|
183
|
-
// (
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
// main
|
|
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
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
package/spec-cli/src/mentions.ts
CHANGED
|
@@ -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.
|
|
10
|
-
// a
|
|
11
|
-
|
|
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:
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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)
|