spexcode 0.2.8 → 0.3.0

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 (97) hide show
  1. package/README.md +56 -44
  2. package/package.json +3 -3
  3. package/spec-cli/bin/spex.mjs +2 -2
  4. package/spec-cli/hooks/dispatch.sh +1 -1
  5. package/spec-cli/hooks/harness.sh +26 -6
  6. package/spec-cli/src/attach.ts +2 -2
  7. package/spec-cli/src/cli.ts +695 -536
  8. package/spec-cli/src/client.ts +31 -30
  9. package/spec-cli/src/contract-filter.ts +1 -1
  10. package/spec-cli/src/doctor.ts +40 -13
  11. package/spec-cli/src/gateway.ts +11 -7
  12. package/spec-cli/src/git.ts +2 -2
  13. package/spec-cli/src/{board.ts → graph.ts} +44 -14
  14. package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
  15. package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
  16. package/spec-cli/src/graphStream.ts +288 -0
  17. package/spec-cli/src/guide.ts +123 -96
  18. package/spec-cli/src/harness-select.ts +2 -2
  19. package/spec-cli/src/harness.ts +30 -14
  20. package/spec-cli/src/help.ts +289 -384
  21. package/spec-cli/src/hooks.ts +1 -1
  22. package/spec-cli/src/index.ts +130 -103
  23. package/spec-cli/src/init.ts +9 -9
  24. package/spec-cli/src/issues.ts +89 -33
  25. package/spec-cli/src/layout.ts +5 -5
  26. package/spec-cli/src/lint.ts +73 -8
  27. package/spec-cli/src/localIssues.ts +42 -60
  28. package/spec-cli/src/materialize.ts +1 -1
  29. package/spec-cli/src/mentions.ts +15 -15
  30. package/spec-cli/src/migrate-table.ts +397 -0
  31. package/spec-cli/src/migrate.ts +386 -0
  32. package/spec-cli/src/ranker.ts +30 -4
  33. package/spec-cli/src/reaper.ts +117 -0
  34. package/spec-cli/src/search.bench.mjs +10 -10
  35. package/spec-cli/src/search.ts +1 -1
  36. package/spec-cli/src/sessions.ts +244 -138
  37. package/spec-cli/src/specs.ts +25 -15
  38. package/spec-cli/src/supervise.ts +2 -2
  39. package/spec-cli/src/tree.ts +5 -5
  40. package/spec-cli/src/uninstall.ts +4 -4
  41. package/spec-cli/templates/hooks/post-checkout +1 -1
  42. package/spec-cli/templates/hooks/post-merge +3 -3
  43. package/spec-cli/templates/hooks/pre-commit +9 -9
  44. package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
  45. package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
  46. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
  47. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
  48. package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
  49. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
  50. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
  51. package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
  52. package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
  53. package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
  54. package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +4 -4
  55. package/spec-cli/templates/spec/project/{.config → .plugins}/forge-link/spec.md +1 -1
  56. package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
  57. package/spec-cli/templates/spec/project/{.config → .plugins}/reproduce-before-fix/spec.md +3 -3
  58. package/spec-cli/templates/spec/project/{.config → .plugins}/spec.md +4 -4
  59. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
  60. package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
  61. package/spec-cli/templates/spec/project/spec.md +1 -1
  62. package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-C7Bzsv86.js} +9 -9
  63. package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +2 -0
  64. package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-D5iB4Ac2.js} +1 -1
  65. package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-CMFTsQhg.js} +1 -1
  66. package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-DwuTKgdP.js} +1 -1
  67. package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-CBS5_cmK.js} +3 -3
  68. package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CqAnjWfI.js} +2 -2
  69. package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BW5f0OaW.js} +1 -1
  70. package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-Cc26X4ce.css} +1 -1
  71. package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-Ce0wDyQS.js} +8 -8
  72. package/spec-dashboard/dist/index.html +2 -2
  73. package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
  74. package/{spec-yatsu → spec-eval}/src/cli.ts +164 -95
  75. package/{spec-yatsu → spec-eval}/src/evaltab.ts +24 -24
  76. package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
  77. package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
  78. package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
  79. package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
  80. package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
  81. package/{spec-yatsu → spec-eval}/src/sidecar.ts +7 -1
  82. package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
  83. package/spec-forge/src/__fixtures__/github-forge.json +9 -9
  84. package/spec-forge/src/cli.ts +14 -13
  85. package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
  86. package/spec-cli/src/boardStream.ts +0 -179
  87. package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
  88. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
  89. package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
  90. /package/spec-cli/templates/presets/careful/{.config → .plugins}/clarify-before-code/spec.md +0 -0
  91. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
  92. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
  93. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
  94. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
  95. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
  96. /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
  97. /package/spec-cli/templates/spec/project/{.config → .plugins}/memory-hygiene/spec.md +0 -0
@@ -0,0 +1,386 @@
1
+ // @@@ spex doctor --migrate - the ONE-SHOT 0.2.x → 0.3.0 migrator for an ADOPTER repo ([[migrate]]).
2
+ // Ships with 0.3.0, deleted in 0.4.0 (a released, named, term-limited migrator — not a runtime residue).
3
+ // It performs the v0.3.0 vocabulary migration in one run: tree renames (.config → .plugins, config →
4
+ // plugin-system, yatsu.md → eval.md, yatsu.evals.ndjson → evals.ndjson), template hook-asset upgrades,
5
+ // body vocabulary rewrites (only what is EXECUTED or PARSED — command spellings, lint labels, mentions,
6
+ // routes, .spec paths, issue-frontmatter bindings — never conceptual prose), and the legacy settings key.
7
+ //
8
+ // Iron rules, enforced structurally:
9
+ // • NO GUESSING: a hook asset is replaced only when its git blob sha matches a KNOWN historical stock
10
+ // template version (migrate-table.ts, generated from this repo's own git history). Anything else is
11
+ // flagged for human review and left byte-identical.
12
+ // • FAIL LOUD, NEVER HALF-MIGRATE: every precondition (clean tree, drained sessions, tracked .spec,
13
+ // main checkout, no rename collisions) is checked BEFORE the first write; any failure prints ALL
14
+ // refusals and exits 2 with the tree untouched.
15
+ // • EVERYTHING STAGED, NOTHING COMMITTED: the operator reviews the staged diff + flagged items and
16
+ // commits through the ritual; `git reset --hard` undoes the whole run.
17
+ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, renameSync } from 'node:fs'
18
+ import { join, relative, dirname, basename } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+ import { execFileSync } from 'node:child_process'
21
+ import { TEMPLATE_ASSETS, RETIRED_ASSETS, CONFIG_NODE_SHAS } from './migrate-table.js'
22
+
23
+ const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url))
24
+
25
+ // loud git: any failure throws with git's own stderr. GIT_DIR/GIT_INDEX_FILE are stripped (same reason as
26
+ // git.ts's git(): an inherited hook env would silently point repo discovery at the wrong place).
27
+ function git(cwd: string, args: string[]): string {
28
+ const env = { ...process.env }
29
+ delete env.GIT_DIR; delete env.GIT_INDEX_FILE; delete env.GIT_WORK_TREE
30
+ return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env, maxBuffer: 64 * 1024 * 1024 }).trim()
31
+ }
32
+ function tryGit(cwd: string, args: string[]): string | null {
33
+ try { return git(cwd, args) } catch { return null }
34
+ }
35
+
36
+ function walk(dir: string): string[] {
37
+ let out: string[] = []
38
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
39
+ const p = join(dir, e.name)
40
+ if (e.isDirectory()) out = out.concat(walk(p))
41
+ else if (e.isFile()) out.push(p)
42
+ }
43
+ return out
44
+ }
45
+
46
+ // ---------- the vocabulary tables (§3.1/§3.2 of the v0.3.0 plan — old spelling → its ONE new home) ----------
47
+ // Only surfaces that are executed or parsed. Ordered: longer/more-specific rules run before their prefixes.
48
+ const COMMAND_REWRITES: [RegExp, string][] = [
49
+ // eval domain (né yatsu)
50
+ [/\bspex yatsu check-staged\b/g, 'spex internal check-staged'],
51
+ [/\bspex yatsu eval\b/g, 'spex eval add'],
52
+ [/\bspex yatsu show\b/g, 'spex eval ls'],
53
+ [/\bspex yatsu scan\b/g, 'spex eval lint'],
54
+ [/\bspex yatsu retract\b/g, 'spex eval retract'],
55
+ [/\bspex yatsu clean\b/g, 'spex eval clean'],
56
+ [/\bspex guide yatsu\b/g, 'spex guide eval'],
57
+ [/\bspex guide config\b/g, 'spex guide settings'],
58
+ [/\bspex blob put\b/g, 'spex evidence put'],
59
+ [/\bspex blob get\b/g, 'spex evidence get'],
60
+ // issue domain (né issues/forge drawers)
61
+ [/\bspex issues nudge\b/g, 'spex internal nudge'],
62
+ [/\bspex issues open\b/g, 'spex issue open'],
63
+ [/\bspex issues reply\b/g, 'spex issue reply'],
64
+ [/\bspex issues close\b/g, 'spex issue close'],
65
+ [/\bspex issues promote\b/g, 'spex issue promote'],
66
+ [/\bspex issues ls\b/g, 'spex issue ls'],
67
+ [/\bspex issues\b(?!\s+(?:on|off|status)\b)/g, 'spex issue ls'], // bare `spex issues` listed; on|off|status are DELETED → flagged below
68
+ [/\bspex forge links\b/g, 'spex issue links'],
69
+ [/\bspex forge eval-pending\b/g, 'spex issue links --pending'],
70
+ // remark domain (né bare remark/resolve/retract)
71
+ [/\bspex resolve\b/g, 'spex remark resolve'],
72
+ [/\bspex retract\b/g, 'spex remark retract'],
73
+ [/\bspex remark\b(?!\s+(?:add|resolve|retract)\b)/g, 'spex remark add'],
74
+ // session drawer: renamed subs first, then the retired promoted spellings
75
+ [/\bspex session reopen\b/g, 'spex session resume'],
76
+ [/\bspex session exit\b/g, 'spex session stop'],
77
+ [/\bspex session rawkey\b/g, 'spex session send --keys'],
78
+ [/\bspex session capture\b/g, 'spex session show --capture'],
79
+ [/\bspex session prompt\b/g, 'spex session show'],
80
+ [/\bspex session state\b/g, 'spex internal session-state'],
81
+ [/\bspex session fail\b/g, 'spex internal session-fail'],
82
+ [/\bspex session idle\b/g, 'spex internal session-idle'],
83
+ [/\bspex session commit-gate\b/g, 'spex internal commit-gate'],
84
+ [/\bspex reopen\b/g, 'spex session resume'],
85
+ [/\bspex exit\b/g, 'spex session stop'],
86
+ [/\bspex rawkey\b/g, 'spex session send --keys'],
87
+ [/\bspex capture\b/g, 'spex session show --capture'],
88
+ [/\bspex prompt\b/g, 'spex session show'],
89
+ [/\bspex new\b/g, 'spex session new'],
90
+ [/\bspex ls\b/g, 'spex session ls'],
91
+ [/\bspex watch\b/g, 'spex session watch'],
92
+ [/\bspex wait\b/g, 'spex session wait'],
93
+ [/\bspex review\b(?!\s+proof\b)/g, 'spex session review'], // `spex review proof` is DELETED → flagged below
94
+ [/\bspex merge\b/g, 'spex session merge'],
95
+ [/\bspex done\b/g, 'spex session done'],
96
+ [/\bspex park\b/g, 'spex session park'],
97
+ [/\bspex ask\b/g, 'spex session ask'],
98
+ [/\bspex send\b/g, 'spex session send'],
99
+ [/\bspex close\b/g, 'spex session close'],
100
+ [/\bspex attach\b/g, 'spex session attach'],
101
+ [/\bspex rename\b/g, 'spex session rename'],
102
+ // spec drawer (né promoted verbs)
103
+ [/\bspex search\b/g, 'spex spec search'],
104
+ [/\bspex owner\b/g, 'spex spec owner'],
105
+ [/\bspex lint\b/g, 'spex spec lint'],
106
+ [/\bspex ack\b/g, 'spex spec ack'],
107
+ // project verbs
108
+ [/\bspex board\b/g, 'spex graph --json'],
109
+ [/\bspex tree\b/g, 'spex graph'],
110
+ [/\bspex dashboard\b/g, 'spex serve ui'],
111
+ [/\bspex self\b/g, 'spex doctor'],
112
+ [/\bspex doctor contract\b/g, 'spex doctor --contract'],
113
+ [/\bspex doctor conflicts\b/g, 'spex doctor --conflicts'],
114
+ ]
115
+ const ROUTE_REWRITES: [RegExp, string][] = [
116
+ [/\/api\/board\/stream\b/g, '/api/graph/stream'],
117
+ [/\/api\/board\b/g, '/api/graph'],
118
+ [/\/api\/sessions\/graph\b/g, '/api/sessions/edges'],
119
+ [/\/api\/yatsu\/blob\b/g, '/api/evidence'],
120
+ [/\/yatsu\/eval\b/g, '/evals'],
121
+ [/\/api\/config\b/g, '/api/plugins'],
122
+ [/\/api\/layout\b/g, '/api/settings'],
123
+ [/\/api\/launchers\b/g, '/api/settings'],
124
+ ]
125
+ const LABEL_REWRITES: [RegExp, string][] = [
126
+ [/\byatsu-uncovered:/g, 'eval-coverage:'],
127
+ [/\byatsu-(schema|missing|drift|dangling|owners):/g, 'eval-$1:'],
128
+ ]
129
+ const FILE_REWRITES: [RegExp, string][] = [
130
+ [/\byatsu\.evals\.ndjson\b/g, 'evals.ndjson'], // also rewrites the `.yatsu.evals.ndjson` suffix form
131
+ [/\byatsu\.md\b/g, 'eval.md'],
132
+ ]
133
+ // dead spellings that survive every rewrite rule (no deterministic new home) → review items, never guessed at.
134
+ const MD_FLAG_PATTERNS: [RegExp, string][] = [
135
+ [/\bspex yatsu\b/, 'unmapped `spex yatsu …` spelling'], // dead-words-ok: the migrator names the old vocabulary it migrates from
136
+ [/\byatsu (scan|eval|show|retract|clean)\b/, 'un-prefixed yatsu verb (script-style invocation)'], // dead-words-ok: the migrator names the old vocabulary it migrates from
137
+ [/\bspex forge\b/, 'unmapped `spex forge …` spelling (drawer dissolved into `spex issue links`)'],
138
+ [/\bspex issues (on|off|status)\b/, '`spex issues on|off|status` was deleted — the switch is the spexcode.json `issues.enabled` key'],
139
+ [/\bspex review proof\b/, '`spex review proof` was deleted — use `spex eval ls --session <SEL> --export`'], // dead-words-ok: the migrator names the old vocabulary it migrates from
140
+ [/\bspex eval\b(?!\s+(?:add|ls|scenario|lint|retract|clean)\b|\s+--|\s*[.,;:)`'"]|\s*$)/m, 'old top-level `spex eval <SEL>` session read — now `spex eval ls --session <SEL>`'],
141
+ [/\byatsu-[a-z]+:/, 'unmapped yatsu-* lint label'], // dead-words-ok: the migrator names the old vocabulary it migrates from
142
+ [/\byatsu\.md\b/, 'a yatsu.md reference survived rewriting'], // dead-words-ok: the migrator names the old vocabulary it migrates from
143
+ ]
144
+ // executable patterns that make an UNKNOWN (non-template) script a review item — scripts are never rewritten.
145
+ const SCRIPT_FLAG_PATTERNS: [RegExp, string][] = [
146
+ [/\byatsu\b/, 'invokes/greps the yatsu vocabulary'], // dead-words-ok: the migrator names the old vocabulary it migrates from
147
+ [/\bsession (state|fail|idle|commit-gate)\b/, 'calls a hook verb that moved to `spex internal …`'],
148
+ [/\bblob (put|get)\b/, 'calls the blob verbs (now `spex evidence put|get`)'], // dead-words-ok: the migrator names the old vocabulary it migrates from
149
+ [/\bspex (board|tree|dashboard|search|owner|lint|ack|new|ls|watch|wait|review|merge)\b/, 'calls a retired top-level spelling'],
150
+ [/\/api\/(board|config|layout|launchers|yatsu)\b/, 'hits a renamed API route'],
151
+ ]
152
+
153
+ type Flag = { file: string; line: number | null; reason: string }
154
+
155
+ // ---------- preconditions: check EVERYTHING before the first write ----------
156
+ type World = {
157
+ root: string
158
+ specRoots: string[] // .spec/<root> dirs that are spec roots (have spec.md)
159
+ configRoots: string[] // spec roots carrying an old-world .config
160
+ yatsuFiles: string[] // absolute paths of yatsu.md / *yatsu.evals.ndjson under .spec
161
+ configNode: string | null // .spec/<root>/config dir to rename, iff hash-gated as spexcode's plugin-system spec
162
+ configNodeFlag: Flag | null // a node named `config` that is NOT stock → flagged, not renamed
163
+ }
164
+
165
+ // inspectAsync (bottom of file) gathers the world and appends every refusal — ALL preconditions are
166
+ // evaluated before runMigrate performs its first write.
167
+
168
+ export async function runMigrate(): Promise<number> {
169
+ const refusals: string[] = []
170
+ let world: World | null = null
171
+ try { world = await inspectAsync(process.cwd(), refusals) } catch (e) { refusals.push((e as Error).message) }
172
+ if (refusals.length || !world) {
173
+ console.error('spex doctor --migrate: REFUSED — nothing was changed. Fix these and re-run:')
174
+ for (const r of refusals) console.error(` ✗ ${r}`)
175
+ return 2
176
+ }
177
+ const { root } = world
178
+ const rel = (f: string) => relative(root, f)
179
+ const flags: Flag[] = world.configNodeFlag ? [world.configNodeFlag] : []
180
+ const summary: string[] = []
181
+ const renamedNodes: [string, string][] = [] // node-id renames actually performed → drives mention re-pointing
182
+
183
+ console.log('spex doctor --migrate — one-shot 0.2.x → 0.3.0 migration (everything staged, nothing committed)\n')
184
+
185
+ // ---------- 1. tree renames (git mv — history-preserving) ----------
186
+ for (const dir of world.configRoots) {
187
+ git(root, ['mv', join(dir, '.config'), join(dir, '.plugins')])
188
+ summary.push(`renamed ${rel(dir)}/.config → .plugins (git mv)`)
189
+ }
190
+ renamedNodes.push(['.config', '.plugins'])
191
+ if (world.configNode) {
192
+ git(root, ['mv', world.configNode, join(dirname(world.configNode), 'plugin-system')])
193
+ renamedNodes.push(['config', 'plugin-system'])
194
+ summary.push(`renamed node ${rel(world.configNode)} → plugin-system (git mv; stock plugin-system spec, hash-verified)`)
195
+ }
196
+ // yatsu file renames — re-walk (paths moved under .plugins)
197
+ const yatsuNow = walk(join(root, '.spec')).filter((f) => basename(f) === 'yatsu.md' || basename(f).endsWith('yatsu.evals.ndjson')) // dead-words-ok: the migrator names the old vocabulary it migrates from
198
+ for (const f of yatsuNow) {
199
+ const target = basename(f) === 'yatsu.md' ? join(dirname(f), 'eval.md') : join(dirname(f), basename(f).replace(/yatsu\.evals\.ndjson$/, 'evals.ndjson')) // dead-words-ok: the migrator names the old vocabulary it migrates from
200
+ git(root, ['mv', f, target])
201
+ }
202
+ if (yatsuNow.length) summary.push(`renamed ${yatsuNow.length} measurement file(s): yatsu.md → eval.md, *yatsu.evals.ndjson → *evals.ndjson (git mv)`) // dead-words-ok: the migrator names the old vocabulary it migrates from
203
+
204
+ // ---------- 2. template hook-asset upgrade (exact-match replace or flag — NEVER a silent rewrite) ----------
205
+ let replaced = 0, current = 0, retiredStock = 0
206
+ const touched = new Set<string>() // files replaced (skip vocab) …
207
+ const frozen = new Set<string>() // … and files flagged (must stay byte-identical)
208
+ for (const dir of world.configRoots) {
209
+ const plugRoot = join(dir, '.plugins')
210
+ for (const f of walk(plugRoot)) {
211
+ const r = relative(plugRoot, f)
212
+ const asset = TEMPLATE_ASSETS.find((a) => a.rel === r)
213
+ if (asset) {
214
+ const newContent = readFileSync(join(PKG_ROOT, asset.template), 'utf8')
215
+ if (readFileSync(f, 'utf8') === newContent) { current++; continue }
216
+ const sha = git(root, ['hash-object', f])
217
+ if (asset.oldShas.includes(sha)) {
218
+ writeFileSync(f, newContent); git(root, ['add', f]); replaced++; touched.add(f)
219
+ } else {
220
+ flags.push({ file: rel(f), line: null, reason: `differs from EVERY known stock template version (hand-customized?) — left untouched; port your customization onto the new template by hand (shipped at ${join('spec-cli', asset.template)})` })
221
+ frozen.add(f)
222
+ }
223
+ } else {
224
+ // not shipped any more: a stock copy of a retired template is reported; an unknown script with
225
+ // executable old vocabulary is flagged. Unknown .md bodies fall through to the vocabulary pass.
226
+ const oldNames = [r, r.replace(/eval\.md$/, 'yatsu.md'), r.replace(/evals\.ndjson$/, 'yatsu.evals.ndjson')] // dead-words-ok: the migrator names the old vocabulary it migrates from
227
+ const retiredKey = oldNames.find((n) => RETIRED_ASSETS[n])
228
+ if (retiredKey && RETIRED_ASSETS[retiredKey].includes(git(root, ['hash-object', f]))) {
229
+ retiredStock++
230
+ flags.push({ file: rel(f), line: null, reason: `stock copy of a template the current release no longer ships (${retiredKey}) — kept; review whether to keep or delete it` })
231
+ // hash-verified STOCK content: a retired .md still gets the vocabulary pass (we know every byte
232
+ // is ours, so rewriting its executable surfaces is not a guess); a retired SCRIPT has no shipped
233
+ // successor and no rewrite table, so it stays frozen byte-identical.
234
+ if (!f.endsWith('.md')) frozen.add(f)
235
+ } else if (!f.endsWith('.md')) {
236
+ const text = readFileSync(f, 'utf8')
237
+ for (const [re, why] of SCRIPT_FLAG_PATTERNS) {
238
+ const i = text.split('\n').findIndex((l) => re.test(l))
239
+ if (i >= 0) { flags.push({ file: rel(f), line: i + 1, reason: `unknown script ${why} — scripts are never auto-rewritten; update it by hand` }); frozen.add(f); break }
240
+ }
241
+ }
242
+ }
243
+ }
244
+ }
245
+ summary.push(`template assets: ${replaced} upgraded (stock, hash-verified), ${current} already current, ${retiredStock} retired-stock, ${flags.length} flagged for review`)
246
+
247
+ // ---------- 3. body vocabulary pass (every .spec .md not replaced/frozen above) ----------
248
+ let rewrote = 0
249
+ const mentionRules: [RegExp, string][] = renamedNodes.map(([o, n]) => [new RegExp(`\\[\\[${o.replace('.', '\\.')}\\]\\]`, 'g'), `[[${n}]]`])
250
+ for (const f of walk(join(root, '.spec'))) {
251
+ if (!f.endsWith('.md') || touched.has(f) || frozen.has(f)) continue
252
+ const before = readFileSync(f, 'utf8')
253
+ let text = before
254
+ for (const [re, to] of [...FILE_REWRITES, ...LABEL_REWRITES, ...COMMAND_REWRITES, ...ROUTE_REWRITES, ...mentionRules]) text = text.replace(re, to)
255
+ // .spec-internal path strings (code: frontmatter, prose paths): /.config/ → /.plugins/ on lines that name .spec
256
+ text = text.split('\n').map((l) => l.includes('.spec/') ? l.replaceAll('/.config/', '/.plugins/') : l).join('\n')
257
+ // issue-thread frontmatter `nodes:` bindings follow the performed node renames
258
+ if (renamedNodes.some(([o]) => o === 'config'))
259
+ text = text.replace(/^(nodes:.*)$/m, (line) => line.replace(/\bconfig\b/g, 'plugin-system'))
260
+ if (text !== before) { writeFileSync(f, text); git(root, ['add', f]); rewrote++ }
261
+ // whatever old vocabulary SURVIVES the rewrite has no deterministic home → review, never guess
262
+ const lines = text.split('\n')
263
+ for (const [re, why] of MD_FLAG_PATTERNS) {
264
+ const i = lines.findIndex((l) => re.test(l))
265
+ if (i >= 0) flags.push({ file: rel(f), line: i + 1, reason: why })
266
+ }
267
+ }
268
+ summary.push(`bodies: ${rewrote} .md file(s) rewritten (command spellings · lint labels · file/route names · [[mention]] re-pointing · .spec paths)`)
269
+
270
+ // ---------- 4. legacy settings key: proposals.enabled → issues.enabled ----------
271
+ for (const name of ['spexcode.json', 'spexcode.local.json']) {
272
+ const p = join(root, name)
273
+ if (!existsSync(p)) continue
274
+ let cfg: Record<string, unknown>
275
+ try { cfg = JSON.parse(readFileSync(p, 'utf8')) } catch { flags.push({ file: name, line: null, reason: 'unparseable JSON — legacy `proposals` key (if any) not migrated' }); continue }
276
+ if (!('proposals' in cfg)) continue
277
+ const prop = cfg.proposals as { enabled?: unknown }
278
+ const issues = (cfg.issues ?? {}) as Record<string, unknown>
279
+ if (typeof prop?.enabled === 'boolean' && !('enabled' in issues)) cfg.issues = { ...issues, enabled: prop.enabled }
280
+ delete cfg.proposals
281
+ writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n')
282
+ if (tryGit(root, ['ls-files', '--error-unmatch', name])) git(root, ['add', p])
283
+ summary.push(`${name}: legacy \`proposals\` key rewritten to \`issues.enabled\` (v0.3.0 reads only the new key)`)
284
+ }
285
+
286
+ // ---------- 5. per-clone evidence cache: yatsu-blobs → evidence (lossless dir rename) ----------
287
+ const commonDir = git(root, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
288
+ const oldCache = join(commonDir, 'spexcode', 'yatsu-blobs'), newCache = join(commonDir, 'spexcode', 'evidence') // dead-words-ok: the migrator names the old vocabulary it migrates from
289
+ if (existsSync(oldCache) && !existsSync(newCache)) { renameSync(oldCache, newCache); summary.push('evidence cache: .git/spexcode/yatsu-blobs → evidence (bytes preserved)') } // dead-words-ok: the migrator names the old vocabulary it migrates from
290
+
291
+ // ---------- 6. post-checks: the new CLI must actually work on the migrated tree ----------
292
+ console.log('— migration applied; running post-checks —\n')
293
+ const post: string[] = []
294
+ try {
295
+ const { specLint } = await import('./lint.js')
296
+ const findings = await specLint()
297
+ const errs = findings.filter((x) => x.level === 'error')
298
+ for (const x of findings) console.error(` ${x.level === 'error' ? '✗' : '•'} ${x.rule}: ${x.msg}`)
299
+ post.push(`spec lint: ${errs.length} error(s), ${findings.length - errs.length} warning(s)${errs.length ? ' — FIX BEFORE COMMITTING' : ''}`)
300
+ } catch (e) { post.push(`spec lint FAILED to run: ${(e as Error).message}`) }
301
+ try {
302
+ const { runEval } = await import('../../spec-eval/src/cli.js')
303
+ await runEval(['lint'])
304
+ post.push('eval lint: ran (advisory — findings above, if any)')
305
+ } catch (e) { post.push(`eval lint FAILED to run: ${(e as Error).message}`) }
306
+ try {
307
+ const { materialize } = await import('./materialize.js')
308
+ post.push(`materialize: ok (content-hash ${materialize(root)}) — hook manifest + contract now speak .plugins`)
309
+ } catch (e) { post.push(`materialize FAILED: ${(e as Error).message}`) }
310
+
311
+ // ---------- summary ----------
312
+ console.log('\n===== migration summary =====')
313
+ for (const s of summary) console.log(` ✓ ${s}`)
314
+ for (const s of post) console.log(` · ${s}`)
315
+ if (flags.length) {
316
+ console.log(`\n NEEDS REVIEW (${flags.length}) — reported, deliberately NOT rewritten:`)
317
+ for (const fl of flags) console.log(` ! ${fl.file}${fl.line ? `:${fl.line}` : ''} — ${fl.reason}`)
318
+ }
319
+ console.log(`\n Everything is STAGED, nothing committed. Undo entirely with: git reset --hard
320
+ Next steps:
321
+ 1. review the staged diff (git diff --cached) and every NEEDS REVIEW item above
322
+ 2. commit through the ritual (on a trunk this is topology surgery: SPEXCODE_ALLOW_MAIN=1 git commit)
323
+ 3. npm run hooks — in EVERY clone of this repo (git hooks are per-clone copies)
324
+ 4. full backend restart (the supervisor env must be respawned), then rebuild the dashboard dist
325
+ 5. if a node was renamed above, retarget OPEN forge issues' \`Spec:\` lines via your forge (closed ones are archive — leave them)`)
326
+ return 0
327
+ }
328
+
329
+ // the precondition gatherer — every check appends a refusal; the caller only proceeds on an empty list.
330
+ async function inspectAsync(cwd: string, refusals: string[]): Promise<World | null> {
331
+ const root = tryGit(cwd, ['rev-parse', '--show-toplevel'])
332
+ if (!root) { refusals.push('not inside a git repository.'); return null }
333
+ const gitDir = git(root, ['rev-parse', '--path-format=absolute', '--git-dir'])
334
+ const commonDir = git(root, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
335
+ if (gitDir !== commonDir) refusals.push(`this is a linked worktree (${root}) — run the migration from the main checkout.`)
336
+ const specDir = join(root, '.spec')
337
+ if (!existsSync(specDir)) { refusals.push(`no .spec/ at ${root} — this repo has not adopted SpexCode; nothing to migrate.`); return null }
338
+
339
+ const specRoots: string[] = [], configRoots: string[] = [], pluginRoots: string[] = []
340
+ for (const e of readdirSync(specDir, { withFileTypes: true })) {
341
+ if (!e.isDirectory() || e.name === '.issues') continue
342
+ const dir = join(specDir, e.name)
343
+ if (!existsSync(join(dir, 'spec.md'))) continue
344
+ specRoots.push(dir)
345
+ if (existsSync(join(dir, '.config'))) configRoots.push(dir)
346
+ if (existsSync(join(dir, '.plugins'))) pluginRoots.push(dir)
347
+ }
348
+ for (const p of pluginRoots) refusals.push(`${relative(root, p)}/.plugins already exists — this tree is already migrated${configRoots.length ? ' (and a .config root ALSO exists — half-migrated state, resolve by hand)' : ''}.`)
349
+
350
+ const yatsuFiles = walk(specDir).filter((f) => basename(f) === 'yatsu.md' || basename(f).endsWith('yatsu.evals.ndjson')) // dead-words-ok: the migrator names the old vocabulary it migrates from
351
+ if (!configRoots.length && !yatsuFiles.length && !pluginRoots.length)
352
+ refusals.push('nothing to migrate — no .config plugin root and no yatsu.md/yatsu.evals.ndjson files under .spec/ (this tree already speaks 0.3.0, or never had the 0.2.x vocabulary).') // dead-words-ok: the migrator names the old vocabulary it migrates from
353
+
354
+ if (!git(root, ['ls-files', '.spec']).length)
355
+ refusals.push('.spec is not tracked by git (an old `/.spec` exclude line?) — remove the exclude, `git add .spec` and commit it first, then re-run.')
356
+
357
+ const dirty = git(root, ['status', '--porcelain']).split('\n').filter((l) => l && !l.startsWith('??'))
358
+ if (dirty.length) refusals.push(`working tree not clean — commit or stash first:\n${dirty.slice(0, 10).map((l) => ' ' + l).join('\n')}${dirty.length > 10 ? `\n … +${dirty.length - 10} more` : ''}`)
359
+
360
+ try {
361
+ const { listSessionIds, readRawRecord } = await import('./layout.js')
362
+ const ids = listSessionIds()
363
+ if (ids.length) {
364
+ const rows = ids.map((id) => { const r = readRawRecord(id); return ` ${id} (${r?.status ?? 'unreadable'})` })
365
+ refusals.push(`${ids.length} session(s) still exist for this project — drain them first (merge, then \`spex session close <id>\`; a worktree branched pre-migration would conflict):\n${rows.join('\n')}`)
366
+ }
367
+ } catch (e) { refusals.push(`could not enumerate this project's sessions (${(e as Error).message}) — refusing to migrate blind.`) }
368
+
369
+ for (const dir of configRoots) if (existsSync(join(dir, '.plugins'))) refusals.push(`${relative(root, dir)}/.plugins already exists beside .config — resolve by hand.`)
370
+ for (const f of yatsuFiles) {
371
+ const target = basename(f) === 'yatsu.md' ? join(dirname(f), 'eval.md') : join(dirname(f), basename(f).replace(/yatsu\.evals\.ndjson$/, 'evals.ndjson')) // dead-words-ok: the migrator names the old vocabulary it migrates from
372
+ if (existsSync(target)) refusals.push(`rename collision: ${relative(root, target)} already exists beside ${relative(root, f)}.`)
373
+ }
374
+
375
+ let configNode: string | null = null, configNodeFlag: Flag | null = null
376
+ for (const dir of specRoots) {
377
+ const cand = join(dir, 'config')
378
+ if (!existsSync(join(cand, 'spec.md'))) continue
379
+ if (existsSync(join(dir, 'plugin-system'))) { refusals.push(`${relative(root, dir)}/plugin-system already exists beside config/ — resolve by hand.`); continue }
380
+ const sha = git(root, ['hash-object', join(cand, 'spec.md')])
381
+ if (CONFIG_NODE_SHAS.includes(sha)) configNode = cand
382
+ else configNodeFlag = { file: relative(root, join(cand, 'spec.md')), line: null, reason: 'a node named `config` exists but its spec.md matches no stock version of spexcode\'s plugin-system spec — left untouched (rename it by hand ONLY if it really is the plugin-system spec)' }
383
+ }
384
+
385
+ return { root, specRoots, configRoots, yatsuFiles, configNode, configNodeFlag }
386
+ }
@@ -5,7 +5,7 @@
5
5
  // is why they sit in flat plateaus rather than being fitted to any case.
6
6
  const W_NAME_PREFIX = 8
7
7
  const W_NAME_SUBSTR = 5
8
- const W_DESC = 2
8
+ const W_DESC = 2.2
9
9
  const W_BODY = 1
10
10
 
11
11
  // a tiny stoplist of question scaffolding + length-1 tokens, dropped so "how does the … is it …" can't drown
@@ -68,6 +68,24 @@ function stem(t: string): string {
68
68
  function nameMatch(term: string, w: string): boolean { return w.startsWith(stem(term)) }
69
69
  function textMatch(term: string, w: string): boolean { return w.startsWith(stem(term)) || (w.length >= 3 && term.startsWith(w)) }
70
70
 
71
+ // how strongly a query term's name-prefix hit counts, over a doc's name words: the FRACTION of the matched
72
+ // name word the query term actually spans. A full word (`port`→`port`, `api`→`api`) is 1.0 — the strong
73
+ // signal a name tier is for; a short query term that only PREFIXES a longer, unrelated word (`port`→`portable`,
74
+ // `governs`→`governed`) covers less of it and is weaker evidence, so it earns proportionally less than the
75
+ // full name weight. Floored at 0.5 (a prefix is still evidence, not noise). Returns 0 when no name word is
76
+ // prefixed. This is what stops a corpus-growth collision — a sibling whose NAME merely starts with a query
77
+ // word (portable-layout for "port", governed-related for "governs") — from outranking the node that owns the
78
+ // concept; the floor sat at an implicit 1.0 before, which let those partial prefixes score a full name hit.
79
+ const NAME_COVER_FLOOR = 0.5
80
+ function nameCover<T>(term: string, n: Fields<T>): number {
81
+ const st = stem(term)
82
+ let best = 0
83
+ for (const w of n.nameWords) {
84
+ if (w.startsWith(st)) { const r = st.length / w.length; if (r > best) best = r }
85
+ }
86
+ return best > 0 ? Math.max(NAME_COVER_FLOOR, best) : 0
87
+ }
88
+
71
89
  // classic BM25 tf: frequency with saturation (K1 sets how fast it saturates) and length-normalisation (B),
72
90
  // both in a wide insensitive plateau. tf=0 → 0.
73
91
  const K1 = 1.2
@@ -82,13 +100,16 @@ type Fields<T> = { ref: T; name: string; nameWords: string[]; desc: string; desc
82
100
 
83
101
  // the pre-IDF weight a term earns against one doc, picking its single best tier (three fields): a name
84
102
  // word-prefix beats a name substring beats a desc hit beats a body hit. Name is a short, chosen field →
85
- // binary presence. Desc is presence too (a curated one-liner repetition there is stuffing, not evidence)
103
+ // near-binary, scaled only by prefix COVERAGE (a full word counts full; a query term that is a mere prefix
104
+ // of a longer name word counts proportionally less — see nameCover). Desc is presence too (a curated
105
+ // one-liner — repetition there is stuffing, not evidence)
86
106
  // but LENGTH-NORMALISED: it was flat-binary until descs drifted long and a bloated desc became a cheat code
87
107
  // (one 60-word desc catches every query term a curated one-liner can't). bm25tf(1, avgLen, avgLen) = 1, so
88
108
  // a hit in an average-length desc scores exactly the old binary W_DESC — the normalisation only bites
89
109
  // outliers. The body keeps the full BM25-saturated term-frequency that discriminates the long ties.
90
110
  function tierWeight<T>(term: string, n: Fields<T>, avgBodyLen: number, avgDescLen: number): number {
91
- if (n.nameWords.some((w) => nameMatch(term, w))) return W_NAME_PREFIX
111
+ const cover = nameCover(term, n)
112
+ if (cover > 0) return W_NAME_PREFIX * cover
92
113
  if (n.name.includes(term)) return W_NAME_SUBSTR
93
114
  if (n.descWords.some((w) => textMatch(term, w))) return W_DESC * bm25tf(1, n.descWords.length, avgDescLen)
94
115
  const tf = n.bodyWords.reduce((c, w) => c + (textMatch(term, w) ? 1 : 0), 0)
@@ -156,7 +177,12 @@ export function rankDocs<T>(query: string, inputs: RankInput<T>[], opts: { limit
156
177
  const scored: Ranked<T>[] = []
157
178
  for (const n of docs) {
158
179
  let score = 0
159
- for (const t of qterms) score += tierWeight(t, n, avgBodyLen, avgDescLen) * idf[t]
180
+ // cap EACH term's contribution at W_NAME_PREFIX: idf and BM25 order the tiers WITHIN this ceiling, but no
181
+ // single query term may out-score a full name hit. A many-word question should be answered by the node
182
+ // that matches it BROADLY, not by one that spikes on a single rare word it happens to carry in its NAME
183
+ // (a `spex search`-named node swallowing "…searches specs…", an injected-* node swallowing "injected…") —
184
+ // the corpus-growth failure where one uncapped idf×name term buried a node matching more of the query.
185
+ for (const t of qterms) score += Math.min(tierWeight(t, n, avgBodyLen, avgDescLen) * idf[t], W_NAME_PREFIX)
160
186
  if (score <= 0) continue
161
187
  scored.push({ ref: n.ref, score: Math.round(score * 100) / 100, snippet: snippetFor(n.snippetText, n.desc, qterms) })
162
188
  }
@@ -0,0 +1,117 @@
1
+ // @@@ socket-level connection reaper ([[spec-cli]]) - the ONE mechanism that actually reaps abandoned
2
+ // sockets, because Node's own http.Server timeouts do not. MEASURED (eval server-reaps-abandoned-connections,
3
+ // minimal http.createServer on Node 20/22/24): `headersTimeout` and `requestTimeout` DO NOT reap an
4
+ // incomplete request — a slow-loris (TCP connect + partial headers, never completed) survives indefinitely
5
+ // past the `connectionsCheckingInterval` sweep; only `keepAliveTimeout` (the idle-between-requests case) ever
6
+ // fires. So the abandoned-connection pileup protection those options claim (the 135-conn starvation that
7
+ // wedged the public port and triggered the mass-restore cascade) was NOT delivered. This helper is the real
8
+ // mechanism: an explicit per-socket deadline at the server boundary, independent of the platform sweep.
9
+ //
10
+ // It keys on "no request has completed yet / idle between requests" — never on response DURATION — so a
11
+ // long-lived ESTABLISHED stream (the /api/graph/stream SSE, a terminal WebSocket upgrade) is exempt for as
12
+ // long as it streams. The lifecycle per socket:
13
+ // - on socket birth: arm the HEADER deadline — the socket must produce a fully-parsed request within it,
14
+ // else it is a slow-loris and gets destroyed.
15
+ // - on 'request' (headers complete → the request is in flight): disarm. An active request/response is
16
+ // never reaped, however long its body/response takes (a slow board build or a streaming SSE response).
17
+ // - when the response finishes/closes and no request is left in flight: re-arm the IDLE deadline (the
18
+ // keep-alive window). Another request disarms it again; silence past it reaps the idle keep-alive socket.
19
+ // - on 'upgrade' (WebSocket): mark exempt permanently — a persistent bidirectional stream, not a request
20
+ // that ever "completes".
21
+ //
22
+ // WHICH socket the deadline lives on is load-bearing. On a TLS server (the public gateway) 'connection'
23
+ // fires with the RAW TCP socket, but every 'request'/'upgrade' reports the wrapping TLSSocket — a deadline
24
+ // armed on the raw socket is unreachable from request handling, never disarms, and destroys every TLS
25
+ // connection at the header deadline no matter how alive it is (MEASURED, eval
26
+ // stream-survives-public-gateway: the dashboard's actively-pinging SSE + terminal WS through the https
27
+ // gateway died every ~30s — the "reconnecting…" storm). So per-socket state is keyed on the socket requests
28
+ // actually report: the TLSSocket (born at 'secureConnection') on a TLS server, the plain socket elsewhere.
29
+ // The raw pre-handshake phase gets its own header-deadline guard (a TCP connect that never completes the TLS
30
+ // handshake is the same slow-loris one layer down), linked raw→TLS by the connection's addr:port 4-tuple —
31
+ // public API only, no reliance on Node's private TLSSocket internals.
32
+ //
33
+ // The child's reaped close propagates back through the supervisor's raw-TCP proxy (which pairs socket
34
+ // halves), so a reaped upstream frees its public-side socket too — no separate raw timeout on the proxy,
35
+ // which would blind it to a legitimately-idle WS/SSE.
36
+ import type { Server as HttpServer } from 'node:http'
37
+ import type { Socket } from 'node:net'
38
+ import { Server as TlsServer } from 'node:tls'
39
+
40
+ export interface ReaperOptions {
41
+ // ms a freshly-connected (or post-response idle) socket has to START a new request before it is reaped as a
42
+ // slow-loris. Env: SPEXCODE_REAP_HEADER_MS. Default 30s.
43
+ headerMs?: number
44
+ // ms a keep-alive socket may sit idle BETWEEN requests before reaping. Env: SPEXCODE_REAP_IDLE_MS. Default 15s.
45
+ idleMs?: number
46
+ }
47
+
48
+ interface SocketState { timer?: NodeJS.Timeout; active: number; upgraded: boolean; arm(ms: number): void; disarm(): void }
49
+ const STATE = Symbol('spexcode.reaper')
50
+
51
+ function resolveMs(explicit: number | undefined, env: string | undefined, fallback: number): number {
52
+ if (typeof explicit === 'number' && explicit > 0) return explicit
53
+ const n = Number(env)
54
+ return Number.isFinite(n) && n > 0 ? n : fallback
55
+ }
56
+
57
+ // Attach the reaper to a Node http/https server. Call it right after the server is created (before or just
58
+ // after listen); it hooks the socket-birth event ('connection', or 'secureConnection' on TLS servers),
59
+ // 'request' and 'upgrade', and needs no changes to the request handlers.
60
+ export function installConnectionReaper(server: HttpServer, opts: ReaperOptions = {}): void {
61
+ const headerMs = resolveMs(opts.headerMs, process.env.SPEXCODE_REAP_HEADER_MS, 30000)
62
+ const idleMs = resolveMs(opts.idleMs, process.env.SPEXCODE_REAP_IDLE_MS, 15000)
63
+
64
+ // per-socket tracking, on the SAME socket object 'request'/'upgrade' will report (see header comment).
65
+ const track = (socket: Socket) => {
66
+ const state: SocketState = {
67
+ timer: undefined, active: 0, upgraded: false,
68
+ disarm() { if (state.timer) { clearTimeout(state.timer); state.timer = undefined } },
69
+ arm(ms: number) { state.disarm(); state.timer = setTimeout(() => socket.destroy(), ms); state.timer.unref?.() },
70
+ }
71
+ ;(socket as unknown as Record<symbol, SocketState>)[STATE] = state
72
+ state.arm(headerMs) // slow-loris guard: first request's headers must complete within headerMs
73
+ socket.once('close', state.disarm) // socket gone → drop its pending timer
74
+ }
75
+
76
+ if (server instanceof TlsServer) {
77
+ // TLS: requests report the TLSSocket, so that is where the deadline must live. The raw phase before the
78
+ // handshake completes still needs a guard of its own; the 4-tuple key hands it off to the TLSSocket.
79
+ const pendingHandshake = new Map<string, () => void>()
80
+ server.on('connection', (raw: Socket) => {
81
+ const key = `${raw.remoteAddress}:${raw.remotePort}`
82
+ const timer = setTimeout(() => raw.destroy(), headerMs)
83
+ timer.unref?.()
84
+ const done = () => { clearTimeout(timer); pendingHandshake.delete(key) }
85
+ pendingHandshake.set(key, done)
86
+ raw.once('close', done)
87
+ })
88
+ server.on('secureConnection', (tlsSocket) => {
89
+ pendingHandshake.get(`${tlsSocket.remoteAddress}:${tlsSocket.remotePort}`)?.()
90
+ track(tlsSocket as unknown as Socket)
91
+ })
92
+ } else {
93
+ server.on('connection', track)
94
+ }
95
+
96
+ server.on('request', (req, res) => {
97
+ const s = (req.socket as unknown as Record<symbol, SocketState>)[STATE]
98
+ if (!s) return
99
+ s.active++
100
+ s.disarm() // a request is in flight — never reap an active request/response
101
+ let ended = false
102
+ const done = () => {
103
+ if (ended) return
104
+ ended = true
105
+ s.active--
106
+ // response over and nothing else in flight → this is now an idle keep-alive socket; re-arm.
107
+ if (s.active === 0 && !s.upgraded && !req.socket.destroyed) s.arm(idleMs)
108
+ }
109
+ res.once('finish', done) // response fully sent
110
+ res.once('close', done) // response aborted / connection dropped
111
+ })
112
+
113
+ server.on('upgrade', (req) => {
114
+ const s = (req.socket as unknown as Record<symbol, SocketState>)[STATE]
115
+ if (s) { s.upgraded = true; s.disarm() } // persistent stream — exempt for its lifetime
116
+ })
117
+ }
@@ -1,5 +1,5 @@
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.
1
+ // throwaway benchmark harness for spec-search — drives the REAL `spex spec search --json` over the holdout
2
+ // cases and reports recall@1, recall@3, MRR. The cases live in the node's eval.md.
3
3
  //
4
4
  // Labels are node LEAF names, matched with the same de-collision rule the loader applies (specs.ts reId):
5
5
  // a returned id matches a label if it IS the label or ends with `_<label>` — so a bare leaf keeps matching
@@ -21,7 +21,7 @@ const CASES = [
21
21
  ['session-order', 'how is the order of sessions in the session list decided?', ['session-console']],
22
22
  ['node-status', 'what makes a node show as pending vs active vs merged vs drift?', ['spec-node-states']],
23
23
  ['dashboard-backend', 'how does the dashboard reach the backend API and on which port?', ['api-endpoint']],
24
- ['loss-measured', "how is a node's loss measured and its scenarios scored?", ['yatsu-core']],
24
+ ['loss-measured', "how is a node's loss measured and its scenarios scored?", ['eval-core']],
25
25
  ['launch-injection', "what context gets injected into a freshly launched agent's prompt?", ['injected-context']],
26
26
  ['read-before-code', 'the one-shot nudge that makes an agent read its spec before touching code', ['spec-first']],
27
27
  ['hot-reload', 'zero-downtime backend reload without dropping connections', ['supervisor']],
@@ -36,7 +36,7 @@ const matches = (id, label) => id === label || id.endsWith('_' + label)
36
36
  let r1 = 0, r3 = 0, mrr = 0
37
37
  const rows = []
38
38
  for (const [name, query, expect] of CASES) {
39
- const out = execFileSync('node', [BIN, 'search', query, '--json', '--limit', '10'], { encoding: 'utf8' })
39
+ const out = execFileSync('node', [BIN, 'spec', 'search', query, '--json', '--limit', '10'], { encoding: 'utf8' })
40
40
  const results = JSON.parse(out)
41
41
  const ids = results.map((x) => x.id)
42
42
  const rank = ids.findIndex((id) => expect.some((label) => matches(id, label))) + 1 // 1-based; 0 = not found
@@ -55,9 +55,9 @@ for (const row of rows) {
55
55
 
56
56
  // CJK-positive: the corpus is mostly English but a few nodes carry Chinese prose — the root `spexcode`
57
57
  // node's body repeats 节点 throughout. A per-CJK-character tokenizer must let a Chinese content word reach
58
- // that node; `spex search "节点"` MUST return `spexcode` in results (the bug: the old tokenizer split on
58
+ // that node; `spex spec search "节点"` MUST return `spexcode` in results (the bug: the old tokenizer split on
59
59
  // [^a-z0-9]+ and discarded all CJK, so every Chinese query dead-ended at zero results).
60
- const cjkPos = JSON.parse(execFileSync('node', [BIN, 'search', '节点', '--json', '--limit', '10'], { encoding: 'utf8' }))
60
+ const cjkPos = JSON.parse(execFileSync('node', [BIN, 'spec', 'search', '节点', '--json', '--limit', '10'], { encoding: 'utf8' }))
61
61
  const cjkPosRank = cjkPos.map((x) => x.id).findIndex((id) => matches(id, 'spexcode')) + 1
62
62
  const cjkPosPass = cjkPosRank >= 1
63
63
  console.log(`${cjkPosPass ? `✓${cjkPosRank}` : '✗ '} cjk-positive want=spexcode(节点) rank=${cjkPosRank || '—'} top3: ${cjkPos.slice(0, 3).map((x) => x.id).join(', ')}`)
@@ -66,14 +66,14 @@ console.log(`${cjkPosPass ? `✓${cjkPosRank}` : '✗ '} cjk-positive w
66
66
  // nothing, and the zero-result message must still carry the corpus-is-English translate-and-retry fact
67
67
  // (fail-loud) plus the browse-all next step — CJK support does not suppress the honest zero-result route.
68
68
  // (No nearest titles: a CJK query has nothing to be lexically near.)
69
- const cjk = execFileSync('node', [BIN, 'search', '会话'], { encoding: 'utf8' })
70
- const cjkPass = cjk.includes('corpus is English') && cjk.includes('spex tree')
69
+ const cjk = execFileSync('node', [BIN, 'spec', 'search', '会话'], { encoding: 'utf8' })
70
+ const cjkPass = cjk.includes('corpus is English') && cjk.includes('spex graph')
71
71
  console.log(`${cjkPass ? '✓ ' : '✗ '} cjk-zero-result want=corpus-is-English + spex-tree ${cjkPass ? 'both present' : 'MISSING: ' + cjk.trim()}`)
72
72
 
73
73
  // zero-result typo routing: an English typo that matches nothing must surface the nearest node titles
74
74
  // (per-word edit distance) plus the browse-all next step, so a typo routes forward instead of dead-ending.
75
- const typo = execFileSync('node', [BIN, 'search', 'kyeboard'], { encoding: 'utf8' })
76
- const typoPass = typo.includes('nearest titles') && typo.includes('keyboard-nav') && typo.includes('spex tree')
75
+ const typo = execFileSync('node', [BIN, 'spec', 'search', 'kyeboard'], { encoding: 'utf8' })
76
+ const typoPass = typo.includes('nearest titles') && typo.includes('keyboard-nav') && typo.includes('spex graph')
77
77
  console.log(`${typoPass ? '✓ ' : '✗ '} typo-zero-result want=nearest-titles(keyboard-nav) + spex-tree ${typoPass ? 'both present' : 'MISSING: ' + typo.trim()}`)
78
78
 
79
79
  console.log('—'.repeat(72))