great-cto 3.12.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/data-readers.mjs +58 -4
- 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/lib/verdicts.mjs +25 -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/board/scripts/lib/verdict-record.mjs +21 -2
- 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"
|
|
@@ -155,7 +155,18 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
|
|
|
155
155
|
for (let i = 0; i <= days; i++) {
|
|
156
156
|
const d = new Date(now - i * 86400000);
|
|
157
157
|
const key = d.toISOString().slice(0, 10);
|
|
158
|
-
|
|
158
|
+
// `runs` was one field incremented in two places for two different things:
|
|
159
|
+
// once per verdict that carried a cost, and once per closed task on a day that
|
|
160
|
+
// happened to have no cost data. Its meaning therefore changed from day to day
|
|
161
|
+
// depending on whether anything had been measured, and the two sums were
|
|
162
|
+
// indistinguishable once added.
|
|
163
|
+
//
|
|
164
|
+
// Measured against the verdict logs on disk, it was wrong in BOTH directions —
|
|
165
|
+
// +18 on one project over 90 days (closed tasks counted as agent runs) and −3
|
|
166
|
+
// on two others (verdicts with no cost contributing nothing). Split into the
|
|
167
|
+
// two facts, with `runs` kept as their sum so every existing reader keeps
|
|
168
|
+
// working and no chart silently changes shape.
|
|
169
|
+
buckets.set(key, { date: key, llm: 0, human: 0, plans: 0, runs: 0, agent_runs: 0, task_estimates: 0 });
|
|
159
170
|
}
|
|
160
171
|
|
|
161
172
|
// Plans: file mtime as date
|
|
@@ -165,6 +176,20 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
|
|
|
165
176
|
// "LLM" and another near "Human", and FIRE the sanity check below to
|
|
166
177
|
// reject pathological pairs (the 7,638× regression — total_human present
|
|
167
178
|
// but total_llm fell to zero because the LLM regex was too strict).
|
|
179
|
+
// Whether a plan carried a cost at all, kept apart from what that cost was.
|
|
180
|
+
//
|
|
181
|
+
// The pair of regexes below reads a format nothing writes: measured across
|
|
182
|
+
// this repository's 41 plans, ZERO carry an LLM figure at the start of a line
|
|
183
|
+
// and one carries a Human figure — which the guard then subtracts back out. So
|
|
184
|
+
// `total_human` was $0 on every window, beside a plan count in the dozens, and
|
|
185
|
+
// read as "these plans cost nothing" rather than "no plan states a cost".
|
|
186
|
+
//
|
|
187
|
+
// There is no plan template to blame the plans for: the only template shipped
|
|
188
|
+
// is GAP-WAVE-PLAN-template.yaml, so no cost line was ever specified. Counted
|
|
189
|
+
// rather than parsed harder — the dollar figures that DO appear in plans are
|
|
190
|
+
// prose ("10 × ~$0.15", "assert total < $5") and reading them as this plan's
|
|
191
|
+
// cost would be inventing a number.
|
|
192
|
+
let planCostParsed = 0, planCostAbsent = 0, planHumanSuppressed = 0;
|
|
168
193
|
const plansDir = path.join(cwd, 'docs/plans');
|
|
169
194
|
if (fs.existsSync(plansDir)) {
|
|
170
195
|
const planFiles = fs.readdirSync(plansDir).filter(x => x.endsWith('.md')).map(x => path.join(plansDir, x));
|
|
@@ -199,8 +224,10 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
|
|
|
199
224
|
if (humanMatch && !llmMatch && b.human > 0) {
|
|
200
225
|
// Reverse the suppression — drop the bogus single-sided Human entry.
|
|
201
226
|
b.human -= parseFloat(humanMatch[1].replace(/,/g, ''));
|
|
227
|
+
planHumanSuppressed += 1;
|
|
202
228
|
}
|
|
203
229
|
b.plans++;
|
|
230
|
+
if (llmMatch || humanMatch) planCostParsed += 1; else planCostAbsent += 1;
|
|
204
231
|
}
|
|
205
232
|
}
|
|
206
233
|
|
|
@@ -217,18 +244,28 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
|
|
|
217
244
|
// feature=X aggregation — answers "how much did stripe-webhook cost?"
|
|
218
245
|
const featureMap = new Map(); // feature → { llm, runs }
|
|
219
246
|
for (const v of verdicts) {
|
|
220
|
-
if (v.cost_usd == null) continue;
|
|
221
247
|
const dayKey = (v.ts || '').slice(0, 10);
|
|
222
248
|
if (!buckets.has(dayKey)) continue;
|
|
223
249
|
const b = buckets.get(dayKey);
|
|
224
|
-
|
|
250
|
+
// A run that was never priced is still a run.
|
|
251
|
+
//
|
|
252
|
+
// This began `if (v.cost_usd == null) continue`, so a verdict carrying no
|
|
253
|
+
// cost contributed nothing at all — not even its own existence. Measured
|
|
254
|
+
// against the logs: one project had four verdicts and reported ZERO agent
|
|
255
|
+
// runs, because none of the four had been priced. "We do not know what it
|
|
256
|
+
// cost" was being rendered as "it did not happen", which is the confusion
|
|
257
|
+
// this codebase keeps having to unpick.
|
|
258
|
+
//
|
|
259
|
+
// Cost is added only when there is one; the run is counted either way.
|
|
260
|
+
if (v.cost_usd != null) b.llm += v.cost_usd;
|
|
261
|
+
b.agent_runs++;
|
|
225
262
|
b.runs++;
|
|
226
263
|
// Extract feature= tag from raw verdict line
|
|
227
264
|
const featMatch = v.raw && v.raw.match(/\bfeature=([^\s|]+)/);
|
|
228
265
|
if (featMatch) {
|
|
229
266
|
const feat = featMatch[1];
|
|
230
267
|
const f = featureMap.get(feat) || { llm: 0, runs: 0 };
|
|
231
|
-
f.llm += v.cost_usd;
|
|
268
|
+
if (v.cost_usd != null) f.llm += v.cost_usd;
|
|
232
269
|
f.runs++;
|
|
233
270
|
featureMap.set(feat, f);
|
|
234
271
|
}
|
|
@@ -258,6 +295,10 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
|
|
|
258
295
|
if (b.llm === 0) {
|
|
259
296
|
const mins = t.estimated_minutes || DEFAULT_TASK_MIN;
|
|
260
297
|
b.llm += mins / 60 * LLM_RATE_PER_HR;
|
|
298
|
+
// A closed task is not an agent run. It is counted, and counted
|
|
299
|
+
// separately, so a caller asking "how many times did agents run" is not
|
|
300
|
+
// handed a number that is partly something else.
|
|
301
|
+
b.task_estimates++;
|
|
261
302
|
b.runs++;
|
|
262
303
|
}
|
|
263
304
|
}
|
|
@@ -267,6 +308,11 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
|
|
|
267
308
|
let totalLlm = series.reduce((a, b) => a + b.llm, 0);
|
|
268
309
|
let totalHuman = series.reduce((a, b) => a + b.human, 0);
|
|
269
310
|
const totalPlans = series.reduce((a, b) => a + b.plans, 0);
|
|
311
|
+
// Reported alongside the sum rather than instead of it: `total_runs` keeps its
|
|
312
|
+
// meaning for every existing caller, and the two facts it was made of are now
|
|
313
|
+
// separately answerable.
|
|
314
|
+
const totalAgentRuns = series.reduce((a, b) => a + b.agent_runs, 0);
|
|
315
|
+
const totalTaskEstimates = series.reduce((a, b) => a + b.task_estimates, 0);
|
|
270
316
|
|
|
271
317
|
// SANITY GUARD — anti-7,638× regression. If ratio > 1000×, one of the
|
|
272
318
|
// numbers is wrong. Almost always: total_llm collapsed to ~0 because plan
|
|
@@ -291,6 +337,14 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
|
|
|
291
337
|
total_llm: Math.round(totalLlm * 100) / 100,
|
|
292
338
|
total_human: Math.round(totalHuman),
|
|
293
339
|
total_plans: totalPlans,
|
|
340
|
+
total_agent_runs: totalAgentRuns,
|
|
341
|
+
total_task_estimates: totalTaskEstimates,
|
|
342
|
+
// Three states, not a number and a silence: a plan that states a cost, a
|
|
343
|
+
// plan that states none, and a plan whose Human figure was suppressed
|
|
344
|
+
// because its LLM counterpart could not be read.
|
|
345
|
+
plans_with_cost: planCostParsed,
|
|
346
|
+
plans_without_cost: planCostAbsent,
|
|
347
|
+
plans_human_suppressed: planHumanSuppressed,
|
|
294
348
|
daily_avg: Math.round(dayRate * 100) / 100,
|
|
295
349
|
projected_monthly: projectedMonthly,
|
|
296
350
|
monthly_budget: budget,
|
|
@@ -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';
|
|
@@ -92,6 +92,28 @@ function readVerdicts(cwd = null, health = null) {
|
|
|
92
92
|
continue;
|
|
93
93
|
}
|
|
94
94
|
for (const file of files) {
|
|
95
|
+
// Only per-agent verdict logs. `<agent>-YYYY-MM-DD-HHMMSS.log` is a different
|
|
96
|
+
// artefact that lives in the same directory: a free-text report of one run,
|
|
97
|
+
// written as prose. Reading it as a verdict log made its first words into
|
|
98
|
+
// records — `ts: "DONE:"`, `ts: "artifact:"`, `ts: "next:"`, with the agent
|
|
99
|
+
// name taken from the filename including its timestamp. Six of the ten
|
|
100
|
+
// "verdicts" one project appeared to have were paragraph openings.
|
|
101
|
+
//
|
|
102
|
+
// Skipped rather than parsed leniently: a run report has no verdict token, no
|
|
103
|
+
// cost and no meta, so anything recovered from it would be invented. Counted,
|
|
104
|
+
// though — a directory whose contents this reader ignores is worth saying out
|
|
105
|
+
// loud, because from outside it looks identical to a project that never ran.
|
|
106
|
+
if (!file.endsWith('.log')) continue;
|
|
107
|
+
// Rejected by the shape that IDENTIFIES a run report, not by trying to
|
|
108
|
+
// describe what a valid agent name looks like. The first attempt did the
|
|
109
|
+
// latter — `/^[A-Za-z0-9_:.-]+\.log$/` — and matched
|
|
110
|
+
// `architect-2026-08-26-134109.log` perfectly well, because digits and
|
|
111
|
+
// hyphens are exactly what an agent name may contain. An allowlist that
|
|
112
|
+
// cannot exclude the thing it was written to exclude is not a filter.
|
|
113
|
+
if (RUN_REPORT_FILE.test(file)) {
|
|
114
|
+
health?.notes?.push(`${file} is a run report, not a verdict log — not read`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
95
117
|
const agent = file.replace('.log', '');
|
|
96
118
|
let lines;
|
|
97
119
|
try {
|
|
@@ -192,6 +214,9 @@ function readVerdicts(cwd = null, health = null) {
|
|
|
192
214
|
return results.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
193
215
|
}
|
|
194
216
|
|
|
217
|
+
/** `<agent>-YYYY-MM-DD-HHMMSS.log` — a prose report of one run, not a verdict. */
|
|
218
|
+
const RUN_REPORT_FILE = /-\d{4}-\d{2}-\d{2}-\d{6}\.log$/;
|
|
219
|
+
|
|
195
220
|
function readPlanCosts(cwd = process.cwd(), sinceMsAgo = null) {
|
|
196
221
|
const plansDir = path.join(cwd, 'docs/plans');
|
|
197
222
|
let totalLlmMin = 0, totalLlmUsd = 0, totalHumanUsd = 0, count = 0;
|