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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +13 -10
- package/spec-cli/hooks/dispatch.sh +28 -40
- package/spec-cli/hooks/harness.sh +43 -7
- package/spec-cli/src/board.ts +2 -1
- package/spec-cli/src/boardCache.ts +27 -1
- package/spec-cli/src/boardStream.ts +5 -4
- package/spec-cli/src/cli.ts +93 -20
- package/spec-cli/src/commit-surgery.ts +81 -0
- package/spec-cli/src/contract-filter.ts +156 -0
- package/spec-cli/src/doctor.ts +11 -5
- package/spec-cli/src/git.ts +18 -4
- package/spec-cli/src/guide.ts +94 -38
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +22 -8
- package/spec-cli/src/help.ts +28 -16
- package/spec-cli/src/index.ts +9 -6
- package/spec-cli/src/init.ts +17 -10
- package/spec-cli/src/issues.ts +7 -5
- package/spec-cli/src/layout.ts +31 -13
- package/spec-cli/src/lint.ts +19 -0
- package/spec-cli/src/localIssues.ts +16 -4
- package/spec-cli/src/materialize.ts +214 -144
- package/spec-cli/src/mentions.ts +5 -3
- package/spec-cli/src/plugin-harness.ts +10 -9
- package/spec-cli/src/ranker.ts +25 -8
- package/spec-cli/src/runtime-guard.ts +44 -0
- package/spec-cli/src/search.bench.mjs +15 -5
- package/spec-cli/src/sessions.ts +96 -22
- package/spec-cli/src/specs.ts +38 -18
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tsx-bin.ts +6 -8
- package/spec-cli/src/uninstall.ts +18 -19
- package/spec-cli/src/worktree-sources.ts +52 -13
- package/spec-cli/templates/hooks/post-checkout +22 -0
- package/spec-cli/templates/hooks/post-merge +15 -9
- package/spec-cli/templates/hooks/pre-commit +10 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +26 -8
- package/spec-cli/templates/spec/project/.config/distill/digest.mjs +136 -0
- package/spec-cli/templates/spec/project/.config/distill/spec.md +74 -0
- package/spec-dashboard/dist/assets/Dashboard-BlRRsxE7.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BzVE38-Z.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-DFuLVOeu.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CzDaazhe.js +1 -0
- package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-CXQrQCNp.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-D1pUBl6q.js} +8 -8
- package/spec-dashboard/dist/assets/SessionWindow-Y25Bwg1e.js +9 -0
- package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-R610Vbzd.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BO0Zuweu.js} +2 -2
- package/spec-dashboard/dist/assets/index-uGs9v_9o.css +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-forge/src/cli.ts +2 -2
- package/spec-forge/src/drivers/gitlab.ts +168 -0
- package/spec-forge/src/drivers.ts +80 -2
- package/spec-forge/src/resident.ts +10 -5
- package/spec-yatsu/src/cli.ts +37 -16
- package/spec-yatsu/src/evaltab.ts +6 -3
- package/spec-yatsu/src/filing.ts +13 -8
- package/spec-yatsu/src/freshness.ts +97 -22
- package/spec-yatsu/src/proof.ts +14 -3
- package/spec-yatsu/src/scenariofresh.ts +38 -11
- package/spec-yatsu/src/yatsu.ts +52 -28
- package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +0 -3
- package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +0 -1
- package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +0 -9
- package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +0 -1
package/spec-cli/src/ranker.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
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'}`)
|
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
506
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
817
|
-
|
|
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 .
|
|
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) + .
|
|
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
|
-
//
|
|
1066
|
-
//
|
|
1067
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
1229
|
-
//
|
|
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
|
-
|
|
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)
|
|
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) : ''
|
|
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
|
})
|
package/spec-cli/src/specs.ts
CHANGED
|
@@ -92,22 +92,32 @@ function walk(dir: string, parent: string | null, acc: Raw[]) {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
for
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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, '..')) //
|
|
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,
|
|
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.
|
package/spec-cli/src/tsx-bin.ts
CHANGED
|
@@ -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 -
|
|
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
|
-
//
|
|
8
|
-
//
|
|
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
|
-
|
|
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,
|
|
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
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
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
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
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(`✓
|
|
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
|
|