great-cto 3.13.0 → 3.14.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.
@@ -2,7 +2,7 @@
2
2
  "name": "great_cto",
3
3
  "id": "great_cto",
4
4
  "description": "Engineering process for solo founders and teams up to 50 engineers. Agents do architecture, code review, QA, and security. You make two decisions per feature.",
5
- "version": "3.13.0",
5
+ "version": "3.14.0",
6
6
  "author": {
7
7
  "name": "Great CTO",
8
8
  "url": "https://github.com/avelikiy/great_cto"
@@ -44,9 +44,86 @@ export const DOC_GROUPS = Object.freeze([
44
44
  { key: 'plans', label: 'Plans', dirs: ['docs/plans'], why: 'what was going to be done' },
45
45
  { key: 'reviews', label: 'Reviews', dirs: ['docs/qa', 'docs/security', 'docs/quality'], why: 'what was checked, and what it found' },
46
46
  { key: 'design', label: 'Design', dirs: ['docs/design', 'docs/product'], why: 'what it should look like and for whom' },
47
+ // Added because it was carrying real weight in `other`, not for symmetry: one
48
+ // project keeps 17 runbooks under `docs/runbooks`, and this repository has
49
+ // `docs/reference`, `docs/tutorials`, `docs/operations` and a FAQ. "How do I
50
+ // run this" is a question a reader arrives with, and it is not answered by any
51
+ // of the six groups above.
52
+ { key: 'guides', label: 'How to run it', dirs: ['docs/runbooks', 'docs/reference', 'docs/tutorials', 'docs/operations'], why: 'how to operate it and how to use it' },
47
53
  { key: 'other', label: 'Other', dirs: ['docs'], why: 'everything else the project wrote down' },
48
54
  ]);
49
55
 
56
+ /**
57
+ * The word a document uses to say what KIND of document it is, and the group
58
+ * that word belongs to.
59
+ *
60
+ * Path alone recognised 24% of one project's corpus — 165 of 217 documents fell
61
+ * into `other`, which is a classifier that has stopped classifying. The reason
62
+ * is that `docs/architecture`, `docs/adr`, `docs/plans` are this repository's
63
+ * own conventions and real projects do not share them: they write
64
+ * `docs/impl-briefs/IMPL-BRIEF-*.md`, `docs/research/2026-04-06-max-backtest.md`,
65
+ * `docs/agent_quality_hardening_plan.md`.
66
+ *
67
+ * So the type token is looked for wherever an author might have put it — a
68
+ * directory name, the filename, or the first heading — and each of those is
69
+ * split into words rather than matched as a prefix, because `IMPL-BRIEF-x`,
70
+ * `sec-threats/` and `agent_quality_hardening_plan` all announce their type in
71
+ * a word that is not at position zero.
72
+ *
73
+ * Words that are a document's SUBJECT more often than its type are deliberately
74
+ * absent, and each omission was bought by a wrong answer: `quality` put
75
+ * `agent_quality_hardening_plan.md` in Reviews, `ux` put a plan named
76
+ * `…-flow-compiler-ux.md` in Design, `product` pulled `PRODUCT-BUILDER-DIRECTION.md`
77
+ * out of the strategy directory it was filed in. `deploy`, `live` and `prod`
78
+ * never made it in for the same reason. A confident wrong group is worse for a
79
+ * reader than `other` — `docs/quality`, `docs/product` and `docs/design` are
80
+ * still canonical directories below, so a project that files by that word keeps
81
+ * the grouping; what is gone is guessing from the word appearing anywhere.
82
+ */
83
+ const TYPE_TOKENS = Object.freeze({
84
+ adr: 'decisions', adrs: 'decisions', dec: 'decisions', decision: 'decisions',
85
+ decisions: 'decisions', rfc: 'decisions',
86
+
87
+ arch: 'architecture', architecture: 'architecture', spec: 'architecture',
88
+ specs: 'architecture', schema: 'architecture',
89
+
90
+ plan: 'plans', plans: 'plans', roadmap: 'plans', backlog: 'plans',
91
+ impl: 'plans', strategy: 'plans', proposal: 'plans',
92
+
93
+ qa: 'reviews', uat: 'reviews', audit: 'reviews', audits: 'reviews',
94
+ review: 'reviews', reviews: 'reviews', test: 'reviews', tests: 'reviews',
95
+ testing: 'reviews', security: 'reviews', sec: 'reviews', tm: 'reviews',
96
+ threat: 'reviews', risk: 'reviews', risks: 'reviews', incident: 'reviews',
97
+ incidents: 'reviews', postmortem: 'reviews', research: 'reviews',
98
+ analysis: 'reviews', benchmark: 'reviews', benchmarks: 'reviews',
99
+ bench: 'reviews', report: 'reviews', reports: 'reviews', eval: 'reviews',
100
+ evaluation: 'reviews', measurement: 'reviews', validation: 'reviews',
101
+ checklist: 'reviews', backtest: 'reviews',
102
+
103
+ design: 'design', designs: 'design', brief: 'design', briefs: 'design',
104
+ prd: 'design',
105
+
106
+ runbook: 'guides', runbooks: 'guides', guide: 'guides', guides: 'guides',
107
+ tutorial: 'guides', tutorials: 'guides', howto: 'guides',
108
+ reference: 'guides', operations: 'guides', faq: 'guides', help: 'guides',
109
+ setup: 'guides', onboarding: 'guides',
110
+ });
111
+
112
+ /** Words of a name, so `IMPL-BRIEF-x`, `sec-threats` and `a_b_plan` all yield tokens. */
113
+ function words(s) {
114
+ return String(s).toLowerCase().split(/[^a-z0-9]+/i).filter(Boolean);
115
+ }
116
+
117
+ /** The first word that names a kind of document, left to right — a type is announced early. */
118
+ function tokenGroup(name, { limit = Infinity } = {}) {
119
+ const w = words(name);
120
+ for (let i = 0; i < w.length && i < limit; i++) {
121
+ const g = TYPE_TOKENS[w[i]];
122
+ if (g) return g;
123
+ }
124
+ return null;
125
+ }
126
+
50
127
  /** Never walked: large, generated, or not this project's writing. */
51
128
  const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', 'screenshots', 'vendor']);
52
129
 
@@ -61,11 +138,14 @@ export const MAX_DOCS = 500;
61
138
  * is in the first few lines or it is not a title.
62
139
  */
63
140
  export function titleOf(absPath, { read = fs.readFileSync } = {}) {
64
- try {
65
- const head = String(read(absPath, 'utf8')).slice(0, 2000);
66
- const m = head.match(/^#\s+(.+?)\s*$/m);
67
- return m ? m[1].replace(/\.md$/i, '').trim() : null;
68
- } catch { return null; }
141
+ try { return titleFromText(String(read(absPath, 'utf8'))); }
142
+ catch { return null; }
143
+ }
144
+
145
+ /** The same heading, from text already in hand — the classifier reads it too. */
146
+ export function titleFromText(text) {
147
+ const m = String(text || '').slice(0, 2000).match(/^#\s+(.+?)\s*$/m);
148
+ return m ? m[1].replace(/\.md$/i, '').trim() : null;
69
149
  }
70
150
 
71
151
  function walk(dir, root, out, depth = 0) {
@@ -84,16 +164,110 @@ function walk(dir, root, out, depth = 0) {
84
164
  }
85
165
  }
86
166
 
87
- /** Which group a path belongs to — first match wins, so `other` catches the rest. */
88
- export function groupFor(rel) {
167
+ /**
168
+ * Which group a document belongs to.
169
+ *
170
+ * Signals in order of how much they actually know, which is not the order they
171
+ * look convincing in:
172
+ *
173
+ * 1. an exact named file README.md / CLAUDE.md — not a guess at all
174
+ * 2. front-matter `type:` the author said it outright
175
+ * 3. the LEADING filename token `ADR-019-…`, `PLAN-…`, `IMPL-BRIEF-…`,
176
+ * `2026-08-21-…` after the date is skipped
177
+ * 4. a canonical directory docs/adr, docs/plans, docs/product, …
178
+ * 5. any other directory segment deepest first: `superpowers/plans` is a
179
+ * plans directory, `superpowers` is not
180
+ * 6. a token elsewhere in the name `agent_quality_hardening_plan.md`
181
+ * 7. the first `# ` heading opening words only
182
+ * 8. a bare README a name that describes nothing else
183
+ *
184
+ * The leading filename token outranks the directory, and that ordering was
185
+ * bought: 14 `docs/architecture/ADR-0NN-*.md` files were being filed as
186
+ * Architecture, and `docs/design/PLAN-*.md` as Design. A directory is where a
187
+ * project dumps a category; the front of a filename is what the author called
188
+ * THIS document. Mid-name tokens rank below the directory instead, because
189
+ * there the word is usually the subject — which is why
190
+ * `…-flow-compiler-ux.md`, a plan, must not leave `docs/superpowers/plans`.
191
+ *
192
+ * `other` stays the answer when nothing above speaks. A document that will not
193
+ * classify is still listed — never dropped, never hidden.
194
+ *
195
+ * @param {string} rel path relative to the project root
196
+ * @param {{text?: string}} hints the document's text, when it has been read
197
+ */
198
+ export function groupFor(rel, { text = '' } = {}) {
89
199
  const p = rel.split(path.sep).join('/');
200
+ const base = path.basename(p, '.md');
201
+
90
202
  for (const g of DOC_GROUPS) {
91
203
  if ((g.files || []).includes(p)) return g.key;
204
+ }
205
+
206
+ const declared = declaredType(text);
207
+ if (declared && TYPE_TOKENS[declared]) return TYPE_TOKENS[declared];
208
+
209
+ const lead = leadingTypeToken(base);
210
+ if (lead) return lead;
211
+
212
+ // `other` owns `docs/`, which would swallow every path — it is the fallback,
213
+ // not a directory rule, so it is skipped here and returned at the end.
214
+ for (const g of DOC_GROUPS) {
215
+ if (g.key === 'other') continue;
92
216
  if ((g.dirs || []).some((d) => p === d || p.startsWith(`${d}/`))) return g.key;
93
217
  }
218
+
219
+ const segments = p.split('/').slice(0, -1);
220
+ for (let i = segments.length - 1; i >= 0; i--) {
221
+ const g = tokenGroup(segments[i]);
222
+ if (g) return g;
223
+ }
224
+
225
+ const fromName = tokenGroup(base);
226
+ if (fromName) return fromName;
227
+
228
+ // Only the heading's opening words. A title announces its type at the front
229
+ // ("QA report — …", "ADR-011: …"); four words in, "The details the README used
230
+ // to carry" filed a page about the README under "This project", and
231
+ // "Positioning vocabulary — product-builder language" landed in Design.
232
+ const heading = text ? titleFromText(text) : null;
233
+ if (heading) {
234
+ const g = tokenGroup(heading, { limit: 2 });
235
+ if (g) return g;
236
+ }
237
+
238
+ // Last, not in TYPE_TOKENS: `README` names no type, so it must not outrank a
239
+ // directory that does — `docs/uat/README.md` is a review and
240
+ // `docs/benchmarks/briefs/README.md` is a brief. Reaching here means nothing
241
+ // else spoke, and then it is the project's own front page (including
242
+ // `docs/ru/README.md` and the nine other translations).
243
+ if (base.toLowerCase() === 'readme') return 'state';
244
+
94
245
  return 'other';
95
246
  }
96
247
 
248
+ /**
249
+ * The type token at the FRONT of a filename, past any leading date or number.
250
+ *
251
+ * `ADR-019-…`, `TM-…`, `UAT-2026-08-21-0824`, `00_Backlog` all announce their
252
+ * type first; `2026-04-16-blog-quality-improvement` announces a date first and
253
+ * then a subject, and must not be read as a type at all.
254
+ */
255
+ function leadingTypeToken(base) {
256
+ for (const w of words(base)) {
257
+ if (/^\d+$/.test(w)) continue; // a leading date or ordinal, not a type
258
+ return TYPE_TOKENS[w] || null; // the first real word decides, or nothing does
259
+ }
260
+ return null;
261
+ }
262
+
263
+ /** An author's own `type:` / `group:` / `kind:` / `category:` in YAML front-matter. */
264
+ function declaredType(text) {
265
+ const fm = String(text || '').replace(/^\uFEFF/, '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
266
+ if (!fm) return null;
267
+ const m = fm[1].match(/^(?:doc_?type|type|group|kind|category):\s*["']?([A-Za-z][\w-]*)/im);
268
+ return m ? m[1].toLowerCase() : null;
269
+ }
270
+
97
271
  /**
98
272
  * Every document in the project, grouped and newest first within each group.
99
273
  *
@@ -102,30 +276,46 @@ export function groupFor(rel) {
102
276
  * than hiding it.
103
277
  */
104
278
  /**
105
- * One document's freshness, as three states rather than a date.
279
+ * One document's freshness three states, and an absence that is not a state.
106
280
  *
107
- * `unknown` covers two different absences and says which: a file we could not
108
- * read at all, and a file that simply declares no date. Neither may render as
109
- * fresh the whole reason `stale_after` exists is that a document nobody can
110
- * judge must not look like one that passed.
281
+ * 'stale' a review date was declared and it has passed, or the doc's own
282
+ * date is older than the threshold. The actionable one.
283
+ * 'fresh' a date was declared and the doc is inside it.
284
+ * null the doc declares no review date. This is the NORM, not a finding:
285
+ * of 1634 md files in one project, zero declare `stale_after`; of
286
+ * 2541 here, five do. Measured on the live board, the old code put
287
+ * `unknown` on 198 of 217 documents in one project and 148 of 187
288
+ * in another — a mark on nine rows in ten distinguishes nothing,
289
+ * and it hid the 19 documents that had actually been judged.
290
+ * `null` rather than a word so a caller can skip the badge with
291
+ * `if (!doc.freshness)` and never has to know a vocabulary.
292
+ *
293
+ * 'unknown' reserved for the one case that IS a defect: the file could not be
294
+ * read. That must not render as fresh and must not be filed with
295
+ * the ordinary majority — `freshnessBasis: 'unreadable'` says which.
296
+ *
297
+ * `freshnessBasis` and `freshnessWhy` are kept in every case, so nothing that
298
+ * was in the payload has been removed: 'declared' | 'mtime' | 'undeclared' |
299
+ * 'unreadable' still names the rule that produced (or did not produce) a verdict.
111
300
  */
112
- function freshnessOf(abs, nowMs = Date.now(), staleDays = 180) {
113
- let text;
114
- try { text = fs.readFileSync(abs, 'utf8'); }
115
- catch (e) {
301
+ function freshnessOf(text, nowMs = Date.now(), staleDays = 180) {
302
+ if (text === null) {
116
303
  return { freshness: 'unknown', freshnessBasis: 'unreadable', staleAfter: null,
117
- freshnessWhy: `could not read this file: ${String(e?.message || e)}` };
304
+ freshnessWhy: 'could not read this file' };
118
305
  }
119
306
  try {
120
307
  const j = judgeFreshness({ text, dateType: 'any', nowMs, staleDays });
308
+ if (!j.declared) {
309
+ return { freshness: null, freshnessBasis: 'undeclared', staleAfter: null,
310
+ freshnessWhy: 'declares no review date — most documents do not, so there is nothing to judge and nothing wrong' };
311
+ }
121
312
  return {
122
313
  freshness: j.verdict,
123
314
  freshnessBasis: j.basis,
124
315
  staleAfter: j.staleAfter,
125
316
  freshnessWhy: j.basis === 'declared'
126
317
  ? `the author declared it good until ${j.staleAfter}`
127
- : (j.date ? `judged by its own date ${j.date} (${j.ageDays}d, threshold ${staleDays}d)`
128
- : 'no stale_after and no date — nothing to judge it by'),
318
+ : `judged by its own date ${j.date} (${j.ageDays}d, threshold ${staleDays}d)`,
129
319
  };
130
320
  } catch (e) {
131
321
  return { freshness: 'unknown', freshnessBasis: 'unreadable', staleAfter: null,
@@ -151,11 +341,17 @@ export function listDocs(root, { max = MAX_DOCS } = {}) {
151
341
  for (const d of found) {
152
342
  if (seen.has(d.rel) || docs.length >= max) continue;
153
343
  seen.add(d.rel);
344
+ // Read once. The title, the group and the freshness verdict are three
345
+ // questions about the same bytes, and this used to open every file twice.
346
+ // `null` distinguishes "could not be read" from "read and said nothing" —
347
+ // the distinction the freshness badge now rests on.
348
+ let text = null;
349
+ try { text = fs.readFileSync(d.abs, 'utf8'); } catch { /* unreadable */ }
154
350
  docs.push({
155
351
  path: d.rel,
156
352
  name: path.basename(d.rel),
157
- title: titleOf(d.abs) || path.basename(d.rel, '.md'),
158
- group: groupFor(d.rel),
353
+ title: (text !== null && titleFromText(text)) || path.basename(d.rel, '.md'),
354
+ group: groupFor(d.rel, { text: text ?? '' }),
159
355
  size: d.size,
160
356
  modified: d.modified,
161
357
  // A modification time answers "when was this file last touched", which is
@@ -163,7 +359,7 @@ export function listDocs(root, { max = MAX_DOCS } = {}) {
163
359
  // document that stopped being true months earlier, and the list showed
164
360
  // only the former. `judgeFreshness` gives three verdicts and names which
165
361
  // rule produced each — see scripts/lib/freshness.mjs.
166
- ...freshnessOf(d.abs),
362
+ ...freshnessOf(text),
167
363
  });
168
364
  }
169
365
 
@@ -123,6 +123,26 @@ function getMetrics(cwd = process.cwd(), days = 30) {
123
123
  // with the "Last 30 days" panel ($6.42) shown directly below it. Now both
124
124
  // sit on the same N-day window so the dashboard numbers reconcile.
125
125
  const costWindowMs = days * 86400_000;
126
+ // How much of the requested window actually contains anything.
127
+ //
128
+ // The operator switched 1D → 7D → 30D → 90D, saw the same figures every time,
129
+ // and reported the period selector as broken. It was not: every task in that
130
+ // project closed within the last 24 hours, so a wider window selects the same
131
+ // work. The numbers were right and unreadable — a screen that answers
132
+ // identically to four different questions gives you no way to tell "nothing
133
+ // changed" from "nothing is being computed".
134
+ //
135
+ // So the payload carries the span the data actually occupies. A caller can
136
+ // then say "90 days requested, 1 day has data" instead of showing four
137
+ // identical tiles and leaving the reader to guess which failure it is.
138
+ const _closedTs = tasks
139
+ .filter((t) => t.closed_at)
140
+ .map((t) => new Date(t.closed_at).getTime())
141
+ .filter((ms) => Number.isFinite(ms) && (now - ms) <= costWindowMs);
142
+ const _daysWithData = new Set(_closedTs.map((ms) => new Date(ms).toISOString().slice(0, 10))).size;
143
+ const _spanDays = _closedTs.length
144
+ ? Math.max(1, Math.ceil((Math.max(..._closedTs) - Math.min(..._closedTs)) / 86400_000))
145
+ : 0;
126
146
  // AI active time per task: use estimated_minutes if set, else DEFAULT_TASK_MIN (30m).
127
147
  // We deliberately DO NOT use wall-clock (closed_at - created_at) because that
128
148
  // includes idle time — tasks that sit in backlog for days before being closed
@@ -227,7 +247,15 @@ function getMetrics(cwd = process.cwd(), days = 30) {
227
247
  llm_usd: Math.round(verdictLlmTotal * 100) / 100,
228
248
  human_usd: Math.round(humanLeg),
229
249
  savings_x: humanLeg > 0 ? Math.round(humanLeg / verdictLlmTotal) : null,
230
- window_days: 30,
250
+ // `days`, not a hardcoded 30. Both of these branches asserted a 30-day
251
+ // window whatever was asked for, so a request for one day came back
252
+ // labelled as a month. The values were windowed correctly all along — only
253
+ // the label lied, which is the harder kind to notice.
254
+ window_days: days,
255
+ // Reported beside the window, not instead of it: "90 days requested, 1 day
256
+ // has data" is a different statement from "90 days of data".
257
+ days_with_data: _daysWithData,
258
+ data_span_days: _spanDays,
231
259
  count: verdictsWithCost,
232
260
  coverage: doneInWindowCount > 0 ? Math.round((verdictsWithCost / doneInWindowCount) * 100) : null,
233
261
  source: 'measured',
@@ -244,7 +272,11 @@ function getMetrics(cwd = process.cwd(), days = 30) {
244
272
  human_usd: Math.round(taskHumanTotal),
245
273
  savings_x: null,
246
274
  rate_ratio: Math.round(HUMAN_RATE_PER_HR / LLM_RATE_PER_HR),
247
- window_days: 30,
275
+ window_days: days,
276
+ // Reported beside the window, not instead of it: "90 days requested, 1 day
277
+ // has data" is a different statement from "90 days of data".
278
+ days_with_data: _daysWithData,
279
+ data_span_days: _spanDays,
248
280
  count: 0,
249
281
  source: 'tasks',
250
282
  real_llm_usd: verdictLlmTotal > 0 ? Math.round(verdictLlmTotal * 10000) / 10000 : null,
@@ -12,6 +12,7 @@ import { sseClients, notifHistory } from './state.mjs';
12
12
  import { autoRegisterProject, listProjects, resolveProjectCwd, resolveProjectInfo, getChangeTier, readProjectsRegistry, getRegistryDegradation } from './projects.mjs';
13
13
  import { readVerdictsWithHealth } from './verdicts.mjs';
14
14
  import { readScores, summarizeScores } from '../../../scripts/lib/scores.mjs';
15
+ import { status as routerKeyStatus, writeKey as writeRouterKey } from '../../../scripts/lib/router-key.mjs';
15
16
  import { broadcastTasks } from './sse.mjs';
16
17
  import { saveNotifHistory } from './notifications.mjs';
17
18
  import { getMemory, getPipeline, getCostHistory, getInbox } from './data-readers.mjs';
@@ -690,6 +691,57 @@ async function dispatch(req, res, url, cwd) {
690
691
  // caller cannot render "100%" without also being handed the count it is out
691
692
  // of. An agent with nine unverifiable runs and one verified one is not a 100%
692
693
  // agent, and the payload refuses to let the UI say it is.
694
+ // Is the judge connected, and let the operator connect it.
695
+ //
696
+ // Nothing breaks without a key — every stage comes back `unverifiable`, which
697
+ // is honest. But "the judge is not connected" and "the judge found nothing to
698
+ // check" read identically from here, and the user has no way to discover a key
699
+ // is involved: the README mentions OpenRouter once without saying where the key
700
+ // goes, and the router's own hint names a file it is not read from.
701
+ //
702
+ // GET NEVER RETURNS THE KEY. It answers presence, which of the three locations
703
+ // it came from, and eight characters of it — enough to tell two keys apart,
704
+ // useless as a key. A read path would make any future XSS in a 7,600-line
705
+ // single-page app a secret disclosure, and buys nothing: nobody needs to read
706
+ // back a key they already hold.
707
+ if (pathname === '/api/router-key' && req.method === 'GET') {
708
+ res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
709
+ res.end(JSON.stringify(routerKeyStatus({ cwd })));
710
+ return true;
711
+ }
712
+
713
+ if (pathname === '/api/router-key' && req.method === 'POST') {
714
+ if (!originAllowed(req)) {
715
+ res.writeHead(403, { 'Content-Type': 'application/json' });
716
+ res.end(JSON.stringify({ error: 'origin not allowed' }));
717
+ return true;
718
+ }
719
+ let body = '';
720
+ req.on('data', (c) => { body += c; if (body.length > 4096) req.destroy(); });
721
+ req.on('end', () => {
722
+ let parsed;
723
+ try { parsed = JSON.parse(body || '{}'); }
724
+ catch (e) {
725
+ res.writeHead(400, { 'Content-Type': 'application/json' });
726
+ res.end(JSON.stringify({ error: 'invalid_json', message: String(e.message || e) }));
727
+ return;
728
+ }
729
+ const r = writeRouterKey(String(parsed.key || ''));
730
+ if (!r.ok) {
731
+ res.writeHead(400, { 'Content-Type': 'application/json' });
732
+ res.end(JSON.stringify({ error: r.error }));
733
+ return;
734
+ }
735
+ // The reply carries the new status, not the key, and names the backup —
736
+ // a write to a file holding other credentials should say what it saved
737
+ // first, because this file has been destroyed once by a careless write.
738
+ res.writeHead(200, { 'Content-Type': 'application/json' });
739
+ res.end(JSON.stringify({ ok: true, replaced: r.replaced, backup: r.backup,
740
+ status: routerKeyStatus({ cwd }) }));
741
+ });
742
+ return true;
743
+ }
744
+
693
745
  if (pathname === '/api/scores') {
694
746
  const agent = url.searchParams.get('agent') || null;
695
747
  const name = url.searchParams.get('name') || 'independent-verify';