spexcode 0.2.1 → 0.2.3
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 +158 -103
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +24 -1
- package/spec-cli/src/attach.ts +50 -0
- package/spec-cli/src/cli.ts +217 -64
- package/spec-cli/src/client.ts +47 -9
- package/spec-cli/src/{self.ts → doctor.ts} +26 -25
- package/spec-cli/src/guide.ts +79 -21
- package/spec-cli/src/harness.ts +53 -29
- package/spec-cli/src/help.ts +137 -49
- package/spec-cli/src/index.ts +31 -11
- package/spec-cli/src/issues.ts +48 -21
- package/spec-cli/src/layout.ts +3 -5
- package/spec-cli/src/lint.ts +34 -5
- package/spec-cli/src/localIssues.ts +44 -60
- package/spec-cli/src/materialize.ts +4 -2
- package/spec-cli/src/mentions.ts +22 -1
- package/spec-cli/src/pty-bridge.ts +39 -4
- package/spec-cli/src/ranker.ts +31 -12
- package/spec-cli/src/search.bench.mjs +30 -7
- package/spec-cli/src/search.ts +39 -0
- package/spec-cli/src/sessions.ts +160 -69
- package/spec-cli/src/specs.ts +16 -4
- package/spec-cli/src/supervise.ts +30 -6
- package/spec-cli/src/tree.ts +118 -0
- package/spec-cli/templates/hooks/post-merge +2 -2
- package/spec-cli/templates/hooks/pre-commit +34 -15
- package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
- package/spec-cli/templates/spexcode.json +7 -0
- package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
- package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +3 -0
- package/spec-dashboard/dist/assets/FoldToggle-B5leylLf.js +1 -0
- package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-RHNECU6x.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-YLD6IOmC.js +71 -0
- package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +9 -0
- package/spec-dashboard/dist/assets/Settings-ZnOwskMZ.js +1 -0
- package/spec-dashboard/dist/assets/index-BdRQfrkR.js +41 -0
- package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-yatsu/src/cli.ts +128 -26
- package/spec-yatsu/src/evaltab.ts +7 -6
- package/spec-yatsu/src/filing.ts +6 -3
- package/spec-yatsu/src/proof.ts +10 -0
- package/spec-yatsu/src/scenariofresh.ts +100 -30
- package/spec-yatsu/src/sidecar.ts +25 -3
- package/spec-yatsu/src/timeline.ts +53 -23
- package/README.zh-CN.md +0 -135
- package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +0 -32
- package/spec-dashboard/dist/assets/index-DehTZ-h9.js +0 -145
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
// throwaway benchmark harness for spec-search — drives the REAL `spex search --json` over the
|
|
2
|
-
// cases and reports recall@1, recall@3, MRR.
|
|
1
|
+
// throwaway benchmark harness for spec-search — drives the REAL `spex search --json` over the holdout
|
|
2
|
+
// cases and reports recall@1, recall@3, MRR. The cases live in the node's yatsu.md.
|
|
3
|
+
//
|
|
4
|
+
// Labels are node LEAF names, matched with the same de-collision rule the loader applies (specs.ts reId):
|
|
5
|
+
// a returned id matches a label if it IS the label or ends with `_<label>` — so a bare leaf keeps matching
|
|
6
|
+
// after collision-qualification renames it (e.g. `spec-scout` → `injected-context_spec-scout`). A label may
|
|
7
|
+
// also be written pre-qualified to pin one collision branch.
|
|
3
8
|
import { execFileSync } from 'node:child_process'
|
|
4
9
|
import { fileURLToPath } from 'node:url'
|
|
5
10
|
import { dirname, join } from 'node:path'
|
|
@@ -8,29 +13,33 @@ const BIN = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'spex.mjs
|
|
|
8
13
|
|
|
9
14
|
const CASES = [
|
|
10
15
|
['exit-cleanup', "does /exit remove the session's worktree and tmux, or just orphan them?", ['session-console']],
|
|
11
|
-
['owner-at-edit', 'how does an agent learn which spec governs a file it just edited?', ['
|
|
16
|
+
['owner-at-edit', 'how does an agent learn which spec governs a file it just edited?', ['spec-of-file']],
|
|
12
17
|
['main-block', 'what stops an agent from committing or merging straight into main?', ['main-guard']],
|
|
13
18
|
['main-escape', 'the escape hatch that lets seeding run on the main branch', ['main-guard']],
|
|
14
19
|
['inter-agent-msg', 'how do two running agent sessions send messages to each other?', ['agent-reply-channel', 'comms']],
|
|
15
20
|
['search-hidden-node', 'keyboard shortcut to find a node hidden inside a collapsed subtree', ['keyboard-nav']],
|
|
16
|
-
['session-order', 'how is the order of sessions in the session list decided?', ['session-
|
|
21
|
+
['session-order', 'how is the order of sessions in the session list decided?', ['session-console']],
|
|
17
22
|
['node-status', 'what makes a node show as pending vs active vs merged vs drift?', ['spec-node-states']],
|
|
18
23
|
['dashboard-backend', 'how does the dashboard reach the backend API and on which port?', ['api-endpoint']],
|
|
19
24
|
['loss-measured', "how is a node's loss measured and its scenarios scored?", ['yatsu-core']],
|
|
20
25
|
['launch-injection', "what context gets injected into a freshly launched agent's prompt?", ['injected-context']],
|
|
21
|
-
['read-before-code', 'the one-shot nudge that makes an agent read its spec before touching code', ['
|
|
26
|
+
['read-before-code', 'the one-shot nudge that makes an agent read its spec before touching code', ['spec-first']],
|
|
22
27
|
['hot-reload', 'zero-downtime backend reload without dropping connections', ['supervisor']],
|
|
23
28
|
['many-owners', 'can several specs own the same code file, and what happens if too many do?', ['governed-related']],
|
|
24
29
|
['active-spec-search', 'an injected sub-agent that searches specs for the agent, the spec analog of Explore', ['spec-scout']],
|
|
30
|
+
['declare-done', 'how does a worker declare it is done', ['state']],
|
|
25
31
|
]
|
|
26
32
|
|
|
33
|
+
// de-collision-aware label match: exact id, or the id's trailing `_`-suffix is the label.
|
|
34
|
+
const matches = (id, label) => id === label || id.endsWith('_' + label)
|
|
35
|
+
|
|
27
36
|
let r1 = 0, r3 = 0, mrr = 0
|
|
28
37
|
const rows = []
|
|
29
38
|
for (const [name, query, expect] of CASES) {
|
|
30
39
|
const out = execFileSync('node', [BIN, 'search', query, '--json', '--limit', '10'], { encoding: 'utf8' })
|
|
31
40
|
const results = JSON.parse(out)
|
|
32
41
|
const ids = results.map((x) => x.id)
|
|
33
|
-
const rank = ids.findIndex((id) => expect.
|
|
42
|
+
const rank = ids.findIndex((id) => expect.some((label) => matches(id, label))) + 1 // 1-based; 0 = not found
|
|
34
43
|
const hit1 = rank === 1
|
|
35
44
|
const hit3 = rank >= 1 && rank <= 3
|
|
36
45
|
if (hit1) r1++
|
|
@@ -43,5 +52,19 @@ for (const row of rows) {
|
|
|
43
52
|
const mark = row.rank === 1 ? '✓1' : (typeof row.rank === 'number' && row.rank <= 3 ? '·3' : (row.rank === '—' ? '✗ ' : `·${row.rank}`))
|
|
44
53
|
console.log(`${mark} ${row.name.padEnd(20)} want=${row.expect.padEnd(22)} rank=${String(row.rank).padStart(2)} top3: ${row.top}`)
|
|
45
54
|
}
|
|
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' })
|
|
60
|
+
const cjkPass = cjk.includes('corpus is English') && cjk.includes('spex tree')
|
|
61
|
+
console.log(`${cjkPass ? '✓ ' : '✗ '} cjk-zero-result want=corpus-is-English + spex-tree ${cjkPass ? 'both present' : 'MISSING: ' + cjk.trim()}`)
|
|
62
|
+
|
|
63
|
+
// zero-result typo routing: an English typo that matches nothing must surface the nearest node titles
|
|
64
|
+
// (per-word edit distance) plus the browse-all next step, so a typo routes forward instead of dead-ending.
|
|
65
|
+
const typo = execFileSync('node', [BIN, 'search', 'kyeboard'], { encoding: 'utf8' })
|
|
66
|
+
const typoPass = typo.includes('nearest titles') && typo.includes('keyboard-nav') && typo.includes('spex tree')
|
|
67
|
+
console.log(`${typoPass ? '✓ ' : '✗ '} typo-zero-result want=nearest-titles(keyboard-nav) + spex-tree ${typoPass ? 'both present' : 'MISSING: ' + typo.trim()}`)
|
|
68
|
+
|
|
46
69
|
console.log('—'.repeat(72))
|
|
47
|
-
console.log(`recall@1 = ${r1}/${n} = ${(r1 / n).toFixed(3)} recall@3 = ${r3}/${n} = ${(r3 / n).toFixed(3)} MRR = ${(mrr / n).toFixed(3)}`)
|
|
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'}`)
|
package/spec-cli/src/search.ts
CHANGED
|
@@ -4,6 +4,45 @@ import { rankDocs, type RankInput } from './ranker.js'
|
|
|
4
4
|
export type SearchResult = { id: string; title: string; path: string; score: number; snippet: string }
|
|
5
5
|
export type SearchStats = { nodes: number; tokens: number; ms: number }
|
|
6
6
|
|
|
7
|
+
// zero-result fallback: nearest node titles by per-word edit distance over title+id — the lightweight
|
|
8
|
+
// string distance that routes a typo'd query to a next step. Each query word takes its best normalised
|
|
9
|
+
// Levenshtein similarity against any title/id word (≥0.5, so unrelated words don't accumulate), then the
|
|
10
|
+
// words sum. Same loadSpecsLite read as the ranker; deliberately NOT part of rankDocs scoring (this
|
|
11
|
+
// tolerates typos, the ranker must not).
|
|
12
|
+
export function nearestTitles(query: string, n = 3): { id: string; title: string }[] {
|
|
13
|
+
const words = (s: string) => s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1)
|
|
14
|
+
const lev = (a: string, b: string) => {
|
|
15
|
+
const dp = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
16
|
+
for (let i = 1; i <= a.length; i++) {
|
|
17
|
+
let prev = dp[0]
|
|
18
|
+
dp[0] = i
|
|
19
|
+
for (let j = 1; j <= b.length; j++) {
|
|
20
|
+
const cur = dp[j]
|
|
21
|
+
dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
22
|
+
prev = cur
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return dp[b.length]
|
|
26
|
+
}
|
|
27
|
+
const qws = words(query)
|
|
28
|
+
if (!qws.length) return [] // nothing to be "near" (e.g. a pure-CJK query) — the caller still points at spex tree
|
|
29
|
+
return loadSpecsLite()
|
|
30
|
+
.map((s) => {
|
|
31
|
+
const tws = [...new Set(words(`${s.title} ${s.id}`))]
|
|
32
|
+
let score = 0
|
|
33
|
+
for (const q of qws) {
|
|
34
|
+
let best = 0
|
|
35
|
+
for (const t of tws) best = Math.max(best, 1 - lev(q, t) / Math.max(q.length, t.length))
|
|
36
|
+
if (best >= 0.5) score += best
|
|
37
|
+
}
|
|
38
|
+
return { id: s.id, title: s.title, score }
|
|
39
|
+
})
|
|
40
|
+
.filter((s) => s.score > 0)
|
|
41
|
+
.sort((a, b) => b.score - a.score)
|
|
42
|
+
.slice(0, n)
|
|
43
|
+
.map(({ id, title }) => ({ id, title }))
|
|
44
|
+
}
|
|
45
|
+
|
|
7
46
|
export async function searchSpecs(query: string, opts: { limit?: number; onStats?: (s: SearchStats) => void } = {}): Promise<SearchResult[]> {
|
|
8
47
|
const t0 = performance.now()
|
|
9
48
|
// pre-sort by (shorter id, then id): rankDocs sorts stably, so for equal-scored nodes this IS the tiebreak.
|
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -6,9 +6,10 @@ import { join, dirname, relative, isAbsolute } from 'node:path'
|
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
7
|
import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type ReviewDiffFile } from './git.js'
|
|
8
8
|
import { loadSpecs } from './specs.js'
|
|
9
|
-
import { defaultHarness, harnessById, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
|
|
9
|
+
import { defaultHarness, defaultLauncher, harnessById, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
|
|
10
10
|
import { materialize } from './materialize.js'
|
|
11
11
|
import { mainBranch, gitCommonDir, readConfig, runtimeRoot, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
|
|
12
|
+
import { stripRefSigil } from './mentions.js'
|
|
12
13
|
|
|
13
14
|
// @@@ sessions - the WORKTREE is the durable unit; tmux is a disposable runtime handle. The per-session
|
|
14
15
|
// SOURCE OF TRUTH is an untracked record (`session.json`) in a per-user GLOBAL store keyed by the harness
|
|
@@ -42,13 +43,13 @@ import { mainBranch, gitCommonDir, readConfig, runtimeRoot, sessionStoreDir, ses
|
|
|
42
43
|
// deliberately does NOT touch it. `merges` is METADATA (how many times merged), shown as a badge, not a state.
|
|
43
44
|
//
|
|
44
45
|
// Launch rules (CLAUDE.md / memory): private `tmux -L <label>` socket + `--dangerously-skip-permissions`.
|
|
45
|
-
// SPEXCODE_TMUX
|
|
46
|
+
// SPEXCODE_TMUX overrides the tmux socket for tests; the launch COMMAND comes from the session's pinned
|
|
47
|
+
// launcher ([[launcher-select]]), not an env var.
|
|
46
48
|
|
|
47
49
|
const pexec = promisify(execFile)
|
|
48
|
-
const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
|
|
49
|
-
// the harness
|
|
50
|
-
//
|
|
51
|
-
// Claude. Resolved once here ([[harness-adapter]]); a future codex launcher flips defaultHarness, nothing else.
|
|
50
|
+
export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
|
|
51
|
+
// the legacy/default harness for helpers and old records. New sessions derive their harness from the selected
|
|
52
|
+
// launcher; all harness-specific launch facts still come from the adapter.
|
|
52
53
|
const HARNESS = defaultHarness
|
|
53
54
|
const COLS = 120, ROWS = 32
|
|
54
55
|
// @@@ concurrency cap - the most working agents we let run AT ONCE. Heavy multi-agent load (many claude
|
|
@@ -114,7 +115,7 @@ export type Session = {
|
|
|
114
115
|
raw: { name: string | null; title: string | null } // the bare parts, for explicit consumers only (rename prefill)
|
|
115
116
|
parent: string | null // the SPAWNING session's id ([[session-nesting]]) — set once at creation when `spex new` ran inside another session, else null; the frontend folds a child under it at read time
|
|
116
117
|
harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
|
|
117
|
-
launcher: string | null // the
|
|
118
|
+
launcher: string | null // the launcher profile this session launched under ([[launcher-select]]); null only for old records predating launchers
|
|
118
119
|
lifecycle: Lifecycle; proposal: Proposal | null; merges: number; status: DisplayStatus; liveness: Liveness; note: string | null
|
|
119
120
|
prompt: string | null; promptPreview: string | null; created: number; activity: string | null
|
|
120
121
|
sortKey: number | null // manual drag-reorder override ([[session-reorder]]); null = sort by `created`
|
|
@@ -224,7 +225,7 @@ export type SessRec = {
|
|
|
224
225
|
parent: string | null // the spawning session's id ([[session-nesting]]); null for a top-level launch
|
|
225
226
|
status: Lifecycle; proposal: Proposal | null; merges: number; note: string | null
|
|
226
227
|
sortKey: number | null; createdAt: number; harness: string; harnessSessionId: string | null
|
|
227
|
-
launcher: string | null // the
|
|
228
|
+
launcher: string | null // the launcher profile this session launches under ([[launcher-select]]); null only for old records predating launchers
|
|
228
229
|
launchCmd: string | null // the RESOLVED base launcher command pinned at creation ([[launcher-select]] resume-launcher-pin); null → old record → fall back to the launcher name / ambient
|
|
229
230
|
}
|
|
230
231
|
const LIFECYCLES = new Set<Lifecycle>(['active', 'idle', 'awaiting', 'parked', 'error', 'asking', 'queued'])
|
|
@@ -250,7 +251,7 @@ function fromRaw(raw: RawRecord): SessRec {
|
|
|
250
251
|
status, proposal, merges: Number(raw.merges) || 0, note: raw.note || null, sortKey, createdAt: Number(raw.createdAt) || 0,
|
|
251
252
|
harness: raw.harness || 'claude', // records written before the harness field default to claude
|
|
252
253
|
harnessSessionId: raw.harness_session_id || null,
|
|
253
|
-
launcher: raw.launcher || null, // records written before launchers → null →
|
|
254
|
+
launcher: raw.launcher || null, // records written before launchers → null → old-record fallback
|
|
254
255
|
launchCmd: raw.launch_cmd || null, // records written before the pin → null → fall back to launcher name / ambient
|
|
255
256
|
}
|
|
256
257
|
}
|
|
@@ -371,7 +372,7 @@ async function liveSnapshot(): Promise<LiveSnap> {
|
|
|
371
372
|
async function paneTitles(): Promise<Map<string, string>> {
|
|
372
373
|
const m = new Map<string, string>()
|
|
373
374
|
let out = ''
|
|
374
|
-
try { out = await tmux(['list-panes', '-a', '-F', '#{session_name}\t#{pane_title}']) } catch { return m }
|
|
375
|
+
try { out = await tmux(['list-panes', '-a', '-F', '#{session_name}\t#{pane_title}'], TMUX_PROBE_TIMEOUT_MS) } catch { return m }
|
|
375
376
|
for (const line of out.split('\n')) {
|
|
376
377
|
const tab = line.indexOf('\t'); if (tab < 0) continue
|
|
377
378
|
const id = line.slice(0, tab), title = line.slice(tab + 1)
|
|
@@ -437,8 +438,8 @@ export function selfSummary(paneTitle: string): string | null {
|
|
|
437
438
|
const launchedAt = new Map<string, number>()
|
|
438
439
|
const BOOT_GRACE_MS = 45000 // > SOCKET_READY_TIMEOUT_MS, and spans launchScript's bounded fast-fail retry
|
|
439
440
|
// window (~3 attempts) so a relaunching session reads 'starting', not 'offline'
|
|
440
|
-
const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it exits faster than this
|
|
441
|
-
//
|
|
441
|
+
const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it exits faster than this: fast
|
|
442
|
+
// exit before readiness is retryable, but it is not proof of one specific cause
|
|
442
443
|
|
|
443
444
|
// @@@ liveness - the orthogonal axis ([[state]]): is the agent process up, for ANY session regardless of
|
|
444
445
|
// lifecycle, from a prebuilt runtime snapshot (no per-call spawn — see liveSnapshot) + the adapter's own channel
|
|
@@ -678,11 +679,80 @@ export async function sessionGraph(): Promise<{ nodes: Session[]; edges: Edge[]
|
|
|
678
679
|
return { nodes, edges }
|
|
679
680
|
}
|
|
680
681
|
|
|
682
|
+
// @@@ apiBase resolution - WHICH backend a client verb talks to, resolved ONCE per process with the
|
|
683
|
+
// source kept as a discriminant ([[remote-client]]). The core thesis: a FLAG is the only signal that is
|
|
684
|
+
// provably deliberate — an env var cannot tell "I exported this on the command" from "I inherited this
|
|
685
|
+
// from the backend that launched my shell", and that ambiguity is exactly the misroute bug (a shell
|
|
686
|
+
// carrying project A's SPEXCODE_API_URL silently drives every bare `spex` in project B at A's backend).
|
|
687
|
+
// The ladder:
|
|
688
|
+
// 1. explicit `--api <url>` (`--port <n>` = localhost sugar) — always wins, any verb
|
|
689
|
+
// 2a. WORKER (SPEXCODE_SESSION_ID present): env SPEXCODE_API_URL — the backend-injected lifeline; a
|
|
690
|
+
// worker's state writes must NEVER gamble on cwd discovery, so the env is not demotable by (2b)
|
|
691
|
+
// 2b. HUMAN (no session id): the cwd project's RECORDED live backend (`spex serve` writes it, we
|
|
692
|
+
// /health-probe before trusting — a dead record is ignored, never followed)
|
|
693
|
+
// 3. the other side as fallback (human with no live record → env; worker with no env → record)
|
|
694
|
+
// 4. default http://127.0.0.1:$PORT||8787
|
|
695
|
+
export type ApiBaseSource = 'flag' | 'worker-env' | 'record' | 'env-fallback' | 'default'
|
|
696
|
+
export type ApiBaseInfo = { url: string; source: ApiBaseSource }
|
|
697
|
+
const usageError = (msg: string): Error => { const e = new Error(msg); e.name = 'UsageError'; return e }
|
|
698
|
+
// the explicit routing flag, read from THIS process's argv (never the environment — that's the point).
|
|
699
|
+
// `--port` doubles as a BIND port for serve/dashboard, so the sugar is skipped for those verbs.
|
|
700
|
+
function explicitApiFlag(): string | null {
|
|
701
|
+
const argv = process.argv
|
|
702
|
+
const ai = argv.indexOf('--api')
|
|
703
|
+
if (ai >= 0) {
|
|
704
|
+
const v = argv[ai + 1]
|
|
705
|
+
if (!v || v.startsWith('--')) throw usageError('--api expects a URL (e.g. --api http://127.0.0.1:8901)')
|
|
706
|
+
const withScheme = v.includes('://') ? v : `http://${v}`
|
|
707
|
+
try { new URL(withScheme) } catch { throw usageError(`--api: not a URL: ${v}`) }
|
|
708
|
+
return withScheme.replace(/\/+$/, '')
|
|
709
|
+
}
|
|
710
|
+
if (argv[2] === 'serve' || argv[2] === 'dashboard') return null // their --port is a bind port, not routing
|
|
711
|
+
const pi = argv.indexOf('--port')
|
|
712
|
+
if (pi >= 0) {
|
|
713
|
+
const v = argv[pi + 1]
|
|
714
|
+
if (!v || !Number.isInteger(Number(v))) throw usageError('--port expects an integer (localhost sugar for --api http://127.0.0.1:<n>)')
|
|
715
|
+
return `http://127.0.0.1:${v}`
|
|
716
|
+
}
|
|
717
|
+
return null
|
|
718
|
+
}
|
|
719
|
+
// the cwd project's recorded backend ({url,pid}, written by `spex serve` at bind time into the per-project
|
|
720
|
+
// runtime tier), trusted only after a live /health probe — a stale record must never swallow a command.
|
|
721
|
+
async function liveRecordUrl(): Promise<string | null> {
|
|
722
|
+
let file: string
|
|
723
|
+
try { file = join(runtimeRoot(), 'backend.json') } catch { return null } // cwd not in a git repo → nothing to discover
|
|
724
|
+
let url = ''
|
|
725
|
+
try { const rec = JSON.parse(readFileSync(file, 'utf8')); if (typeof rec?.url === 'string') url = rec.url.trim() } catch { return null }
|
|
726
|
+
if (!url) return null
|
|
727
|
+
const ctrl = new AbortController()
|
|
728
|
+
const t = setTimeout(() => ctrl.abort(), 600)
|
|
729
|
+
try { return (await fetch(`${url}/health`, { signal: ctrl.signal })).ok ? url : null }
|
|
730
|
+
catch { return null }
|
|
731
|
+
finally { clearTimeout(t) }
|
|
732
|
+
}
|
|
733
|
+
async function resolveApiBase(): Promise<ApiBaseInfo> {
|
|
734
|
+
const flag = explicitApiFlag()
|
|
735
|
+
if (flag) return { url: flag, source: 'flag' }
|
|
736
|
+
const env = process.env.SPEXCODE_API_URL?.trim() || null
|
|
737
|
+
if (process.env.SPEXCODE_SESSION_ID?.trim()) {
|
|
738
|
+
if (env) return { url: env, source: 'worker-env' }
|
|
739
|
+
const rec = await liveRecordUrl()
|
|
740
|
+
if (rec) return { url: rec, source: 'record' }
|
|
741
|
+
} else {
|
|
742
|
+
const rec = await liveRecordUrl()
|
|
743
|
+
if (rec) return { url: rec, source: 'record' }
|
|
744
|
+
if (env) return { url: env, source: 'env-fallback' }
|
|
745
|
+
}
|
|
746
|
+
return { url: `http://127.0.0.1:${process.env.PORT || 8787}`, source: 'default' }
|
|
747
|
+
}
|
|
748
|
+
let apiBaseMemo: Promise<ApiBaseInfo> | null = null
|
|
749
|
+
export const apiBaseInfo = (): Promise<ApiBaseInfo> => (apiBaseMemo ??= resolveApiBase())
|
|
750
|
+
export const apiBase = async (): Promise<string> => (await apiBaseInfo()).url
|
|
751
|
+
|
|
681
752
|
// @@@ watch registration (CLIENT side) - a `spex watch` process is separate from the server, so it
|
|
682
753
|
// REPORTS itself to the backend's registration store over HTTP: register+heartbeat while it runs,
|
|
683
754
|
// deregister on exit (see cli.ts `watch`). All best-effort — if the backend is down the watch still
|
|
684
755
|
// streams its events; the graph edge just won't appear until a heartbeat lands. Never throws.
|
|
685
|
-
export const apiBase = () => process.env.SPEXCODE_API_URL || `http://127.0.0.1:${process.env.PORT || 8787}`
|
|
686
756
|
// the agent's OWN session id from the HARNESS env var — the public name used across cli.ts/sessions.ts.
|
|
687
757
|
// Single adapter-routed impl lives in layout.ts (`envSessionId`, iterating each adapter's sessionEnvVar);
|
|
688
758
|
// re-exported here so callers keep one name. Used by `spex watch` + the agent-typed `spex session …`
|
|
@@ -708,7 +778,7 @@ export function withSenderHint(text: string, sender: MsgSender | null): string {
|
|
|
708
778
|
}
|
|
709
779
|
async function postJSON(path: string, body: unknown): Promise<void> {
|
|
710
780
|
try {
|
|
711
|
-
await fetch(`${apiBase()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
781
|
+
await fetch(`${await apiBase()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
712
782
|
} catch { /* best-effort: backend may be down; the next heartbeat / TTL reconciles */ }
|
|
713
783
|
}
|
|
714
784
|
export const reportWatch = (token: string, watcher: string, selectors: string[], ttlMs: number): Promise<void> =>
|
|
@@ -769,28 +839,28 @@ export function launcherCmd(rec: SessRec): string | undefined {
|
|
|
769
839
|
if (rec.launchCmd) return rec.launchCmd
|
|
770
840
|
return rec.launcher ? resolveLauncher(rec.launcher).cmd : undefined
|
|
771
841
|
}
|
|
772
|
-
function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string): string {
|
|
842
|
+
export function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string): string {
|
|
773
843
|
const file = join(storeDir(id), 'launch.sh')
|
|
774
844
|
// NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
|
|
775
845
|
// createSession ([[harness-delivery]]) and the agent auto-discovers them — the SAME path as a self-launched
|
|
776
846
|
// agent. The launch line is just the rendezvous env + the harness command + the session-id/spec-pointer/prompt tail.
|
|
777
847
|
// `cmd` is the session's persisted launcher command ([[launcher-select]]); when set it OVERRIDES the harness's
|
|
778
|
-
// ambient default so resume reuses the same auth
|
|
848
|
+
// ambient default so resume reuses the same auth. Undefined is only for old records before launch_cmd existed.
|
|
779
849
|
const invocation = `${rvEnv(id, harness)} ${harness.launchCmd(id, runtimeRoot(), cmd)} ${tail}`
|
|
780
|
-
// Bounded relaunch on a FAST exit: the agent launcher
|
|
781
|
-
//
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
//
|
|
850
|
+
// Bounded relaunch on a FAST exit: the agent launcher can exit within seconds before the rendezvous socket
|
|
851
|
+
// ever appears. That is enough evidence to retry, but not enough evidence to name the cause. Once the agent
|
|
852
|
+
// has run past LAUNCH_FAST_FAIL_S it has genuinely started; its eventual (much later) exit is a normal
|
|
853
|
+
// session end and is NEVER retried — the loop exits. BOOT_GRACE_MS and SOCKET_READY_TIMEOUT_MS both span this
|
|
854
|
+
// retry window, so liveness stays 'starting' and waitForReady keeps holding the slot across retries. This
|
|
855
|
+
// only closes startup unready failures — it adds no fallback and never masks a genuinely dead agent (3
|
|
856
|
+
// attempts, then give up).
|
|
787
857
|
writeFileSync(file, [
|
|
788
858
|
`for __spex_try in 1 2 3; do`,
|
|
789
859
|
` __spex_t0=$SECONDS`,
|
|
790
860
|
` ${invocation}`,
|
|
791
861
|
` __spex_rc=$?`,
|
|
792
862
|
` [ $(( SECONDS - __spex_t0 )) -ge ${LAUNCH_FAST_FAIL_S} ] && exit $__spex_rc`,
|
|
793
|
-
` printf '[spex launch] attempt %s exited in %ss (rc=%s)
|
|
863
|
+
` printf '[spex launch] attempt %s exited in %ss (rc=%s) - fast launcher exit before readiness; retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
|
|
794
864
|
` sleep 2`,
|
|
795
865
|
`done`,
|
|
796
866
|
`exit $__spex_rc`,
|
|
@@ -897,41 +967,50 @@ export function superviseQueue(intervalMs = 3000): void {
|
|
|
897
967
|
void tick()
|
|
898
968
|
}
|
|
899
969
|
|
|
900
|
-
// @@@ assertProjectMatch -
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
// served root and FAIL LOUD on a provable, same-host mismatch — never a silent
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
-
|
|
970
|
+
// @@@ assertProjectMatch - a WRITE is PROJECT-BOUND, but routing is by URL. A mutating verb's intent is
|
|
971
|
+
// "act on the project my cwd is in", yet the resolved base is a pure URL carrying no project identity —
|
|
972
|
+
// the backend it answers acts on ITS OWN mainRoot, so a stale inherited SPEXCODE_API_URL (pointing at
|
|
973
|
+
// another repo's backend) silently lands the write in the WRONG repo. Read/control-READS deliberately
|
|
974
|
+
// point anywhere (viewer-points-anywhere, see remote-client); every MUTATING verb (new/merge/send/close/
|
|
975
|
+
// rename/rawkey/reopen/exit) is bound to the caller's project. So before writing, compare the caller's
|
|
976
|
+
// repo root to the backend's served root and FAIL LOUD on a provable, same-host mismatch — never a silent
|
|
977
|
+
// misroute. An explicit `--api`/`--port` flag SKIPS the guard: the flag is the one provably-deliberate
|
|
978
|
+
// cross-project signal (that's the whole flag-beats-env thesis). The guard fires only on a positive
|
|
979
|
+
// mismatch: no local repo, an unreachable backend, or a backend root that isn't a resolvable local path
|
|
980
|
+
// (a genuinely remote backend) all fall through to allow, so legit remote drive stays untouched.
|
|
981
|
+
export async function assertProjectMatch(verb: string): Promise<void> {
|
|
982
|
+
const { url, source } = await apiBaseInfo()
|
|
983
|
+
if (source === 'flag') return // explicitly routed — the caller named the target
|
|
910
984
|
let localMain: string
|
|
911
985
|
try { localMain = realpathSync(mainRoot()) } catch { return } // caller not in a repo → can't prove a mismatch
|
|
912
986
|
let served: string | null = null
|
|
913
987
|
try {
|
|
914
|
-
const r = await fetch(`${
|
|
988
|
+
const r = await fetch(`${url}/api/layout`)
|
|
915
989
|
if (r.ok) served = (await r.json() as { main?: string }).main ?? null
|
|
916
|
-
} catch { return } // backend unreachable → the
|
|
990
|
+
} catch { return } // backend unreachable → the write itself surfaces it (fail-loud there)
|
|
917
991
|
if (!served || !isAbsolute(served)) return // unknown / config-aliased root → don't risk a false refusal
|
|
918
992
|
let backendMain: string
|
|
919
993
|
try { backendMain = realpathSync(served) } catch { return } // backend root not a local path → a remote backend, allow
|
|
920
|
-
if (backendMain !== localMain)
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
`
|
|
994
|
+
if (backendMain !== localMain) {
|
|
995
|
+
const e = new Error(
|
|
996
|
+
`${verb}: refusing WRITE — cwd is in ${localMain} but the backend at ${url} serves ${backendMain}.\n` +
|
|
997
|
+
`Name the target explicitly (--api <url> / --port <n>) to write cross-project on purpose,\n` +
|
|
998
|
+
`or run this project's own backend: cd ${localMain} && spex serve. (Reads stay unguarded.)`)
|
|
999
|
+
e.name = 'GuardError'
|
|
1000
|
+
throw e
|
|
1001
|
+
}
|
|
924
1002
|
}
|
|
925
1003
|
|
|
926
1004
|
// @@@ createSession (dispatch via backend) - `spex new` / `spex session new` must launch the worker in the
|
|
927
|
-
// BACKEND's process, not the caller's
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
//
|
|
933
|
-
|
|
934
|
-
|
|
1005
|
+
// BACKEND's process, not the caller's, because the backend is the single owner of the concurrency cap and the
|
|
1006
|
+
// launch QUEUE (drainQueue). An in-process launch by an agent that runs `spex new` (e.g. a supervisor) would
|
|
1007
|
+
// bypass that queue and the maxActive gate. (The launch COMMAND is not a process-env concern anymore — it
|
|
1008
|
+
// comes from the session's pinned launcher, resolved from project config [[launcher-select]], identical in
|
|
1009
|
+
// either process.) So the CLI POSTs to the running backend whenever one answers. Only when NO backend is
|
|
1010
|
+
// reachable do we fall back to launching in this process (with a stderr warning) — the backend's own POST
|
|
1011
|
+
// handler calls newSession directly, so it never re-enters this path.
|
|
1012
|
+
export async function createSession(node: string | null, prompt: string, launcher?: string): Promise<Session> {
|
|
1013
|
+
await assertProjectMatch('spex new')
|
|
935
1014
|
// @@@ parent = the CALLER's own session ([[session-nesting]]). Resolve it HERE, in the caller's process,
|
|
936
1015
|
// via the SAME ownSessionId env read [[agent-reply-channel]] uses for its sender hint — NOT inside the
|
|
937
1016
|
// backend, whose process env carries no acting session id. An agent that runs `spex new` stamps its own id;
|
|
@@ -939,16 +1018,23 @@ export async function createSession(node: string | null, prompt: string, harness
|
|
|
939
1018
|
const parent = ownSessionId()
|
|
940
1019
|
let res: Response
|
|
941
1020
|
try {
|
|
942
|
-
res = await fetch(`${apiBase()}/api/sessions`, {
|
|
1021
|
+
res = await fetch(`${await apiBase()}/api/sessions`, {
|
|
943
1022
|
method: 'POST',
|
|
944
1023
|
headers: { 'content-type': 'application/json' },
|
|
945
|
-
body: JSON.stringify({ node, prompt,
|
|
1024
|
+
body: JSON.stringify({ node, prompt, parent, launcher }),
|
|
946
1025
|
})
|
|
947
1026
|
} catch {
|
|
948
1027
|
console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
|
|
949
|
-
return newSession(node, prompt,
|
|
1028
|
+
return newSession(node, prompt, parent, launcher)
|
|
1029
|
+
}
|
|
1030
|
+
if (!res.ok) {
|
|
1031
|
+
const text = await res.text().catch(() => '')
|
|
1032
|
+
let msg = text
|
|
1033
|
+
try { msg = JSON.parse(text).error || text } catch {}
|
|
1034
|
+
const err = new Error(`backend rejected session (${res.status}): ${msg}`)
|
|
1035
|
+
err.name = 'BackendError'
|
|
1036
|
+
throw err
|
|
950
1037
|
}
|
|
951
|
-
if (!res.ok) throw new Error(`backend rejected session (${res.status}): ${await res.text().catch(() => '')}`)
|
|
952
1038
|
return await res.json() as Session
|
|
953
1039
|
}
|
|
954
1040
|
|
|
@@ -959,16 +1045,14 @@ export async function createSession(node: string | null, prompt: string, harness
|
|
|
959
1045
|
// launched agent does itself (the composer's nn/dd chords just prefill a plain instruction). So the server
|
|
960
1046
|
// only ever launches a session; it never mutates the spec tree ([[mentions]]: the issue store is the sole
|
|
961
1047
|
// programmatic surface, every other surface is prompt only).
|
|
962
|
-
export async function newSession(node: string | null, prompt: string,
|
|
1048
|
+
export async function newSession(node: string | null, prompt: string, parent: string | null = null, launcher?: string): Promise<Session> {
|
|
963
1049
|
const id = randomUUID()
|
|
964
|
-
// a
|
|
965
|
-
//
|
|
966
|
-
//
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
const
|
|
970
|
-
const chosen = lname ? resolveLauncher(lname) : null
|
|
971
|
-
const h = harnessById(chosen ? chosen.harness : harness) // throws on an unknown id — fail loud, never silently launch the wrong harness
|
|
1050
|
+
// a launcher ([[launcher-select]]) fixes BOTH the launch command (persisted below) AND the harness — so
|
|
1051
|
+
// picking one is the ONLY launch choice. Explicit --launcher wins, else the configured defaultLauncher.
|
|
1052
|
+
// A missing/unknown default throws fail-loud; there is no built-in-claude or harness fallback.
|
|
1053
|
+
const lname = launcher ?? defaultLauncher(mainRoot())
|
|
1054
|
+
const chosen = resolveLauncher(lname)
|
|
1055
|
+
const h = harnessById(chosen.harness)
|
|
972
1056
|
// node identity + label: explicit --node wins, else the prompt's first `[[id]]` topic ref; a prompt with
|
|
973
1057
|
// none is node-agnostic and labeled by its first few words.
|
|
974
1058
|
const ref = node || mentionedNode(prompt)
|
|
@@ -988,11 +1072,11 @@ export async function newSession(node: string | null, prompt: string, harness: s
|
|
|
988
1072
|
// mutated after. A self-parent (a resolver quirk) is dropped so a session can't nest under itself.
|
|
989
1073
|
node: ref || null, title, name: null, parent: parent && parent !== id ? parent : null,
|
|
990
1074
|
status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
|
|
991
|
-
harness: h.id, harnessSessionId: null, launcher:
|
|
1075
|
+
harness: h.id, harnessSessionId: null, launcher: chosen.name,
|
|
992
1076
|
// PIN the resolved base launcher command NOW ([[launcher-select]] resume-launcher-pin) so every future
|
|
993
1077
|
// (re)launch replays THIS exact launcher — the one whose config-dir env holds the conversation — instead of
|
|
994
1078
|
// re-resolving against a default that may have flipped (a backend restarted under a different launcher).
|
|
995
|
-
launchCmd: h.baseCmd(chosen
|
|
1079
|
+
launchCmd: h.baseCmd(chosen.cmd),
|
|
996
1080
|
}
|
|
997
1081
|
writeRecord(rec)
|
|
998
1082
|
writePromptFile(id, prompt) // capture the ORIGINATING prompt (the human/manager's ask) as store metadata (best-effort)
|
|
@@ -1104,7 +1188,7 @@ export function markState(status: Lifecycle, opts: { proposal?: Proposal; note?:
|
|
|
1104
1188
|
})
|
|
1105
1189
|
return true
|
|
1106
1190
|
}
|
|
1107
|
-
export const markDone = (proposal: Proposal = 'nothing', sessionId?: string) => markState('awaiting', { proposal, sessionId })
|
|
1191
|
+
export const markDone = (proposal: Proposal = 'nothing', sessionId?: string, note?: string) => markState('awaiting', { proposal, note, sessionId })
|
|
1108
1192
|
export const markError = (sessionId?: string) => markState('error', { sessionId })
|
|
1109
1193
|
export function markHarnessSessionId(sessionId: string | undefined, harnessSessionId: string | undefined): boolean {
|
|
1110
1194
|
const id = sessionId || ownSessionId()
|
|
@@ -1184,6 +1268,7 @@ export type ReviewGates = {
|
|
|
1184
1268
|
}
|
|
1185
1269
|
export type ReviewPayload = {
|
|
1186
1270
|
id: string; node: string | null; branch: string | null
|
|
1271
|
+
label: string // the session's identity, derived ONCE via deriveLabel — the review surface renders THIS, never its own node||branch||id chain
|
|
1187
1272
|
ahead: number // commits the node branch is ahead of main
|
|
1188
1273
|
dirtyNonRuntime: number // uncommitted files excluding SpexCode's own runtime files
|
|
1189
1274
|
diff: ReviewDiffFile[] // the worker's real changes, anchored at the merge-base
|
|
@@ -1250,6 +1335,7 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
|
|
|
1250
1335
|
const dirtyNonRuntime = statusOut.split('\n').filter(Boolean).map(porcelainPath).length
|
|
1251
1336
|
return {
|
|
1252
1337
|
id, node: wt.rec.node, branch: wt.branch,
|
|
1338
|
+
label: deriveLabel({ id, name: wt.rec.name, node: wt.rec.node, title: wt.rec.title, branch: wt.branch }),
|
|
1253
1339
|
ahead: Number(aheadOut.trim()) || 0,
|
|
1254
1340
|
dirtyNonRuntime, diff,
|
|
1255
1341
|
gates: { conflictsWithMain, lint },
|
|
@@ -1382,8 +1468,9 @@ export function matchesSelector(s: Session, q: string): boolean {
|
|
|
1382
1468
|
// names the session, so `watch a,b` and `watch a b` are equivalent. A single name is the one-part case. This
|
|
1383
1469
|
// is what stops a comma-joined selector from silently matching nothing — an id/node/branch never holds a
|
|
1384
1470
|
// comma, so without the split `a,b` would be one literal selector that matches no session and streams in
|
|
1385
|
-
// silence forever.
|
|
1386
|
-
|
|
1471
|
+
// silence forever. Each part sheds an optional reference sigil (stripRefSigil): `@<sel>` / `[[<sel>]]` name
|
|
1472
|
+
// the same session as the bare token, so the dashboard's mention grammar is tolerated in every CLI selector.
|
|
1473
|
+
return q.split(',').map((p) => stripRefSigil(p.trim())).filter(Boolean)
|
|
1387
1474
|
.some((p) => s.id === p || s.id.startsWith(p) || s.node === p || s.branch === p)
|
|
1388
1475
|
}
|
|
1389
1476
|
|
|
@@ -1404,7 +1491,8 @@ export function selectSessions(all: Session[], selectors: string[], statuses?: s
|
|
|
1404
1491
|
// otherwise a lone match is `ok`, several is `ambiguous` (a prefix/node hitting many), none is `none`.
|
|
1405
1492
|
export type Resolved = { ok: Session } | { ambiguous: Session[] } | { none: true }
|
|
1406
1493
|
export function resolveSession(selector: string, sessions: Session[]): Resolved {
|
|
1407
|
-
|
|
1494
|
+
// the exact-id check sheds the optional sigil too, so `@<full-id>` keeps the exact-wins-over-prefix rule
|
|
1495
|
+
const exact = sessions.find((s) => s.id === stripRefSigil(selector))
|
|
1408
1496
|
if (exact) return { ok: exact }
|
|
1409
1497
|
const hits = sessions.filter((s) => matchesSelector(s, selector))
|
|
1410
1498
|
if (hits.length === 1) return { ok: hits[0] }
|
|
@@ -1412,6 +1500,9 @@ export function resolveSession(selector: string, sessions: Session[]): Resolved
|
|
|
1412
1500
|
}
|
|
1413
1501
|
|
|
1414
1502
|
const trunc = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '\u2026' : s)
|
|
1503
|
+
// the board table's NOTE display cap \u2014 exported so the declaration echo (cli.ts) can tell an author
|
|
1504
|
+
// exactly where their note gets cut, instead of the cap living as an anonymous magic number here.
|
|
1505
|
+
export const NOTE_BOARD_LIMIT = 50
|
|
1415
1506
|
// short display label per status (only close-pending differs from the status name) \u2014 used by the legend.
|
|
1416
1507
|
const SHORT: Partial<Record<DisplayStatus, string>> = { 'close-pending': 'close' }
|
|
1417
1508
|
|
|
@@ -1438,7 +1529,7 @@ export function formatTable(sessions: Session[], color = true): string {
|
|
|
1438
1529
|
const st = s.status.padEnd(13)
|
|
1439
1530
|
const merges = (s.merges ? `\u00d7${s.merges}` : '').padEnd(4)
|
|
1440
1531
|
const prompt = c('90', (s.promptPreview ? trunc(s.promptPreview, 40) : '').padEnd(42)) // what it was asked to do
|
|
1441
|
-
const note = s.note ? c('90', trunc(s.note,
|
|
1532
|
+
const note = s.note ? c('90', trunc(s.note, NOTE_BOARD_LIMIT)) : ''
|
|
1442
1533
|
return ` ${c(code, g)} ${c(code, st)} ${name} ${c('90', s.id.slice(0, 8))} ${merges}${prompt}${note}`
|
|
1443
1534
|
})
|
|
1444
1535
|
return [c('1', `SpexCode sessions (${sessions.length})`), header, ...rows, statusLegend(color)].join('\n')
|
|
@@ -1571,7 +1662,7 @@ export async function sendKeys(id: string, text: string, from?: string): Promise
|
|
|
1571
1662
|
// socket. Two channels, two jobs: the socket INJECTS a whole prompt (text + submit), which can drive the
|
|
1572
1663
|
// agent's normal prompt but CANNOT navigate an interactive TUI select menu (e.g. `/model`'s list — ↑/↓ to
|
|
1573
1664
|
// move, ←/→ to adjust, Enter to set, `s` for this-session, Esc to cancel). When the agent is in that
|
|
1574
|
-
// keystroke-navigation state its input box is replaced by the menu, so the dashboard's
|
|
1665
|
+
// keystroke-navigation state its input box is replaced by the menu, so the dashboard's type mode forwards
|
|
1575
1666
|
// each key here in real time. send-keys is exactly right for single raw keys: named keys map to tmux's own
|
|
1576
1667
|
// key names; a single printable char is sent literally (`-l`) so tmux doesn't reinterpret it. The dashboard
|
|
1577
1668
|
// also drives the agent with MODIFIER COMBOS — a terminal's three modifiers carried as a `C-`/`M-`/`S-`
|
package/spec-cli/src/specs.ts
CHANGED
|
@@ -149,19 +149,31 @@ async function rawsAsync(): Promise<Raw[]> {
|
|
|
149
149
|
return acc
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
export function specOwners(file: string): { id: string; desc: string }[] {
|
|
152
|
+
// the claim rule shared by both relations (exact path, dir-prefix, or *-glob). See [[governed-related]].
|
|
153
|
+
function claimMatcher(file: string): (cf: string) => boolean {
|
|
155
154
|
const rel = file.startsWith('/') ? relative(ROOT, file) : file
|
|
156
|
-
|
|
155
|
+
return (cf: string): boolean => {
|
|
157
156
|
if (cf === rel) return true
|
|
158
157
|
if (rel.startsWith(cf.replace(/\/+$/, '') + '/')) return true
|
|
159
158
|
if (cf.includes('*')) return new RegExp('^' + cf.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$').test(rel)
|
|
160
159
|
return false
|
|
161
160
|
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// spec node(s) that GOVERN a file (frontmatter `code:` — source of truth, drives drift/yatsu); reads only
|
|
164
|
+
// frontmatter (cheap, no git) so a per-edit hook can call it.
|
|
165
|
+
export function specOwners(file: string): { id: string; desc: string }[] {
|
|
166
|
+
const claims = claimMatcher(file)
|
|
162
167
|
return raws().filter((r) => list(r.fm.code).some(claims)).map((r) => ({ id: r.id, desc: str(r.fm.desc) }))
|
|
163
168
|
}
|
|
164
169
|
|
|
170
|
+
// spec node(s) that REFERENCE a file (frontmatter `related:` — carries coverage, never drift/yatsu):
|
|
171
|
+
// [[governed-related]]'s other half, same claim rule, same cheap frontmatter-only read.
|
|
172
|
+
export function specRelated(file: string): { id: string; desc: string }[] {
|
|
173
|
+
const claims = claimMatcher(file)
|
|
174
|
+
return raws().filter((r) => list(r.fm.related).some(claims)).map((r) => ({ id: r.id, desc: str(r.fm.desc) }))
|
|
175
|
+
}
|
|
176
|
+
|
|
165
177
|
// memo fileDiffAt by (version sha + spec.md path) — a commit's patch is immutable. Keyed by path too: one
|
|
166
178
|
// commit can patch several nodes' spec.md. `{hash:'',patch:''}` for an unversioned node (no git call).
|
|
167
179
|
const diffCache = new Map<string, { hash: string; patch: string }>()
|