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
@@ -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,6 +4,7 @@ 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 { seedWorktreeHostState } from './worktree-sources.js'
7
8
  import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type ReviewDiffFile } from './git.js'
8
9
  import { loadSpecs } from './specs.js'
9
10
  import { defaultHarness, defaultLauncher, harnessById, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
@@ -501,9 +502,9 @@ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, act
501
502
  }
502
503
 
503
504
  // @@@ renameSession - set (or clear) a session's human display NAME: the user-chosen override that wins
504
- // over the derived label (node/title/branch/id) on every surface. Persisted to the worktree's `.session`
505
- // the only writer of that file so the name survives backend restarts and is read back like any other
506
- // 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
507
508
  // any state (queued/live/offline) since it edits the on-disk record, not the live tmux. Unknown id → false
508
509
  // (the route answers 404). The frontend's right-click rename is the sole caller today.
509
510
  export async function renameSession(id: string, name: string): Promise<boolean> {
@@ -801,7 +802,13 @@ export const isBackendDown = (e: unknown): boolean => e instanceof Error && e.na
801
802
  export const isBackendUnreachable = (e: unknown): boolean =>
802
803
  isBackendDown(e) && (e as { status?: number }).status === undefined
803
804
 
804
- 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'
805
812
 
806
813
  // @@@ node + title from the prompt - the spec node a session works on is whatever it @-mentions, NOT a UI
807
814
  // "focused node": the dashboard prefills `@<focused> ` as a deletable convenience, so the node the user
@@ -812,8 +819,14 @@ const slugify = (s: string | null) => (s || 'session').replace(/[^a-zA-Z0-9_-]/g
812
819
  // (`.config`) keeps the dot — without `\.?` here `[[.config]]` captures nothing and never resolves to a node.
813
820
  const MENTION = /\[\[(\.?[A-Za-z0-9_-]+)\]\]/
814
821
  const mentionedNode = (prompt: string): string | null => prompt.match(MENTION)?.[1] ?? null
815
- function titleFromPrompt(prompt: string): string | null {
816
- const first = (prompt || '').trim().split('\n')[0].trim()
822
+ // @@@ identity-token strip - an `@session` actor mention ([[mentions]]) or a bare UUID-shaped token in the
823
+ // prompt is ANOTHER session's identity, never this one's name. A title/slug wearing it misleads every
824
+ // board/git surface — and a worker tasked with cleaning that session can match its OWN worktree and delete
825
+ // it from under itself. Strip both before deriving; whatever prose remains names the session.
826
+ const UUID_TOKEN = /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g
827
+ const stripIdentityTokens = (s: string) => s.replace(/(^|\s)@[A-Za-z0-9_-]+/g, '$1').replace(UUID_TOKEN, ' ')
828
+ export function titleFromPrompt(prompt: string): string | null {
829
+ const first = stripIdentityTokens(prompt || '').split('\n').map((l) => l.trim()).find(Boolean) || ''
817
830
  const words = first.split(/\s+/).filter(Boolean).slice(0, 7).join(' ')
818
831
  if (!words) return null
819
832
  return words.length > 50 ? words.slice(0, 49).trimEnd() + '…' : words
@@ -953,7 +966,7 @@ export async function drainQueue(): Promise<void> {
953
966
 
954
967
  // @@@ superviseQueue - the periodic drainer. Started once at serve(). The explicit drainQueue() calls on
955
968
  // newSession/close/propose cover the slot-freeing events the SERVER handles, but an agent proposing done or
956
- // going parked writes its .session from a hook subprocess the server never sees, and a crash just makes a
969
+ // going parked writes its global session.json record from a hook subprocess the server never sees, and a crash just makes a
957
970
  // socket vanish — so a timer is what turns those into freed slots. Cheap: one worktree+tmux snapshot per tick,
958
971
  // and a no-op when nothing is queued. Idempotent (guarded), so a second call is harmless.
959
972
  let supervisingQueue = false
@@ -1038,7 +1051,7 @@ export async function createSession(node: string | null, prompt: string, launche
1038
1051
  return await res.json() as Session
1039
1052
  }
1040
1053
 
1041
- // @@@ newSession - durable worktree (branch node/<slug> off main) + .session label. The agent does NOT
1054
+ // @@@ newSession - durable worktree (branch node/<slug> off main) + a global session.json record. The agent does NOT
1042
1055
  // launch inline any more: the worktree is prepared and parked as `queued`, then drainQueue() launches it
1043
1056
  // immediately if we're under the concurrency cap, else it waits its turn. Backs both the dashboard POST and
1044
1057
  // `spex session new`. Creating or deleting a spec node is NOT a server op — it is prompt-driven work the
@@ -1061,6 +1074,10 @@ export async function newSession(node: string | null, prompt: string, parent: st
1061
1074
  const branch = `node/${slug}`
1062
1075
  const path = join(mainRoot(), '.worktrees', slug)
1063
1076
  await gitA(['-C', mainRoot(), 'worktree', 'add', '-b', branch, path, mainBranch()])
1077
+ // the checkout delivers the tracked spec sources and the materialize below delivers the renders; the ONE
1078
+ // thing git cannot carry is the machine-local spexcode.local.json — copied as a snapshot ([[render-policy]];
1079
+ // no-op when the main checkout has none).
1080
+ seedWorktreeHostState(mainRoot(), path)
1064
1081
  // prepared but NOT launched: enters the queue as `queued`. drainQueue() below launches it at once when a
1065
1082
  // slot is free, else it waits — durable as a global record (+ its worktree), so it survives a backend
1066
1083
  // restart and is still findable. governed:true — this is a DASHBOARD/CLI-launched session, so it feeds the
@@ -1085,7 +1102,7 @@ export async function newSession(node: string | null, prompt: string, parent: st
1085
1102
  // self-launched one does — by auto-discovery, not CLI injection. This is why the launch line below carries no
1086
1103
  // --append-system-prompt / --settings, and why we no longer hide CLAUDE.md: hiding it suppressed the agent's
1087
1104
  // own memory load too. One delivery path for both launch modes ([[harness-delivery]]).
1088
- try { materialize(path) } catch { /* best-effort; the dispatch.sh gate re-renders on the first event anyway */ }
1105
+ bootstrapMaterialize(rec)
1089
1106
  let launchPrompt = prompt
1090
1107
  if (ref) {
1091
1108
  // @@@ spec pointer - the ref (explicit --node, else the prompt's first [[id]] ref) named an EXISTING node.
@@ -1104,6 +1121,24 @@ export async function newSession(node: string | null, prompt: string, parent: st
1104
1121
  return toSession(after, queued ? 'queued' : 'working', queued ? 'offline' : 'starting')
1105
1122
  }
1106
1123
 
1124
+ // @@@ bootstrapMaterialize - the creation-time materialize is BOOTSTRAP, not best-effort: it is what renders
1125
+ // the worktree's .claude/.codex shims (the settings.json hook wiring) in the first place, and the dispatch.sh
1126
+ // re-render gate RIDES ON those hooks — so when this render fails, no hook ever fires, the gate never runs,
1127
+ // and the worker comes up ungoverned (no contract block, no stop-gate) with nothing saying so. Fail loud
1128
+ // instead: log the cause + worktree, and stamp the failure on the record's `note` so the board/watch surface
1129
+ // it. The launch still proceeds — a visibly degraded worker the human can close + re-dispatch beats a refused
1130
+ // launch, and status stays agent-authored ([[state]]): we stamp the note, never an inferred `error` state.
1131
+ // `doMaterialize` is injectable only so tests can simulate the failure.
1132
+ export function bootstrapMaterialize(rec: SessRec, doMaterialize: (proj: string) => unknown = materialize): void {
1133
+ try {
1134
+ doMaterialize(rec.worktreePath)
1135
+ } catch (e) {
1136
+ const msg = e instanceof Error ? e.message : String(e)
1137
+ console.error(`spex: materialize failed for worktree ${rec.worktreePath} — hooks/contract not rendered, worker launches UNGOVERNED: ${msg}`)
1138
+ writeRecord({ ...rec, note: `materialize failed at creation — worker ungoverned (no hooks/contract): ${msg}` })
1139
+ }
1140
+ }
1141
+
1107
1142
  // @@@ waitForReady - after a launch/relaunch, the agent needs SEVERAL SECONDS to come up; launch() only TYPES
1108
1143
  // the start line via send-keys and returns immediately, so the agent's online-signal does not exist yet on
1109
1144
  // return. Poll the ADAPTER's liveness ([[harness-adapter]]) at a small interval up to a bounded timeout so the
@@ -1499,7 +1534,41 @@ export function resolveSession(selector: string, sessions: Session[]): Resolved
1499
1534
  return hits.length ? { ambiguous: hits } : { none: true }
1500
1535
  }
1501
1536
 
1502
- const trunc = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '\u2026' : s)
1537
+ // @@@ display width - the table aligns by TERMINAL CELLS, not code units. CJK/fullwidth glyphs render
1538
+ // two cells wide, so `slice`/`padEnd` (which count code units) shear a wide glyph mid-cut and under-pad
1539
+ // the column, misaligning everything after it. A small wcwidth-style range check covers the wide blocks
1540
+ // that actually reach session labels/prompts \u2014 no dependency needed.
1541
+ const isWideCp = (cp: number): boolean =>
1542
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
1543
+ (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || // CJK radicals \u2026 kana \u2026 CJK ideographs \u2026 Yi
1544
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
1545
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
1546
+ (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compatibility forms
1547
+ (cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
1548
+ (cp >= 0xffe0 && cp <= 0xffe6) || // fullwidth signs
1549
+ (cp >= 0x1f300 && cp <= 0x1faff) || // emoji
1550
+ (cp >= 0x20000 && cp <= 0x3fffd) // CJK extensions B+
1551
+ export function displayWidth(s: string): number {
1552
+ let w = 0
1553
+ for (const ch of s) w += isWideCp(ch.codePointAt(0)!) ? 2 : 1
1554
+ return w
1555
+ }
1556
+ // truncate to a display width (the ellipsis occupies its own cell); never cuts a wide glyph in half.
1557
+ export function truncWidth(s: string, max: number): string {
1558
+ if (displayWidth(s) <= max) return s
1559
+ let w = 0
1560
+ let out = ''
1561
+ for (const ch of s) {
1562
+ const cw = isWideCp(ch.codePointAt(0)!) ? 2 : 1
1563
+ if (w + cw > max - 1) break
1564
+ out += ch
1565
+ w += cw
1566
+ }
1567
+ return out + '\u2026'
1568
+ }
1569
+ // pad to a display width \u2014 `padEnd` would count a double-cell glyph as one and under-pad the column.
1570
+ export const padWidth = (s: string, w: number): string => s + ' '.repeat(Math.max(0, w - displayWidth(s)))
1571
+ const trunc = truncWidth
1503
1572
  // the board table's NOTE display cap \u2014 exported so the declaration echo (cli.ts) can tell an author
1504
1573
  // exactly where their note gets cut, instead of the cap living as an anonymous magic number here.
1505
1574
  export const NOTE_BOARD_LIMIT = 50
@@ -1525,10 +1594,10 @@ export function formatTable(sessions: Session[], color = true): string {
1525
1594
  const rows = sessions.map((s) => {
1526
1595
  const g = STATUS_GLYPH[s.status] ?? '\u00b7'
1527
1596
  const code = ANSI[s.status] ?? '0'
1528
- const name = sessionLabel(s).slice(0, 22).padEnd(22)
1597
+ const name = padWidth(truncWidth(sessionLabel(s), 22), 22)
1529
1598
  const st = s.status.padEnd(13)
1530
1599
  const merges = (s.merges ? `\u00d7${s.merges}` : '').padEnd(4)
1531
- const prompt = c('90', (s.promptPreview ? trunc(s.promptPreview, 40) : '').padEnd(42)) // what it was asked to do
1600
+ const prompt = c('90', padWidth(s.promptPreview ? trunc(s.promptPreview, 40) : '', 42)) // what it was asked to do
1532
1601
  const note = s.note ? c('90', trunc(s.note, NOTE_BOARD_LIMIT)) : ''
1533
1602
  return ` ${c(code, g)} ${c(code, st)} ${name} ${c('90', s.id.slice(0, 8))} ${merges}${prompt}${note}`
1534
1603
  })
@@ -92,22 +92,30 @@ 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'
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[] {
104
105
  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]
106
+ return segs.map((s, i) => {
107
107
  let k = 1
108
108
  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
- }
109
+ return suffix(s, k)
110
+ })
111
+ }
112
+
113
+ // re-key each node via the mint (overrides walk's placeholder basename id/parent); the second loop
114
+ // recomputes parent by path-ancestry.
115
+ function reId(acc: Raw[]): void {
116
+ const segs = acc.map((r) => r.relPath.split(/[/\\]/).slice(1, -1)) // path under .spec, minus 'spec.md'
117
+ const ids = mintIds(segs)
118
+ for (let i = 0; i < acc.length; i++) acc[i].id = ids[i]
111
119
  for (let i = 0; i < acc.length; i++) {
112
120
  let best = -1
113
121
  for (let j = 0; j < acc.length; j++) {
@@ -217,11 +225,17 @@ export async function loadSpecs() {
217
225
  const fmSession = str(r.fm.session)
218
226
  const session = h[0]?.session || (fmSession && fmSession !== 'null' ? fmSession : null)
219
227
  const code = list(r.fm.code)
228
+ const related = list(r.fm.related)
220
229
  const S = h[0]?.hash || ''
221
230
  const driftFiles = code
222
231
  .map((f) => ({ file: f, behind: driftFor(didx, S, f) }))
223
232
  .filter((d) => d.behind > 0)
224
233
  const drift = driftFiles.reduce((a, d) => a + d.behind, 0)
234
+ // related drift is the SOFT tier ([[governed-related]]): same ancestry basis, but it stays OUT of
235
+ // `drift` — it never feeds status, the commit gate, or yatsu. It surfaces only as a lint warn nudge.
236
+ const relatedDriftFiles = related
237
+ .map((f) => ({ file: f, behind: driftFor(didx, S, f) }))
238
+ .filter((d) => d.behind > 0)
225
239
  const fmStatus = str(r.fm.status, '') || null
226
240
  return {
227
241
  id: r.id,
@@ -234,13 +248,14 @@ export async function loadSpecs() {
234
248
  hue: Number(str(r.fm.hue, '210')),
235
249
  desc: str(r.fm.desc),
236
250
  code,
237
- related: list(r.fm.related),
251
+ related,
238
252
  version: h.length,
239
253
  reason: h[0]?.reason || '',
240
254
  // ISO date of the node's latest version commit (h is newest-first), or null if unversioned.
241
255
  lastEdited: h[0]?.date || null,
242
256
  drift,
243
257
  driftFiles,
258
+ relatedDriftFiles,
244
259
  // the latest version's spec.md patch is NOT precomputed here (it cost 2 git show forks per node on
245
260
  // cold load); the history tab fetches it lazily via specDiffAt. See [[work-pane]].
246
261
  body: r.body.trim(),
@@ -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 render runs first the forgetting law's empty policy), and this
12
+ // command adds only what a render 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,14 +98,10 @@ 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)
102
-
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)
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 render runs, asserted against the empty policy. One inverse, never a parallel one.
104
+ dematerialize(proj, arts)
106
105
 
107
106
  // 3. the global per-project store — manifest + content-hash marker + gate lock + session records. This is the
108
107
  // runtime tier, not the user's spec asset, so the whole dir is ours.
@@ -124,8 +123,7 @@ export function uninstall(targetArg: string | undefined, opts: { hooks?: boolean
124
123
  }
125
124
  const bundles = sweepPluginBundles(proj, pluginHosts)
126
125
 
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')
126
+ console.log(`✓ dematerialized (contract blocks, shims, Codex trust, skills, sub-agents, ignore blocks, content filter) for ${HARNESSES.map((h) => h.id).join(', ')}`)
129
127
  if (store) console.log(`✓ removed the global per-project store (${store})`)
130
128
  if (bundles.length) console.log(`✓ removed plugin bundle(s): ${bundles.join(', ')}`)
131
129
 
@@ -0,0 +1,56 @@
1
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { git } from './git.js'
4
+
5
+ // @@@ worktree-sources ([[render-policy]]) - a fresh session worktree is fed by THREE transports, one per
6
+ // source kind, and the kind decides the transport — never a mode branch:
7
+ // - TRACKED project state (`.spec`, `spexcode.json`) arrives by GIT CHECKOUT: the sources are always
8
+ // tracked (git is the database), so `git worktree add` alone delivers them. No symlink — a link is a
9
+ // WRITE-SEMANTICS declaration (write-through to the main tree), and spec writes go back through the
10
+ // branch/merge ritual, not through a side channel.
11
+ // - RENDERS (contract blocks, shims, skills) are DERIVED — transported by re-render, not by link or copy:
12
+ // sessions.ts materializes into the worktree at creation, and the dispatch gate re-renders on change.
13
+ // - HOST state (`spexcode.local.json`, machine-local and never tracked) is COPIED — a snapshot: the worker
14
+ // reads the same launchers/policy the host had at dispatch, but its writes land on its own copy and die
15
+ // with the worktree, never on the host's real config (a worker once wrote "its" test config through the
16
+ // old symlink and wiped the host's launchers → every later dispatch 401'd).
17
+ // This module owns only the third transport (plus hiding what it seeds). A failure degrades that worker
18
+ // (default launchers/policy), so it is reported, not swallowed.
19
+ export function seedWorktreeHostState(main: string, wt: string): void {
20
+ const f = 'spexcode.local.json'
21
+ try {
22
+ if (!existsSync(join(main, f)) || existsSync(join(wt, f))) return
23
+ copyFileSync(join(main, f), join(wt, f))
24
+ } catch (e) {
25
+ console.error(`spexcode: could not seed ${f} from ${main} into worktree ${wt} — that worker runs on defaults (${e})`)
26
+ return
27
+ }
28
+ hideSeededFromGit(wt, [f])
29
+ }
30
+
31
+ // what we seed, we hide: a seeded entry git still sees is force-add bait (a real PR once carried seeded
32
+ // files into a product repo). `.git/info/exclude` lives in the COMMON git dir, so one write hides the entry
33
+ // in every linked worktree AND the main checkout. Only entries seeded by THIS call and reported un-ignored
34
+ // by `git check-ignore` are written: idempotent across dispatches, and a repo whose render already ignores
35
+ // the overlay (materialize's block under any policy) writes nothing — the self-heal for a half-configured repo.
36
+ function hideSeededFromGit(wt: string, seeded: string[]): void {
37
+ for (const f of seeded) {
38
+ try {
39
+ if (isIgnored(wt, f)) continue
40
+ const exclude = join(git(['-C', wt, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim(), 'info', 'exclude')
41
+ mkdirSync(dirname(exclude), { recursive: true })
42
+ const cur = existsSync(exclude) ? readFileSync(exclude, 'utf8') : ''
43
+ appendFileSync(exclude, `${cur && !cur.endsWith('\n') ? '\n' : ''}${f}\n`)
44
+ } catch (e) {
45
+ console.error(`spexcode: could not hide seeded ${f} in the shared info/exclude for ${wt} — it will show untracked there (${e})`)
46
+ }
47
+ }
48
+ }
49
+
50
+ function isIgnored(wt: string, f: string): boolean {
51
+ try { git(['-C', wt, 'check-ignore', '-q', f]); return true }
52
+ catch (e: any) {
53
+ if (e?.status === 1) return false // check-ignore's documented "not ignored" exit
54
+ throw e
55
+ }
56
+ }
@@ -12,6 +12,6 @@ The blocking stop gate, with two jobs, each holding a hard loop-break so it neve
12
12
 
13
13
  The COMMIT gate keeps a done/merge proposal honest: such a proposal is rejected while the branch still carries uncommitted work or is zero commits ahead of main, because the ritual commits the spec and code BEFORE proposing. Clean work is allowed to stop; a dirty proposal blocks once with the reason, and if the agent ignores it the gate escapes by downgrading to `asking` so a false "ready to merge" can never stand.
14
14
 
15
- The DECLARE gate refuses to let a session stop in an undeclared `active` state, since a state is a claim the board and other agents act on, not a box ticked to end a turn. A declared state stops freely; an undeclared first stop blocks once to make the agent pick the true state; on the forced continuation it auto-declares a safe default — committed work becomes `awaiting`, otherwise `asking` — so the loop is guaranteed to end.
15
+ The DECLARE gate refuses to let a session stop in an undeclared `active` state, since a state is a claim the board and other agents act on, not a box ticked to end a turn. A declared state stops freely; an undeclared first stop blocks once to make the agent pick the true state; on the forced continuation it auto-declares a safe default — committed work becomes `awaiting`, otherwise `asking` — so the loop is guaranteed to end. The full block text (choices, each with its application condition, plus the discipline of declaring as the turn's LAST call — any later tool call re-flips the record to active) prints once per session, marked by a sentinel file beside the session record; later undeclared stops get a one-line reminder that stays self-explanatory — menu, declare-last, and the `spex help session` entry that recovers the full conditions.
16
16
 
17
17
  It is the enforcement edge of [[core]]: nothing leaves a session except as committed work under a truthful declaration. The freshness it reads is set by [[mark-active]].
@@ -109,12 +109,30 @@ if [ "$cont" = true ]; then
109
109
  exit 0
110
110
  fi
111
111
 
112
- # first stop in an undeclared state -> nudge exactly once. The reason names the PATH-independent CLI ($S)
113
- # ONCE as a shared `<CLI> session <choice>` prefix, then lists the five choices as a compact newline menu of
114
- # bare subcommands so the terminal output stays legible instead of repeating the long abs path per option.
115
- # It EMPHASIZES that each state is a CLAIM others act on (not a box to tick to end the turn) and gives the
116
- # precise APPLICATION CONDITION for each so the agent picks the TRUE one. park is policed hardest because
117
- # a false park (no real background task) reads on the board as "fine, self-resuming" when the agent actually
118
- # needs the human, which is the most damaging mislabel.
119
- printf '{"decision":"block","reason":"Your session state is a CLAIM the board, your supervisor, and other agents act on — not a box to tick to end the turn. Stopping undeclared makes your outcome a guess. Pick the ONE that is TRUE right now and run `%s session <choice>`, choosing the <choice> whose condition holds:\\n • done --propose merge spec+code COMMITTED on the branch and genuinely ready for a human to review/merge (not just probably-done).\\n • done --propose nothing — committed, but you are NOT proposing a merge; paused for the human to look.\\n • park --note <what-you-await> — ONLY when a real BACKGROUND TASK will wake you (a spex wait you backgrounded, a running build/job). If nothing is actually running to resume you, you are NOT parked — you are waiting on the human, so use ask; never use park as a default to clear this gate.\\n • done --propose close — you propose discarding this worktree.\\n • ask --note <your-question> — you need the human: a real question, or you are simply stopped awaiting direction; you resume only when they reply."}\n' "$S"
112
+ # first stop in an undeclared state -> block. The FULL teaching text prints ONCE per session; every later
113
+ # undeclared stop gets a ONE-LINE version (a heavy session hits this gate 15-20x a night re-printing the
114
+ # full menu each time is pure token noise). The once-sentinel is a plain file beside session.json in the
115
+ # session's global store dir the same per-session-sentinel mechanism as the CLI's note-echo-taught; $sdir
116
+ # is already alias-resolved here, so a codex thread id lands on the same file, and an unwritable dir just
117
+ # teaches again (never blocks the block). The terse line must stay SELF-EXPLANATORY: an agent whose context
118
+ # was compacted may never have seen the full text, so the line carries the whole command menu, the
119
+ # declare-LAST discipline, and the `help session` entry that re-explains each choice's condition — every bit
120
+ # of the full-to-terse information gap is recoverable from the entry, none of it from memory.
121
+ taught="$sdir/stop-gate-taught"
122
+ if [ -f "$taught" ]; then
123
+ printf '{"decision":"block","reason":"undeclared stop — pick the ONE true state and declare it as the LAST call of your turn: `%s session <done --propose merge|nothing|close / park --note <what-you-await> / ask --note <your-question>>`. Which choice is true (and why park is never a default): `%s help session`."}\n' "$S" "$S"
124
+ exit 0
125
+ fi
126
+ touch "$taught" 2>/dev/null || true
127
+ # The full reason names the PATH-independent CLI ($S) ONCE as a shared `<CLI> session <choice>` prefix, then
128
+ # lists the five choices as a compact newline menu of bare subcommands — so the terminal output stays legible
129
+ # instead of repeating the long abs path per option. It EMPHASIZES that each state is a CLAIM others act on
130
+ # (not a box to tick to end the turn) and gives the precise APPLICATION CONDITION for each — so the agent
131
+ # picks the TRUE one. park is policed hardest because a false park (no real background task) reads on the
132
+ # board as "fine, self-resuming" when the agent actually needs the human, which is the most damaging mislabel.
133
+ # It ends with the ORDERING discipline — declare LAST, then stop — because a declaration followed by more
134
+ # tool calls honestly re-flips the record to active (mark-active, by design) and re-blocks the next stop;
135
+ # this block text is the one place every undeclared stopper is guaranteed to read, so the teaching that
136
+ # kills the park->block->re-park loop at its source lives here.
137
+ printf '{"decision":"block","reason":"Your session state is a CLAIM the board, your supervisor, and other agents act on — not a box to tick to end the turn. Stopping undeclared makes your outcome a guess. Pick the ONE that is TRUE right now and run `%s session <choice>`, choosing the <choice> whose condition holds:\\n • done --propose merge — spec+code COMMITTED on the branch and genuinely ready for a human to review/merge (not just probably-done).\\n • done --propose nothing — committed, but you are NOT proposing a merge; paused for the human to look.\\n • park --note <what-you-await> — ONLY when a real BACKGROUND TASK will wake you (a spex wait you backgrounded, a running build/job). If nothing is actually running to resume you, you are NOT parked — you are waiting on the human, so use ask; never use park as a default to clear this gate.\\n • done --propose close — you propose discarding this worktree.\\n • ask --note <your-question> — you need the human: a real question, or you are simply stopped awaiting direction; you resume only when they reply.\\n\\nDECLARE LAST, THEN STOP: finish everything else in the turn first — speak, send your messages, arm your background waits — and make the declaration your FINAL call. Any tool call AFTER it flips your record back to active (mark-active, by design: activity is activity), so the next stop re-blocks and demands a fresh declaration; declaring last kills that loop at its source.\\n\\n(This full explanation shows once per session; later undeclared stops get a one-line reminder. `%s help session` re-explains the choices any time.)"}\n' "$S" "$S"
120
138
  exit 0