spexcode 0.2.5 → 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/hooks/dispatch.sh +28 -40
- package/spec-cli/hooks/harness.sh +29 -19
- package/spec-cli/src/cli.ts +21 -11
- package/spec-cli/src/commit-surgery.ts +81 -0
- package/spec-cli/src/contract-filter.ts +16 -14
- package/spec-cli/src/doctor.ts +11 -5
- package/spec-cli/src/guide.ts +61 -58
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +5 -5
- package/spec-cli/src/help.ts +20 -17
- package/spec-cli/src/init.ts +5 -40
- package/spec-cli/src/layout.ts +26 -20
- package/spec-cli/src/materialize.ts +102 -126
- package/spec-cli/src/mentions.ts +5 -3
- package/spec-cli/src/plugin-harness.ts +10 -9
- package/spec-cli/src/sessions.ts +22 -13
- package/spec-cli/src/specs.ts +9 -4
- package/spec-cli/src/uninstall.ts +6 -5
- package/spec-cli/src/worktree-sources.ts +6 -4
- 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/distill/digest.mjs +136 -0
- package/spec-cli/templates/spec/project/.config/distill/spec.md +74 -0
- package/spec-dashboard/dist/assets/{Dashboard-CMRJGfYI.js → Dashboard-BlRRsxE7.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Dj2mxcfW.js → EvalsPage-BzVE38-Z.js} +1 -1
- package/spec-dashboard/dist/assets/{FoldToggle-BfNpeyRQ.js → FoldToggle-DFuLVOeu.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-DfY315kt.js → IssuesPage-CzDaazhe.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-BGdC0A0P.js → MobileApp-CXQrQCNp.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-BOBCAR0t.js → SessionInterface-D1pUBl6q.js} +8 -8
- package/spec-dashboard/dist/assets/{SessionWindow-D7YmjV0i.js → SessionWindow-Y25Bwg1e.js} +5 -5
- package/spec-dashboard/dist/assets/{Settings-BlSNmpH_.js → Settings-R610Vbzd.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BFdzpd_O.js → index-BO0Zuweu.js} +2 -2
- package/spec-dashboard/dist/index.html +1 -1
|
@@ -6,13 +6,15 @@ import { loadSystemConfig, loadSkillConfig, loadAgentConfig, loadConfig } from '
|
|
|
6
6
|
import { compileManifest } from './hooks.js'
|
|
7
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
12
|
import { plantContractFilter, removeContractFilter, settleIndexStat } from './contract-filter.js'
|
|
13
13
|
|
|
14
|
-
// @@@ materialize - the
|
|
15
|
-
//
|
|
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
|
|
16
18
|
// cheaply, so a USER-self-launched claude/codex (no SpexCode process in the launch) gets the whole system via
|
|
17
19
|
// harness-auto-discovered files: (1) the hook MANIFEST (our dispatcher reads it), (2) the CONTRACT — the
|
|
18
20
|
// tracked docs guide (docs/AGENT_GUIDE.md) FOLLOWED BY the surface:system bodies — written WHOLE into each
|
|
@@ -20,7 +22,7 @@ import { plantContractFilter, removeContractFilter, settleIndexStat } from './co
|
|
|
20
22
|
// (Codex's deterministic trusted_hash; Claude none). EVERY harness-specific fact is owned by the
|
|
21
23
|
// [[harness-adapter]] (harness.ts) — this file just loops over HARNESSES.
|
|
22
24
|
//
|
|
23
|
-
// THE FORGETTING LAW ([[harness-delivery]]): materialize(P₂) ∘ materialize(P₁) = materialize(P₂) —
|
|
25
|
+
// THE FORGETTING LAW ([[harness-delivery]]): materialize(P₂) ∘ materialize(P₁) = materialize(P₂) — one pass
|
|
24
26
|
// under the current policy fully forgets every prior policy's artifacts; idempotence is the special case
|
|
25
27
|
// P₂ = P₁, and dematerialize (= materialize(∅), what `spex uninstall` builds on) is the empty policy. The
|
|
26
28
|
// implementation is ERASE-THEN-ASSERT over a CLOSED set of landing points: each is first erased
|
|
@@ -35,15 +37,17 @@ const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
|
|
|
35
37
|
// resolution AND the mid-merge guard (a conflicted source tree degrades to one line + exit 75, not an
|
|
36
38
|
// esbuild stacktrace), so every hook-baked callback inherits both.
|
|
37
39
|
const SPEX = join(PKG, 'bin', 'spex.mjs')
|
|
38
|
-
// the manifest + content-hash marker
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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.
|
|
41
46
|
|
|
42
|
-
// the deterministic content fingerprint of the config roots + THE
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
// the "toolchain updated but nothing re-materialized" hole ([[harness-delivery]]).
|
|
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]]).
|
|
47
51
|
export function contentHash(proj: string): string {
|
|
48
52
|
try {
|
|
49
53
|
const harnessSh = join(PKG, 'hooks', 'harness.sh')
|
|
@@ -51,43 +55,23 @@ export function contentHash(proj: string): string {
|
|
|
51
55
|
} catch { return '' }
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
// @@@
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
export
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return r
|
|
72
|
-
}
|
|
73
|
-
if (cfg.private) {
|
|
74
|
-
// LEGACY private:true — the retired untrack-private mode. Its render half maps to 'hidden'; its
|
|
75
|
-
// data-untrack half is GONE (the spec sources are always tracked now). Loud, non-fatal: the deployment
|
|
76
|
-
// keeps working while the notice names the two migration moves.
|
|
77
|
-
let untracked: string[] = []
|
|
78
|
-
if (proj) untracked = ['.spec', 'spexcode.json'].filter((p) => existsSync(join(proj, p)) && !isTracked(proj, p))
|
|
79
|
-
console.error(
|
|
80
|
-
`spexcode: \`private: true\` is retired — reading it as \`"render": "hidden"\`.\n` +
|
|
81
|
-
` → migrate spexcode.local.json: replace "private": true with "render": "hidden".` +
|
|
82
|
-
(untracked.length
|
|
83
|
-
? `\n → your spec sources are still untracked (${untracked.join(' + ')}); the untrack-private mode is gone, so track them once:\n` +
|
|
84
|
-
` git add ${untracked.join(' ')} (then commit on your branch)\n` +
|
|
85
|
-
` WARNING: tracking is not retroactive secrecy — history already pushed elsewhere cannot be recalled.`
|
|
86
|
-
: ''),
|
|
87
|
-
)
|
|
88
|
-
return 'hidden'
|
|
89
|
-
}
|
|
90
|
-
return '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
|
+
)
|
|
91
75
|
}
|
|
92
76
|
|
|
93
77
|
function gitCommonDirOf(proj: string): string {
|
|
@@ -100,38 +84,28 @@ function isTracked(proj: string, file: string): boolean {
|
|
|
100
84
|
try { git(['-C', proj, 'ls-files', '--error-unmatch', file]); return true } catch { return false }
|
|
101
85
|
}
|
|
102
86
|
|
|
103
|
-
// @@@
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
return
|
|
121
|
-
|
|
122
|
-
`modification you see in git status. That dirtiness is HONEST under the current default (render: ignored),`,
|
|
123
|
-
`kept visible on purpose until you vote where the generated renders should live:`,
|
|
124
|
-
` committed renders become ordinary committed files — teammates/CI get the contract natively`,
|
|
125
|
-
` ignored (current default) renders stay generated+gitignored; a tracked contract file stays dirty`,
|
|
126
|
-
` hidden zero repo footprint — ignore rules live in .git/info/exclude and the tracked file is`,
|
|
127
|
-
` covered by the clean/smudge filter (index stays pristine, status clean)`,
|
|
128
|
-
`Vote once and this note retires: "render": "committed" | "ignored" in spexcode.json (a project fact) or`,
|
|
129
|
-
`"render": "hidden" in spexcode.local.json (a host fact) — or adopt in one step: spex init --render <word>.`,
|
|
130
|
-
`Full model: spex guide footprint.`,
|
|
131
|
-
].join('\n')
|
|
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'))
|
|
132
106
|
}
|
|
133
107
|
// clear a legacy skip-worktree bit (the retired private-overlay mechanism; erase-only now — nothing asserts
|
|
134
|
-
// it). Best-effort: an index race or a non-repo must not fail the
|
|
108
|
+
// it). Best-effort: an index race or a non-repo must not fail the materialize.
|
|
135
109
|
function clearSkipWorktree(proj: string, file: string): void {
|
|
136
110
|
if (!isTracked(proj, file)) return
|
|
137
111
|
try { git(['-C', proj, 'update-index', '--no-skip-worktree', file]) } catch { /* best-effort */ }
|
|
@@ -139,7 +113,7 @@ function clearSkipWorktree(proj: string, file: string): void {
|
|
|
139
113
|
|
|
140
114
|
// the identity stamp on every generated skill/agent file — what lets the erase phase forget a product whose
|
|
141
115
|
// NODE was renamed or deleted (the name-scoped sweep can only reconstruct paths the LIVE config still names).
|
|
142
|
-
const GENERATED_MARK = '<!-- spexcode:generated -->'
|
|
116
|
+
export const GENERATED_MARK = '<!-- spexcode:generated -->'
|
|
143
117
|
function sweepGeneratedSkills(dir: string | null): void {
|
|
144
118
|
if (!dir || !existsSync(dir)) return
|
|
145
119
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -159,11 +133,11 @@ function sweepGeneratedAgents(dir: string | null): void {
|
|
|
159
133
|
|
|
160
134
|
// @@@ dematerialize - materialize(∅): the ERASE phase, also the whole of a backout ([[spex-uninstall]] adds
|
|
161
135
|
// only the global store + plugin sweep + optional git hooks on top). Every removal is gated on an identity
|
|
162
|
-
// stamp, so it deletes ONLY what a
|
|
136
|
+
// stamp, so it deletes ONLY what a materialize wrote — never the user's prose, settings, or any .spec data. Order
|
|
163
137
|
// matters once: the managed blocks leave the WORKING contract files before the content filter's config goes
|
|
164
138
|
// (edge ③ in [[content-filter]] — a block outliving its clean filter surfaces as an uncommitted change).
|
|
165
139
|
// `arts` (live skill/agent node names) widens the sweep to pre-stamp legacy files; the GENERATED_MARK sweep
|
|
166
|
-
// covers everything
|
|
140
|
+
// covers everything materialized since, including products of renamed/deleted nodes.
|
|
167
141
|
export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { skills: [], agents: [] }): void {
|
|
168
142
|
for (const h of HARNESSES) {
|
|
169
143
|
// h.clean = the adapter's surgical inverse: contract block (sentinels, deleteIfEmpty), the dispatch.sh-
|
|
@@ -192,9 +166,9 @@ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { s
|
|
|
192
166
|
}
|
|
193
167
|
}
|
|
194
168
|
|
|
195
|
-
// the whole pay-per-change
|
|
169
|
+
// the whole pay-per-change materialize. proj defaults to cwd. Returns the new content-hash it stamped.
|
|
196
170
|
export function materialize(proj = process.cwd()): string {
|
|
197
|
-
const rt =
|
|
171
|
+
const rt = treeSlotDir(proj) // this tree's slot in the global store, not the worktree
|
|
198
172
|
mkdirSync(rt, { recursive: true })
|
|
199
173
|
// (1) hook manifest (persistent — the dispatcher reads it; regenerated only here, on change).
|
|
200
174
|
writeFileSync(join(rt, 'hooks-manifest'), compileManifest())
|
|
@@ -209,7 +183,7 @@ export function materialize(proj = process.cwd()): string {
|
|
|
209
183
|
// native harness). resolveHarnessTargets FAILS LOUD on an illegal set (plugin+native, plugin w/o folder).
|
|
210
184
|
const cfg = readConfig(mainCheckout(proj))
|
|
211
185
|
const targets = resolveHarnessTargets(cfg.harnesses)
|
|
212
|
-
|
|
186
|
+
retiredAxisNotice(cfg) // [[residence]] — the vote axis is retired
|
|
213
187
|
const { selected, plugins } = partitionHarnesses(targets)
|
|
214
188
|
const skillNodes = loadSkillConfig()
|
|
215
189
|
const agentNodes = loadAgentConfig()
|
|
@@ -224,25 +198,25 @@ export function materialize(proj = process.cwd()): string {
|
|
|
224
198
|
// ---- ASSERT: rewrite each landing point per the CURRENT policy.
|
|
225
199
|
// a skill node → the agentskills.io SKILL.md primitive: `name`+`description` frontmatter (the load-trigger)
|
|
226
200
|
// over the body instructions, closed by the GENERATED_MARK identity stamp (what the erase phase keys on).
|
|
227
|
-
// One pure
|
|
228
|
-
const
|
|
201
|
+
// One pure artifact builder shared by every harness — divergence is only its skillDir.
|
|
202
|
+
const skillArtifact = (sk: { name: string; desc: string; body: string }) =>
|
|
229
203
|
`---\nname: ${sk.name}\ndescription: ${JSON.stringify(sk.desc)}\n---\n\n${sk.body}\n\n${GENERATED_MARK}\n`
|
|
230
204
|
// an agent node → a coding-agent sub-agent definition (the same primitive .claude/agents/*.md ships): the
|
|
231
205
|
// node's `desc` is the on-demand load-trigger, its `tools` the harness tool allowlist, its body the agent's
|
|
232
206
|
// system prompt. Same stamp, same reason.
|
|
233
|
-
const
|
|
207
|
+
const agentArtifact = (ag: { name: string; desc: string; tools: string[]; body: string }) =>
|
|
234
208
|
`---\nname: ${ag.name}\ndescription: ${ag.desc}\ntools: ${ag.tools.join(', ')}\n---\n\n${ag.body}\n\n${GENERATED_MARK}\n`
|
|
235
209
|
// a command node → a host `/`-menu command file: plugin-only (the native path serves command presets via
|
|
236
210
|
// the dashboard /api/slash-commands instead).
|
|
237
|
-
const
|
|
211
|
+
const commandArtifact = (cm: { desc: string; body: string }) =>
|
|
238
212
|
(cm.desc ? `---\ndescription: ${JSON.stringify(cm.desc)}\n---\n\n` : '') + `${cm.body}\n`
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
const renderPaths: 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[] = []
|
|
243
216
|
const machinePaths: string[] = []
|
|
217
|
+
const contractPaths: string[] = []
|
|
244
218
|
for (const h of selected) {
|
|
245
|
-
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) }
|
|
246
220
|
const shimFile = h.shimFile(proj)
|
|
247
221
|
mkdirSync(dirname(shimFile), { recursive: true })
|
|
248
222
|
const shim = h.shim(DISPATCH, SPEX)
|
|
@@ -261,8 +235,8 @@ export function materialize(proj = process.cwd()): string {
|
|
|
261
235
|
const dir = h.skillDir(proj); if (!dir) continue
|
|
262
236
|
const f = join(dir, sk.name, 'SKILL.md')
|
|
263
237
|
mkdirSync(dirname(f), { recursive: true })
|
|
264
|
-
writeFileSync(f,
|
|
265
|
-
|
|
238
|
+
writeFileSync(f, skillArtifact(sk))
|
|
239
|
+
artifactPaths.push(f)
|
|
266
240
|
}
|
|
267
241
|
}
|
|
268
242
|
for (const ag of agentNodes) {
|
|
@@ -270,37 +244,42 @@ export function materialize(proj = process.cwd()): string {
|
|
|
270
244
|
const dir = h.agentDir(proj); if (!dir) continue
|
|
271
245
|
const f = join(dir, `${ag.name}.md`)
|
|
272
246
|
mkdirSync(dirname(f), { recursive: true })
|
|
273
|
-
writeFileSync(f,
|
|
274
|
-
|
|
247
|
+
writeFileSync(f, agentArtifact(ag))
|
|
248
|
+
artifactPaths.push(f)
|
|
275
249
|
}
|
|
276
250
|
}
|
|
277
|
-
// (8) the PLUGIN target ([[plugin-harness]]):
|
|
251
|
+
// (8) the PLUGIN target ([[plugin-harness]]): materialize the whole system into one self-contained Claude-plugin
|
|
278
252
|
// bundle per selected folder. A plugin is EXCLUSIVE (`selected` is empty then). Pruning a DESELECTED
|
|
279
253
|
// folder needs the PREVIOUS folder set, which the live config no longer names — the one landing point
|
|
280
254
|
// the identity-stamped erase cannot enumerate (a folder is an arbitrary path) — so a tiny ledger in the
|
|
281
255
|
// global store records the folders emitted last run; any prev folder absent from the current set is
|
|
282
256
|
// clean()ed, then the current folders are emitted and the ledger rewritten.
|
|
283
257
|
const ledger = join(rt, 'plugin-folders')
|
|
284
|
-
|
|
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) : []
|
|
285
263
|
const curFolders = plugins.map((p) => p.folder)
|
|
286
264
|
for (const f of prevFolders) if (!curFolders.includes(f)) cleanPlugin(proj, f)
|
|
287
265
|
if (plugins.length) {
|
|
288
|
-
const
|
|
266
|
+
const bundle = {
|
|
289
267
|
contract,
|
|
290
|
-
skills: skillNodes.map((s) => ({ name: s.name, content:
|
|
291
|
-
agents: agentNodes.map((a) => ({ name: a.name, content:
|
|
292
|
-
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) })),
|
|
293
271
|
spex: SPEX,
|
|
294
272
|
version: pluginVersion(),
|
|
295
273
|
}
|
|
296
|
-
for (const p of plugins) emitPlugin(proj, p.folder,
|
|
274
|
+
for (const p of plugins) emitPlugin(proj, p.folder, bundle)
|
|
297
275
|
}
|
|
298
276
|
writeFileSync(ledger, curFolders.join('\n'))
|
|
299
|
-
// (9) the ignore rules —
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
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
|
|
304
283
|
// proj, else MAIN-checkout-relative (the codex shim resolves to `.codex/hooks.json` from any checkout; a
|
|
305
284
|
// pattern naming a main-only path is a harmless no-op in a worktree). A path under neither root is dropped.
|
|
306
285
|
const mc = mainCheckout(proj)
|
|
@@ -318,23 +297,20 @@ export function materialize(proj = process.cwd()): string {
|
|
|
318
297
|
...[...machinePaths, ...bundlePaths].map(anchor).filter((p): p is string => p !== null),
|
|
319
298
|
'spexcode.local.json', '.worktrees/', '.session',
|
|
320
299
|
]
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
.flatMap((h) => h.contractFiles(proj))
|
|
330
|
-
.filter((f) => contract && isTracked(proj, f))
|
|
331
|
-
if (trackedContracts.length) plantContractFilter(proj, trackedContracts, contract)
|
|
332
|
-
} else {
|
|
333
|
-
// committed: the renders become ordinary committed files — ONLY their entries leave the block.
|
|
334
|
-
const list = policy === 'committed' ? machineEntries : [...machineEntries, ...renderEntries]
|
|
335
|
-
if (list.length) writeManagedBlock(join(proj, '.gitignore'), entries(list), ['# ', ''])
|
|
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)
|
|
336
308
|
}
|
|
337
|
-
|
|
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).
|
|
338
314
|
const h = contentHash(proj)
|
|
339
315
|
writeFileSync(join(rt, 'content-hash'), h)
|
|
340
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)
|
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type Review
|
|
|
9
9
|
import { loadSpecs } from './specs.js'
|
|
10
10
|
import { defaultHarness, defaultLauncher, harnessById, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
|
|
11
11
|
import { materialize } from './materialize.js'
|
|
12
|
-
import { mainBranch, gitCommonDir, readConfig, runtimeRoot, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
|
|
12
|
+
import { mainBranch, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
|
|
13
13
|
import { stripRefSigil } from './mentions.js'
|
|
14
14
|
|
|
15
15
|
// @@@ sessions - the WORKTREE is the durable unit; tmux is a disposable runtime handle. The per-session
|
|
@@ -76,7 +76,7 @@ function maxActive(): number {
|
|
|
76
76
|
// rvSock is imported only for the two NON-delivery uses that remain product-level: building the launch env var
|
|
77
77
|
// (rvEnv, below) and the best-effort socket sweep on close.
|
|
78
78
|
// env prefix put in front of the spawned agent so it creates this session's rendezvous control socket — and
|
|
79
|
-
// so its hooks + materialize
|
|
79
|
+
// so its hooks + materialize write to the SAME store the backend uses. SPEXCODE_HOME/CODEX_HOME are
|
|
80
80
|
// propagated when set, because the session inherits the tmux SERVER's env (not the backend's), so without this
|
|
81
81
|
// an overridden home would silently leak the session's hook-state + codex-trust to the default ~/.spexcode /
|
|
82
82
|
// ~/.codex. Deterministic: the session's store = the backend's store, never the ambient env's.
|
|
@@ -817,14 +817,17 @@ export const slugify = (s: string | null) =>
|
|
|
817
817
|
// When there is none, the session is node-agnostic and we label it by the first few words of the prompt.
|
|
818
818
|
// The OPTIONAL leading dot is load-bearing: a node id is its dir basename, so a dot-prefixed config root
|
|
819
819
|
// (`.config`) keeps the dot — without `\.?` here `[[.config]]` captures nothing and never resolves to a node.
|
|
820
|
-
|
|
820
|
+
// Token chars are ANY unicode letter/number (slugify's already-made choice): a CJK dir name is a legal node
|
|
821
|
+
// id, so `[[中文节点]]` must bind the session exactly like an ASCII id — ASCII-only here silently launched
|
|
822
|
+
// node-agnostic.
|
|
823
|
+
const MENTION = /\[\[(\.?[\p{L}\p{N}_-]+)\]\]/u
|
|
821
824
|
const mentionedNode = (prompt: string): string | null => prompt.match(MENTION)?.[1] ?? null
|
|
822
825
|
// @@@ identity-token strip - an `@session` actor mention ([[mentions]]) or a bare UUID-shaped token in the
|
|
823
826
|
// prompt is ANOTHER session's identity, never this one's name. A title/slug wearing it misleads every
|
|
824
827
|
// board/git surface — and a worker tasked with cleaning that session can match its OWN worktree and delete
|
|
825
828
|
// it from under itself. Strip both before deriving; whatever prose remains names the session.
|
|
826
829
|
const UUID_TOKEN = /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g
|
|
827
|
-
const stripIdentityTokens = (s: string) => s.replace(/(^|\s)@[
|
|
830
|
+
const stripIdentityTokens = (s: string) => s.replace(/(^|\s)@[\p{L}\p{N}_-]+/gu, '$1').replace(UUID_TOKEN, ' ')
|
|
828
831
|
export function titleFromPrompt(prompt: string): string | null {
|
|
829
832
|
const first = stripIdentityTokens(prompt || '').split('\n').map((l) => l.trim()).find(Boolean) || ''
|
|
830
833
|
const words = first.split(/\s+/).filter(Boolean).slice(0, 7).join(' ')
|
|
@@ -1074,8 +1077,9 @@ export async function newSession(node: string | null, prompt: string, parent: st
|
|
|
1074
1077
|
const branch = `node/${slug}`
|
|
1075
1078
|
const path = join(mainRoot(), '.worktrees', slug)
|
|
1076
1079
|
await gitA(['-C', mainRoot(), 'worktree', 'add', '-b', branch, path, mainBranch()])
|
|
1077
|
-
// the checkout delivers the tracked spec sources and the materialize below delivers the
|
|
1078
|
-
//
|
|
1080
|
+
// the checkout delivers the tracked spec sources and the materialize below delivers the materialized
|
|
1081
|
+
// artifacts; the ONE
|
|
1082
|
+
// thing git cannot carry is the machine-local spexcode.local.json — copied as a snapshot ([[residence]];
|
|
1079
1083
|
// no-op when the main checkout has none).
|
|
1080
1084
|
seedWorktreeHostState(mainRoot(), path)
|
|
1081
1085
|
// prepared but NOT launched: enters the queue as `queued`. drainQueue() below launches it at once when a
|
|
@@ -1097,7 +1101,7 @@ export async function newSession(node: string | null, prompt: string, parent: st
|
|
|
1097
1101
|
}
|
|
1098
1102
|
writeRecord(rec)
|
|
1099
1103
|
writePromptFile(id, prompt) // capture the ORIGINATING prompt (the human/manager's ask) as store metadata (best-effort)
|
|
1100
|
-
//
|
|
1104
|
+
// materialize the harness-discovered artifacts INTO the worktree (CLAUDE.md/AGENTS.md contract block, .claude/.codex
|
|
1101
1105
|
// shims, manifest to the global store) so the launched agent gets the contract + hooks the SAME way a
|
|
1102
1106
|
// self-launched one does — by auto-discovery, not CLI injection. This is why the launch line below carries no
|
|
1103
1107
|
// --append-system-prompt / --settings, and why we no longer hide CLAUDE.md: hiding it suppressed the agent's
|
|
@@ -1121,9 +1125,9 @@ export async function newSession(node: string | null, prompt: string, parent: st
|
|
|
1121
1125
|
return toSession(after, queued ? 'queued' : 'working', queued ? 'offline' : 'starting')
|
|
1122
1126
|
}
|
|
1123
1127
|
|
|
1124
|
-
// @@@ bootstrapMaterialize - the creation-time materialize is BOOTSTRAP, not best-effort: it is what
|
|
1125
|
-
// the worktree's .claude/.codex shims (the settings.json hook wiring) in the first place, and
|
|
1126
|
-
//
|
|
1128
|
+
// @@@ bootstrapMaterialize - the creation-time materialize is BOOTSTRAP, not best-effort: it is what writes
|
|
1129
|
+
// the worktree's .claude/.codex shims (the settings.json hook wiring) in the first place, and every
|
|
1130
|
+
// lifecycle dispatch RIDES ON those hooks — so when this materialize fails, no hook ever fires,
|
|
1127
1131
|
// and the worker comes up ungoverned (no contract block, no stop-gate) with nothing saying so. Fail loud
|
|
1128
1132
|
// instead: log the cause + worktree, and stamp the failure on the record's `note` so the board/watch surface
|
|
1129
1133
|
// it. The launch still proceeds — a visibly degraded worker the human can close + re-dispatch beats a refused
|
|
@@ -1134,7 +1138,7 @@ export function bootstrapMaterialize(rec: SessRec, doMaterialize: (proj: string)
|
|
|
1134
1138
|
doMaterialize(rec.worktreePath)
|
|
1135
1139
|
} catch (e) {
|
|
1136
1140
|
const msg = e instanceof Error ? e.message : String(e)
|
|
1137
|
-
console.error(`spex: materialize failed for worktree ${rec.worktreePath} — hooks/contract not
|
|
1141
|
+
console.error(`spex: materialize failed for worktree ${rec.worktreePath} — hooks/contract not materialized, worker launches UNGOVERNED: ${msg}`)
|
|
1138
1142
|
writeRecord({ ...rec, note: `materialize failed at creation — worker ungoverned (no hooks/contract): ${msg}` })
|
|
1139
1143
|
}
|
|
1140
1144
|
}
|
|
@@ -1256,8 +1260,8 @@ export function markIdle(sessionId?: string): boolean {
|
|
|
1256
1260
|
// done / propose merge. The dogfood ritual lands every change as a COMMIT on the node branch first, so two
|
|
1257
1261
|
// states block a declaration: (1) any uncommitted working-tree change, or (2) 0 commits ahead of main
|
|
1258
1262
|
// (nothing committed to merge). Since the global-store refactor, SpexCode writes NO per-session files into
|
|
1259
|
-
// the worktree (the runtime lives in ~/.spexcode), and the only in-tree SpexCode artifacts are
|
|
1260
|
-
//
|
|
1263
|
+
// the worktree (the runtime lives in ~/.spexcode), and the only in-tree SpexCode artifacts are exclude-
|
|
1264
|
+
// hidden materialized artifacts or filter-covered contract blocks ([[residence]]), so
|
|
1261
1265
|
// neither shows as an uncommitted change — the worktree is pristine and EVERY dirty path is genuine spec/code
|
|
1262
1266
|
// work, no runtime-file filtering needed.
|
|
1263
1267
|
// Runs from cwd = the session worktree; ALL git goes through git() so the hook's exported GIT_DIR/GIT_INDEX_FILE
|
|
@@ -1446,12 +1450,17 @@ export async function exitSession(id: string): Promise<boolean> {
|
|
|
1446
1450
|
// the session's whole global-store record dir — the work is gone, not just stopped. Same stop primitive as
|
|
1447
1451
|
// exitSession (no duplicate kill path), then the git worktree/branch teardown that exit deliberately skips,
|
|
1448
1452
|
// then the store sweep (exit KEEPS the record so the session stays on the board offline; close discards it).
|
|
1453
|
+
// The tree's materialize slot ([[runtime]] trees/<enc>) retires with the worktree — its key needs the live tree,
|
|
1454
|
+
// so it is resolved BEFORE the removal; both sweeps are best-effort (residue is swept at uninstall anyway).
|
|
1449
1455
|
export async function closeSession(id: string): Promise<boolean> {
|
|
1450
1456
|
const wt = await findWorktree(id)
|
|
1451
1457
|
await stopAgentProcess(id)
|
|
1452
1458
|
if (wt) {
|
|
1459
|
+
let slot: string | null = null
|
|
1460
|
+
try { slot = treeSlotDir(wt.path) } catch { /* tree already unresolvable — nothing to key the slot by */ }
|
|
1453
1461
|
await gitA(['-C', mainRoot(), 'worktree', 'remove', '--force', wt.path])
|
|
1454
1462
|
if (wt.branch) await gitA(['-C', mainRoot(), 'branch', '-D', wt.branch])
|
|
1463
|
+
if (slot) { try { rmSync(slot, { recursive: true, force: true }) } catch { /* best-effort GC */ } }
|
|
1455
1464
|
}
|
|
1456
1465
|
try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) } catch { /* best-effort sweep of the global record */ }
|
|
1457
1466
|
void drainQueue() // a close frees a slot — start the next queued session if any
|