spexcode 0.2.1 → 0.2.2
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 +147 -103
- package/README.zh-CN.md +123 -88
- 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 +214 -62
- package/spec-cli/src/client.ts +47 -9
- package/spec-cli/src/{self.ts → doctor.ts} +26 -25
- package/spec-cli/src/guide.ts +63 -11
- package/spec-cli/src/harness.ts +48 -19
- 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 +4 -4
- 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 +149 -62
- 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-dashboard/dist/assets/Dashboard-BwZ2KzxB.js +27 -0
- package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
- package/spec-dashboard/dist/assets/EvalsPage-DV75EdP4.js +3 -0
- package/spec-dashboard/dist/assets/FoldToggle-GwE0-k1d.js +1 -0
- package/spec-dashboard/dist/assets/IssuesPage-B17pnl9I.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-WEZbR8M1.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-Sh8kHpnj.js +71 -0
- package/spec-dashboard/dist/assets/SessionWindow-EzFq-hLG.js +9 -0
- package/spec-dashboard/dist/assets/Settings-Dgtg-Xb9.js +1 -0
- package/spec-dashboard/dist/assets/index-CCmnCbKS.css +1 -0
- package/spec-dashboard/dist/assets/index-Dd0_U5rk.js +41 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-yatsu/src/cli.ts +89 -15
- package/spec-yatsu/src/scenariofresh.ts +100 -30
- package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +0 -32
- package/spec-dashboard/dist/assets/index-DehTZ-h9.js +0 -145
package/spec-cli/src/layout.ts
CHANGED
|
@@ -27,14 +27,14 @@ type Config = {
|
|
|
27
27
|
}
|
|
28
28
|
sessions?: {
|
|
29
29
|
maxActive?: number // concurrency cap: max agents AUTONOMOUSLY PROGRESSING at once (default 8; see sessions.ts maxActive)
|
|
30
|
-
claudeCmd?: string // the
|
|
31
|
-
codexCmd?: string // the
|
|
30
|
+
claudeCmd?: string // the built-in `claude` launcher command (default 'claude --dangerously-skip-permissions'); env SPEXCODE_CLAUDE_CMD overrides. A host-specific ABS path belongs in the gitignored spexcode.local.json, not here.
|
|
31
|
+
codexCmd?: string // the built-in `codex` launcher command (default 'codex --yolo'); env SPEXCODE_CODEX_CMD overrides. Same host-path rule.
|
|
32
32
|
// named launcher profiles: a session picks ONE by name at create time ([[launcher-select]]), fixing both
|
|
33
33
|
// its harness AND its exact launch command; the chosen NAME is persisted on the record so resume reuses the
|
|
34
34
|
// same auth. `harness` defaults to 'claude'. Host-specific `cmd`s (abs wrapper paths) belong in the
|
|
35
35
|
// gitignored spexcode.local.json — the name is portable, the cmd is a machine fact.
|
|
36
36
|
launchers?: { [name: string]: { harness?: 'claude' | 'codex'; cmd: string } }
|
|
37
|
-
defaultLauncher?: string // the launcher a create with no explicit --launcher/dropdown pick uses
|
|
37
|
+
defaultLauncher?: string // the launcher a create with no explicit --launcher/dropdown pick uses; required for no-choice creates
|
|
38
38
|
}
|
|
39
39
|
serve?: {
|
|
40
40
|
// public-exposure config for `spex serve --public` (resolved gateway-side; see [[public-mode]] / gateway.ts).
|
|
@@ -161,7 +161,7 @@ export type RawRecord = {
|
|
|
161
161
|
node: string | null; title: string | null; name: string | null; parent?: string | null
|
|
162
162
|
status: string; proposal: string | null; merges: number; note: string | null
|
|
163
163
|
sortkey: number | null; createdAt: number; harness?: string; harness_session_id?: string
|
|
164
|
-
launcher?: string // the
|
|
164
|
+
launcher?: string // the launcher profile this session was created under ([[launcher-select]]); absent/empty only on old records predating launchers
|
|
165
165
|
launch_cmd?: string // the RESOLVED base launcher command PINNED at creation, so a resume replays the EXACT launcher (and its config-dir env) that made the conversation, never a since-changed default ([[launcher-select]] resume-launcher-pin); absent → old record, fall back to the launcher name / ambient
|
|
166
166
|
}
|
|
167
167
|
// the agent's OWN session id from the environment — the only locator now that the record left the worktree.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// [[mentions]]). A thread IS an Issue whose `store` is 'local' — membership implied by WHERE the file lives,
|
|
3
3
|
// never written into it. One thread = one PLAIN markdown file at <main>/.spec/.issues/<id>.md; there is
|
|
4
4
|
// deliberately no content-kind taxonomy (a change suggestion, an annotation, a Q&A are the same mechanism —
|
|
5
|
-
// the prose says what it is). Others
|
|
5
|
+
// the prose says what it is). Others reply/discuss like an async chatroom; a supervisor drains it via
|
|
6
6
|
// `spex issues` (reading is the port's job, issues.ts — this module owns only the store + its write verbs).
|
|
7
7
|
// Because a thread file is NOT named spec.md, the spec walk never nodes it and isSpecMd ignores it —
|
|
8
8
|
// invisible to lint / drift / deriveStatus / board with ZERO exemption. The store lives on the TRUNK, not
|
|
@@ -87,10 +87,9 @@ const currentSession = (): string => envSessionId() || 'unknown'
|
|
|
87
87
|
const sleep = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
88
88
|
|
|
89
89
|
// ── file format ──────────────────────────────────────────────────────────────────────────────────────
|
|
90
|
-
// frontmatter (concern/by/status/nodes/evidence/
|
|
90
|
+
// frontmatter (concern/by/status/nodes/evidence/created) + a prose body, then any replies — each
|
|
91
91
|
// preceded by a `<!-- reply: <by> @ <iso> -->` sentinel: invisible in rendered markdown, unambiguous to
|
|
92
|
-
// parse. Only the store writes these files, so a fixed shape is safe.
|
|
93
|
-
// +1 sign is a one-line change.
|
|
92
|
+
// parse. Only the store writes these files, so a fixed shape is safe.
|
|
94
93
|
//
|
|
95
94
|
// A REMARK ([[remark-substrate]]) is a reply carrying extra state, appended to the SAME sentinel as a
|
|
96
95
|
// ` :: <space-joined k=v attrs>` tail (a plain reply has no tail → parses unchanged, backward compatible):
|
|
@@ -120,9 +119,8 @@ function parse(id: string, text: string): Issue {
|
|
|
120
119
|
store: 'local',
|
|
121
120
|
concern: fm.concern || id,
|
|
122
121
|
by: fm.by || 'unknown',
|
|
123
|
-
status: fm.status
|
|
122
|
+
status: fm.status === 'landed' ? 'landed' : 'open',
|
|
124
123
|
nodes: list(fm.nodes),
|
|
125
|
-
signers: list(fm.signers),
|
|
126
124
|
created: fm.created || '',
|
|
127
125
|
body: body.join('\n').trim(),
|
|
128
126
|
replies: replies.map((r) => ({ ...r, body: r.body.trim() })),
|
|
@@ -165,7 +163,6 @@ function serialize(p: Issue): string {
|
|
|
165
163
|
`status: ${safeScalar(p.status)}`,
|
|
166
164
|
p.nodes.length ? `nodes: ${p.nodes.join(', ')}` : '',
|
|
167
165
|
p.evidence.length ? `evidence: ${p.evidence.join(', ')}` : '',
|
|
168
|
-
p.signers.length ? `signers: ${p.signers.join(', ')}` : '',
|
|
169
166
|
`created: ${p.created}`,
|
|
170
167
|
].filter(Boolean)
|
|
171
168
|
let out = `---\n${fm.join('\n')}\n---\n\n${safeBody(p.body)}\n`
|
|
@@ -259,7 +256,7 @@ function ensureStoreMigrated(): void {
|
|
|
259
256
|
// structurally invisible to spec-lint, and the commit is provably store-only (one .spec/.issues/ path), so the
|
|
260
257
|
// pre-commit gate would only pass anyway — running it just burns seconds (tsx cold-start) holding the lock.
|
|
261
258
|
// A NO-CHANGE write is idempotent SUCCESS, never a failure: when the serialized bytes already equal the
|
|
262
|
-
// stored state (a duplicate
|
|
259
|
+
// stored state (a duplicate close — the store IS the requested state), `git commit` would exit 1
|
|
263
260
|
// 'nothing to commit', so we detect the no-op after `add` (the staged path equals HEAD → status is silent)
|
|
264
261
|
// and skip the commit. Returns whether the store actually changed, so a caller can say 'already <state>'.
|
|
265
262
|
// MUST run while holding withStoreLock — it is the write half of a locked read-modify-write; its callers
|
|
@@ -286,7 +283,7 @@ function writeStoreFile(p: Issue, message: string): boolean {
|
|
|
286
283
|
}
|
|
287
284
|
|
|
288
285
|
// prepare (a FRESH read-modify or a new thread) + write + commit a single store file, all under the store
|
|
289
|
-
// lock so the read-modify-write is atomic. prepare() runs INSIDE the lock, so a reply/
|
|
286
|
+
// lock so the read-modify-write is atomic. prepare() runs INSIDE the lock, so a reply/close reads the
|
|
290
287
|
// current thread, never a stale copy. A pre-rename store migrates first (before the lock — ensure takes it).
|
|
291
288
|
// `changed: false` = the store already held exactly this state (see writeStoreFile) — success, nothing committed.
|
|
292
289
|
function commitStore(message: string, prepare: () => Issue): { issue: Issue; changed: boolean } {
|
|
@@ -312,7 +309,6 @@ export function openIssue(concern: string, opts: { nodes?: string[]; body?: stri
|
|
|
312
309
|
by: opts.author || currentSession(),
|
|
313
310
|
status: 'open',
|
|
314
311
|
nodes,
|
|
315
|
-
signers: [],
|
|
316
312
|
created: new Date().toISOString(),
|
|
317
313
|
body: (opts.body || `(no detail given — ${concern})`).trim(),
|
|
318
314
|
replies: [],
|
|
@@ -395,25 +391,13 @@ export async function postLocalIssue(
|
|
|
395
391
|
return { thread, outcomes }
|
|
396
392
|
}
|
|
397
393
|
|
|
398
|
-
export function
|
|
399
|
-
const
|
|
400
|
-
return commitStore(`issue(${id}): signed by ${by}`, () => {
|
|
394
|
+
export function closeLocalIssue(id: string): { status: 'landed'; already: boolean } {
|
|
395
|
+
const { changed } = commitStore(`issue(${id}): close`, () => {
|
|
401
396
|
const p = loadOne(id)
|
|
402
|
-
|
|
403
|
-
return p
|
|
404
|
-
}).issue.signers
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
const RESOLUTIONS = new Set(['accepted', 'rejected', 'landed'])
|
|
408
|
-
// `already` = the thread was in that state before this call — an idempotent success, nothing committed.
|
|
409
|
-
export function resolve(id: string, as: string): { as: string; already: boolean } {
|
|
410
|
-
if (!RESOLUTIONS.has(as)) throw new Error(`resolution must be one of: ${[...RESOLUTIONS].join(' | ')}`)
|
|
411
|
-
const { changed } = commitStore(`issue(${id}): resolve ${as}`, () => {
|
|
412
|
-
const p = loadOne(id)
|
|
413
|
-
p.status = as
|
|
397
|
+
p.status = 'landed'
|
|
414
398
|
return p
|
|
415
399
|
})
|
|
416
|
-
return {
|
|
400
|
+
return { status: 'landed', already: !changed }
|
|
417
401
|
}
|
|
418
402
|
|
|
419
403
|
// ── remarks ([[remark-substrate]]) ──────────────────────────────────────────────────────────────────
|
|
@@ -439,7 +423,7 @@ function findOrCreateEvalThread(node: string, scenario: string, author: string):
|
|
|
439
423
|
if (existing) return existing
|
|
440
424
|
const p: Issue = {
|
|
441
425
|
id: uniqueId(concern), store: 'local', concern, by: author, status: 'open',
|
|
442
|
-
nodes: [node],
|
|
426
|
+
nodes: [node], created: new Date().toISOString(),
|
|
443
427
|
body: `Remarks on the \`${scenario}\` eval of [[${node}]].`, replies: [], evidence: [],
|
|
444
428
|
}
|
|
445
429
|
writeStoreFile(p, `issue: ${concern}`)
|
|
@@ -479,16 +463,18 @@ function parseRemarkRef(ref: string): { id: string; rid: string } {
|
|
|
479
463
|
return { id: ref.slice(0, i), rid: ref.slice(i + 1) }
|
|
480
464
|
}
|
|
481
465
|
|
|
482
|
-
// resolve a remark (R3): a DELIBERATE call
|
|
483
|
-
//
|
|
484
|
-
// MONOTONIC (no un-resolve — a regression is a NEW remark).
|
|
466
|
+
// resolve a remark (R3): a DELIBERATE call by a SECOND PARTY — never the author (no self-resolve: an
|
|
467
|
+
// identity comparison, so the dashboard's `human` cannot resolve a human-authored remark either), and
|
|
468
|
+
// MONOTONIC (no un-resolve — a regression is a NEW remark). The resolver's identity is derived by the
|
|
469
|
+
// SURFACE ([[remark-substrate]] LAW L): a governed session id from the CLI, the `human` sentinel from the
|
|
470
|
+
// dashboard — both are real second parties, so the same rule runs on both. `by` is the resolving party.
|
|
485
471
|
export function resolveRemark(ref: string, by: string): { thread: Issue; rid: string } {
|
|
486
472
|
const { id, rid } = parseRemarkRef(ref)
|
|
487
473
|
const { issue: thread } = commitStore(`remark(${id}#${rid}): resolved by ${by}`, () => {
|
|
488
474
|
const p = loadOne(id)
|
|
489
475
|
const r = p.replies.find((x) => x.rid === rid)
|
|
490
476
|
if (!r) throw new Error(`no remark '${ref}' in that thread`)
|
|
491
|
-
if (!by || by === '
|
|
477
|
+
if (!by || by === 'unknown') throw new Error(`resolve needs a real identity (got '${by || 'none'}'): run it under a governed session, or from the dashboard (its actor is 'human')`)
|
|
492
478
|
if (r.resolved) throw new Error(`remark '${ref}' is already resolved — monotonic: a regression is a NEW remark, never an un-resolve`)
|
|
493
479
|
if (r.by === by) throw new Error(`refusing to self-resolve '${ref}': the author (${by}) may not resolve their own remark — resolve is a second party's deliberate judgment`)
|
|
494
480
|
r.resolved = true; r.resolvedBy = by; r.resolvedAt = new Date().toISOString()
|
|
@@ -525,16 +511,16 @@ export function nudge(node: string): string {
|
|
|
525
511
|
'── issues ─────────────────────────────────────────────────────────',
|
|
526
512
|
`Your work (${node || 'this node'}) just landed. Two issue checks before you close:`,
|
|
527
513
|
'',
|
|
528
|
-
'1. CLOSE what you finished. An issue whose work just landed is
|
|
514
|
+
'1. CLOSE what you finished. An issue whose work just landed is closed, not',
|
|
529
515
|
' left open — the open set is the OUTSTANDING work, so a stale open reads as a',
|
|
530
|
-
' lie: spex issues --store local then spex issues
|
|
516
|
+
' lie: spex issues --store local then spex issues close <id>',
|
|
531
517
|
'',
|
|
532
518
|
'2. RECORD only what OUTLIVES this task — a concern you are NOT acting on now:',
|
|
533
519
|
' an off-mainline smell / awkward boundary / wish, or a trivial-but-must-not-',
|
|
534
520
|
" forget to-do that doesn't earn a spec node. NOT a bug tracker (that is the",
|
|
535
521
|
' spec graph + the forge), NOT your assigned task or a fix you are about to',
|
|
536
522
|
' make — those need no issue. Only the taste that would otherwise evaporate:',
|
|
537
|
-
' spex issues # read first —
|
|
523
|
+
' spex issues # read first — reply if already raised',
|
|
538
524
|
' spex issues open "<concern>" [--node <id>] # else open one',
|
|
539
525
|
'A supervisor drains the store later. (Advisory — skip if nothing is owed.)',
|
|
540
526
|
'───────────────────────────────────────────────────────────────────',
|
|
@@ -547,7 +533,7 @@ export function nudge(node: string): string {
|
|
|
547
533
|
// still-open local threads THIS session touched (authored or replied; eval `eval: <node> · <scenario>`
|
|
548
534
|
// containers excluded — they host remarks and outlive every session by design) and prints NOTHING when the
|
|
549
535
|
// session owes nothing, when the feature is OFF, or when there is no session identity. Some issues rightly
|
|
550
|
-
// outlive their session (a taste concern waiting for the drain), so the ask is
|
|
536
|
+
// outlive their session (a taste concern waiting for the drain), so the ask is close OR say why it stays
|
|
551
537
|
// open — never a forced close.
|
|
552
538
|
export function closeoutNudge(sessionId: string | null | undefined): string {
|
|
553
539
|
if (!sessionId || sessionId === 'unknown' || !issuesEnabled()) return ''
|
|
@@ -555,7 +541,7 @@ export function closeoutNudge(sessionId: string | null | undefined): string {
|
|
|
555
541
|
t.status === 'open' && !EVAL_CONCERN_RE.test(t.concern) &&
|
|
556
542
|
(t.by === sessionId || t.replies.some((r) => r.by === sessionId)))
|
|
557
543
|
if (!mine.length) return ''
|
|
558
|
-
return `\n\nIssue closeout — ${mine.length} still-open local issue(s) you touched (opened or replied): ${mine.map((t) => t.id).join(', ')}. For each,
|
|
544
|
+
return `\n\nIssue closeout — ${mine.length} still-open local issue(s) you touched (opened or replied): ${mine.map((t) => t.id).join(', ')}. For each, close it now if its work is finished (\`spex issues close <id>\`), or reply why it should stay open past this session (\`spex issues reply <id> --body "<why>"\`). Some issues rightly outlive their session — this is a reminder to sweep, not a gate.`
|
|
559
545
|
}
|
|
560
546
|
|
|
561
547
|
// ───────────────────────── CLI ─────────────────────────
|
|
@@ -563,7 +549,7 @@ const fl = (args: string[], name: string): string | undefined => {
|
|
|
563
549
|
const i = args.indexOf(`--${name}`)
|
|
564
550
|
return i >= 0 ? args[i + 1] : undefined
|
|
565
551
|
}
|
|
566
|
-
const VALUE_FLAGS = new Set(['--node', '--body', '--
|
|
552
|
+
const VALUE_FLAGS = new Set(['--node', '--body', '--evidence', '--scenario', '--code-sha', '--store'])
|
|
567
553
|
// bare positionals, skipping flags + their values.
|
|
568
554
|
function bare(args: string[]): string[] {
|
|
569
555
|
const out: string[] = []
|
|
@@ -585,9 +571,10 @@ const repeated = (args: string[], name: string): string[] =>
|
|
|
585
571
|
args.flatMap((a, i) => (a === `--${name}` ? [args[i + 1]] : [])).filter(Boolean) as string[]
|
|
586
572
|
|
|
587
573
|
// the local-issue WRITE verbs, folded into the one issues surface (`spex issues <sub>` — the read routes
|
|
588
|
-
// here when its first positional is a write sub): open "<concern>" [--
|
|
589
|
-
// [--body -|text], the id-based reply
|
|
590
|
-
// is internal (the post-merge hook's caller). Store is a property of the issue,
|
|
574
|
+
// here when its first positional is a write sub): open "<concern>" [--store local|<host>] [--node id…]
|
|
575
|
+
// [--evidence hash…] [--body -|text], the id-based reply, and the feature toggle
|
|
576
|
+
// on | off | status. `nudge` is internal (the post-merge hook's caller). Store is a property of the issue,
|
|
577
|
+
// never a second command — open and reply route by it (issues.ts createIssue/replyIssue).
|
|
591
578
|
export async function runIssueWrite(args: string[]): Promise<number> {
|
|
592
579
|
const sub = args[0]
|
|
593
580
|
try {
|
|
@@ -611,35 +598,32 @@ export async function runIssueWrite(args: string[]): Promise<number> {
|
|
|
611
598
|
if (s) console.log(` ${s}`)
|
|
612
599
|
return 0
|
|
613
600
|
}
|
|
614
|
-
if (sub === 'sign') {
|
|
615
|
-
const id = bare(args.slice(1))[0]
|
|
616
|
-
if (!id) { console.error('usage: spex issues sign <issue-id>'); return 2 }
|
|
617
|
-
console.log(`signed '${id}' — signers: ${sign(id).join(', ')}`)
|
|
618
|
-
return 0
|
|
619
|
-
}
|
|
620
|
-
if (sub === 'resolve') {
|
|
621
|
-
const id = bare(args.slice(1))[0]
|
|
622
|
-
const as = fl(args, 'as')
|
|
623
|
-
if (!id || !as) { console.error('usage: spex issues resolve <issue-id> --as accepted|rejected|landed'); return 2 }
|
|
624
|
-
const r = resolve(id, as)
|
|
625
|
-
console.log(r.already ? `'${id}' already ${r.as} — store unchanged` : `resolved '${id}' → ${r.as}`)
|
|
626
|
-
return 0
|
|
627
|
-
}
|
|
628
601
|
if (sub === 'nudge') {
|
|
629
602
|
// internal: the post-merge hook calls this to print the (toggle-aware) nudge for a merged node.
|
|
630
603
|
const text = nudge(bare(args.slice(1))[0] || '')
|
|
631
604
|
if (text) console.log(text)
|
|
632
605
|
return 0
|
|
633
606
|
}
|
|
634
|
-
// `open`: start a new
|
|
607
|
+
// `open`: start a new issue — STORE-ROUTED through the one creation port ([[issues]] createIssue, the
|
|
608
|
+
// same routine POST /api/issues runs): default local commits to the trunk store; `--store <host>`
|
|
609
|
+
// creates the real forge issue through that store's driver (no promote round-trip when the concern is
|
|
610
|
+
// born forge-visible). The concern is the bare positional(s) after the sub.
|
|
635
611
|
const concern = sub === 'open' ? bare(args.slice(1)).join(' ').trim() : ''
|
|
636
612
|
if (!concern) {
|
|
637
|
-
console.error('usage: spex issues open "<concern>" [--node <id>…] [--evidence <hash>…] [--body -|<text>]\n spex issues reply|
|
|
613
|
+
console.error('usage: spex issues open "<concern>" [--store local|<host>] [--node <id>…] [--evidence <hash>…] [--body -|<text>]\n spex issues reply|close|promote <issue-id> … | on|off|status')
|
|
638
614
|
return 2
|
|
639
615
|
}
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
616
|
+
const r = await (await import('./issues.js')).createIssue(concern, {
|
|
617
|
+
store: fl(args, 'store'),
|
|
618
|
+
nodes: repeated(args, 'node'),
|
|
619
|
+
body: readBody(args),
|
|
620
|
+
evidence: repeated(args, 'evidence'),
|
|
621
|
+
})
|
|
622
|
+
const re = r.nodes.length ? ` (re: ${r.nodes.join(', ')})` : ''
|
|
623
|
+
console.log(r.store === 'local'
|
|
624
|
+
? `opened '${r.id}'${re} — committed to the local issue store; read it with \`spex issues\``
|
|
625
|
+
: `opened '${r.id}' on ${r.store}${re} — ${r.url}`)
|
|
626
|
+
const s = summarize(r.outcomes)
|
|
643
627
|
if (s) console.log(` ${s}`)
|
|
644
628
|
return 0
|
|
645
629
|
} catch (e) {
|
|
@@ -650,7 +634,7 @@ export async function runIssueWrite(args: string[]): Promise<number> {
|
|
|
650
634
|
|
|
651
635
|
// the first positionals runIssueWrite handles — the issues command routes these to it, everything else is
|
|
652
636
|
// the read. Exported so the router and the runner can never drift.
|
|
653
|
-
export const ISSUE_WRITE_SUBS = new Set(['open', 'reply', '
|
|
637
|
+
export const ISSUE_WRITE_SUBS = new Set(['open', 'reply', 'on', 'off', 'status', 'nudge'])
|
|
654
638
|
|
|
655
639
|
// ── remark CLI ([[remark-substrate]]) — CLI-first: the whole author→resolve→retract loop, no server needed ──
|
|
656
640
|
// `spex remark <host> --body -|<text> [--code-sha <sha>] [--scenario <name>] [--evidence <hash>…]`
|
|
@@ -9,7 +9,6 @@ import { git } from './git.js'
|
|
|
9
9
|
import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
|
|
10
10
|
import { resolveHarnessTargets, partitionHarnesses } from './harness-select.js'
|
|
11
11
|
import { emitPlugin, cleanPlugin, pluginBundleDir, pluginVersion } from './plugin-harness.js'
|
|
12
|
-
import { tsxBin } from './tsx-bin.js'
|
|
13
12
|
|
|
14
13
|
// @@@ materialize - the "pay-per-change" node step (≈0.85s) the cheap shell gate invokes ONLY when the
|
|
15
14
|
// .config content-hash moved. It renders the spec tree's surface nodes into the flat artifacts each
|
|
@@ -26,7 +25,10 @@ import { tsxBin } from './tsx-bin.js'
|
|
|
26
25
|
|
|
27
26
|
const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
|
|
28
27
|
const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
|
|
29
|
-
|
|
28
|
+
// the ONE spex entry: the launcher (bin/spex.mjs), never a raw `tsx cli.ts` pair — the launcher owns tsx
|
|
29
|
+
// resolution AND the mid-merge guard (a conflicted source tree degrades to one line + exit 75, not an
|
|
30
|
+
// esbuild stacktrace), so every hook-baked callback inherits both.
|
|
31
|
+
const SPEX = join(PKG, 'bin', 'spex.mjs')
|
|
30
32
|
// the manifest + content-hash marker render into the GLOBAL per-project store (layout.runtimeRoot), NOT the
|
|
31
33
|
// worktree — the worktree keeps zero SpexCode-rendered runtime; only the harness-discovered contract files +
|
|
32
34
|
// shims (which the harness must find in-tree) are written under proj below.
|
package/spec-cli/src/mentions.ts
CHANGED
|
@@ -13,6 +13,18 @@ const NODE_RE = /\[\[([^\]\s]+)\]\]/g
|
|
|
13
13
|
|
|
14
14
|
const uniq = (xs: string[]): string[] => [...new Set(xs)]
|
|
15
15
|
|
|
16
|
+
// ── CLI sigil tolerance ───────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
// In FREE TEXT the sigils are required — they are what marks a reference apart from prose. In a CLI
|
|
18
|
+
// ARGUMENT the whole token IS the reference, so the sigil is optional: `spex review @graph` ≡
|
|
19
|
+
// `spex review graph`, `spex yatsu eval [[cli-surface]]` ≡ `spex yatsu eval cli-surface`. One shared strip,
|
|
20
|
+
// applied by the session-selector matcher and every node-arg read site, so the habit a user learns in the
|
|
21
|
+
// dashboard's input boxes works verbatim on the CLI — never a second grammar to learn.
|
|
22
|
+
export function stripRefSigil(token: string): string {
|
|
23
|
+
const wrapped = /^\[\[(.*)\]\]$/.exec(token)
|
|
24
|
+
if (wrapped) return wrapped[1]
|
|
25
|
+
return token.startsWith('@') ? token.slice(1) : token
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
export function parseMentions(text: string): { actors: string[]; nodes: string[] } {
|
|
17
29
|
const actors: string[] = []
|
|
18
30
|
const nodes: string[] = []
|
|
@@ -47,6 +59,15 @@ export function resolveActors(tokens: string[], sessions: ActorSession[]): Resol
|
|
|
47
59
|
})
|
|
48
60
|
}
|
|
49
61
|
|
|
62
|
+
// Any spawn's parent = its originator ([[session-nesting]]): the `@new` worker nests under the session that
|
|
63
|
+
// wrote the mention — but ONLY when the author IS a real board session id. A dashboard 'human', a CLI
|
|
64
|
+
// 'unknown', or a forge login resolves to no session → null → a top-level worker, never a phantom nest.
|
|
65
|
+
// Exact id match only (lineage is provenance, not addressing — no prefix/name resolution), any liveness:
|
|
66
|
+
// a parent that later closes is auto-promoted at read time by the derived tree.
|
|
67
|
+
export function spawnParent(author: string, sessions: { id: string }[]): string | null {
|
|
68
|
+
return sessions.some((s) => s.id === author) ? author : null
|
|
69
|
+
}
|
|
70
|
+
|
|
50
71
|
// ── dispatch (integration) ─────────────────────────────────────────────────────────────────────────────
|
|
51
72
|
export type DispatchOutcome = { token: string; result: 'sent' | 'spawned' | 'offline' | 'unresolved' | 'failed'; detail?: string; note?: string }
|
|
52
73
|
|
|
@@ -92,7 +113,7 @@ export async function dispatchMentions(
|
|
|
92
113
|
// deliberate audit/re-measure), but the worker prompt carries the status and the outcome line warns.
|
|
93
114
|
const settled = ctx.status && ctx.status !== 'open' ? ctx.status : undefined
|
|
94
115
|
try {
|
|
95
|
-
const s = await newSession(ctx.node, newWorkerPrompt(ctx.threadId, ctx.node, ctx.author, text, ctx.status))
|
|
116
|
+
const s = await newSession(ctx.node, newWorkerPrompt(ctx.threadId, ctx.node, ctx.author, text, ctx.status), spawnParent(ctx.author, sessions))
|
|
96
117
|
out.push({ token: r.token, result: 'spawned', detail: s.id, ...(settled ? { note: `thread ${settled}` } : {}) })
|
|
97
118
|
} catch (e) { out.push({ token: r.token, result: 'failed', detail: e instanceof Error ? e.message : String(e) }) }
|
|
98
119
|
continue
|
|
@@ -17,6 +17,11 @@ type Pending = (lines: Buffer[]) => void
|
|
|
17
17
|
type Bridge = {
|
|
18
18
|
id: string; pty: IPty; cols: number; rows: number; prewarmed: boolean
|
|
19
19
|
repaintToken: number
|
|
20
|
+
// the size VOTE: whether this client currently asserts window size. Only a bridge some viewer has SIZED
|
|
21
|
+
// (visible connect / resize — never a hidden board-load connect) votes; all others carry tmux's
|
|
22
|
+
// ignore-size client flag and are size-NEUTRAL, so a foreign backend instance sharing the socket can
|
|
23
|
+
// never move a window a human is watching (see setVote).
|
|
24
|
+
voting: boolean
|
|
20
25
|
// control-mode parser state: an incomplete-line BYTE buffer, the in-flight command block (%begin..%end) with
|
|
21
26
|
// its command number, a FIFO of one resolver per command sent (tmux answers in order), and the last
|
|
22
27
|
// %layout-change size so a repaint knows the pane already converged and needn't wait for the event.
|
|
@@ -269,10 +274,15 @@ function ensureBridge(id: string, prewarm = false): Bridge | null {
|
|
|
269
274
|
env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' } as Record<string, string>,
|
|
270
275
|
})
|
|
271
276
|
} catch { return null }
|
|
272
|
-
b = { id, pty: p, cols, rows, prewarmed: prewarm, repaintToken: 0, buf: Buffer.alloc(0), block: null, blockNum: '', cmdQ: [], needsFull: true }
|
|
277
|
+
b = { id, pty: p, cols, rows, prewarmed: prewarm, repaintToken: 0, voting: false, buf: Buffer.alloc(0), block: null, blockNum: '', cmdQ: [], needsFull: true }
|
|
273
278
|
bridges.set(id, b)
|
|
274
279
|
const bx = b
|
|
275
280
|
p.onData((d) => feed(bx, d as unknown as Buffer)) // encoding:null → d is a Buffer (typings say string)
|
|
281
|
+
// every client starts size-NEUTRAL: flag it before any refresh-client -C can enter the FIFO (a bare
|
|
282
|
+
// attach asserts nothing — measured: only -C moves a window — so there is no pre-flag race). Sent as a
|
|
283
|
+
// stream command, not an attach-time `-f`, so a pre-3.2 tmux degrades to a harmless in-stream %error
|
|
284
|
+
// (old size-fight behaviour) instead of a client that cannot attach at all.
|
|
285
|
+
void command(b, 'refresh-client -f ignore-size')
|
|
276
286
|
// attach-session exited (session died or we detached): drop the bridge, unblock any awaiting command AND any
|
|
277
287
|
// %layout-change waiter (no timer backs it now, so a bridge that dies mid-convergence MUST resolve its
|
|
278
288
|
// waiter or the awaiting repaint hangs), and if viewers remain kick a reconcile to re-bind fast.
|
|
@@ -285,6 +295,19 @@ function ensureBridge(id: string, prewarm = false): Bridge | null {
|
|
|
285
295
|
return b
|
|
286
296
|
}
|
|
287
297
|
|
|
298
|
+
// flip this client's size vote — the arbitration that makes ANY number of backend instances share one tmux
|
|
299
|
+
// socket without size-fights. tmux's `ignore-size` client flag means "yield while any unflagged client is
|
|
300
|
+
// attached" (server-wide, and void when ALL clients are flagged — then everyone counts again, which is what
|
|
301
|
+
// keeps the single-backend warm hold working). So: a bridge votes (unflags) from the moment a viewer SIZES
|
|
302
|
+
// it, and goes neutral again when its last viewer leaves. A suppressed refresh-client -C still receives its
|
|
303
|
+
// one %layout-change (measured — announcing the window's real size), so the deterministic resize wait and
|
|
304
|
+
// the accept-any-announced-size rule need no change on either side of the flag.
|
|
305
|
+
function setVote(b: Bridge, on: boolean): void {
|
|
306
|
+
if (b.voting === on) return
|
|
307
|
+
b.voting = on
|
|
308
|
+
void command(b, `refresh-client -f ${on ? '!' : ''}ignore-size`)
|
|
309
|
+
}
|
|
310
|
+
|
|
288
311
|
function killBridge(id: string): void {
|
|
289
312
|
const b = bridges.get(id)
|
|
290
313
|
if (!b) return
|
|
@@ -312,6 +335,7 @@ export function attachViewer(id: string, v: Viewer, initialSize?: { cols: number
|
|
|
312
335
|
if (!b) return false // spawn failed → caller closes the socket → detachViewer prunes this subscriber
|
|
313
336
|
b.needsFull = true // a (re)connecting viewer's xterm is blank / just reset → its first frame must be FULL
|
|
314
337
|
if (initialSize && initialSize.cols > 0 && initialSize.rows > 0) {
|
|
338
|
+
setVote(b, true) // a sized viewer → this client asserts window size
|
|
315
339
|
applySize(b, initialSize.cols, initialSize.rows) // resize-then-repaint at the client's true size
|
|
316
340
|
}
|
|
317
341
|
// else HIDDEN connect (0×0, no size): paint nothing now — the first frame is driven purely by the client's
|
|
@@ -455,6 +479,7 @@ export function detachViewer(id: string, v: Viewer): void {
|
|
|
455
479
|
subscribers.delete(id)
|
|
456
480
|
const b = bridges.get(id)
|
|
457
481
|
if (b && !b.prewarmed) killBridge(id)
|
|
482
|
+
else if (b) setVote(b, false) // kept warm → back to size-neutral: an unwatched client must not out-vote a watched one
|
|
458
483
|
}
|
|
459
484
|
// a viewer fitted xterm → record the size as the last-known fit (even with no bridge yet, for pre-warm)
|
|
460
485
|
// and resize the shared client. Repaints even on an unchanged size (a reconnect needs the frame). `full` (a
|
|
@@ -464,7 +489,7 @@ export function resizeBridge(id: string, cols: number, rows: number, full = fals
|
|
|
464
489
|
if (!(cols > 0 && rows > 0)) return
|
|
465
490
|
lastFit.set(id, { cols, rows }); lastFitAny = { cols, rows }
|
|
466
491
|
const b = bridges.get(id)
|
|
467
|
-
if (b) { if (full) b.needsFull = true; applySize(b, cols, rows) }
|
|
492
|
+
if (b) { if (full) b.needsFull = true; setVote(b, true); applySize(b, cols, rows) }
|
|
468
493
|
}
|
|
469
494
|
// resize the client + repaint WITHOUT recording a viewer fit — the primitive both a real resize and the
|
|
470
495
|
// supervisor's pre-sizing share, so the supervisor can't clobber lastFit/lastFitAny with a stale value.
|
|
@@ -482,12 +507,22 @@ async function reconcileOnce(): Promise<void> {
|
|
|
482
507
|
if (!(await alive(s.id))) continue
|
|
483
508
|
live.add(s.id)
|
|
484
509
|
// already ours → keep warm and resize a stale warm bridge to the last-known viewer size off-screen,
|
|
485
|
-
// so a first open finds the pane already at its size.
|
|
510
|
+
// so a first open finds the pane already at its size. TWO staleness guards, by vote state: a NEUTRAL
|
|
511
|
+
// client's hold is suppressed while any sized viewer votes on the socket, so its own client size would
|
|
512
|
+
// read "converged" after one suppressed attempt and wedge the hold forever — compare against the
|
|
513
|
+
// WINDOW's real size (lastLayout) instead, retrying each tick (one suppressed no-op command) until the
|
|
514
|
+
// first tick after the socket goes quiet: deferred, not lost. A VOTING client keeps the client-size
|
|
515
|
+
// guard: its -C lands, and when two voting instances watch ONE session the window is genuinely
|
|
516
|
+
// contended — latest assert wins and STOPS (a window-truth guard would re-assert every tick and turn
|
|
517
|
+
// that contention into a visible size ping-pong war).
|
|
486
518
|
const existing = bridges.get(s.id)
|
|
487
519
|
if (existing) {
|
|
488
520
|
existing.prewarmed = true
|
|
489
521
|
const want = prewarmSize(s.id)
|
|
490
|
-
|
|
522
|
+
const stale = existing.voting
|
|
523
|
+
? want.cols !== existing.cols || want.rows !== existing.rows
|
|
524
|
+
: existing.lastLayout !== `${want.cols}x${want.rows}`
|
|
525
|
+
if (stale) applySize(existing, want.cols, want.rows)
|
|
491
526
|
continue
|
|
492
527
|
}
|
|
493
528
|
// no bridge for a live session: viewers waiting → re-bind and repaint (nothing else re-arms an idle
|
package/spec-cli/src/ranker.ts
CHANGED
|
@@ -10,7 +10,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
|
|
12
12
|
// the content words. Deliberately small and general — NOT tuned to any benchmark; just the function words a
|
|
13
|
-
// natural-language query carries that match nothing meaningful.
|
|
13
|
+
// natural-language query carries that match nothing meaningful. Quantifiers (many, several, same, too…) are
|
|
14
|
+
// NOT stopped: in this corpus they are load-bearing ("too many owners" IS the multi-ownership concept —
|
|
15
|
+
// dropping them measurably breaks that reach).
|
|
14
16
|
const STOP = new Set([
|
|
15
17
|
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'is', 'it', 'its', 'as', 'at', 'by', 'for',
|
|
16
18
|
'how', 'does', 'do', 'what', 'which', 'that', 'this', 'these', 'those', 'with', 'from', 'into', 'are',
|
|
@@ -32,11 +34,24 @@ function words(text: string): string[] {
|
|
|
32
34
|
return text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
// light query-side stem for prefix matching: drop a trailing plural 's' (len≥4, not 'ss') then a mute 'e'
|
|
38
|
+
// (len≥5) — so `sessions` prefix-reaches `session`, `merge`→`merg` reaches `merging`, `declare`→`declar`
|
|
39
|
+
// reaches `declaration`. Without the e-drop the spec's promised merge↔merging reach silently never worked
|
|
40
|
+
// (`'merging'.startsWith('merge')` is false). Query-side only; IDF self-neutralises the extra reach (a
|
|
41
|
+
// looser term matches more docs → bigger df → smaller idf), so no flood.
|
|
42
|
+
function stem(t: string): string {
|
|
43
|
+
let s = t
|
|
44
|
+
if (s.length >= 4 && s.endsWith('s') && !s.endsWith('ss')) s = s.slice(0, -1)
|
|
45
|
+
if (s.length >= 5 && s.endsWith('e')) s = s.slice(0, -1)
|
|
46
|
+
return s
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// name match is forward-only (a chosen short field — reverse would let a stray short word swallow it);
|
|
50
|
+
// desc/body match bidirectionally so a longer doc word still reaches a shorter query term, reverse gated to
|
|
51
|
+
// words ≥3 chars so a stray short word can't swallow a longer term (IDF neutralises the generic words it
|
|
52
|
+
// pulls in).
|
|
53
|
+
function nameMatch(term: string, w: string): boolean { return w.startsWith(stem(term)) }
|
|
54
|
+
function textMatch(term: string, w: string): boolean { return w.startsWith(stem(term)) || (w.length >= 3 && term.startsWith(w)) }
|
|
40
55
|
|
|
41
56
|
// classic BM25 tf: frequency with saturation (K1 sets how fast it saturates) and length-normalisation (B),
|
|
42
57
|
// both in a wide insensitive plateau. tf=0 → 0.
|
|
@@ -51,13 +66,16 @@ function bm25tf(tf: number, len: number, avgLen: number): number {
|
|
|
51
66
|
type Fields<T> = { ref: T; name: string; nameWords: string[]; desc: string; descWords: string[]; bodyWords: string[]; snippetText: string }
|
|
52
67
|
|
|
53
68
|
// the pre-IDF weight a term earns against one doc, picking its single best tier (three fields): a name
|
|
54
|
-
// word-prefix beats a name substring beats a desc hit beats a body hit. Name
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
69
|
+
// word-prefix beats a name substring beats a desc hit beats a body hit. Name is a short, chosen field →
|
|
70
|
+
// binary presence. Desc is presence too (a curated one-liner — repetition there is stuffing, not evidence)
|
|
71
|
+
// but LENGTH-NORMALISED: it was flat-binary until descs drifted long and a bloated desc became a cheat code
|
|
72
|
+
// (one 60-word desc catches every query term a curated one-liner can't). bm25tf(1, avgLen, avgLen) = 1, so
|
|
73
|
+
// a hit in an average-length desc scores exactly the old binary W_DESC — the normalisation only bites
|
|
74
|
+
// outliers. The body keeps the full BM25-saturated term-frequency that discriminates the long ties.
|
|
75
|
+
function tierWeight<T>(term: string, n: Fields<T>, avgBodyLen: number, avgDescLen: number): number {
|
|
58
76
|
if (n.nameWords.some((w) => nameMatch(term, w))) return W_NAME_PREFIX
|
|
59
77
|
if (n.name.includes(term)) return W_NAME_SUBSTR
|
|
60
|
-
if (n.descWords.some((w) => textMatch(term, w))) return W_DESC
|
|
78
|
+
if (n.descWords.some((w) => textMatch(term, w))) return W_DESC * bm25tf(1, n.descWords.length, avgDescLen)
|
|
61
79
|
const tf = n.bodyWords.reduce((c, w) => c + (textMatch(term, w) ? 1 : 0), 0)
|
|
62
80
|
return W_BODY * bm25tf(tf, n.bodyWords.length, avgBodyLen)
|
|
63
81
|
}
|
|
@@ -108,6 +126,7 @@ export function rankDocs<T>(query: string, inputs: RankInput<T>[], opts: { limit
|
|
|
108
126
|
// a rare one carries the rank. Read from the corpus, not hand-set.
|
|
109
127
|
const N = docs.length
|
|
110
128
|
const avgBodyLen = docs.reduce((a, n) => a + n.bodyWords.length, 0) / (N || 1)
|
|
129
|
+
const avgDescLen = docs.reduce((a, n) => a + n.descWords.length, 0) / (N || 1)
|
|
111
130
|
const idf: Record<string, number> = {}
|
|
112
131
|
for (const t of qterms) {
|
|
113
132
|
let df = 0
|
|
@@ -120,7 +139,7 @@ export function rankDocs<T>(query: string, inputs: RankInput<T>[], opts: { limit
|
|
|
120
139
|
const scored: Ranked<T>[] = []
|
|
121
140
|
for (const n of docs) {
|
|
122
141
|
let score = 0
|
|
123
|
-
for (const t of qterms) score += tierWeight(t, n, avgBodyLen) * idf[t]
|
|
142
|
+
for (const t of qterms) score += tierWeight(t, n, avgBodyLen, avgDescLen) * idf[t]
|
|
124
143
|
if (score <= 0) continue
|
|
125
144
|
scored.push({ ref: n.ref, score: Math.round(score * 100) / 100, snippet: snippetFor(n.snippetText, n.desc, qterms) })
|
|
126
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'}`)
|