spexcode 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +13 -10
  4. package/spec-cli/hooks/dispatch.sh +28 -40
  5. package/spec-cli/hooks/harness.sh +43 -7
  6. package/spec-cli/src/board.ts +2 -1
  7. package/spec-cli/src/boardCache.ts +27 -1
  8. package/spec-cli/src/boardStream.ts +5 -4
  9. package/spec-cli/src/cli.ts +93 -20
  10. package/spec-cli/src/commit-surgery.ts +81 -0
  11. package/spec-cli/src/contract-filter.ts +156 -0
  12. package/spec-cli/src/doctor.ts +11 -5
  13. package/spec-cli/src/git.ts +18 -4
  14. package/spec-cli/src/guide.ts +94 -38
  15. package/spec-cli/src/harness-select.ts +2 -2
  16. package/spec-cli/src/harness.ts +22 -8
  17. package/spec-cli/src/help.ts +28 -16
  18. package/spec-cli/src/index.ts +9 -6
  19. package/spec-cli/src/init.ts +17 -10
  20. package/spec-cli/src/issues.ts +7 -5
  21. package/spec-cli/src/layout.ts +31 -13
  22. package/spec-cli/src/lint.ts +19 -0
  23. package/spec-cli/src/localIssues.ts +16 -4
  24. package/spec-cli/src/materialize.ts +214 -144
  25. package/spec-cli/src/mentions.ts +5 -3
  26. package/spec-cli/src/plugin-harness.ts +10 -9
  27. package/spec-cli/src/ranker.ts +25 -8
  28. package/spec-cli/src/runtime-guard.ts +44 -0
  29. package/spec-cli/src/search.bench.mjs +15 -5
  30. package/spec-cli/src/sessions.ts +96 -22
  31. package/spec-cli/src/specs.ts +38 -18
  32. package/spec-cli/src/supervise.ts +2 -2
  33. package/spec-cli/src/tsx-bin.ts +6 -8
  34. package/spec-cli/src/uninstall.ts +18 -19
  35. package/spec-cli/src/worktree-sources.ts +52 -13
  36. package/spec-cli/templates/hooks/post-checkout +22 -0
  37. package/spec-cli/templates/hooks/post-merge +15 -9
  38. package/spec-cli/templates/hooks/pre-commit +10 -0
  39. package/spec-cli/templates/spec/project/.config/core/stop-gate/spec.md +1 -1
  40. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +26 -8
  41. package/spec-cli/templates/spec/project/.config/distill/digest.mjs +136 -0
  42. package/spec-cli/templates/spec/project/.config/distill/spec.md +74 -0
  43. package/spec-dashboard/dist/assets/Dashboard-BlRRsxE7.js +27 -0
  44. package/spec-dashboard/dist/assets/EvalsPage-BzVE38-Z.js +2 -0
  45. package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-DFuLVOeu.js} +1 -1
  46. package/spec-dashboard/dist/assets/IssuesPage-CzDaazhe.js +1 -0
  47. package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-CXQrQCNp.js} +1 -1
  48. package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-D1pUBl6q.js} +8 -8
  49. package/spec-dashboard/dist/assets/SessionWindow-Y25Bwg1e.js +9 -0
  50. package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-R610Vbzd.js} +1 -1
  51. package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BO0Zuweu.js} +2 -2
  52. package/spec-dashboard/dist/assets/index-uGs9v_9o.css +1 -0
  53. package/spec-dashboard/dist/index.html +2 -2
  54. package/spec-forge/src/cli.ts +2 -2
  55. package/spec-forge/src/drivers/gitlab.ts +168 -0
  56. package/spec-forge/src/drivers.ts +80 -2
  57. package/spec-forge/src/resident.ts +10 -5
  58. package/spec-yatsu/src/cli.ts +37 -16
  59. package/spec-yatsu/src/evaltab.ts +6 -3
  60. package/spec-yatsu/src/filing.ts +13 -8
  61. package/spec-yatsu/src/freshness.ts +97 -22
  62. package/spec-yatsu/src/proof.ts +14 -3
  63. package/spec-yatsu/src/scenariofresh.ts +38 -11
  64. package/spec-yatsu/src/yatsu.ts +52 -28
  65. package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +0 -27
  66. package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +0 -3
  67. package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +0 -1
  68. package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +0 -9
  69. package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +0 -1
@@ -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
+ }
@@ -53,10 +53,20 @@ for (const row of rows) {
53
53
  console.log(`${mark} ${row.name.padEnd(20)} want=${row.expect.padEnd(22)} rank=${String(row.rank).padStart(2)} top3: ${row.top}`)
54
54
  }
55
55
 
56
- // zero-result fail-loud regression: a CJK query over the English corpus returns nothing, and the
57
- // zero-result message must carry the corpus-is-English translate-and-retry fact (fail-loud, unconditional)
58
- // plus the browse-all next step (no nearest titles a CJK query has nothing to be lexically near).
59
- const cjk = execFileSync('node', [BIN, 'search', '重命名一个会话'], { encoding: 'utf8' })
56
+ // CJK-positive: the corpus is mostly English but a few nodes carry Chinese prose the root `spexcode`
57
+ // node's body repeats 节点 throughout. A per-CJK-character tokenizer must let a Chinese content word reach
58
+ // that node; `spex search "节点"` MUST return `spexcode` in results (the bug: the old tokenizer split on
59
+ // [^a-z0-9]+ and discarded all CJK, so every Chinese query dead-ended at zero results).
60
+ const cjkPos = JSON.parse(execFileSync('node', [BIN, 'search', '节点', '--json', '--limit', '10'], { encoding: 'utf8' }))
61
+ const cjkPosRank = cjkPos.map((x) => x.id).findIndex((id) => matches(id, 'spexcode')) + 1
62
+ const cjkPosPass = cjkPosRank >= 1
63
+ console.log(`${cjkPosPass ? `✓${cjkPosRank}` : '✗ '} cjk-positive want=spexcode(节点) rank=${cjkPosRank || '—'} top3: ${cjkPos.slice(0, 3).map((x) => x.id).join(', ')}`)
64
+
65
+ // zero-result fail-loud regression: a CJK query whose characters appear NOWHERE in the corpus (会/话) returns
66
+ // nothing, and the zero-result message must still carry the corpus-is-English translate-and-retry fact
67
+ // (fail-loud) plus the browse-all next step — CJK support does not suppress the honest zero-result route.
68
+ // (No nearest titles: a CJK query has nothing to be lexically near.)
69
+ const cjk = execFileSync('node', [BIN, 'search', '会话'], { encoding: 'utf8' })
60
70
  const cjkPass = cjk.includes('corpus is English') && cjk.includes('spex tree')
61
71
  console.log(`${cjkPass ? '✓ ' : '✗ '} cjk-zero-result want=corpus-is-English + spex-tree ${cjkPass ? 'both present' : 'MISSING: ' + cjk.trim()}`)
62
72
 
@@ -67,4 +77,4 @@ const typoPass = typo.includes('nearest titles') && typo.includes('keyboard-nav'
67
77
  console.log(`${typoPass ? '✓ ' : '✗ '} typo-zero-result want=nearest-titles(keyboard-nav) + spex-tree ${typoPass ? 'both present' : 'MISSING: ' + typo.trim()}`)
68
78
 
69
79
  console.log('—'.repeat(72))
70
- console.log(`recall@1 = ${r1}/${n} = ${(r1 / n).toFixed(3)} recall@3 = ${r3}/${n} = ${(r3 / n).toFixed(3)} MRR = ${(mrr / n).toFixed(3)} cjk-hint = ${cjkPass ? 'PASS' : 'FAIL'} typo-route = ${typoPass ? 'PASS' : 'FAIL'}`)
80
+ console.log(`recall@1 = ${r1}/${n} = ${(r1 / n).toFixed(3)} recall@3 = ${r3}/${n} = ${(r3 / n).toFixed(3)} MRR = ${(mrr / n).toFixed(3)} cjk-positive = ${cjkPosPass ? 'PASS' : 'FAIL'} cjk-hint = ${cjkPass ? 'PASS' : 'FAIL'} typo-route = ${typoPass ? 'PASS' : 'FAIL'}`)
@@ -4,12 +4,12 @@ import { randomUUID } from 'node:crypto'
4
4
  import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync } from 'node:fs'
5
5
  import { join, dirname, relative, isAbsolute } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
- import { linkUntrackedSpecSources } from './worktree-sources.js'
7
+ import { seedWorktreeHostState } from './worktree-sources.js'
8
8
  import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type ReviewDiffFile } from './git.js'
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.
@@ -502,9 +502,9 @@ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, act
502
502
  }
503
503
 
504
504
  // @@@ renameSession - set (or clear) a session's human display NAME: the user-chosen override that wins
505
- // over the derived label (node/title/branch/id) on every surface. Persisted to the worktree's `.session`
506
- // the only writer of that file so the name survives backend restarts and is read back like any other
507
- // field. A blank name CLEARS the override, reverting the row to its derived label. Works for a session in
505
+ // over the derived label (node/title/branch/id) on every surface. Persisted to the session's global
506
+ // record (`session.json` in the store, like every other field) so the name survives backend restarts
507
+ // and is read back like any other field. A blank name CLEARS the override, reverting the row to its derived label. Works for a session in
508
508
  // any state (queued/live/offline) since it edits the on-disk record, not the live tmux. Unknown id → false
509
509
  // (the route answers 404). The frontend's right-click rename is the sole caller today.
510
510
  export async function renameSession(id: string, name: string): Promise<boolean> {
@@ -802,7 +802,13 @@ export const isBackendDown = (e: unknown): boolean => e instanceof Error && e.na
802
802
  export const isBackendUnreachable = (e: unknown): boolean =>
803
803
  isBackendDown(e) && (e as { status?: number }).status === undefined
804
804
 
805
- const slugify = (s: string | null) => (s || 'session').replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'session'
805
+ // @@@ slugify - the branch/worktree-safe slug. Keeps ANY unicode letter/number (git refs and the filesystem
806
+ // take unicode), so a CJK prompt survives as the readable name its author typed instead of being stripped to
807
+ // nothing — transliteration would buy ASCII at the cost of a dependency and a name nobody wrote. NFC pins one
808
+ // canonical byte form across IME/OS variants. Non-empty is guaranteed by the 'session' fallback; uniqueness
809
+ // is the caller's job (newSession suffixes the session short-id).
810
+ export const slugify = (s: string | null) =>
811
+ (s || 'session').normalize('NFC').replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '') || 'session'
806
812
 
807
813
  // @@@ node + title from the prompt - the spec node a session works on is whatever it @-mentions, NOT a UI
808
814
  // "focused node": the dashboard prefills `@<focused> ` as a deletable convenience, so the node the user
@@ -811,10 +817,19 @@ const slugify = (s: string | null) => (s || 'session').replace(/[^a-zA-Z0-9_-]/g
811
817
  // When there is none, the session is node-agnostic and we label it by the first few words of the prompt.
812
818
  // The OPTIONAL leading dot is load-bearing: a node id is its dir basename, so a dot-prefixed config root
813
819
  // (`.config`) keeps the dot — without `\.?` here `[[.config]]` captures nothing and never resolves to a node.
814
- 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
815
824
  const mentionedNode = (prompt: string): string | null => prompt.match(MENTION)?.[1] ?? null
816
- function titleFromPrompt(prompt: string): string | null {
817
- const first = (prompt || '').trim().split('\n')[0].trim()
825
+ // @@@ identity-token strip - an `@session` actor mention ([[mentions]]) or a bare UUID-shaped token in the
826
+ // prompt is ANOTHER session's identity, never this one's name. A title/slug wearing it misleads every
827
+ // board/git surface — and a worker tasked with cleaning that session can match its OWN worktree and delete
828
+ // it from under itself. Strip both before deriving; whatever prose remains names the session.
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
830
+ const stripIdentityTokens = (s: string) => s.replace(/(^|\s)@[\p{L}\p{N}_-]+/gu, '$1').replace(UUID_TOKEN, ' ')
831
+ export function titleFromPrompt(prompt: string): string | null {
832
+ const first = stripIdentityTokens(prompt || '').split('\n').map((l) => l.trim()).find(Boolean) || ''
818
833
  const words = first.split(/\s+/).filter(Boolean).slice(0, 7).join(' ')
819
834
  if (!words) return null
820
835
  return words.length > 50 ? words.slice(0, 49).trimEnd() + '…' : words
@@ -954,7 +969,7 @@ export async function drainQueue(): Promise<void> {
954
969
 
955
970
  // @@@ superviseQueue - the periodic drainer. Started once at serve(). The explicit drainQueue() calls on
956
971
  // newSession/close/propose cover the slot-freeing events the SERVER handles, but an agent proposing done or
957
- // going parked writes its .session from a hook subprocess the server never sees, and a crash just makes a
972
+ // going parked writes its global session.json record from a hook subprocess the server never sees, and a crash just makes a
958
973
  // socket vanish — so a timer is what turns those into freed slots. Cheap: one worktree+tmux snapshot per tick,
959
974
  // and a no-op when nothing is queued. Idempotent (guarded), so a second call is harmless.
960
975
  let supervisingQueue = false
@@ -1039,7 +1054,7 @@ export async function createSession(node: string | null, prompt: string, launche
1039
1054
  return await res.json() as Session
1040
1055
  }
1041
1056
 
1042
- // @@@ newSession - durable worktree (branch node/<slug> off main) + .session label. The agent does NOT
1057
+ // @@@ newSession - durable worktree (branch node/<slug> off main) + a global session.json record. The agent does NOT
1043
1058
  // launch inline any more: the worktree is prepared and parked as `queued`, then drainQueue() launches it
1044
1059
  // immediately if we're under the concurrency cap, else it waits its turn. Backs both the dashboard POST and
1045
1060
  // `spex session new`. Creating or deleting a spec node is NOT a server op — it is prompt-driven work the
@@ -1062,9 +1077,11 @@ export async function newSession(node: string | null, prompt: string, parent: st
1062
1077
  const branch = `node/${slug}`
1063
1078
  const path = join(mainRoot(), '.worktrees', slug)
1064
1079
  await gitA(['-C', mainRoot(), 'worktree', 'add', '-b', branch, path, mainBranch()])
1065
- // a private-overlay repo keeps the spec sources out of git, so the checkout alone leaves this worktree
1066
- // spec-blind and hook-dead — link them from main ([[private-overlay]]; no-op on a default-mode repo).
1067
- linkUntrackedSpecSources(mainRoot(), path)
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]];
1083
+ // no-op when the main checkout has none).
1084
+ seedWorktreeHostState(mainRoot(), path)
1068
1085
  // prepared but NOT launched: enters the queue as `queued`. drainQueue() below launches it at once when a
1069
1086
  // slot is free, else it waits — durable as a global record (+ its worktree), so it survives a backend
1070
1087
  // restart and is still findable. governed:true — this is a DASHBOARD/CLI-launched session, so it feeds the
@@ -1084,12 +1101,12 @@ export async function newSession(node: string | null, prompt: string, parent: st
1084
1101
  }
1085
1102
  writeRecord(rec)
1086
1103
  writePromptFile(id, prompt) // capture the ORIGINATING prompt (the human/manager's ask) as store metadata (best-effort)
1087
- // 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
1088
1105
  // shims, manifest to the global store) so the launched agent gets the contract + hooks the SAME way a
1089
1106
  // self-launched one does — by auto-discovery, not CLI injection. This is why the launch line below carries no
1090
1107
  // --append-system-prompt / --settings, and why we no longer hide CLAUDE.md: hiding it suppressed the agent's
1091
1108
  // own memory load too. One delivery path for both launch modes ([[harness-delivery]]).
1092
- try { materialize(path) } catch { /* best-effort; the dispatch.sh gate re-renders on the first event anyway */ }
1109
+ bootstrapMaterialize(rec)
1093
1110
  let launchPrompt = prompt
1094
1111
  if (ref) {
1095
1112
  // @@@ spec pointer - the ref (explicit --node, else the prompt's first [[id]] ref) named an EXISTING node.
@@ -1108,6 +1125,24 @@ export async function newSession(node: string | null, prompt: string, parent: st
1108
1125
  return toSession(after, queued ? 'queued' : 'working', queued ? 'offline' : 'starting')
1109
1126
  }
1110
1127
 
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,
1131
+ // and the worker comes up ungoverned (no contract block, no stop-gate) with nothing saying so. Fail loud
1132
+ // instead: log the cause + worktree, and stamp the failure on the record's `note` so the board/watch surface
1133
+ // it. The launch still proceeds — a visibly degraded worker the human can close + re-dispatch beats a refused
1134
+ // launch, and status stays agent-authored ([[state]]): we stamp the note, never an inferred `error` state.
1135
+ // `doMaterialize` is injectable only so tests can simulate the failure.
1136
+ export function bootstrapMaterialize(rec: SessRec, doMaterialize: (proj: string) => unknown = materialize): void {
1137
+ try {
1138
+ doMaterialize(rec.worktreePath)
1139
+ } catch (e) {
1140
+ const msg = e instanceof Error ? e.message : String(e)
1141
+ console.error(`spex: materialize failed for worktree ${rec.worktreePath} — hooks/contract not materialized, worker launches UNGOVERNED: ${msg}`)
1142
+ writeRecord({ ...rec, note: `materialize failed at creation — worker ungoverned (no hooks/contract): ${msg}` })
1143
+ }
1144
+ }
1145
+
1111
1146
  // @@@ waitForReady - after a launch/relaunch, the agent needs SEVERAL SECONDS to come up; launch() only TYPES
1112
1147
  // the start line via send-keys and returns immediately, so the agent's online-signal does not exist yet on
1113
1148
  // return. Poll the ADAPTER's liveness ([[harness-adapter]]) at a small interval up to a bounded timeout so the
@@ -1225,8 +1260,8 @@ export function markIdle(sessionId?: string): boolean {
1225
1260
  // done / propose merge. The dogfood ritual lands every change as a COMMIT on the node branch first, so two
1226
1261
  // states block a declaration: (1) any uncommitted working-tree change, or (2) 0 commits ahead of main
1227
1262
  // (nothing committed to merge). Since the global-store refactor, SpexCode writes NO per-session files into
1228
- // the worktree (the runtime lives in ~/.spexcode), and the only in-tree SpexCode artifacts are gitignored
1229
- // (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
1230
1265
  // neither shows as an uncommitted change — the worktree is pristine and EVERY dirty path is genuine spec/code
1231
1266
  // work, no runtime-file filtering needed.
1232
1267
  // Runs from cwd = the session worktree; ALL git goes through git() so the hook's exported GIT_DIR/GIT_INDEX_FILE
@@ -1415,12 +1450,17 @@ export async function exitSession(id: string): Promise<boolean> {
1415
1450
  // the session's whole global-store record dir — the work is gone, not just stopped. Same stop primitive as
1416
1451
  // exitSession (no duplicate kill path), then the git worktree/branch teardown that exit deliberately skips,
1417
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).
1418
1455
  export async function closeSession(id: string): Promise<boolean> {
1419
1456
  const wt = await findWorktree(id)
1420
1457
  await stopAgentProcess(id)
1421
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 */ }
1422
1461
  await gitA(['-C', mainRoot(), 'worktree', 'remove', '--force', wt.path])
1423
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 */ } }
1424
1464
  }
1425
1465
  try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) } catch { /* best-effort sweep of the global record */ }
1426
1466
  void drainQueue() // a close frees a slot — start the next queued session if any
@@ -1503,7 +1543,41 @@ export function resolveSession(selector: string, sessions: Session[]): Resolved
1503
1543
  return hits.length ? { ambiguous: hits } : { none: true }
1504
1544
  }
1505
1545
 
1506
- const trunc = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '\u2026' : s)
1546
+ // @@@ display width - the table aligns by TERMINAL CELLS, not code units. CJK/fullwidth glyphs render
1547
+ // two cells wide, so `slice`/`padEnd` (which count code units) shear a wide glyph mid-cut and under-pad
1548
+ // the column, misaligning everything after it. A small wcwidth-style range check covers the wide blocks
1549
+ // that actually reach session labels/prompts \u2014 no dependency needed.
1550
+ const isWideCp = (cp: number): boolean =>
1551
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
1552
+ (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || // CJK radicals \u2026 kana \u2026 CJK ideographs \u2026 Yi
1553
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
1554
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
1555
+ (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compatibility forms
1556
+ (cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
1557
+ (cp >= 0xffe0 && cp <= 0xffe6) || // fullwidth signs
1558
+ (cp >= 0x1f300 && cp <= 0x1faff) || // emoji
1559
+ (cp >= 0x20000 && cp <= 0x3fffd) // CJK extensions B+
1560
+ export function displayWidth(s: string): number {
1561
+ let w = 0
1562
+ for (const ch of s) w += isWideCp(ch.codePointAt(0)!) ? 2 : 1
1563
+ return w
1564
+ }
1565
+ // truncate to a display width (the ellipsis occupies its own cell); never cuts a wide glyph in half.
1566
+ export function truncWidth(s: string, max: number): string {
1567
+ if (displayWidth(s) <= max) return s
1568
+ let w = 0
1569
+ let out = ''
1570
+ for (const ch of s) {
1571
+ const cw = isWideCp(ch.codePointAt(0)!) ? 2 : 1
1572
+ if (w + cw > max - 1) break
1573
+ out += ch
1574
+ w += cw
1575
+ }
1576
+ return out + '\u2026'
1577
+ }
1578
+ // pad to a display width \u2014 `padEnd` would count a double-cell glyph as one and under-pad the column.
1579
+ export const padWidth = (s: string, w: number): string => s + ' '.repeat(Math.max(0, w - displayWidth(s)))
1580
+ const trunc = truncWidth
1507
1581
  // the board table's NOTE display cap \u2014 exported so the declaration echo (cli.ts) can tell an author
1508
1582
  // exactly where their note gets cut, instead of the cap living as an anonymous magic number here.
1509
1583
  export const NOTE_BOARD_LIMIT = 50
@@ -1529,10 +1603,10 @@ export function formatTable(sessions: Session[], color = true): string {
1529
1603
  const rows = sessions.map((s) => {
1530
1604
  const g = STATUS_GLYPH[s.status] ?? '\u00b7'
1531
1605
  const code = ANSI[s.status] ?? '0'
1532
- const name = sessionLabel(s).slice(0, 22).padEnd(22)
1606
+ const name = padWidth(truncWidth(sessionLabel(s), 22), 22)
1533
1607
  const st = s.status.padEnd(13)
1534
1608
  const merges = (s.merges ? `\u00d7${s.merges}` : '').padEnd(4)
1535
- const prompt = c('90', (s.promptPreview ? trunc(s.promptPreview, 40) : '').padEnd(42)) // what it was asked to do
1609
+ const prompt = c('90', padWidth(s.promptPreview ? trunc(s.promptPreview, 40) : '', 42)) // what it was asked to do
1536
1610
  const note = s.note ? c('90', trunc(s.note, NOTE_BOARD_LIMIT)) : ''
1537
1611
  return ` ${c(code, g)} ${c(code, st)} ${name} ${c('90', s.id.slice(0, 8))} ${merges}${prompt}${note}`
1538
1612
  })
@@ -92,22 +92,32 @@ function walk(dir: string, parent: string | null, acc: Raw[]) {
92
92
  }
93
93
  }
94
94
 
95
- // re-key each node to the shortest globally-unique trailing path-suffix (overrides walk's placeholder
96
- // basename id/parent); the second loop recomputes parent by path-ancestry.
97
- // A node id is a URL-safe single token ([[id-url-safe]]) — never a '/'-joined path, which would break
98
- // every `:id` route and fetch that treats an id as one path segment. So the disambiguation separator is
99
- // '_': like '/' it never occurs inside a dir basename (so the join stays unambiguous), but unlike '/' it
100
- // is a URL/wikilink/DOM-safe unreserved char, so a collision-qualified id (e.g. `.config_spec-scout`)
101
- // stays a single token everywhere it is resolved.
102
- function reId(acc: Raw[]): void {
103
- const segs = acc.map((r) => r.relPath.split(/[/\\]/).slice(1, -1)) // path under .spec, minus 'spec.md'
104
- const suffix = (s: string[], k: number) => s.slice(s.length - k).join('_')
105
- for (let i = 0; i < acc.length; i++) {
106
- const s = segs[i]
95
+ // the id MINT ([[id-url-safe]]): key each node given its path segments under .spec to its leaf dir
96
+ // name, or on a leaf collision the shortest globally-unique trailing path-suffix. A node id is a URL-safe
97
+ // single token — never a '/'-joined path, which would break every `:id` route and fetch that treats an id
98
+ // as one path segment. So the disambiguation separator is '_': like '/' it never occurs inside a dir
99
+ // basename (so the join stays unambiguous), but unlike '/' it is a URL/wikilink/DOM-safe unreserved char,
100
+ // so a collision-qualified id (e.g. `.config_spec-scout`) stays a single token everywhere it is resolved.
101
+ // Exported as the ONE mint every id producer shares: spec-yatsu mints its node ids through this same
102
+ // function over this same universe (every spec node), so a colliding leaf carries one canonical id
103
+ // system-wide instead of a second, diverging bare-leaf scheme.
104
+ export function mintIds(segs: string[][]): string[] {
105
+ // NFC pins one canonical byte form for a non-ASCII dir name (macOS hands out NFD basenames), so a typed
106
+ // `[[中文节点]]` (NFC, what an IME emits) string-matches the minted id on every platform.
107
+ const suffix = (s: string[], k: number) => s.slice(s.length - k).join('_').normalize('NFC')
108
+ return segs.map((s, i) => {
107
109
  let k = 1
108
110
  while (k < s.length && segs.some((o, j) => j !== i && o.length >= k && suffix(o, k) === suffix(s, k))) k++
109
- acc[i].id = suffix(s, k)
110
- }
111
+ return suffix(s, k)
112
+ })
113
+ }
114
+
115
+ // re-key each node via the mint (overrides walk's placeholder basename id/parent); the second loop
116
+ // recomputes parent by path-ancestry.
117
+ function reId(acc: Raw[]): void {
118
+ const segs = acc.map((r) => r.relPath.split(/[/\\]/).slice(1, -1)) // path under .spec, minus 'spec.md'
119
+ const ids = mintIds(segs)
120
+ for (let i = 0; i < acc.length; i++) acc[i].id = ids[i]
111
121
  for (let i = 0; i < acc.length; i++) {
112
122
  let best = -1
113
123
  for (let j = 0; j < acc.length; j++) {
@@ -217,11 +227,17 @@ export async function loadSpecs() {
217
227
  const fmSession = str(r.fm.session)
218
228
  const session = h[0]?.session || (fmSession && fmSession !== 'null' ? fmSession : null)
219
229
  const code = list(r.fm.code)
230
+ const related = list(r.fm.related)
220
231
  const S = h[0]?.hash || ''
221
232
  const driftFiles = code
222
233
  .map((f) => ({ file: f, behind: driftFor(didx, S, f) }))
223
234
  .filter((d) => d.behind > 0)
224
235
  const drift = driftFiles.reduce((a, d) => a + d.behind, 0)
236
+ // related drift is the SOFT tier ([[governed-related]]): same ancestry basis, but it stays OUT of
237
+ // `drift` — it never feeds status, the commit gate, or yatsu. It surfaces only as a lint warn nudge.
238
+ const relatedDriftFiles = related
239
+ .map((f) => ({ file: f, behind: driftFor(didx, S, f) }))
240
+ .filter((d) => d.behind > 0)
225
241
  const fmStatus = str(r.fm.status, '') || null
226
242
  return {
227
243
  id: r.id,
@@ -234,13 +250,14 @@ export async function loadSpecs() {
234
250
  hue: Number(str(r.fm.hue, '210')),
235
251
  desc: str(r.fm.desc),
236
252
  code,
237
- related: list(r.fm.related),
253
+ related,
238
254
  version: h.length,
239
255
  reason: h[0]?.reason || '',
240
256
  // ISO date of the node's latest version commit (h is newest-first), or null if unversioned.
241
257
  lastEdited: h[0]?.date || null,
242
258
  drift,
243
259
  driftFiles,
260
+ relatedDriftFiles,
244
261
  // the latest version's spec.md patch is NOT precomputed here (it cost 2 git show forks per node on
245
262
  // cold load); the history tab fetches it lazily via specDiffAt. See [[work-pane]].
246
263
  body: r.body.trim(),
@@ -342,7 +359,10 @@ function loadSurface(surface: 'command' | 'system' | 'hook' | 'skill' | 'agent')
342
359
  // @@@ skip pending - a `status: pending` plugin is DECLARED INTENT, not yet active. It renders on the
343
360
  // board (via loadSpecs) but must NOT gather: neither a command preset, nor folded into a system prompt,
344
361
  // nor a live hook. Only built/active plugins surface here, so pending stubs stay inert.
345
- if (str(fm.surface) === surface && str(fm.status) !== 'pending') {
362
+ // the surface field may name SEVERAL surfaces (comma-separated or a YAML list) — the node plugs
363
+ // into every one it lists, so the match is membership, not equality.
364
+ const surfaces = list(fm.surface).flatMap((v) => String(v).split(',')).map((v) => v.trim()).filter(Boolean)
365
+ if (surfaces.includes(surface) && str(fm.status) !== 'pending') {
346
366
  out.push({
347
367
  name,
348
368
  title: str(fm.title, name),
@@ -375,10 +395,10 @@ export function loadSystemConfig(): ConfigPreset[] { return loadSurface('system'
375
395
  // the hook handlers (compiled into the per-session hook manifest the dispatcher reads). Each carries its
376
396
  // `events`/`order`/`block` binding + co-located script `files`.
377
397
  export function loadHookConfig(): ConfigPreset[] { return loadSurface('hook') }
378
- // the skill bundles (rendered into each harness's auto-discovered SKILL.md dir). Each node's `desc` is the
398
+ // the skill bundles (materialized into each harness's auto-discovered SKILL.md dir). Each node's `desc` is the
379
399
  // load-trigger and its `body` is the on-demand instructions; loadSurface passes the folder basename as `name`.
380
400
  export function loadSkillConfig(): ConfigPreset[] { return loadSurface('skill') }
381
- // the sub-agent definitions (rendered into each harness's auto-discovered agent dir, e.g. claude's
401
+ // the sub-agent definitions (materialized into each harness's auto-discovered agent dir, e.g. claude's
382
402
  // .claude/agents/<name>.md). Like a skill, the node's `desc` is the on-demand load-trigger and its `body` is the
383
403
  // agent's system prompt; additionally its `tools` field is the harness tool allowlist for the spawned agent.
384
404
  export function loadAgentConfig(): ConfigPreset[] { return loadSurface('agent') }
@@ -17,7 +17,7 @@ import { runtimeRoot } from './layout.js'
17
17
  installProcessGuards()
18
18
 
19
19
  const here = dirname(fileURLToPath(import.meta.url))
20
- const tsx = tsxBin(join(here, '..')) // this package's own tsxspec-cli/node_modules (dev) or pkg root (published)
20
+ const tsx = tsxBin(join(here, '..')) // tsx's JS entry (dist/cli.mjs), run via `node` below — dev or published
21
21
  const entry = join(here, 'index.ts') // the real Hono server
22
22
  const publicPort = Number(process.env.PORT || 8787)
23
23
 
@@ -79,7 +79,7 @@ async function boot(): Promise<Backend | null> {
79
79
  // process.env.SPEXCODE_API_URL: the env this serve itself inherited may carry ANOTHER project's backend
80
80
  // (the exact misroute [[remote-client]]'s ladder exists to kill), and a worker's env is its routing
81
81
  // LIFELINE — it must be a deterministic backend-injected fact, not an inheritance gamble.
82
- const child = spawn(tsx, [entry], { stdio: 'inherit', env: { ...process.env, PORT: String(port), SPEXCODE_API_URL: childApiBase } })
82
+ const child = spawn(process.execPath, [tsx, entry], { stdio: 'inherit', env: { ...process.env, PORT: String(port), SPEXCODE_API_URL: childApiBase } })
83
83
  // if the ACTIVE backend dies unexpectedly (crash, OOM), restart it so the public port keeps serving.
84
84
  // Planned retirement sets current to the NEW child first, so the old child's exit fails this identity
85
85
  // check and is ignored. boot()'s ~5s health budget rate-limits any crash loop.
@@ -1,20 +1,18 @@
1
- import { existsSync } from 'node:fs'
2
1
  import { createRequire } from 'node:module'
3
2
  import { dirname, join } from 'node:path'
4
3
 
5
- // @@@ tsxBin - where the tsx executable lives, dev-or-published. In the dev monorepo it sits in
4
+ // @@@ tsxBin - tsx's JS ENTRY (dist/cli.mjs), dev-or-published, run through `node` by the caller
5
+ // (`spawn(process.execPath, [tsxBin(pkgDir), entry, …])`). In the dev monorepo tsx sits in
6
6
  // spec-cli/node_modules; in an installed `spexcode` package npm may hoist it to the consumer's node_modules
7
- // instead of nesting it under the package. Resolve explicit local candidates first, then let Node's own
8
- // package resolver walk upward from spec-cli so dev, global, and project-local installs share one mechanism.
7
+ // Node's own package resolver from spec-cli covers dev, global, and project-local installs in one rule.
8
+ // We resolve the .mjs entry rather than the `.bin/tsx` shim on purpose: the shim is an unspawnable sh
9
+ // script on Windows, so `node dist/cli.mjs` (identical to the shim on POSIX) is the one cross-platform form.
9
10
  // `pkgDir` is the spec-cli directory.
10
11
  export function tsxBin(pkgDir: string): string {
11
- const candidates = [join(pkgDir, 'node_modules', '.bin', 'tsx'), join(pkgDir, '..', 'node_modules', '.bin', 'tsx')]
12
- const local = candidates.find(existsSync)
13
- if (local) return local
14
12
  try {
15
13
  const req = createRequire(join(pkgDir, 'package.json'))
16
14
  return join(dirname(req.resolve('tsx/package.json')), 'dist', 'cli.mjs')
17
15
  } catch {
18
- return candidates[0]
16
+ throw new Error(`tsx runtime not found from ${pkgDir} — run \`npm install\` in the SpexCode package`)
19
17
  }
20
18
  }
@@ -1,17 +1,20 @@
1
1
  import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
2
2
  import { join, resolve, relative } from 'node:path'
3
3
  import { execFileSync } from 'node:child_process'
4
- import { HARNESSES, removeManagedBlock, type HarnessArtifacts } from './harness.js'
4
+ import { HARNESSES, type HarnessArtifacts } from './harness.js'
5
5
  import { runtimeRoot, readConfig, mainCheckout } from './layout.js'
6
6
  import { resolveHarnessTargets } from './harness-select.js'
7
7
  import { loadSkillConfig, loadAgentConfig } from './specs.js'
8
+ import { dematerialize } from './materialize.js'
8
9
 
9
- // @@@ spex-uninstall - the surgical inverse of spex-init/materialize. init + harness-delivery WRITE the
10
- // SpexCode footprint into a repo; this REMOVES it, so a project can fully back out. EVERY removal is gated on a
11
- // SpexCode IDENTITY STAMP (the managed-block sentinels, the shim's own dispatch.sh command line, the trust
12
- // sentinels, the name-scoped on-demand paths, the plugin name stamp), so it can only ever delete what SpexCode
13
- // itself generated. The one inviolable rule: the user's spec ASSET (.spec/.config) is NEVER touched — uninstall
14
- // removes only generated WIRING, not the spec graph that wiring served.
10
+ // @@@ spex-uninstall - materialize(∅) plus the store: the in-tree/global-config backout IS dematerialize (the
11
+ // same identity-stamped erase phase every materialize runs first the forgetting law's empty policy), and this
12
+ // command adds only what a materialize never owns per-run: the global per-project store, the plugin-bundle sweep,
13
+ // and the optional git hooks. EVERY removal is gated on a SpexCode IDENTITY STAMP (the managed-block
14
+ // sentinels, the shim's own dispatch.sh command line, the trust sentinels, the generated mark / name-scoped
15
+ // on-demand paths, the plugin name stamp), so it can only ever delete what SpexCode itself generated. The one
16
+ // inviolable rule: the user's spec ASSET (.spec/.config) is NEVER touched — uninstall removes only generated
17
+ // WIRING, not the spec graph that wiring served.
15
18
 
16
19
  // the standard plugin-host folders a host agent scans (in addition to any named in spexcode.json's `harnesses`).
17
20
  const DEFAULT_PLUGIN_HOSTS = ['.claude', '.codex'] as const
@@ -95,17 +98,14 @@ export function uninstall(targetArg: string | undefined, opts: { hooks?: boolean
95
98
  process.chdir(prevCwd)
96
99
  }
97
100
 
98
- // 1. every harness's own artifacts — clean() is the adapter's surgical inverse (managed contract block w/
99
- // deleteIfEmpty, generated shim, trust block, named skill/agent files). materialize cleans only UNSELECTED
100
- // harnesses; uninstall cleans EVERY one. clean() already calls removeTrust, so it is the full inverse.
101
- for (const h of HARNESSES) h.clean(proj, arts)
101
+ // 1+2. materialize(∅): every harness's artifacts (contract block, shim, trust, skills/agents), the managed
102
+ // .gitignore + info/exclude blocks, any legacy skip-worktree bit, and the content filter the SAME
103
+ // erase phase every materialize runs, asserted against the empty policy. One inverse, never a parallel one.
104
+ dematerialize(proj, arts)
102
105
 
103
- // 2. the shared .gitignore block — the one in-tree artifact no adapter owns (materialize writes it directly),
104
- // so strip it directly too: the managed `#` block, deleteIfEmpty so a wholly-ours .gitignore is removed.
105
- removeManagedBlock(join(proj, '.gitignore'), ['# ', ''], true)
106
-
107
- // 3. the global per-project store — manifest + content-hash marker + gate lock + session records. This is the
108
- // runtime tier, not the user's spec asset, so the whole dir is ours.
106
+ // 3. the global per-project store — the per-tree materialize slots (trees/<enc>: manifest + content-hash +
107
+ // plugin ledger), any legacy pre-slot manifest, and the session records. This is the runtime tier,
108
+ // not the user's spec asset, so the whole dir is ours.
109
109
  let store: string | null = null
110
110
  try {
111
111
  store = runtimeRoot(proj)
@@ -124,8 +124,7 @@ export function uninstall(targetArg: string | undefined, opts: { hooks?: boolean
124
124
  }
125
125
  const bundles = sweepPluginBundles(proj, pluginHosts)
126
126
 
127
- console.log(`✓ pruned harness artifacts (CLAUDE.md/AGENTS.md block, shims, Codex trust, skills, sub-agents) for ${HARNESSES.map((h) => h.id).join(', ')}`)
128
- console.log('✓ stripped the .gitignore spexcode block')
127
+ console.log(`✓ dematerialized (contract blocks, shims, Codex trust, skills, sub-agents, ignore blocks, content filter) for ${HARNESSES.map((h) => h.id).join(', ')}`)
129
128
  if (store) console.log(`✓ removed the global per-project store (${store})`)
130
129
  if (bundles.length) console.log(`✓ removed plugin bundle(s): ${bundles.join(', ')}`)
131
130