spexcode 0.2.3 → 0.2.5

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 (58) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +13 -10
  4. package/spec-cli/hooks/harness.sh +32 -6
  5. package/spec-cli/src/board.ts +2 -1
  6. package/spec-cli/src/boardCache.ts +27 -1
  7. package/spec-cli/src/boardStream.ts +5 -4
  8. package/spec-cli/src/cli.ts +82 -19
  9. package/spec-cli/src/contract-filter.ts +154 -0
  10. package/spec-cli/src/git.ts +18 -4
  11. package/spec-cli/src/guide.ts +91 -38
  12. package/spec-cli/src/harness.ts +18 -4
  13. package/spec-cli/src/help.ts +16 -7
  14. package/spec-cli/src/index.ts +9 -6
  15. package/spec-cli/src/init.ts +52 -10
  16. package/spec-cli/src/issues.ts +7 -5
  17. package/spec-cli/src/layout.ts +20 -8
  18. package/spec-cli/src/lint.ts +19 -0
  19. package/spec-cli/src/localIssues.ts +16 -4
  20. package/spec-cli/src/materialize.ts +218 -124
  21. package/spec-cli/src/ranker.ts +25 -8
  22. package/spec-cli/src/runtime-guard.ts +44 -0
  23. package/spec-cli/src/search.bench.mjs +15 -5
  24. package/spec-cli/src/sessions.ts +81 -12
  25. package/spec-cli/src/specs.ts +29 -14
  26. package/spec-cli/src/supervise.ts +2 -2
  27. package/spec-cli/src/tsx-bin.ts +6 -8
  28. package/spec-cli/src/uninstall.ts +15 -17
  29. package/spec-cli/src/worktree-sources.ts +56 -0
  30. package/spec-cli/templates/spec/project/.config/core/stop-gate/spec.md +1 -1
  31. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +26 -8
  32. package/spec-dashboard/dist/assets/Dashboard-CMRJGfYI.js +27 -0
  33. package/spec-dashboard/dist/assets/EvalsPage-Dj2mxcfW.js +2 -0
  34. package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-BfNpeyRQ.js} +1 -1
  35. package/spec-dashboard/dist/assets/IssuesPage-DfY315kt.js +1 -0
  36. package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-BGdC0A0P.js} +1 -1
  37. package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-BOBCAR0t.js} +5 -5
  38. package/spec-dashboard/dist/assets/SessionWindow-D7YmjV0i.js +9 -0
  39. package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-BlSNmpH_.js} +1 -1
  40. package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BFdzpd_O.js} +2 -2
  41. package/spec-dashboard/dist/assets/index-uGs9v_9o.css +1 -0
  42. package/spec-dashboard/dist/index.html +2 -2
  43. package/spec-forge/src/cli.ts +2 -2
  44. package/spec-forge/src/drivers/gitlab.ts +168 -0
  45. package/spec-forge/src/drivers.ts +80 -2
  46. package/spec-forge/src/resident.ts +10 -5
  47. package/spec-yatsu/src/cli.ts +37 -16
  48. package/spec-yatsu/src/evaltab.ts +6 -3
  49. package/spec-yatsu/src/filing.ts +13 -8
  50. package/spec-yatsu/src/freshness.ts +97 -22
  51. package/spec-yatsu/src/proof.ts +14 -3
  52. package/spec-yatsu/src/scenariofresh.ts +38 -11
  53. package/spec-yatsu/src/yatsu.ts +52 -28
  54. package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +0 -27
  55. package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +0 -3
  56. package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +0 -1
  57. package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +0 -9
  58. package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +0 -1
@@ -1,27 +1,33 @@
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
9
  import { runtimeRoot, 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
14
  // @@@ materialize - the "pay-per-change" node step (≈0.85s) the cheap shell gate invokes ONLY when the
14
- // .config content-hash moved. It renders the spec tree's surface nodes into the flat artifacts each
15
- // consumer reads cheaply, so a USER-self-launched claude/codex (no SpexCode process in the launch) gets the
16
- // whole system via harness-auto-discovered files: (1) the hook MANIFEST (our dispatcher reads it),
17
- // (2) the CONTRACT — the tracked docs guide (docs/AGENT_GUIDE.md) FOLLOWED BY the surface:system bodies —
18
- // written WHOLE into each harness's contract file(s), which are GENERATED, gitignored artifacts (like the
19
- // shims + skills): regenerated per clone/launch, never committed, so a self-launched agent still discovers
20
- // guide + contract while the repo tracks only the guide source, (3) the thin SHIMS (every event dispatch.sh),
21
- // (4) the per-harness TRUST (Codex's deterministic trusted_hash; Claude none) so the self-launch is zero-prompt.
22
- // EVERY harness-specific fact is owned by the [[harness-adapter]] (harness.ts) this file just loops over
23
- // HARNESSES, so adding a harness adds an adapter, not a branch here. All writes are idempotent + scoped. The
24
- // content-hash marker is stamped last.
15
+ // content-hash moved. It renders the spec tree's surface nodes into the flat artifacts each consumer reads
16
+ // cheaply, so a USER-self-launched claude/codex (no SpexCode process in the launch) gets the whole system via
17
+ // harness-auto-discovered files: (1) the hook MANIFEST (our dispatcher reads it), (2) the CONTRACT — the
18
+ // tracked docs guide (docs/AGENT_GUIDE.md) FOLLOWED BY the surface:system bodies — written WHOLE into each
19
+ // harness's contract file(s), (3) the thin SHIMS (every event → dispatch.sh), (4) the per-harness TRUST
20
+ // (Codex's deterministic trusted_hash; Claude none). EVERY harness-specific fact is owned by the
21
+ // [[harness-adapter]] (harness.ts) this file just loops over HARNESSES.
22
+ //
23
+ // THE FORGETTING LAW ([[harness-delivery]]): materialize(P₂) materialize(P₁) = materialize(P₂) a render
24
+ // under the current policy fully forgets every prior policy's artifacts; idempotence is the special case
25
+ // P₂ = P₁, and dematerialize (= materialize(∅), what `spex uninstall` builds on) is the empty policy. The
26
+ // implementation is ERASE-THEN-ASSERT over a CLOSED set of landing points: each is first erased
27
+ // unconditionally by its IDENTITY STAMP (sentinel blocks, the shim's dispatch.sh command line, the generated
28
+ // mark on skills/agents, the filter config namespace, the skip-worktree bit), then rewritten per the current
29
+ // policy (possibly to nothing). No ledger of past states, no pairwise migration branches — the erase IS the
30
+ // migration, whatever the previous state was.
25
31
 
26
32
  const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
27
33
  const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
@@ -33,9 +39,11 @@ const SPEX = join(PKG, 'bin', 'spex.mjs')
33
39
  // worktree — the worktree keeps zero SpexCode-rendered runtime; only the harness-discovered contract files +
34
40
  // shims (which the harness must find in-tree) are written under proj below.
35
41
 
36
- // the deterministic content fingerprint of the config roots. ONE definition — `hp_config_hash` in the shell
37
- // mirror (harness.sh) — which the dispatch.sh gate ALSO calls, so the gate and this renderer can never disagree
38
- // on "changed" (they used to inline the identical find-pipeline in two places, each commenting the other "MUST match").
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]]).
39
47
  export function contentHash(proj: string): string {
40
48
  try {
41
49
  const harnessSh = join(PKG, 'hooks', 'harness.sh')
@@ -43,15 +51,45 @@ export function contentHash(proj: string): string {
43
51
  } catch { return '' }
44
52
  }
45
53
 
46
- // @@@ private overlay ([[private-overlay]]) - the DEFAULT render commits .spec + spexcode.json and ignores its
47
- // generated wiring through a managed block in the TRACKED <repo>/.gitignore; that only truly hides a file that
48
- // is wholly ours, so on a host that ALREADY tracks CLAUDE.md/AGENTS.md/.gitignore the folded-in block still
49
- // lands in a tracked file and leaks. Private mode (spexcode.local.json `private:true`) closes that: it moves the
50
- // ignore block to the per-clone `.git/info/exclude` (never committed), widens it to hide .spec + spexcode.json
51
- // too, and `skip-worktree`s any TRACKED contract file so the block sits in the working copy (the harness still
52
- // discovers it) yet never stages. Every step has an inverse the DEFAULT mode re-asserts, so the toggle is
53
- // reversible and idempotent. It hides UNTRACKED paths; a pre-existing tracked .spec must be `git rm --cached`d
54
- // once (the render can't untrack for you — reported, not silently ignored).
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'
91
+ }
92
+
55
93
  function gitCommonDirOf(proj: string): string {
56
94
  return git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
57
95
  }
@@ -61,11 +99,97 @@ function infoExcludePath(proj: string): string {
61
99
  function isTracked(proj: string, file: string): boolean {
62
100
  try { git(['-C', proj, 'ls-files', '--error-unmatch', file]); return true } catch { return false }
63
101
  }
64
- // mark/unmark a TRACKED file skip-worktree (a no-op on an untracked path — info/exclude hides those instead).
65
- // Best-effort: an index race or a non-repo must not fail the render.
66
- function setSkipWorktree(proj: string, file: string, on: boolean): void {
102
+
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')
132
+ }
133
+ // 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.
135
+ function clearSkipWorktree(proj: string, file: string): void {
67
136
  if (!isTracked(proj, file)) return
68
- try { git(['-C', proj, 'update-index', on ? '--skip-worktree' : '--no-skip-worktree', file]) } catch { /* best-effort */ }
137
+ try { git(['-C', proj, 'update-index', '--no-skip-worktree', file]) } catch { /* best-effort */ }
138
+ }
139
+
140
+ // the identity stamp on every generated skill/agent file — what lets the erase phase forget a product whose
141
+ // NODE was renamed or deleted (the name-scoped sweep can only reconstruct paths the LIVE config still names).
142
+ const GENERATED_MARK = '<!-- spexcode:generated -->'
143
+ function sweepGeneratedSkills(dir: string | null): void {
144
+ if (!dir || !existsSync(dir)) return
145
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
146
+ if (!e.isDirectory()) continue
147
+ const f = join(dir, e.name, 'SKILL.md')
148
+ try { if (existsSync(f) && readFileSync(f, 'utf8').includes(GENERATED_MARK)) rmSync(join(dir, e.name), { recursive: true, force: true }) } catch { /* unreadable → not provably ours */ }
149
+ }
150
+ }
151
+ function sweepGeneratedAgents(dir: string | null): void {
152
+ if (!dir || !existsSync(dir)) return
153
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
154
+ if (!e.isFile() || !e.name.endsWith('.md')) continue
155
+ const f = join(dir, e.name)
156
+ try { if (readFileSync(f, 'utf8').includes(GENERATED_MARK)) rmSync(f, { force: true }) } catch { /* unreadable → not provably ours */ }
157
+ }
158
+ }
159
+
160
+ // @@@ dematerialize - materialize(∅): the ERASE phase, also the whole of a backout ([[spex-uninstall]] adds
161
+ // 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
163
+ // matters once: the managed blocks leave the WORKING contract files before the content filter's config goes
164
+ // (edge ③ in [[content-filter]] — a block outliving its clean filter surfaces as an uncommitted change).
165
+ // `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.
167
+ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { skills: [], agents: [] }): void {
168
+ for (const h of HARNESSES) {
169
+ // h.clean = the adapter's surgical inverse: contract block (sentinels, deleteIfEmpty), the dispatch.sh-
170
+ // stamped shim + worktree anchor, the trust block, and the arts-named skill/agent files.
171
+ h.clean(proj, arts)
172
+ for (const f of h.contractFiles(proj)) clearSkipWorktree(proj, f) // legacy private-overlay bit — erase-only
173
+ sweepGeneratedSkills(h.skillDir(proj))
174
+ sweepGeneratedAgents(h.agentDir(proj))
175
+ }
176
+ // same authorship rule as the contract files: deleteIfEmpty only when .gitignore is UNTRACKED (wholly-ours
177
+ // generated file); a HOST-TRACKED .gitignore that carried nothing but our block is stripped, never deleted.
178
+ removeManagedBlock(join(proj, '.gitignore'), ['# ', ''], !isTracked(proj, '.gitignore'))
179
+ try { removeManagedBlock(infoExcludePath(proj), ['# ', ''], false) } catch { /* not a git repo */ }
180
+ removeContractFilter(proj) // AFTER the blocks left the working files
181
+ // the block-strip left tracked contract files stat-dirty (under a filter git NEVER content-verifies them,
182
+ // and even unfiltered the phantom-`M` lingers) — settle the index stat, content-guarded so a user's real
183
+ // unstaged edit is never staged ([[content-filter]] edge 2).
184
+ try { settleIndexStat(proj, HARNESSES.flatMap((h) => h.contractFiles(proj))) } catch { /* not a git repo */ }
185
+ // leaving nothing behind: drop the now-EMPTY dirs the assert phase mkdir'ed (.claude/.codex and their
186
+ // skills/agents subdirs — children listed before parents). rmdirSync is NON-recursive, so a dir holding
187
+ // any user file survives untouched; `.git/spexcode/` is deliberately NOT swept (shared per-clone home).
188
+ for (const h of HARNESSES) {
189
+ const anchor = h.worktreeHookAnchor(proj)
190
+ for (const d of [h.skillDir(proj), h.agentDir(proj), dirname(h.shimFile(proj)), anchor ? dirname(anchor) : null])
191
+ if (d) { try { rmdirSync(d) } catch { /* non-empty or absent — keep */ } }
192
+ }
69
193
  }
70
194
 
71
195
  // the whole pay-per-change render. proj defaults to cwd. Returns the new content-hash it stamped.
@@ -77,93 +201,85 @@ export function materialize(proj = process.cwd()): string {
77
201
  // (2) the contract = the tracked docs guide (the hand-written agent/contributor notes — the ONE piece of
78
202
  // in-tree prose) FOLLOWED BY the surface:system bodies (in name order), written WHOLE into EACH harness's
79
203
  // 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
204
  const guidePath = join(proj, 'docs', 'AGENT_GUIDE.md')
83
205
  const guide = existsSync(guidePath) ? readFileSync(guidePath, 'utf8').trim() : ''
84
206
  const systemBodies = loadSystemConfig().map((c) => c.body.trim()).filter(Boolean)
85
207
  const contract = [guide, ...systemBodies].filter(Boolean).join('\n\n')
86
208
  // WHICH harnesses to deliver into ([[harness-select]]): the spexcode.json `harnesses` set (default = every
87
209
  // 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
210
  const cfg = readConfig(mainCheckout(proj))
91
211
  const targets = resolveHarnessTargets(cfg.harnesses)
92
- const priv = !!cfg.private // [[private-overlay]] — hide in .git/info/exclude + skip-worktree, not the tracked .gitignore
93
- const { selected, unselected, plugins } = partitionHarnesses(targets)
212
+ const policy = resolveRenderPolicy(cfg, proj) // [[render-policy]] — committed | ignored | hidden
213
+ const { selected, plugins } = partitionHarnesses(targets)
94
214
  const skillNodes = loadSkillConfig()
95
215
  const agentNodes = loadAgentConfig()
96
216
  const commandNodes = loadConfig()
217
+ const arts: HarnessArtifacts = { skills: skillNodes.map((s) => s.name), agents: agentNodes.map((a) => a.name) }
218
+
219
+ // ---- ERASE (the forgetting law): every landing point cleared by identity stamp, whatever policy — or
220
+ // legacy mode — wrote it last. Unselected harnesses need no separate prune branch: the erase already
221
+ // forgot them, and only the selected ones are asserted below.
222
+ dematerialize(proj, arts)
223
+
224
+ // ---- ASSERT: rewrite each landing point per the CURRENT policy.
97
225
  // a skill node → the agentskills.io SKILL.md primitive: `name`+`description` frontmatter (the load-trigger)
98
- // over the body instructions. One pure render shared by every harness divergence is only its skillDir.
226
+ // 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.
99
228
  const renderSkill = (sk: { name: string; desc: string; body: string }) =>
100
- `---\nname: ${sk.name}\ndescription: ${JSON.stringify(sk.desc)}\n---\n\n${sk.body}\n`
229
+ `---\nname: ${sk.name}\ndescription: ${JSON.stringify(sk.desc)}\n---\n\n${sk.body}\n\n${GENERATED_MARK}\n`
101
230
  // an agent node → a coding-agent sub-agent definition (the same primitive .claude/agents/*.md ships): the
102
231
  // node's `desc` is the on-demand load-trigger, its `tools` the harness tool allowlist, its body the agent's
103
- // system prompt. One pure render shared by every harness — divergence is only its agentDir.
232
+ // system prompt. Same stamp, same reason.
104
233
  const renderAgent = (ag: { name: string; desc: string; tools: string[]; body: string }) =>
105
- `---\nname: ${ag.name}\ndescription: ${ag.desc}\ntools: ${ag.tools.join(', ')}\n---\n\n${ag.body}\n`
106
- // a command node → a host `/`-menu command file: the node's `desc` as the dropdown description over its body
107
- // (the preset prompt). Only the PLUGIN bundle ships these as files; the native path serves command presets via
108
- // the dashboard /api/slash-commands instead, so this render is plugin-only.
234
+ `---\nname: ${ag.name}\ndescription: ${ag.desc}\ntools: ${ag.tools.join(', ')}\n---\n\n${ag.body}\n\n${GENERATED_MARK}\n`
235
+ // a command node → a host `/`-menu command file: plugin-only (the native path serves command presets via
236
+ // the dashboard /api/slash-commands instead).
109
237
  const renderCommand = (cm: { desc: string; body: string }) =>
110
238
  (cm.desc ? `---\ndescription: ${JSON.stringify(cm.desc)}\n---\n\n` : '') + `${cm.body}\n`
111
- // shim ABSOLUTE paths; relativized into the managed .gitignore block below (checkout-invariant see there).
112
- const shimPaths: string[] = []
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[] = []
243
+ const machinePaths: string[] = []
113
244
  for (const h of selected) {
114
- if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract); shimPaths.push(f); setSkipWorktree(proj, f, priv) }
245
+ if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract); renderPaths.push(f) }
115
246
  const shimFile = h.shimFile(proj)
116
247
  mkdirSync(dirname(shimFile), { recursive: true })
117
248
  const shim = h.shim(DISPATCH, SPEX)
118
249
  writeFileSync(shimFile, shim.json)
119
250
  h.writeTrust(proj, shim.cmd)
120
- shimPaths.push(shimFile)
251
+ machinePaths.push(shimFile)
121
252
  // a linked-worktree ANCHOR copy of the shim, when the harness needs one (codex: the shim lives at the main
122
- // checkout, so the worktree gets no `.codex/` unless we place one and codex only discovers a worktree
123
- // thread's hooks if a `.codex/` under the worktree root anchors the project layer). One adapter line; null
124
- // for a harness whose shim already sits in the worktree (claude) or on the main checkout itself.
253
+ // checkout, so the worktree gets no `.codex/` unless we place one). One adapter line; null otherwise.
125
254
  const anchor = h.worktreeHookAnchor(proj)
126
- if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.json); shimPaths.push(anchor) }
255
+ if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.json); machinePaths.push(anchor) }
127
256
  }
128
- // (6) skills - each `surface: skill` node → a SKILL.md the harness auto-discovers, written into every
129
- // harness's own skillDir (Claude .claude/skills, Codex .codex/skills). Generated wiring, so the paths
130
- // join the same managed .gitignore block below. A harness with no skill primitive (skillDir null) is skipped.
257
+ // (6) skills + (7) sub-agents each surface node → the file the harness auto-discovers, one per selected
258
+ // harness that has the primitive (skillDir/agentDir null skips the divergence is the adapter's line).
131
259
  for (const sk of skillNodes) {
132
260
  for (const h of selected) {
133
261
  const dir = h.skillDir(proj); if (!dir) continue
134
262
  const f = join(dir, sk.name, 'SKILL.md')
135
263
  mkdirSync(dirname(f), { recursive: true })
136
264
  writeFileSync(f, renderSkill(sk))
137
- shimPaths.push(f) // reuse the same managed .gitignore block (absolute; relativized below)
265
+ renderPaths.push(f)
138
266
  }
139
267
  }
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
268
  for (const ag of agentNodes) {
145
269
  for (const h of selected) {
146
270
  const dir = h.agentDir(proj); if (!dir) continue
147
271
  const f = join(dir, `${ag.name}.md`)
148
272
  mkdirSync(dirname(f), { recursive: true })
149
273
  writeFileSync(f, renderAgent(ag))
150
- shimPaths.push(f) // reuse the same managed .gitignore block (absolute; relativized below)
274
+ renderPaths.push(f)
151
275
  }
152
276
  }
153
- // (8) PRUNE every UNSELECTED harness — clean() is the surgical inverse of the write above, removing ONLY this
154
- // harness's own managed block + generated shim + trust + named skill/agent files. So narrowing the
155
- // spexcode.json `harnesses` set (or switching to a plugin, which excludes all natives) removes the
156
- // dropped harness's products here, the user's own prose/data untouched. The names tell clean exactly
157
- // which on-demand artifacts were its to remove ([[harness-select]] / [[harness-adapter]]).
158
- const arts: HarnessArtifacts = { skills: skillNodes.map((s) => s.name), agents: agentNodes.map((a) => a.name) }
159
- for (const h of unselected) h.clean(proj, arts)
160
- // (8b) the PLUGIN target ([[plugin-harness]]): render the whole system into one self-contained Claude-plugin
161
- // bundle per selected folder. A plugin is EXCLUSIVE (so `selected` is already empty — every native was
162
- // pruned above). Pruning a DESELECTED plugin folder (plugin→native, or folder A→B) needs the PREVIOUS
163
- // folder set, which the live config no longer names — so a tiny ledger in the global store records the
164
- // folders emitted last run; any prev folder absent from the current set is clean()ed (the bundle's
165
- // inverse), then the current folders are emitted and the ledger rewritten. Bounded + surgical: cleanPlugin
166
- // is identity-gated on the bundle's own plugin.json.
277
+ // (8) the PLUGIN target ([[plugin-harness]]): render the whole system into one self-contained Claude-plugin
278
+ // bundle per selected folder. A plugin is EXCLUSIVE (`selected` is empty then). Pruning a DESELECTED
279
+ // folder needs the PREVIOUS folder set, which the live config no longer names the one landing point
280
+ // the identity-stamped erase cannot enumerate (a folder is an arbitrary path) so a tiny ledger in the
281
+ // global store records the folders emitted last run; any prev folder absent from the current set is
282
+ // clean()ed, then the current folders are emitted and the ledger rewritten.
167
283
  const ledger = join(rt, 'plugin-folders')
168
284
  const prevFolders = existsSync(ledger) ? readFileSync(ledger, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean) : []
169
285
  const curFolders = plugins.map((p) => p.folder)
@@ -180,65 +296,43 @@ export function materialize(proj = process.cwd()): string {
180
296
  for (const p of plugins) emitPlugin(proj, p.folder, render)
181
297
  }
182
298
  writeFileSync(ledger, curFolders.join('\n'))
183
- // (4b) every artifact this render writes IN-TREE is generated wiring, so gitignore it regenerated per
184
- // clone/launch by this same gate, never committed. That now includes the CONTRACT files (CLAUDE.md/AGENTS.md):
185
- // their whole content is the generated guide+system block, so they are artifacts exactly like the shims +
186
- // skills + sub-agents the only tracked prose is the guide SOURCE (docs/AGENT_GUIDE.md), which this render
187
- // reads. Derived from the adapters' own contractFiles()/shimFile()/skillDir/agentDir, not hardcoded; written
188
- // as a managed `#` block so the user's own .gitignore is preserved.
189
- // The managed block must be CHECKOUT-INVARIANT: `.gitignore` is ONE tracked, committed file shared by the
190
- // main checkout and every worktree, so if the block differed by where materialize ran, whichever flavor got
191
- // committed would leave the OTHER checkout dirtying it on every render. The only artifact that varies is the
192
- // codex hooks shim, which materializes at the MAIN checkout (a linked worktree's codex reads the root's
193
- // hooks — see harness.ts): from main it is `.codex/hooks.json` (inside proj), from a worktree it is `../…`
194
- // (escapes proj). So an ignore entry is anchored to the checkout it LIVES under — proj-relative when inside
195
- // proj, else MAIN-checkout-relative — which resolves the codex shim to `.codex/hooks.json` from any checkout
196
- // (a pattern naming a main-only path is a harmless no-op in a worktree). Every checkout now emits the same
197
- // set → the committed `.gitignore` is stable, never re-dirtied. A path under neither root is not ignorable
198
- // here (dropped).
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
304
+ // proj, else MAIN-checkout-relative (the codex shim resolves to `.codex/hooks.json` from any checkout; a
305
+ // pattern naming a main-only path is a harmless no-op in a worktree). A path under neither root is dropped.
199
306
  const mc = mainCheckout(proj)
200
307
  const anchor = (abs: string): string | null => {
201
308
  const p = relative(proj, abs); if (!p.startsWith('..')) return p
202
309
  const m = relative(mc, abs); if (!m.startsWith('..')) return m
203
310
  return null
204
311
  }
205
- // spexcode.local.json the machine-local config overlay (host-specific values, e.g. an absolute worker
206
- // launcher path; see portable-layout)joins the SAME block on the same rationale: machine-specific, must
207
- // never be committed. Without it an adopter who follows our own guidance to put a host path there would
208
- // `git add -A` and leak it the exact thing the overlay exists to prevent.
209
- // each emitted plugin bundle is a generated, machine-local artifact too (its hooks.json bakes THIS install's
210
- // SPEX path), so its dir joins the same managed block — regenerated per clone/launch, never committed.
312
+ // machine facts + run residue, ignored under EVERY policy: the shims/anchors/bundles (bake this install's
313
+ // abs path), spexcode.local.json (the host overlay a `git add -A` must never leak it), and the session
314
+ // residue (`.worktrees/` where launches plant worktrees; `.session` is the legacy per-worktree state file
315
+ // an old backend wrote). Static strings stay checkout-invariant.
211
316
  const bundlePaths = curFolders.map((f) => pluginBundleDir(proj, f))
212
- // SpexCode's own SESSION artifacts also join the managed block: a launch creates a worktree under
213
- // `.worktrees/` (hardcoded in sessions.ts) plus a per-worktree `.session` state file the layout linker
214
- // reads. Neither is the adopter's code, both are per-clone/never-committed — but until now materialize
215
- // never ignored them, so an ADOPTED project leaked its session worktrees into `git status` (a `git add -A`
216
- // would commit them). Static strings, so they stay checkout-invariant. The dogfood hand-lists these; every
217
- // other repo gets them here, in the default `.gitignore` block AND (widened) the private `.git/info/exclude`.
218
- const sessionIgnores = ['.worktrees/', '.session']
219
- const ignorable = [...[...shimPaths, ...bundlePaths].map(anchor).filter((p): p is string => p !== null), 'spexcode.local.json', ...sessionIgnores]
220
- const gitignore = join(proj, '.gitignore')
221
- if (priv) {
222
- // PRIVATE ([[private-overlay]]): hide EVERYTHING including the .spec tree + spexcode.json the DEFAULT
223
- // mode commits ("git is the database") — in the per-clone `.git/info/exclude` (never committed, never
224
- // shared), and strip any managed block a prior DEFAULT render left in the tracked .gitignore so switching
225
- // INTO private mode leaves the host's shared history clean.
226
- const entries = [...new Set([...ignorable, '.spec/', 'spexcode.json'])].sort().join('\n')
227
- writeManagedBlock(infoExcludePath(proj), entries, ['# ', ''])
228
- removeManagedBlock(gitignore, ['# ', ''], true)
229
- // the ONE manual step private mode can't do for you: .git/info/exclude hides UNTRACKED paths only, so a
230
- // .spec/spexcode.json ALREADY committed (a repo that adopted DEFAULT mode first) stays visible until
231
- // un-tracked. Surface it loudly rather than leaving a silent leak — the agent doing setup acts on this.
232
- const stillTracked = ['.spec', 'spexcode.json'].filter((p) => isTracked(proj, p))
233
- if (stillTracked.length) console.error(
234
- `spexcode private: ${stillTracked.join(' + ')} still git-tracked — private mode hides UNTRACKED paths only.\n` +
235
- ` → run once: git rm -r --cached ${stillTracked.join(' ')} (keeps the files on disk, stops tracking them; then commit on your branch)`,
236
- )
317
+ const machineEntries = [
318
+ ...[...machinePaths, ...bundlePaths].map(anchor).filter((p): p is string => p !== null),
319
+ 'spexcode.local.json', '.worktrees/', '.session',
320
+ ]
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)
237
332
  } 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)
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), ['# ', ''])
242
336
  }
243
337
  // (5) stamp the content-hash marker LAST (so a crash mid-render leaves it stale → re-renders next gate).
244
338
  const h = contentHash(proj)
@@ -5,7 +5,7 @@
5
5
  // is why they sit in flat plateaus rather than being fitted to any case.
6
6
  const W_NAME_PREFIX = 8
7
7
  const W_NAME_SUBSTR = 5
8
- const W_DESC = 3
8
+ const W_DESC = 2
9
9
  const W_BODY = 1
10
10
 
11
11
  // a tiny stoplist of question scaffolding + length-1 tokens, dropped so "how does the … is it …" can't drown
@@ -19,19 +19,34 @@ const STOP = new Set([
19
19
  'be', 'can', 'just', 'them', 'they', 'their', 'so', 'if', 'not', 'no', 'but', 'vs', 'us', 'we', 'you',
20
20
  ])
21
21
 
22
- // split on non-alphanumeric, lowercase, drop stopwords + length-1 tokens, de-dup.
22
+ // The corpus is mostly English but not only some nodes carry CJK prose (the root spexcode node is a whole
23
+ // Chinese paragraph), and the dashboard palette ([[shared-ranker]]) ranks session/issue titles that are
24
+ // frequently Chinese. CJK has no spaces, so a whitespace/`[^a-z0-9]` split silently discards ALL of it. We
25
+ // tokenize the SAME way on both sides: an ASCII alphanumeric run is one token; each CJK character is its own
26
+ // token (a unigram). Unigrams — not bigrams — keep the shared prefix-match/IDF/BM25 machinery untouched (a
27
+ // single-char query still matches, no bigram edge cases) and stay BLUNT & ROBUST, the floor's whole stance.
28
+ // Han (incl. Ext-A + compat) plus Japanese kana; enough to cover the CJK a spec body or a session title carries.
29
+ const CJK = '\\u3400-\\u4dbf\\u4e00-\\u9fff\\uf900-\\ufaff\\u3040-\\u30ff' // Ext-A · Unified · Compat · kana
30
+ const TOKEN_RE = new RegExp(`[a-z0-9]+|[${CJK}]`, 'g')
31
+ const CJK_RE = new RegExp(`[${CJK}]`)
32
+ function isCjk(t: string): boolean { return CJK_RE.test(t) }
33
+ function tokenize(text: string): string[] {
34
+ return text.toLowerCase().match(TOKEN_RE) ?? []
35
+ }
36
+
37
+ // tokenize, lowercase, drop stopwords + length-1 ASCII tokens (a length-1 CJK token is a real word — keep it), de-dup.
23
38
  export function terms(query: string): string[] {
24
39
  const seen = new Set<string>()
25
- for (const w of query.toLowerCase().split(/[^a-z0-9]+/)) {
26
- if (w.length > 1 && !STOP.has(w)) seen.add(w)
40
+ for (const w of tokenize(query)) {
41
+ if (isCjk(w) || (w.length > 1 && !STOP.has(w))) seen.add(w)
27
42
  }
28
43
  return [...seen]
29
44
  }
30
45
 
31
46
  // the words of a field, lowercased — used for word-boundary (prefix-of-a-word) matching, which kills
32
- // short-token pollution (`main` must not match inside `domain`).
47
+ // short-token pollution (`main` must not match inside `domain`); CJK chars are single-char words.
33
48
  function words(text: string): string[] {
34
- return text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
49
+ return tokenize(text)
35
50
  }
36
51
 
37
52
  // light query-side stem for prefix matching: drop a trailing plural 's' (len≥4, not 'ss') then a mute 'e'
@@ -87,8 +102,10 @@ function snippetFor(text: string, desc: string, qterms: string[], window = 140):
87
102
  const lower = flat.toLowerCase()
88
103
  let at = -1
89
104
  for (const t of qterms) {
90
- const m = lower.match(new RegExp('\\b' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
91
- if (m && m.index !== undefined && (at < 0 || m.index < at)) at = m.index
105
+ // ASCII terms locate at a word boundary (so `main` doesn't hit inside `domain`); a CJK term has no
106
+ // `\b` around it (JS `\b` is ASCII-only), so locate it by plain substring.
107
+ const i = isCjk(t) ? lower.indexOf(t) : lower.search(new RegExp('\\b' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
108
+ if (i >= 0 && (at < 0 || i < at)) at = i
92
109
  }
93
110
  if (at < 0) {
94
111
  const fb = (desc || flat).replace(/\s+/g, ' ').trim()
@@ -0,0 +1,44 @@
1
+ import { spawnSync } from 'node:child_process'
2
+
3
+ // @@@ session-runtime guard ([[platform-support]]) - SpexCode's session orchestration rests on POSIX
4
+ // primitives with no native-Windows analog: tmux (the durable detached PTY + capture-pane scrollback +
5
+ // multi-client reattach fabric), hand-written bash launch scripts, and filesystem-path AF_UNIX control
6
+ // sockets. The supported runtime is POSIX — Linux, macOS, or Windows *via WSL2* (a real Linux kernel where
7
+ // tmux/bash/unix-sockets all work). This is the honest gate at the entry to the session runtime: detect the
8
+ // load-bearing primitive (tmux) missing and print ONE actionable line naming the fix, instead of letting a
9
+ // cryptic downstream ENOENT be the user's first signal. Read-only CLI (init/lint/board) never calls this,
10
+ // so it still runs anywhere the launcher does — only the session-launch path (`spex serve`) is gated.
11
+
12
+ // tmux presence is the primitive we actually depend on, so probe THAT rather than assuming by platform:
13
+ // this also catches a bare POSIX box that simply hasn't installed tmux, not only native Windows.
14
+ export function hasTmux(): boolean {
15
+ try {
16
+ return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
17
+ } catch {
18
+ return false
19
+ }
20
+ }
21
+
22
+ // Pure so it is unit-testable without spawning or exiting: null = runtime OK; otherwise the stderr lines.
23
+ // The pointer branches on platform because the honest repair differs — WSL2 on Windows (no POSIX analog at
24
+ // all), install-tmux on a POSIX host that merely lacks it.
25
+ export function sessionRuntimeBlock(env: { hasTmux: boolean; platform: string }): string[] | null {
26
+ if (env.hasTmux) return null
27
+ const lines = ['spex: the session runtime needs a POSIX host (tmux, bash, and unix-domain sockets).']
28
+ if (env.platform === 'win32') {
29
+ lines.push('Native Windows has no analog for these — run SpexCode under WSL2 (a real Linux kernel):')
30
+ lines.push(' wsl --install # then, in the Ubuntu shell: nvm install 22 && npm i -g spexcode')
31
+ } else {
32
+ lines.push('tmux is not on PATH — install it and retry (e.g. `apt install tmux`, `brew install tmux`).')
33
+ }
34
+ return lines
35
+ }
36
+
37
+ // Called at the top of the session-launching command path (`spex serve`). Exits 69 (EX_UNAVAILABLE: a
38
+ // required support program does not exist) — a distinct, honest code, not a swallowed error or a stacktrace.
39
+ export function assertSessionRuntime(): void {
40
+ const block = sessionRuntimeBlock({ hasTmux: hasTmux(), platform: process.platform })
41
+ if (!block) return
42
+ for (const line of block) console.error(line)
43
+ process.exit(69)
44
+ }