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.
Files changed (36) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/dispatch.sh +28 -40
  4. package/spec-cli/hooks/harness.sh +29 -19
  5. package/spec-cli/src/cli.ts +21 -11
  6. package/spec-cli/src/commit-surgery.ts +81 -0
  7. package/spec-cli/src/contract-filter.ts +16 -14
  8. package/spec-cli/src/doctor.ts +11 -5
  9. package/spec-cli/src/guide.ts +61 -58
  10. package/spec-cli/src/harness-select.ts +2 -2
  11. package/spec-cli/src/harness.ts +5 -5
  12. package/spec-cli/src/help.ts +20 -17
  13. package/spec-cli/src/init.ts +5 -40
  14. package/spec-cli/src/layout.ts +26 -20
  15. package/spec-cli/src/materialize.ts +102 -126
  16. package/spec-cli/src/mentions.ts +5 -3
  17. package/spec-cli/src/plugin-harness.ts +10 -9
  18. package/spec-cli/src/sessions.ts +22 -13
  19. package/spec-cli/src/specs.ts +9 -4
  20. package/spec-cli/src/uninstall.ts +6 -5
  21. package/spec-cli/src/worktree-sources.ts +6 -4
  22. package/spec-cli/templates/hooks/post-checkout +22 -0
  23. package/spec-cli/templates/hooks/post-merge +15 -9
  24. package/spec-cli/templates/hooks/pre-commit +10 -0
  25. package/spec-cli/templates/spec/project/.config/distill/digest.mjs +136 -0
  26. package/spec-cli/templates/spec/project/.config/distill/spec.md +74 -0
  27. package/spec-dashboard/dist/assets/{Dashboard-CMRJGfYI.js → Dashboard-BlRRsxE7.js} +7 -7
  28. package/spec-dashboard/dist/assets/{EvalsPage-Dj2mxcfW.js → EvalsPage-BzVE38-Z.js} +1 -1
  29. package/spec-dashboard/dist/assets/{FoldToggle-BfNpeyRQ.js → FoldToggle-DFuLVOeu.js} +1 -1
  30. package/spec-dashboard/dist/assets/{IssuesPage-DfY315kt.js → IssuesPage-CzDaazhe.js} +1 -1
  31. package/spec-dashboard/dist/assets/{MobileApp-BGdC0A0P.js → MobileApp-CXQrQCNp.js} +1 -1
  32. package/spec-dashboard/dist/assets/{SessionInterface-BOBCAR0t.js → SessionInterface-D1pUBl6q.js} +8 -8
  33. package/spec-dashboard/dist/assets/{SessionWindow-D7YmjV0i.js → SessionWindow-Y25Bwg1e.js} +5 -5
  34. package/spec-dashboard/dist/assets/{Settings-BlSNmpH_.js → Settings-R610Vbzd.js} +1 -1
  35. package/spec-dashboard/dist/assets/{index-BFdzpd_O.js → index-BO0Zuweu.js} +2 -2
  36. 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 "pay-per-change" node step (≈0.85s) the cheap shell gate invokes ONLY when the
15
- // content-hash moved. It renders the spec tree's surface nodes into the flat artifacts each consumer reads
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₂) — a render
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 render into the GLOBAL per-project store (layout.runtimeRoot), NOT the
39
- // worktree the worktree keeps zero SpexCode-rendered runtime; only the harness-discovered contract files +
40
- // 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.
41
46
 
42
- // the deterministic content fingerprint of the config roots + THE RENDERER ITSELF. ONE definition
43
- // `hp_config_hash` in the shell mirror (harness.sh) which the dispatch.sh gate ALSO calls, so the gate and
44
- // this renderer can never disagree on "changed". It folds in hp_renderer_version (the toolchain-side content
45
- // hash), so a TOOLCHAIN update moves the key and the next gate self-heals the rendered artifacts — closing
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
- // @@@ render policy ([[render-policy]]) - ONE share axis, voted only for the machine-independent RENDERS.
55
- // `.spec` + `spexcode.json` are ALWAYS tracked (git is the database no knob can untrack them); machine
56
- // facts (shims, spexcode.local.json) are ALWAYS ignored; run residue (.worktrees/) is always ignored. The
57
- // vote decides where the renders (contract blocks, skills, agents) sit and, recursively, where their
58
- // ignore rules live: `committed` drops the render entries from the ignore block (the renders are ordinary
59
- // committed files), `ignored` (default) keeps the block in the TRACKED .gitignore, `hidden` moves the whole
60
- // block to the per-clone .git/info/exclude and covers a HOST-TRACKED contract file with the clean/smudge
61
- // content filter ([[content-filter]]) instead of the retired skip-worktree bit.
62
- export type RenderPolicy = 'committed' | 'ignored' | 'hidden'
63
- export function resolveRenderPolicy(cfg: { render?: string; private?: boolean }, proj?: string): RenderPolicy {
64
- const r = cfg.render?.trim()
65
- if (r) {
66
- if (r !== 'committed' && r !== 'ignored' && r !== 'hidden') {
67
- const err = new Error(`invalid render policy '${r}' the render axis has three words: committed | ignored | hidden (see \`spex guide footprint\`)`)
68
- err.name = 'ConfigError'
69
- throw err
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
- // @@@ adoption vote hint ([[render-policy]] story 4 + 21) - the one-time decision guidance for the "mystery M"
104
- // moment: under the DEFAULT policy a host-TRACKED contract file (a team's own CLAUDE.md/AGENTS.md) carries the
105
- // generated block and shows honestly dirty in status deliberate, but a first-minute adopter just sees an
106
- // unexplained modification. So the HUMAN surfaces (spex init + the manual `spex materialize` verb never the
107
- // silent gate/bootstrap renders) print this guidance exactly while the vote is still open: only when (a) no
108
- // explicit `render` is set (the default `ignored` is in effect by omission, not by choice an explicit vote,
109
- // any word, retires the hint) and (b) a SELECTED harness's contract file is actually host-tracked. Mechanism,
110
- // not interaction: plain stdout naming the three words, their consequences, and where the vote lives — init is
111
- // routinely run by agents, so there is no TUI prompt to hang on.
112
- export function renderVoteHint(proj = process.cwd()): string | null {
113
- const cfg = readConfig(mainCheckout(proj))
114
- if (cfg.render?.trim() || cfg.private) return null // an explicit vote (or the legacy mapping) decided
115
- const { selected } = partitionHarnesses(resolveHarnessTargets(cfg.harnesses))
116
- const tracked = selected.flatMap((h) => h.contractFiles(proj)).filter((f) => isTracked(proj, f))
117
- if (!tracked.length) return null
118
- const names = tracked.map((f) => relative(proj, f)).join(' + ')
119
- const [be, carry] = tracked.length > 1 ? ['are', 'carry'] : ['is', 'carries']
120
- return [
121
- `note: ${names} ${be} tracked by this repo and now ${carry} the generated <!-- spexcode --> block — the`,
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 render.
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 render wrote — never the user's prose, settings, or any .spec data. Order
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 rendered since, including products of renamed/deleted nodes.
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 render. proj defaults to cwd. Returns the new content-hash it stamped.
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 = 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
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
- const policy = resolveRenderPolicy(cfg, proj) // [[render-policy]] — committed | ignored | hidden
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 render shared by every harness — divergence is only its skillDir.
228
- const renderSkill = (sk: { name: string; desc: string; body: string }) =>
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 renderAgent = (ag: { name: string; desc: string; tools: string[]; body: string }) =>
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 renderCommand = (cm: { desc: string; body: string }) =>
211
+ const commandArtifact = (cm: { desc: string; body: string }) =>
238
212
  (cm.desc ? `---\ndescription: ${JSON.stringify(cm.desc)}\n---\n\n` : '') + `${cm.body}\n`
239
- // two ignore classes, split because only `committed` treats them differently: RENDERS are machine-
240
- // independent products the team may choose to commit; MACHINE paths (shims bake this install's abs path,
241
- // bundles too) are never committable under any policy.
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); renderPaths.push(f) }
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, renderSkill(sk))
265
- renderPaths.push(f)
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, renderAgent(ag))
274
- renderPaths.push(f)
247
+ writeFileSync(f, agentArtifact(ag))
248
+ artifactPaths.push(f)
275
249
  }
276
250
  }
277
- // (8) the PLUGIN target ([[plugin-harness]]): render the whole system into one self-contained Claude-plugin
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
- 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) : []
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 render = {
266
+ const bundle = {
289
267
  contract,
290
- skills: skillNodes.map((s) => ({ name: s.name, content: renderSkill(s) })),
291
- agents: agentNodes.map((a) => ({ name: a.name, content: renderAgent(a) })),
292
- 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) })),
293
271
  spex: SPEX,
294
272
  version: pluginVersion(),
295
273
  }
296
- for (const p of plugins) emitPlugin(proj, p.folder, render)
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 — themselves an artifact whose HOME the same axis decides ([[render-policy]]):
300
- // committed/ignored a managed `#` block in the TRACKED .gitignore (the team sees the rule);
301
- // hidden the identical block in the per-clone .git/info/exclude (zero repo footprint).
302
- // Entries must be CHECKOUT-INVARIANT: `.gitignore` is ONE tracked file shared by the main checkout and
303
- // every worktree, so each entry is anchored to the checkout it LIVES under proj-relative when inside
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
- const renderEntries = renderPaths.map(anchor).filter((p): p is string => p !== null)
322
- const entries = (list: string[]) => [...new Set(list)].sort().join('\n')
323
- if (policy === 'hidden') {
324
- writeManagedBlock(infoExcludePath(proj), entries([...machineEntries, ...renderEntries]), ['# ', ''])
325
- // a HOST-TRACKED contract file cannot be ignored — cover it with the clean/smudge content filter
326
- // ([[content-filter]]) so the block lives in the working tree while the index keeps the pristine prose.
327
- // Untracked contract files are wholly ours: generate + exclude suffices, no filter (weakest tool).
328
- const trackedContracts = selected
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
- // (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).
338
314
  const h = contentHash(proj)
339
315
  writeFileSync(join(rt, 'content-hash'), h)
340
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)
@@ -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 render to the SAME store the backend uses. SPEXCODE_HOME/CODEX_HOME are
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
- const MENTION = /\[\[(\.?[A-Za-z0-9_-]+)\]\]/
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)@[A-Za-z0-9_-]+/g, '$1').replace(UUID_TOKEN, ' ')
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 renders; the ONE
1078
- // thing git cannot carry is the machine-local spexcode.local.json — copied as a snapshot ([[render-policy]];
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
- // render the harness-discovered artifacts INTO the worktree (CLAUDE.md/AGENTS.md contract block, .claude/.codex
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 renders
1125
- // the worktree's .claude/.codex shims (the settings.json hook wiring) in the first place, and the dispatch.sh
1126
- // re-render gate RIDES ON those hooks — so when this render fails, no hook ever fires, the gate never runs,
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 rendered, worker launches UNGOVERNED: ${msg}`)
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 gitignored
1260
- // (the materialize shims/skills) or tracked-and-committed (the contract block in CLAUDE.md/AGENTS.md), so
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