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.
- package/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/lib/docs.mjs +218 -22
- package/board/packages/board/lib/metrics.mjs +34 -2
- package/board/packages/board/lib/routes.mjs +52 -0
- package/board/packages/board/public/index.html +353 -27
- package/board/scripts/lib/freshness.mjs +18 -3
- package/board/scripts/lib/router-key.mjs +164 -0
- package/dist/board-path.js +31 -3
- package/dist/main.js +13 -3
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
/**
|
|
88
|
-
|
|
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
|
|
279
|
+
* One document's freshness — three states, and an absence that is not a state.
|
|
106
280
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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(
|
|
113
|
-
|
|
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:
|
|
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
|
-
:
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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';
|