@spexcode/spec-cli 0.6.5

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 (201) hide show
  1. package/README.md +95 -0
  2. package/bin/spex.mjs +76 -0
  3. package/dist/attach.d.ts +2 -0
  4. package/dist/attach.js +48 -0
  5. package/dist/claude-headless.d.ts +33 -0
  6. package/dist/claude-headless.js +307 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +1859 -0
  9. package/dist/client.d.ts +111 -0
  10. package/dist/client.js +476 -0
  11. package/dist/cockpit.d.ts +7 -0
  12. package/dist/cockpit.js +26 -0
  13. package/dist/codex-headless.d.ts +1 -0
  14. package/dist/codex-headless.js +7 -0
  15. package/dist/codex-runtime-generations.d.ts +53 -0
  16. package/dist/codex-runtime-generations.js +686 -0
  17. package/dist/commit-surgery.d.ts +1 -0
  18. package/dist/commit-surgery.js +90 -0
  19. package/dist/contract-filter.d.ts +14 -0
  20. package/dist/contract-filter.js +219 -0
  21. package/dist/dashboard-assets.d.ts +6 -0
  22. package/dist/dashboard-assets.js +50 -0
  23. package/dist/delivery-queue.d.ts +23 -0
  24. package/dist/delivery-queue.js +179 -0
  25. package/dist/doctor.d.ts +9 -0
  26. package/dist/doctor.js +547 -0
  27. package/dist/eval-host.d.ts +1 -0
  28. package/dist/eval-host.js +19 -0
  29. package/dist/execution-trace.d.ts +26 -0
  30. package/dist/execution-trace.js +475 -0
  31. package/dist/file-write.d.ts +2 -0
  32. package/dist/file-write.js +26 -0
  33. package/dist/flat.d.ts +89 -0
  34. package/dist/flat.js +1076 -0
  35. package/dist/gateway-auth.d.ts +56 -0
  36. package/dist/gateway-auth.js +168 -0
  37. package/dist/gateway-hub.d.ts +28 -0
  38. package/dist/gateway-hub.js +361 -0
  39. package/dist/gateway.d.ts +37 -0
  40. package/dist/gateway.js +503 -0
  41. package/dist/graphCache.d.ts +27 -0
  42. package/dist/graphCache.js +716 -0
  43. package/dist/graphSnapshot.d.ts +14 -0
  44. package/dist/graphSnapshot.js +35 -0
  45. package/dist/graphStream.d.ts +85 -0
  46. package/dist/graphStream.js +1195 -0
  47. package/dist/guidance-catalog.d.ts +64 -0
  48. package/dist/guidance-catalog.js +166 -0
  49. package/dist/guide.d.ts +8 -0
  50. package/dist/guide.js +684 -0
  51. package/dist/harness-select.d.ts +18 -0
  52. package/dist/harness-select.js +62 -0
  53. package/dist/harness.d.ts +325 -0
  54. package/dist/harness.js +3026 -0
  55. package/dist/headless-controller.d.ts +9 -0
  56. package/dist/headless-controller.js +47 -0
  57. package/dist/help.d.ts +10 -0
  58. package/dist/help.js +574 -0
  59. package/dist/hook-prompts.d.ts +18 -0
  60. package/dist/hook-prompts.js +122 -0
  61. package/dist/hooks.d.ts +1 -0
  62. package/dist/hooks.js +27 -0
  63. package/dist/host-resources.d.ts +97 -0
  64. package/dist/host-resources.js +795 -0
  65. package/dist/host.d.ts +88 -0
  66. package/dist/host.js +663 -0
  67. package/dist/index.d.ts +1 -0
  68. package/dist/index.js +899 -0
  69. package/dist/init.d.ts +1 -0
  70. package/dist/init.js +284 -0
  71. package/dist/issues-cli.d.ts +6 -0
  72. package/dist/issues-cli.js +334 -0
  73. package/dist/issues.d.ts +69 -0
  74. package/dist/issues.js +189 -0
  75. package/dist/lint.d.ts +34 -0
  76. package/dist/lint.js +476 -0
  77. package/dist/listen.d.ts +7 -0
  78. package/dist/listen.js +24 -0
  79. package/dist/localIssues.d.ts +65 -0
  80. package/dist/localIssues.js +582 -0
  81. package/dist/login-page.d.ts +5 -0
  82. package/dist/login-page.js +86 -0
  83. package/dist/loop-in.d.ts +23 -0
  84. package/dist/loop-in.js +68 -0
  85. package/dist/machine-peer.d.ts +69 -0
  86. package/dist/machine-peer.js +603 -0
  87. package/dist/materialize.d.ts +18 -0
  88. package/dist/materialize.js +468 -0
  89. package/dist/mentions.d.ts +50 -0
  90. package/dist/mentions.js +117 -0
  91. package/dist/opencode-headless.d.ts +4 -0
  92. package/dist/opencode-headless.js +208 -0
  93. package/dist/opencode.d.ts +3 -0
  94. package/dist/opencode.js +142 -0
  95. package/dist/pi-harness.d.ts +3 -0
  96. package/dist/pi-harness.js +129 -0
  97. package/dist/pi-headless.d.ts +25 -0
  98. package/dist/pi-headless.js +196 -0
  99. package/dist/plugin-harness.d.ts +21 -0
  100. package/dist/plugin-harness.js +146 -0
  101. package/dist/pty-bridge.d.ts +10 -0
  102. package/dist/pty-bridge.js +404 -0
  103. package/dist/pty-helper.d.mts +1 -0
  104. package/dist/pty-helper.mjs +101 -0
  105. package/dist/pty-native-helper.d.mts +2 -0
  106. package/dist/pty-native-helper.mjs +23 -0
  107. package/dist/public-graph.d.ts +46 -0
  108. package/dist/public-graph.js +56 -0
  109. package/dist/ranker.d.ts +15 -0
  110. package/dist/ranker.js +188 -0
  111. package/dist/reaper.d.ts +6 -0
  112. package/dist/reaper.js +81 -0
  113. package/dist/reviews.d.ts +189 -0
  114. package/dist/reviews.js +350 -0
  115. package/dist/runtime-guard.d.ts +6 -0
  116. package/dist/runtime-guard.js +37 -0
  117. package/dist/runtime-ownership.d.ts +21 -0
  118. package/dist/runtime-ownership.js +84 -0
  119. package/dist/search.bench.d.mts +1 -0
  120. package/dist/search.bench.mjs +75 -0
  121. package/dist/search.d.ts +20 -0
  122. package/dist/search.js +61 -0
  123. package/dist/session-cursors.d.ts +14 -0
  124. package/dist/session-cursors.js +82 -0
  125. package/dist/session-declarations.d.ts +13 -0
  126. package/dist/session-declarations.js +126 -0
  127. package/dist/session-execution.d.ts +10 -0
  128. package/dist/session-execution.js +70 -0
  129. package/dist/session-files.d.ts +28 -0
  130. package/dist/session-files.js +130 -0
  131. package/dist/session-follow.d.ts +31 -0
  132. package/dist/session-follow.js +179 -0
  133. package/dist/session-reparent.d.ts +10 -0
  134. package/dist/session-reparent.js +33 -0
  135. package/dist/session-timeline.d.ts +51 -0
  136. package/dist/session-timeline.js +233 -0
  137. package/dist/session-web.d.ts +24 -0
  138. package/dist/session-web.js +141 -0
  139. package/dist/sessions.d.ts +412 -0
  140. package/dist/sessions.js +4239 -0
  141. package/dist/sh.d.ts +1 -0
  142. package/dist/sh.js +3 -0
  143. package/dist/shim-runtime.d.ts +1 -0
  144. package/dist/shim-runtime.js +161 -0
  145. package/dist/slash-commands.d.ts +9 -0
  146. package/dist/slash-commands.js +307 -0
  147. package/dist/source-files.d.ts +9 -0
  148. package/dist/source-files.js +113 -0
  149. package/dist/supervise.d.ts +1 -0
  150. package/dist/supervise.js +308 -0
  151. package/dist/tree.d.ts +25 -0
  152. package/dist/tree.js +98 -0
  153. package/dist/tsx-bin.d.ts +3 -0
  154. package/dist/tsx-bin.js +41 -0
  155. package/dist/uninstall.d.ts +3 -0
  156. package/dist/uninstall.js +177 -0
  157. package/dist/uploads.d.ts +36 -0
  158. package/dist/uploads.js +243 -0
  159. package/dist/worktree-sources.d.ts +1 -0
  160. package/dist/worktree-sources.js +47 -0
  161. package/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  162. package/hooks/compat/mark-active-sed-v0.fixture +46 -0
  163. package/hooks/dispatch.sh +105 -0
  164. package/hooks/harness.sh +272 -0
  165. package/package.json +47 -0
  166. package/templates/hooks/commit-msg +14 -0
  167. package/templates/hooks/post-checkout +25 -0
  168. package/templates/hooks/post-merge +34 -0
  169. package/templates/hooks/pre-commit +120 -0
  170. package/templates/hooks/prepare-commit-msg +35 -0
  171. package/templates/hooks/reference-transaction +105 -0
  172. package/templates/spec/project/.plugins/commands/extract/spec.md +66 -0
  173. package/templates/spec/project/.plugins/commands/regroup/spec.md +25 -0
  174. package/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  175. package/templates/spec/project/.plugins/commands/spec.md +30 -0
  176. package/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  177. package/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
  178. package/templates/spec/project/.plugins/core/comment-altitude/spec.md +9 -0
  179. package/templates/spec/project/.plugins/core/idle/idle.sh +15 -0
  180. package/templates/spec/project/.plugins/core/idle/spec.md +13 -0
  181. package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +59 -0
  182. package/templates/spec/project/.plugins/core/mark-active/spec.md +22 -0
  183. package/templates/spec/project/.plugins/core/session-fail/fail.sh +12 -0
  184. package/templates/spec/project/.plugins/core/session-fail/spec.md +15 -0
  185. package/templates/spec/project/.plugins/core/spec-first/spec-first.sh +43 -0
  186. package/templates/spec/project/.plugins/core/spec-first/spec.md +17 -0
  187. package/templates/spec/project/.plugins/core/spec-of-file/spec-of-file.sh +66 -0
  188. package/templates/spec/project/.plugins/core/spec-of-file/spec.md +15 -0
  189. package/templates/spec/project/.plugins/core/spec.md +22 -0
  190. package/templates/spec/project/.plugins/core/stop-gate/spec.md +23 -0
  191. package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +149 -0
  192. package/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +15 -0
  193. package/templates/spec/project/.plugins/prompts/forge-link/spec.md +10 -0
  194. package/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +13 -0
  195. package/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +12 -0
  196. package/templates/spec/project/.plugins/prompts/spec.md +24 -0
  197. package/templates/spec/project/.plugins/skills/distill/digest.mjs +136 -0
  198. package/templates/spec/project/.plugins/skills/distill/spec.md +67 -0
  199. package/templates/spec/project/.plugins/skills/spec.md +19 -0
  200. package/templates/spec/project/.plugins/spec.md +23 -0
  201. package/templates/spec/project/spec.md +20 -0
package/dist/ranker.js ADDED
@@ -0,0 +1,188 @@
1
+ // tier multipliers, name > desc > body. A doc's NAME is the strongest signal, its one-line DESC a curated
2
+ // summary (next), its BODY the weakest per-hit — but the body carries BM25 term-frequency so a doc that
3
+ // genuinely concentrates a rare word still climbs. IDF scales all three. The spread only ORDERS the tiers;
4
+ // the discriminating magnitude comes from rarity (IDF) and body-density (BM25), not these constants — which
5
+ // is why they sit in flat plateaus rather than being fitted to any case.
6
+ const W_NAME_PREFIX = 8;
7
+ const W_NAME_SUBSTR = 5;
8
+ const W_DESC = 2.2;
9
+ const W_BODY = 1;
10
+ // a tiny stoplist of question scaffolding + length-1 tokens, dropped so "how does the … is it …" can't drown
11
+ // the content words. Deliberately small and general — NOT tuned to any benchmark; just the function words a
12
+ // natural-language query carries that match nothing meaningful. Quantifiers (many, several, same, too…) are
13
+ // NOT stopped: in this corpus they are load-bearing ("too many owners" IS the multi-ownership concept —
14
+ // dropping them measurably breaks that reach).
15
+ const STOP = new Set([
16
+ 'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'is', 'it', 'its', 'as', 'at', 'by', 'for',
17
+ 'how', 'does', 'do', 'what', 'which', 'that', 'this', 'these', 'those', 'with', 'from', 'into', 'are',
18
+ 'be', 'can', 'just', 'them', 'they', 'their', 'so', 'if', 'not', 'no', 'but', 'vs', 'us', 'we', 'you',
19
+ ]);
20
+ // The corpus is mostly English but not only — some nodes carry CJK prose (the root spexcode node is a whole
21
+ // Chinese paragraph), and the dashboard palette ([[shared-ranker]]) ranks session/issue titles that are
22
+ // frequently Chinese. CJK has no spaces, so a whitespace/`[^a-z0-9]` split silently discards ALL of it. We
23
+ // tokenize the SAME way on both sides: an ASCII alphanumeric run is one token; each CJK character is its own
24
+ // token (a unigram). Unigrams — not bigrams — keep the shared prefix-match/IDF/BM25 machinery untouched (a
25
+ // single-char query still matches, no bigram edge cases) and stay BLUNT & ROBUST, the floor's whole stance.
26
+ // Han (incl. Ext-A + compat) plus Japanese kana; enough to cover the CJK a spec body or a session title carries.
27
+ const CJK = '\\u3400-\\u4dbf\\u4e00-\\u9fff\\uf900-\\ufaff\\u3040-\\u30ff'; // Ext-A · Unified · Compat · kana
28
+ const TOKEN_RE = new RegExp(`[a-z0-9]+|[${CJK}]`, 'g');
29
+ const CJK_RE = new RegExp(`[${CJK}]`);
30
+ function isCjk(t) { return CJK_RE.test(t); }
31
+ function tokenize(text) {
32
+ return text.toLowerCase().match(TOKEN_RE) ?? [];
33
+ }
34
+ // tokenize, lowercase, drop stopwords + length-1 ASCII tokens (a length-1 CJK token is a real word — keep it), de-dup.
35
+ export function terms(query) {
36
+ const seen = new Set();
37
+ for (const w of tokenize(query)) {
38
+ if (isCjk(w) || (w.length > 1 && !STOP.has(w)))
39
+ seen.add(w);
40
+ }
41
+ return [...seen];
42
+ }
43
+ // the words of a field, lowercased — used for word-boundary (prefix-of-a-word) matching, which kills
44
+ // short-token pollution (`main` must not match inside `domain`); CJK chars are single-char words.
45
+ function words(text) {
46
+ return tokenize(text);
47
+ }
48
+ // light query-side stem for prefix matching: drop a trailing plural 's' (len≥4, not 'ss') then a mute 'e'
49
+ // (len≥5) — so `sessions` prefix-reaches `session`, `merge`→`merg` reaches `merging`, `declare`→`declar`
50
+ // reaches `declaration`. Without the e-drop the spec's promised merge↔merging reach silently never worked
51
+ // (`'merging'.startsWith('merge')` is false). Query-side only; IDF self-neutralises the extra reach (a
52
+ // looser term matches more docs → bigger df → smaller idf), so no flood.
53
+ function stem(t) {
54
+ let s = t;
55
+ if (s.length >= 4 && s.endsWith('s') && !s.endsWith('ss'))
56
+ s = s.slice(0, -1);
57
+ if (s.length >= 5 && s.endsWith('e'))
58
+ s = s.slice(0, -1);
59
+ return s;
60
+ }
61
+ // name match is forward-only (a chosen short field — reverse would let a stray short word swallow it);
62
+ // desc/body match bidirectionally so a longer doc word still reaches a shorter query term, reverse gated to
63
+ // words ≥3 chars so a stray short word can't swallow a longer term (IDF neutralises the generic words it
64
+ // pulls in).
65
+ function nameMatch(term, w) { return w.startsWith(stem(term)); }
66
+ function textMatch(term, w) { return w.startsWith(stem(term)) || (w.length >= 3 && term.startsWith(w)); }
67
+ // how strongly a query term's name-prefix hit counts, over a doc's name words: the FRACTION of the matched
68
+ // name word the query term actually spans. A full word (`port`→`port`, `api`→`api`) is 1.0 — the strong
69
+ // signal a name tier is for; a short query term that only PREFIXES a longer, unrelated word (`port`→`portable`,
70
+ // `governs`→`governed`) covers less of it and is weaker evidence, so it earns proportionally less than the
71
+ // full name weight. Floored at 0.5 (a prefix is still evidence, not noise). Returns 0 when no name word is
72
+ // prefixed. This is what stops a corpus-growth collision — a sibling whose NAME merely starts with a query
73
+ // word (portable-layout for "port", governed-related for "governs") — from outranking the node that owns the
74
+ // concept; the floor sat at an implicit 1.0 before, which let those partial prefixes score a full name hit.
75
+ const NAME_COVER_FLOOR = 0.5;
76
+ function nameCover(term, n) {
77
+ const st = stem(term);
78
+ let best = 0;
79
+ for (const w of n.nameWords) {
80
+ if (w.startsWith(st)) {
81
+ const r = st.length / w.length;
82
+ if (r > best)
83
+ best = r;
84
+ }
85
+ }
86
+ return best > 0 ? Math.max(NAME_COVER_FLOOR, best) : 0;
87
+ }
88
+ // classic BM25 tf: frequency with saturation (K1 sets how fast it saturates) and length-normalisation (B),
89
+ // both in a wide insensitive plateau. tf=0 → 0.
90
+ const K1 = 1.2;
91
+ const B = 0.4;
92
+ function bm25tf(tf, len, avgLen) {
93
+ if (tf <= 0)
94
+ return 0;
95
+ return (tf * (K1 + 1)) / (tf + K1 * (1 - B + (B * len) / (avgLen || 1)));
96
+ }
97
+ // the pre-IDF weight a term earns against one doc, picking its single best tier (three fields): a name
98
+ // word-prefix beats a name substring beats a desc hit beats a body hit. Name is a short, chosen field →
99
+ // near-binary, scaled only by prefix COVERAGE (a full word counts full; a query term that is a mere prefix
100
+ // of a longer name word counts proportionally less — see nameCover). Desc is presence too (a curated
101
+ // one-liner — repetition there is stuffing, not evidence)
102
+ // but LENGTH-NORMALISED: it was flat-binary until descs drifted long and a bloated desc became a cheat code
103
+ // (one 60-word desc catches every query term a curated one-liner can't). bm25tf(1, avgLen, avgLen) = 1, so
104
+ // a hit in an average-length desc scores exactly the old binary W_DESC — the normalisation only bites
105
+ // outliers. The body keeps the full BM25-saturated term-frequency that discriminates the long ties.
106
+ function tierWeight(term, n, avgBodyLen, avgDescLen) {
107
+ const cover = nameCover(term, n);
108
+ if (cover > 0)
109
+ return W_NAME_PREFIX * cover;
110
+ if (n.name.includes(term))
111
+ return W_NAME_SUBSTR;
112
+ if (n.descWords.some((w) => textMatch(term, w)))
113
+ return W_DESC * bm25tf(1, n.descWords.length, avgDescLen);
114
+ const tf = n.bodyWords.reduce((c, w) => c + (textMatch(term, w) ? 1 : 0), 0);
115
+ return W_BODY * bm25tf(tf, n.bodyWords.length, avgBodyLen);
116
+ }
117
+ // a short single-line window of prose around the FIRST matched term, so a reader sees WHY it matched. Falls
118
+ // back to the desc (then the text head) when only the name matched. Collapsed to one line, ~window chars.
119
+ function snippetFor(text, desc, qterms, window = 140) {
120
+ const flat = text.replace(/\s+/g, ' ').trim();
121
+ const lower = flat.toLowerCase();
122
+ let at = -1;
123
+ for (const t of qterms) {
124
+ // ASCII terms locate at a word boundary (so `main` doesn't hit inside `domain`); a CJK term has no
125
+ // `\b` around it (JS `\b` is ASCII-only), so locate it by plain substring.
126
+ const i = isCjk(t) ? lower.indexOf(t) : lower.search(new RegExp('\\b' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
127
+ if (i >= 0 && (at < 0 || i < at))
128
+ at = i;
129
+ }
130
+ if (at < 0) {
131
+ const fb = (desc || flat).replace(/\s+/g, ' ').trim();
132
+ return fb.length > window ? fb.slice(0, window).trimEnd() + '…' : fb;
133
+ }
134
+ const start = Math.max(0, at - Math.floor(window / 3));
135
+ let s = flat.slice(start, start + window).trim();
136
+ if (start > 0)
137
+ s = '…' + s;
138
+ if (start + window < flat.length)
139
+ s = s + '…';
140
+ return s;
141
+ }
142
+ // the shared entrypoint: sum each query term's best-tier weight × IDF, keep docs hitting ≥1 term, sort by
143
+ // score desc (stable — equal scores keep the caller's pre-sorted input order), cap to `limit` (default 10).
144
+ export function rankDocs(query, inputs, opts = {}) {
145
+ const limit = opts.limit ?? 10;
146
+ const qterms = terms(query);
147
+ if (!qterms.length)
148
+ return [];
149
+ const docs = inputs.map((d) => {
150
+ const name = d.name.toLowerCase();
151
+ return {
152
+ ref: d.ref, name, nameWords: words(name),
153
+ desc: d.desc.toLowerCase(), descWords: words(d.desc),
154
+ bodyWords: words(d.body),
155
+ snippetText: `${d.desc}\n${d.body}`,
156
+ };
157
+ });
158
+ // IDF per query term: df = docs containing it (any field), idf = ln(N/df) — a term in every doc scores 0,
159
+ // a rare one carries the rank. Read from the corpus, not hand-set.
160
+ const N = docs.length;
161
+ const avgBodyLen = docs.reduce((a, n) => a + n.bodyWords.length, 0) / (N || 1);
162
+ const avgDescLen = docs.reduce((a, n) => a + n.descWords.length, 0) / (N || 1);
163
+ const idf = {};
164
+ for (const t of qterms) {
165
+ let df = 0;
166
+ for (const n of docs) {
167
+ if (n.nameWords.some((w) => nameMatch(t, w)) || n.descWords.some((w) => textMatch(t, w)) || n.bodyWords.some((w) => textMatch(t, w)))
168
+ df++;
169
+ }
170
+ idf[t] = df > 0 ? Math.log(N / df) : 0;
171
+ }
172
+ const scored = [];
173
+ for (const n of docs) {
174
+ let score = 0;
175
+ // cap EACH term's contribution at W_NAME_PREFIX: idf and BM25 order the tiers WITHIN this ceiling, but no
176
+ // single query term may out-score a full name hit. A many-word question should be answered by the node
177
+ // that matches it BROADLY, not by one that spikes on a single rare word it happens to carry in its NAME
178
+ // (a `spex search`-named node swallowing "…searches specs…", an injected-* node swallowing "injected…") —
179
+ // the corpus-growth failure where one uncapped idf×name term buried a node matching more of the query.
180
+ for (const t of qterms)
181
+ score += Math.min(tierWeight(t, n, avgBodyLen, avgDescLen) * idf[t], W_NAME_PREFIX);
182
+ if (score <= 0)
183
+ continue;
184
+ scored.push({ ref: n.ref, score: Math.round(score * 100) / 100, snippet: snippetFor(n.snippetText, n.desc, qterms) });
185
+ }
186
+ scored.sort((a, b) => b.score - a.score); // stable: equal scores keep the caller's pre-sorted input order
187
+ return scored.slice(0, limit);
188
+ }
@@ -0,0 +1,6 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ export interface ReaperOptions {
3
+ headerMs?: number;
4
+ idleMs?: number;
5
+ }
6
+ export declare function installConnectionReaper(server: HttpServer, opts?: ReaperOptions): void;
package/dist/reaper.js ADDED
@@ -0,0 +1,81 @@
1
+ import { Server as TlsServer } from 'node:tls';
2
+ const STATE = Symbol('spexcode.reaper');
3
+ function resolveMs(explicit, env, fallback) {
4
+ if (typeof explicit === 'number' && explicit > 0)
5
+ return explicit;
6
+ const n = Number(env);
7
+ return Number.isFinite(n) && n > 0 ? n : fallback;
8
+ }
9
+ // Attach the reaper to a Node http/https server. Call it right after the server is created (before or just
10
+ // after listen); it hooks the socket-birth event ('connection', or 'secureConnection' on TLS servers),
11
+ // 'request' and 'upgrade', and needs no changes to the request handlers.
12
+ export function installConnectionReaper(server, opts = {}) {
13
+ const headerMs = resolveMs(opts.headerMs, process.env.SPEXCODE_REAP_HEADER_MS, 30000);
14
+ const idleMs = resolveMs(opts.idleMs, process.env.SPEXCODE_REAP_IDLE_MS, 15000);
15
+ // claim single ownership of the phases the reaper covers (see header comment): Node's overlapping
16
+ // timeouts would otherwise race these deadlines and silently cap the env knobs. `requestTimeout` is
17
+ // deliberately LEFT at Node's default (~5 min): it bounds the in-flight request-body phase the reaper
18
+ // exempts (a silently-abandoned mid-body upload has no other reaper), and 5 min shadows no sane knob.
19
+ server.headersTimeout = 0;
20
+ server.keepAliveTimeout = 0;
21
+ // per-socket tracking, on the SAME socket object 'request'/'upgrade' will report (see header comment).
22
+ const track = (socket) => {
23
+ const state = {
24
+ timer: undefined, active: 0, upgraded: false,
25
+ disarm() { if (state.timer) {
26
+ clearTimeout(state.timer);
27
+ state.timer = undefined;
28
+ } },
29
+ arm(ms) { state.disarm(); state.timer = setTimeout(() => socket.destroy(), ms); state.timer.unref?.(); },
30
+ };
31
+ socket[STATE] = state;
32
+ state.arm(headerMs); // slow-loris guard: first request's headers must complete within headerMs
33
+ socket.once('close', state.disarm); // socket gone → drop its pending timer
34
+ };
35
+ if (server instanceof TlsServer) {
36
+ // TLS: requests report the TLSSocket, so that is where the deadline must live. The raw phase before the
37
+ // handshake completes still needs a guard of its own; the 4-tuple key hands it off to the TLSSocket.
38
+ const pendingHandshake = new Map();
39
+ server.on('connection', (raw) => {
40
+ const key = `${raw.remoteAddress}:${raw.remotePort}`;
41
+ const timer = setTimeout(() => raw.destroy(), headerMs);
42
+ timer.unref?.();
43
+ const done = () => { clearTimeout(timer); pendingHandshake.delete(key); };
44
+ pendingHandshake.set(key, done);
45
+ raw.once('close', done);
46
+ });
47
+ server.on('secureConnection', (tlsSocket) => {
48
+ pendingHandshake.get(`${tlsSocket.remoteAddress}:${tlsSocket.remotePort}`)?.();
49
+ track(tlsSocket);
50
+ });
51
+ }
52
+ else {
53
+ server.on('connection', track);
54
+ }
55
+ server.on('request', (req, res) => {
56
+ const s = req.socket[STATE];
57
+ if (!s)
58
+ return;
59
+ s.active++;
60
+ s.disarm(); // a request is in flight — never reap an active request/response
61
+ let ended = false;
62
+ const done = () => {
63
+ if (ended)
64
+ return;
65
+ ended = true;
66
+ s.active--;
67
+ // response over and nothing else in flight → this is now an idle keep-alive socket; re-arm.
68
+ if (s.active === 0 && !s.upgraded && !req.socket.destroyed)
69
+ s.arm(idleMs);
70
+ };
71
+ res.once('finish', done); // response fully sent
72
+ res.once('close', done); // response aborted / connection dropped
73
+ });
74
+ server.on('upgrade', (req) => {
75
+ const s = req.socket[STATE];
76
+ if (s) {
77
+ s.upgraded = true;
78
+ s.disarm();
79
+ } // persistent stream — exempt for its lifetime
80
+ });
81
+ }
@@ -0,0 +1,189 @@
1
+ import { type SessionEvalOrderRow, type SessionEvals } from '@spexcode/spec-eval/sessioneval';
2
+ import { evalTimeline } from '@spexcode/spec-eval/evaltab';
3
+ export declare const REVIEW_PER_PAGE = 25;
4
+ type ReviewItem = Record<string, unknown>;
5
+ export type ReviewCount = number | Record<string, number>;
6
+ type ReviewOption = {
7
+ value: string;
8
+ label?: string;
9
+ count?: number;
10
+ };
11
+ type ReviewFacet = {
12
+ key: string;
13
+ label?: string;
14
+ value: string;
15
+ meaningful?: boolean;
16
+ options: ReviewOption[];
17
+ };
18
+ type EvalNeighbor = {
19
+ node: string;
20
+ scenario: string;
21
+ state: string;
22
+ };
23
+ export type PagedReview<T extends ReviewItem = ReviewItem> = {
24
+ items: T[];
25
+ page: number;
26
+ perPage: number;
27
+ total: number;
28
+ sourceTotal: number;
29
+ pageCount: number;
30
+ prev: number | null;
31
+ next: number | null;
32
+ revision: string;
33
+ counts: Record<string, ReviewCount>;
34
+ facets: Record<string, ReviewFacet>;
35
+ section: {
36
+ key: string;
37
+ value: string;
38
+ options: ReviewOption[];
39
+ } | null;
40
+ };
41
+ export type EvalDetailReview = {
42
+ scope: string | null;
43
+ requestedScope: string | null;
44
+ scopeFallback: 'trunk' | null;
45
+ availability: 'measured' | 'unmeasured' | 'missing';
46
+ selected: ReviewItem | null;
47
+ history: ReviewItem[];
48
+ neighbors: {
49
+ prev: EvalNeighbor[];
50
+ next: EvalNeighbor[];
51
+ total: number;
52
+ index: number | null;
53
+ order: 'default';
54
+ };
55
+ revision: string;
56
+ summary?: SessionEvals['summary'];
57
+ evalRevision?: SessionEvals['evalRevision'];
58
+ };
59
+ type EvalDetailMetadata = {
60
+ scope?: string | null;
61
+ requestedScope?: string | null;
62
+ scopeFallback?: 'trunk' | null;
63
+ summary?: SessionEvals['summary'];
64
+ evalRevision?: SessionEvals['evalRevision'];
65
+ sequence?: {
66
+ node: string;
67
+ scenario: string;
68
+ }[];
69
+ };
70
+ export declare function reviewPageNumber(value: unknown): number;
71
+ export declare function paginateReview<T extends ReviewItem>(source: T[], shown: T[], model: any, requestedPage: unknown, revisionInputs: unknown): PagedReview<T>;
72
+ export declare function issuesReview(query: string | undefined, requestedPage: unknown): Promise<{
73
+ items: any[];
74
+ page: number;
75
+ perPage: number;
76
+ total: number;
77
+ sourceTotal: number;
78
+ pageCount: number;
79
+ prev: number | null;
80
+ next: number | null;
81
+ revision: string;
82
+ counts: Record<string, ReviewCount>;
83
+ facets: Record<string, ReviewFacet>;
84
+ section: {
85
+ key: string;
86
+ value: string;
87
+ options: ReviewOption[];
88
+ } | null;
89
+ enabled: boolean;
90
+ stores: import("./issues.js").IssueStore[];
91
+ }>;
92
+ export declare function trunkEvalReviewItems(nodes: any[]): ReviewItem[];
93
+ export declare function scopedEvalReviewItems(model: SessionEvals): ReviewItem[];
94
+ export declare function boundedEvalNeighbors(sequence: {
95
+ node: string;
96
+ scenario: string;
97
+ }[], node: string, scenario: string, stateOf: (row: {
98
+ node: string;
99
+ scenario: string;
100
+ }) => string, want?: number): {
101
+ prev: never[];
102
+ next: never[];
103
+ total: number;
104
+ index: null;
105
+ order: "default";
106
+ } | {
107
+ prev: EvalNeighbor[];
108
+ next: EvalNeighbor[];
109
+ total: number;
110
+ index: number;
111
+ order: "default";
112
+ };
113
+ export declare function projectEvalDetail(items: ReviewItem[], historySource: ReviewItem[], node: string, scenario: string, metadata?: EvalDetailMetadata): EvalDetailReview;
114
+ export declare function measuredSequence(order: SessionEvalOrderRow[]): {
115
+ node: string;
116
+ scenario: string;
117
+ }[];
118
+ export declare function focusNodes(order: SessionEvalOrderRow[], node: string, scenario: string): string[];
119
+ export declare function evalDetailReview(node: string, scenario: string, scope?: string | null): Promise<EvalDetailReview>;
120
+ export declare function timelineEvalReviewItems(timeline: Awaited<ReturnType<typeof evalTimeline>>, node: string): ReviewItem[];
121
+ export declare function evalsReview(query: string | undefined, requestedPage: unknown, options?: {
122
+ view?: string;
123
+ }): Promise<{
124
+ items: ReviewItem[];
125
+ page: number;
126
+ perPage: number;
127
+ total: number;
128
+ sourceTotal: number;
129
+ pageCount: number;
130
+ prev: number | null;
131
+ next: number | null;
132
+ revision: string;
133
+ counts: Record<string, ReviewCount>;
134
+ facets: Record<string, ReviewFacet>;
135
+ section: {
136
+ key: string;
137
+ value: string;
138
+ options: ReviewOption[];
139
+ } | null;
140
+ scope: null;
141
+ view: string;
142
+ node: any;
143
+ hasEvalFile: boolean;
144
+ gates: never[];
145
+ unknown: number;
146
+ } | {
147
+ items: ReviewItem[];
148
+ page: number;
149
+ perPage: number;
150
+ total: number;
151
+ sourceTotal: number;
152
+ pageCount: number;
153
+ prev: number | null;
154
+ next: number | null;
155
+ revision: string;
156
+ counts: Record<string, ReviewCount>;
157
+ facets: Record<string, ReviewFacet>;
158
+ section: {
159
+ key: string;
160
+ value: string;
161
+ options: ReviewOption[];
162
+ } | null;
163
+ scope: any;
164
+ gates: never[];
165
+ unknown: number;
166
+ summary: import("@spexcode/spec-eval/sessioneval").SessionEvalSummary | undefined;
167
+ evalRevision: import("@spexcode/spec-eval/sessioneval").SessionEvalRevision;
168
+ } | {
169
+ items: ReviewItem[];
170
+ page: number;
171
+ perPage: number;
172
+ total: number;
173
+ sourceTotal: number;
174
+ pageCount: number;
175
+ prev: number | null;
176
+ next: number | null;
177
+ revision: string;
178
+ counts: Record<string, ReviewCount>;
179
+ facets: Record<string, ReviewFacet>;
180
+ section: {
181
+ key: string;
182
+ value: string;
183
+ options: ReviewOption[];
184
+ } | null;
185
+ scope: null;
186
+ gates: never[];
187
+ unknown: number;
188
+ } | null>;
189
+ export {};