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.
Files changed (52) hide show
  1. package/README.md +158 -103
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +24 -1
  4. package/spec-cli/src/attach.ts +50 -0
  5. package/spec-cli/src/cli.ts +217 -64
  6. package/spec-cli/src/client.ts +47 -9
  7. package/spec-cli/src/{self.ts → doctor.ts} +26 -25
  8. package/spec-cli/src/guide.ts +79 -21
  9. package/spec-cli/src/harness.ts +53 -29
  10. package/spec-cli/src/help.ts +137 -49
  11. package/spec-cli/src/index.ts +31 -11
  12. package/spec-cli/src/issues.ts +48 -21
  13. package/spec-cli/src/layout.ts +3 -5
  14. package/spec-cli/src/lint.ts +34 -5
  15. package/spec-cli/src/localIssues.ts +44 -60
  16. package/spec-cli/src/materialize.ts +4 -2
  17. package/spec-cli/src/mentions.ts +22 -1
  18. package/spec-cli/src/pty-bridge.ts +39 -4
  19. package/spec-cli/src/ranker.ts +31 -12
  20. package/spec-cli/src/search.bench.mjs +30 -7
  21. package/spec-cli/src/search.ts +39 -0
  22. package/spec-cli/src/sessions.ts +160 -69
  23. package/spec-cli/src/specs.ts +16 -4
  24. package/spec-cli/src/supervise.ts +30 -6
  25. package/spec-cli/src/tree.ts +118 -0
  26. package/spec-cli/templates/hooks/post-merge +2 -2
  27. package/spec-cli/templates/hooks/pre-commit +34 -15
  28. package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
  29. package/spec-cli/templates/spexcode.json +7 -0
  30. package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
  31. package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +27 -0
  32. package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +3 -0
  33. package/spec-dashboard/dist/assets/FoldToggle-B5leylLf.js +1 -0
  34. package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +1 -0
  35. package/spec-dashboard/dist/assets/MobileApp-RHNECU6x.js +1 -0
  36. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
  37. package/spec-dashboard/dist/assets/SessionInterface-YLD6IOmC.js +71 -0
  38. package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +9 -0
  39. package/spec-dashboard/dist/assets/Settings-ZnOwskMZ.js +1 -0
  40. package/spec-dashboard/dist/assets/index-BdRQfrkR.js +41 -0
  41. package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +1 -0
  42. package/spec-dashboard/dist/index.html +2 -2
  43. package/spec-yatsu/src/cli.ts +128 -26
  44. package/spec-yatsu/src/evaltab.ts +7 -6
  45. package/spec-yatsu/src/filing.ts +6 -3
  46. package/spec-yatsu/src/proof.ts +10 -0
  47. package/spec-yatsu/src/scenariofresh.ts +100 -30
  48. package/spec-yatsu/src/sidecar.ts +25 -3
  49. package/spec-yatsu/src/timeline.ts +53 -23
  50. package/README.zh-CN.md +0 -135
  51. package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +0 -32
  52. package/spec-dashboard/dist/assets/index-DehTZ-h9.js +0 -145
@@ -27,14 +27,12 @@ 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 UNNAMED default worker launcher for Claude (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 UNNAMED default worker launcher for Codex (default 'codex --yolo'); env SPEXCODE_CODEX_CMD overrides. Same host-path rule.
32
30
  // named launcher profiles: a session picks ONE by name at create time ([[launcher-select]]), fixing both
33
31
  // its harness AND its exact launch command; the chosen NAME is persisted on the record so resume reuses the
34
32
  // same auth. `harness` defaults to 'claude'. Host-specific `cmd`s (abs wrapper paths) belong in the
35
33
  // gitignored spexcode.local.json — the name is portable, the cmd is a machine fact.
36
34
  launchers?: { [name: string]: { harness?: 'claude' | 'codex'; cmd: string } }
37
- defaultLauncher?: string // the launcher a create with no explicit --launcher/dropdown pick uses (else the unnamed claudeCmd/codexCmd path)
35
+ defaultLauncher?: string // the launcher a create with no explicit --launcher/dropdown pick uses; required for no-choice creates
38
36
  }
39
37
  serve?: {
40
38
  // public-exposure config for `spex serve --public` (resolved gateway-side; see [[public-mode]] / gateway.ts).
@@ -78,7 +76,7 @@ export function readJsonConfig(p: string): any {
78
76
  // committed `spexcode.json` with an OPTIONAL machine-local `spexcode.local.json` layered on top (gitignored).
79
77
  // The local layer is the durable home for HOST-SPECIFIC values that must never be committed — e.g. an
80
78
  // absolute worker-launcher path (the host-path leak the repo otherwise warns against). Precedence per field:
81
- // local over committed; an env var (e.g. SPEXCODE_CLAUDE_CMD) still overrides both at its read site.
79
+ // local over committed; a targeted env override (e.g. SPEXCODE_CODEX_SERVER_CMD) still wins at its read site.
82
80
  export function readConfig(root: string): Config {
83
81
  const committed = readJsonConfig(join(root, 'spexcode.json'))
84
82
  const local = readJsonConfig(join(root, 'spexcode.local.json'))
@@ -161,7 +159,7 @@ export type RawRecord = {
161
159
  node: string | null; title: string | null; name: string | null; parent?: string | null
162
160
  status: string; proposal: string | null; merges: number; note: string | null
163
161
  sortkey: number | null; createdAt: number; harness?: string; harness_session_id?: string
164
- launcher?: string // the named launcher profile this session was created under ([[launcher-select]]); absent/empty the unnamed global default
162
+ launcher?: string // the launcher profile this session was created under ([[launcher-select]]); absent/empty only on old records predating launchers
165
163
  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
164
  }
167
165
  // the agent's OWN session id from the environment — the only locator now that the record left the worktree.
@@ -32,9 +32,34 @@ export function loadConfig(root: string): LintConfig {
32
32
  // Absent spexcode.json → tuned defaults; a MALFORMED one throws LOUD (readJsonConfig) rather than
33
33
  // silently reverting the author's budgets to defaults and green-washing the very warnings they tuned.
34
34
  const c = readJsonConfig(join(root, 'spexcode.json'))?.lint ?? {}
35
- return { ...DEFAULT_CONFIG, ...c, altitude: { ...DEFAULT_CONFIG.altitude, ...(c.altitude ?? {}) } }
35
+ const merged = { ...DEFAULT_CONFIG, ...c, altitude: { ...DEFAULT_CONFIG.altitude, ...(c.altitude ?? {}) } }
36
+ return normalizeConfig(merged)
36
37
  }
37
38
 
39
+ // canonicalize two adopter-input footguns that would otherwise SILENTLY match ZERO files (the same failure
40
+ // class as an unset governedRoots — a green board that governs nothing). Both are natural mistakes a non-web
41
+ // adopter makes reading the prose, so we accept-what-they-meant rather than reject:
42
+ // - a LEADING DOT on an extension: the matcher is `\.(ext)$`, so a literal ".ts" becomes `\..ts$` and never
43
+ // matches. Strip leading dots → ["ts"] and [".ts"] both work (prose historically showed ".ts").
44
+ // - a testGlob with NO "/": globs anchor to the full repo-relative path, so a bare "*.test.ts" matches only
45
+ // ROOT-level files and leaks every nested test into coverage. A slash-less glob is a basename intent →
46
+ // prepend "**/" so it matches that basename at any depth (the default "**/*.test.*" already does).
47
+ export function normalizeConfig(cfg: LintConfig): LintConfig {
48
+ const dedot = (xs: string[]) => xs.map((x) => x.replace(/^\.+/, ''))
49
+ return {
50
+ ...cfg,
51
+ sourceExtensions: dedot(cfg.sourceExtensions),
52
+ identifierExtensions: dedot(cfg.identifierExtensions),
53
+ testGlobs: cfg.testGlobs.map((g) => (g.includes('/') ? g : `**/${g}`)),
54
+ }
55
+ }
56
+
57
+ // the source-file matcher, built from the configurable `sourceExtensions` knob. Coverage uses it to decide
58
+ // which tracked files must be governed; yatsu's `yatsu-uncovered` reuses THE SAME knob so ONE setting
59
+ // defines "source" for both coverage axes — a non-web project (Rust/Go/Python .rs/.go/.py) sets it once and
60
+ // both the coverage warning and the loss-signal blind-spot check follow, with no second web-only allowlist.
61
+ export const sourceExtRe = (extensions: string[]) => new RegExp(`\\.(${extensions.join('|')})$`)
62
+
38
63
  // a minimal glob → RegExp anchored to the full repo-relative path: `**` = any dirs, `*` = within a segment.
39
64
  function globToRe(glob: string): RegExp {
40
65
  const body = glob.split(/(\*\*\/|\*\*|\*|\?)/).map((seg) => {
@@ -103,7 +128,7 @@ export async function specLint(): Promise<Finding[]> {
103
128
  const root = repoRoot()
104
129
  const cfg = loadConfig(root)
105
130
  const ident = identRe(cfg.identifierExtensions)
106
- const srcRe = new RegExp(`\\.(${cfg.sourceExtensions.join('|')})$`)
131
+ const srcRe = sourceExtRe(cfg.sourceExtensions)
107
132
  const specs = await loadSpecs()
108
133
  const out: Finding[] = []
109
134
 
@@ -159,10 +184,14 @@ export async function specLint(): Promise<Finding[]> {
159
184
 
160
185
  // coverage: every governed source file must be claimed by at least one spec.
161
186
  const governed = trackedSourceFiles(root, cfg.governedRoots, srcRe, cfg.testGlobs)
162
- // no governed source found at all → the defaults name this repo's own dirs, so an adopter who never set
163
- // lint.governedRoots would otherwise see a falsely-clean board. Make it loud and point at the knob.
187
+ // no governed source found at all → make it a SELF-EXPLANATORY repair entrypoint, not a dead end. The two
188
+ // knobs governing this are BOTH web-tuned by default (extensions ts/tsx/js/jsx; roots this repo's own dirs),
189
+ // so a non-web adopter (Rust/Go/Python) hits zero source two ways: right dir but wrong extension, or an
190
+ // unset root. Naming BOTH knobs, echoing their CURRENT values (so the mismatch is visible — "searching .ts
191
+ // in a .py tree"), and stating the `lint`-key nesting (a top-level key silently no-ops) turns the warning
192
+ // into the fix. Concrete non-web extension examples so the repair is copy-pasteable, not a schema hunt.
164
193
  if (governed.length === 0)
165
- out.push({ level: 'warn', rule: 'coverage', msg: `governing NOTHING — no source files under governedRoots [${cfg.governedRoots.join(', ')}]. Set lint.governedRoots in spexcode.json to your project's source dirs.` })
194
+ out.push({ level: 'warn', rule: 'coverage', msg: `governing NOTHING — 0 source files matched extensions [${cfg.sourceExtensions.join(', ')}] under governedRoots [${cfg.governedRoots.join(', ')}]. Both knobs live under the "lint" key in spexcode.json (a top-level key is ignored): set governedRoots to your source dir(s) (e.g. ["src"]) AND sourceExtensions to your language (e.g. ["rs"] / ["go"] / ["py"]).` })
166
195
  for (const f of governed)
167
196
  if (!claimed.has(f)) out.push({ level: 'warn', rule: 'coverage', file: f, msg: `no spec governs: ${f}` })
168
197
 
@@ -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 sign/reply/discuss like an async chatroom; a supervisor drains it via
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/signers/created) + a prose body, then any replies — each
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. Lists are `key: a, b` scalars so a
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 || 'open',
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 resolve/sign — the store IS the requested state), `git commit` would exit 1
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/sign/resolve reads the
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 sign(id: string): string[] {
399
- const by = currentSession()
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
- if (!p.signers.includes(by)) p.signers.push(by)
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 { as, already: !changed }
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], signers: [], created: new Date().toISOString(),
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, agent-only (the dashboard's `human` is rejected a human
483
- // RETRACTS their own, resolve is a second party's judgment), NEVER the author (no self-resolve), and
484
- // MONOTONIC (no un-resolve — a regression is a NEW remark). `by` is the resolving party.
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 === 'human' || by === 'unknown') throw new Error(`resolve is agent-only (needs a real session identity, got '${by || 'none'}'): a human withdraws their own remark with \`spex retract\`, not resolve`)
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 resolved, not',
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 resolve <id> --as landed',
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 — sign/reply if already raised',
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 resolve OR say why it stays
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, resolve it now if its work is finished (\`spex issues resolve <id> --as landed|rejected|accepted\`), 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.`
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', '--as', '--evidence', '--scenario', '--code-sha'])
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>" [--node id…] [--evidence hash…]
589
- // [--body -|text], the id-based reply | sign | resolve, and the feature toggle on | off | status. `nudge`
590
- // is internal (the post-merge hook's caller). Store is a property of the issue, never a second command.
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 local issue. The concern is the bare positional(s) after the sub.
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|sign|resolve <issue-id> … | on|off|status')
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 p = openIssue(concern, { nodes: repeated(args, 'node'), body: readBody(args), evidence: repeated(args, 'evidence') })
641
- console.log(`opened '${p.id}'${p.nodes.length ? ` (re: ${p.nodes.join(', ')})` : ''} — committed to the local issue store; read it with \`spex issues\``)
642
- const s = summarize(await dispatchMentions(p.body || concern, { threadId: p.id, node: p.nodes[0] || null, author: p.by, status: p.status }))
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', 'sign', 'resolve', 'on', 'off', 'status', 'nudge'])
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
- const SPEX = `${tsxBin(PKG)} ${join(PKG, 'src', 'cli.ts')}`
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.
@@ -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. The size-diff guard makes a converged bridge a no-op.
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
- if (want.cols !== existing.cols || want.rows !== existing.rows) applySize(existing, want.cols, want.rows)
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
@@ -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
- // name match is forward-only (a chosen short field reverse would let a plural query light up every `spec-*`
36
- // name); desc/body match bidirectionally so a singular/plural mismatch still hits, reverse gated to words ≥3
37
- // chars so a stray short word can't swallow a longer term (IDF neutralises the generic words it pulls in).
38
- function nameMatch(term: string, w: string): boolean { return w.startsWith(term) }
39
- function textMatch(term: string, w: string): boolean { return w.startsWith(term) || (w.length >= 3 && term.startsWith(w)) }
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 and desc are short, chosen
55
- // fields binary presence; the body carries the BM25-saturated, length-normalised frequency that
56
- // discriminates the long ties.
57
- function tierWeight<T>(term: string, n: Fields<T>, avgBodyLen: number): number {
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
  }