great-cto 2.99.0 → 3.1.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/alerts.mjs +11 -4
- package/board/packages/board/lib/beads.mjs +231 -7
- package/board/packages/board/lib/fleet.mjs +41 -2
- package/board/packages/board/lib/metrics.mjs +32 -3
- package/board/packages/board/lib/portfolio.mjs +58 -7
- package/board/packages/board/lib/routes.mjs +153 -9
- package/board/packages/board/lib/watchers.mjs +36 -2
- package/board/packages/board/public/index.html +884 -29
- package/board/packages/board/server.mjs +24 -0
- package/board/scripts/lib/agent-budget.mjs +224 -0
- package/board/scripts/lib/pipeline-health.mjs +127 -0
- 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": "
|
|
5
|
+
"version": "3.1.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Great CTO",
|
|
8
8
|
"url": "https://github.com/avelikiy/great_cto"
|
|
@@ -12,7 +12,14 @@ import { listProjects, readProjectMd } from './projects.mjs';
|
|
|
12
12
|
import { addNotification } from './notifications.mjs';
|
|
13
13
|
import { getMetrics } from './metrics.mjs';
|
|
14
14
|
import { getCostHistory, getInbox } from './data-readers.mjs';
|
|
15
|
-
|
|
15
|
+
// Every sweep below runs over EVERY registered project, and `bd list` is
|
|
16
|
+
// spawnSync — the event loop is held for the whole of it. At 16 projects and
|
|
17
|
+
// 2-6 s each that is ~60 s per sweep, and three of these fire every five
|
|
18
|
+
// minutes. Sweeps therefore read at sweep freshness, not interactive freshness:
|
|
19
|
+
// see SWEEP_MAX_AGE_MS in beads.mjs for the arithmetic that made the board
|
|
20
|
+
// unanswerable while reporting "live · synced just now".
|
|
21
|
+
import { getTasks, SWEEP_MAX_AGE_MS } from './beads.mjs';
|
|
22
|
+
const SWEEP = { maxAgeMs: SWEEP_MAX_AGE_MS };
|
|
16
23
|
import { readVerdicts } from './verdicts.mjs';
|
|
17
24
|
import { isFailure } from './fleet.mjs';
|
|
18
25
|
import { getShareState, toggleShare } from './share.mjs';
|
|
@@ -146,7 +153,7 @@ function startAlertCron() {
|
|
|
146
153
|
const projects = listProjects();
|
|
147
154
|
const now = Date.now();
|
|
148
155
|
for (const proj of projects) {
|
|
149
|
-
const tasks = getTasks(proj.path);
|
|
156
|
+
const tasks = getTasks(proj.path, SWEEP);
|
|
150
157
|
const p0 = tasks.filter(t => {
|
|
151
158
|
if (t.priority !== 0) return false;
|
|
152
159
|
if (t.raw_status === 'closed' || t.raw_status === 'done') return false;
|
|
@@ -220,7 +227,7 @@ function startAlertCron() {
|
|
|
220
227
|
try {
|
|
221
228
|
const projects = listProjects();
|
|
222
229
|
for (const proj of projects) {
|
|
223
|
-
const tasks = getTasks(proj.path);
|
|
230
|
+
const tasks = getTasks(proj.path, SWEEP);
|
|
224
231
|
const gates = tasks.filter(t => t.is_gate && t.raw_status !== 'closed' && t.raw_status !== 'blocked');
|
|
225
232
|
for (const g of gates) {
|
|
226
233
|
const created = new Date(g.created_at || g.updated_at || 0).getTime();
|
|
@@ -348,7 +355,7 @@ function startAlertCron() {
|
|
|
348
355
|
const blocked = inbox.summary?.blocked ?? 0;
|
|
349
356
|
const gates = inbox.summary?.gates ?? 0;
|
|
350
357
|
// Tasks closed yesterday
|
|
351
|
-
const tasks = (() => { try { return getTasks(proj.path); } catch { return []; } })();
|
|
358
|
+
const tasks = (() => { try { return getTasks(proj.path, SWEEP); } catch { return []; } })();
|
|
352
359
|
const doneYesterday = tasks.filter(t => {
|
|
353
360
|
if (!t.closed_at) return false;
|
|
354
361
|
return t.closed_at.slice(0, 10) === yesterday;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
-
import { spawnSync } from 'child_process';
|
|
4
|
+
import { spawnSync, spawn } from 'child_process';
|
|
5
5
|
import { readSafe } from './util.mjs';
|
|
6
6
|
import { bdCache } from './state.mjs';
|
|
7
7
|
import { log } from './log.mjs';
|
|
@@ -22,9 +22,50 @@ import { log } from './log.mjs';
|
|
|
22
22
|
// `bdCacheInvalidate` fires on every write through the board, and the file
|
|
23
23
|
// watchers fire when anything changes the store underneath us. The TTL is only
|
|
24
24
|
// the backstop for a change that arrived through neither.
|
|
25
|
-
|
|
25
|
+
// 5 minutes, and the number is a backstop rather than a freshness promise.
|
|
26
|
+
//
|
|
27
|
+
// Staleness is bounded by INVALIDATION, not by this clock. Every write through
|
|
28
|
+
// the board drops the entry (four call sites in routes.mjs), and the file
|
|
29
|
+
// watchers drop it for the project a client is actually watching on any change
|
|
30
|
+
// underneath it. What the TTL covers is the residue: a change that arrived
|
|
31
|
+
// through neither path, in a project nobody has open.
|
|
32
|
+
//
|
|
33
|
+
// 30 s was worse than it looks. `bd list` costs 6.8 s on this repository, so a
|
|
34
|
+
// person who clicks, reads for half a minute, and clicks again pays the full
|
|
35
|
+
// cost every single time — the cache only ever helped a burst. Measured after
|
|
36
|
+
// the sweep fix, with no `bd` child present in twenty seconds of sampling: the
|
|
37
|
+
// board was no longer saturated and still answered /api/inbox in 8-11 s,
|
|
38
|
+
// because each request arrived just after the entry expired.
|
|
39
|
+
const BD_CACHE_TTL_MS = Number(process.env.GREAT_CTO_BD_CACHE_TTL_MS || 300000);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Drop a directory's entry so the next read is guaranteed fresh.
|
|
43
|
+
*
|
|
44
|
+
* For a WRITE the board just performed. The operator clicked approve and is
|
|
45
|
+
* waiting for the result; paying for the read is correct there, and serving the
|
|
46
|
+
* pre-approval state would be a lie about what they just did.
|
|
47
|
+
*/
|
|
48
|
+
function bdCacheInvalidate(cwd) {
|
|
49
|
+
clearSelfTouch(cwd);
|
|
50
|
+
bdCache.delete(cwd);
|
|
51
|
+
}
|
|
26
52
|
|
|
27
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Mark a directory's entry stale WITHOUT dropping it.
|
|
55
|
+
*
|
|
56
|
+
* For a change that arrived from outside — a file event under a project someone
|
|
57
|
+
* is watching. Deleting made the next reader, who asked for something unrelated,
|
|
58
|
+
* pay 623 ms warm and 6.8 s cold for a write they did not make. An entry marked
|
|
59
|
+
* stale is still served, and the refresh happens off the event loop.
|
|
60
|
+
*
|
|
61
|
+
* The difference is intent, not mechanism: a write we performed must be visible
|
|
62
|
+
* to whoever asked for it; a change we merely noticed must not stop the board.
|
|
63
|
+
*/
|
|
64
|
+
function bdCacheStale(cwd) {
|
|
65
|
+
clearSelfTouch(cwd);
|
|
66
|
+
const cached = bdCache.get(cwd);
|
|
67
|
+
if (cached) bdCache.set(cwd, { ...cached, ts: 0 });
|
|
68
|
+
}
|
|
28
69
|
|
|
29
70
|
// ── bd binary resolution (BH-32) ────────────────────────────────────────────
|
|
30
71
|
// A board launched from a GUI / launchd / a login shell that didn't source the
|
|
@@ -143,11 +184,183 @@ function bdReason(result) {
|
|
|
143
184
|
return (line || 'bd exited non-zero without a message').slice(0, 300);
|
|
144
185
|
}
|
|
145
186
|
|
|
146
|
-
|
|
187
|
+
/**
|
|
188
|
+
* @param {object} [opts]
|
|
189
|
+
* @param {number} [opts.maxAgeMs] How stale an entry may be before it is
|
|
190
|
+
* refreshed. Defaults to BD_CACHE_TTL_MS — the interactive freshness a person
|
|
191
|
+
* looking at one project expects.
|
|
192
|
+
*
|
|
193
|
+
* Background sweeps pass a much larger value, and the reason is arithmetic
|
|
194
|
+
* rather than taste. `bd list` costs 2-6 s per project; this machine has 16
|
|
195
|
+
* registered. One sweep is therefore ~60 s of SYNCHRONOUS work — `spawnSync`
|
|
196
|
+
* holds the event loop for the whole of it — while the TTL was 30 s. The first
|
|
197
|
+
* entries expired before the sweep that filled them had finished, so the next
|
|
198
|
+
* sweep re-ran all sixteen. Three of the alert crons do this every five
|
|
199
|
+
* minutes: ~190 s of blocking per 300 s.
|
|
200
|
+
*
|
|
201
|
+
* Measured while it was happening: a `bd` child present in 9 of 10 samples
|
|
202
|
+
* over 20 s, and `/api/version` — a single readdirSync — answering in 1-10 s
|
|
203
|
+
* because it was queued behind the sweep. The board rendered empty, said
|
|
204
|
+
* "live · synced just now", and was telling the truth: SSE was connected and
|
|
205
|
+
* every data request had timed out.
|
|
206
|
+
*
|
|
207
|
+
* A cache whose TTL is shorter than the sweep that fills it never hits. Same
|
|
208
|
+
* defect as the 2 s TTL fixed yesterday, one level up: that one was measured
|
|
209
|
+
* against a single call, this one has to be measured against the whole sweep.
|
|
210
|
+
*/
|
|
211
|
+
/**
|
|
212
|
+
* When we last ran `bd` ourselves in a directory.
|
|
213
|
+
*
|
|
214
|
+
* `bd list` is a READ, and dolt still writes: it touches
|
|
215
|
+
* `.dolt/noms/manifest` and `.dolt/noms/journal.idx` on every invocation. The
|
|
216
|
+
* file watcher watches exactly those files — deliberately, because `bd create`
|
|
217
|
+
* writes only to dolt and never to interactions.jsonl, so they are the only
|
|
218
|
+
* signal for a new issue.
|
|
219
|
+
*
|
|
220
|
+
* The two together form a loop: a request runs `bd list`, dolt touches the
|
|
221
|
+
* journal, the watcher fires, the cache entry is invalidated, and the next
|
|
222
|
+
* request runs `bd list` again. The read destroyed the cache that existed to
|
|
223
|
+
* make the read unnecessary. This is why raising the TTL never worked — not at
|
|
224
|
+
* 2 s, not at 30 s, not at 5 minutes. The entry was never expiring; it was being
|
|
225
|
+
* deleted.
|
|
226
|
+
*
|
|
227
|
+
* A touch within `SELF_TOUCH_WINDOW_MS` of our own run is ours. A real external
|
|
228
|
+
* write inside that window is missed by the watcher and picked up by the next
|
|
229
|
+
* event or by the TTL — the alternative is the loop, which costs every read.
|
|
230
|
+
*/
|
|
231
|
+
const lastBdRunAt = new Map();
|
|
232
|
+
const SELF_TOUCH_WINDOW_MS = 3000;
|
|
233
|
+
|
|
234
|
+
/** True when a file event under `cwd` is the echo of a `bd` we just ran. */
|
|
235
|
+
function isSelfInflictedTouch(cwd) {
|
|
236
|
+
const at = lastBdRunAt.get(cwd);
|
|
237
|
+
return at != null && Date.now() - at < SELF_TOUCH_WINDOW_MS;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* A DELIBERATE invalidation means something really changed, so the file event it
|
|
242
|
+
* is about to produce is not ours to ignore.
|
|
243
|
+
*
|
|
244
|
+
* Without this the self-touch window swallowed real writes. Approving a gate
|
|
245
|
+
* goes: read the inbox (our `bd list`, stamped), approve (a `bd update`), dolt
|
|
246
|
+
* touches the journal, watcher fires — and the touch lands inside the 3 s window
|
|
247
|
+
* opened by our own read, so it was skipped and the SSE broadcast never went out.
|
|
248
|
+
* Live updates stopped, and the comment above said the write would be "picked up
|
|
249
|
+
* by the next event", which for a broadcast means never.
|
|
250
|
+
*
|
|
251
|
+
* Every write path already calls `bdCacheInvalidate`. Clearing the mark there
|
|
252
|
+
* costs nothing and makes the window mean what it says: it suppresses the echo
|
|
253
|
+
* of a READ, never the consequence of a write.
|
|
254
|
+
*/
|
|
255
|
+
function clearSelfTouch(cwd) { lastBdRunAt.delete(cwd); }
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* An EMPTY answer is cached briefly, whatever the TTL says.
|
|
259
|
+
*
|
|
260
|
+
* Empty is the one result most likely to be premature: a board that starts while
|
|
261
|
+
* a project is still being written reads no tasks, and that answer is
|
|
262
|
+
* indistinguishable from a project that genuinely has none. At a 2 s TTL the
|
|
263
|
+
* mistake healed before anyone noticed. At 5 minutes it does not — the board
|
|
264
|
+
* served `{"gates":0,"blocked":0,"p0":0,"stale":0}` for longer than any test
|
|
265
|
+
* runs, and longer than a person will wait before deciding the board is broken.
|
|
266
|
+
*
|
|
267
|
+
* That is exactly what raising the TTL did to this suite: the gate tests create
|
|
268
|
+
* a task, start a board, and poll. The poll used to recover. It stopped
|
|
269
|
+
* recovering, and the failures moved around enough between runs to read as the
|
|
270
|
+
* flakiness that had been there all day — which is why I spent three runs
|
|
271
|
+
* blaming the machine.
|
|
272
|
+
*
|
|
273
|
+
* A short window for empty costs one `bd list` on a project that really is
|
|
274
|
+
* empty, and nothing at all on one that is not.
|
|
275
|
+
*/
|
|
276
|
+
const EMPTY_TTL_MS = Number(process.env.GREAT_CTO_BD_EMPTY_TTL_MS || 5000);
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Directories with a background refresh already in flight.
|
|
280
|
+
*
|
|
281
|
+
* Without this, ten requests arriving while one refresh runs start ten more.
|
|
282
|
+
*/
|
|
283
|
+
const refreshing = new Set();
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Refresh a directory's entry WITHOUT holding the event loop.
|
|
287
|
+
*
|
|
288
|
+
* `spawnSync` is what made this board unanswerable: `bd list` costs seconds and
|
|
289
|
+
* blocks everything for the whole of it — /api/version, one readdirSync,
|
|
290
|
+
* measured at 1-10 s because it was queued behind a task read. Warming at boot
|
|
291
|
+
* moved the first stall out of sight; this removes the rest.
|
|
292
|
+
*
|
|
293
|
+
* Nothing awaits it. It exists to make the NEXT read fast, and a caller that
|
|
294
|
+
* needed the new data would have had to block for it anyway.
|
|
295
|
+
*/
|
|
296
|
+
function bdRefreshAsync(cwd) {
|
|
297
|
+
if (refreshing.has(cwd)) return;
|
|
298
|
+
refreshing.add(cwd);
|
|
299
|
+
lastBdRunAt.set(cwd, Date.now());
|
|
300
|
+
let out = '';
|
|
301
|
+
try {
|
|
302
|
+
const child = spawn(BD_BIN, ['list', '--json', '--all', '--include-gates'], { cwd, env: bdEnv() });
|
|
303
|
+
child.stdout?.on('data', (d) => { out += d; });
|
|
304
|
+
child.on('error', (e) => {
|
|
305
|
+
refreshing.delete(cwd);
|
|
306
|
+
bdFailures.set(cwd, `bd could not be run: ${e?.message || e}`.slice(0, 300));
|
|
307
|
+
});
|
|
308
|
+
child.on('close', (code) => {
|
|
309
|
+
refreshing.delete(cwd);
|
|
310
|
+
lastBdRunAt.set(cwd, Date.now());
|
|
311
|
+
if (code !== 0) { bdFailures.set(cwd, `bd exited ${code}`); return; }
|
|
312
|
+
try {
|
|
313
|
+
const parsed = JSON.parse(out || '[]');
|
|
314
|
+
// Same guard as the sync path: bd 0.6x can answer 0 with a JSON object,
|
|
315
|
+
// and a non-array rendered as no tasks is the silent zero again.
|
|
316
|
+
if (!Array.isArray(parsed)) {
|
|
317
|
+
bdFailures.set(cwd, String(parsed?.error || 'bd returned something that is not a task list').slice(0, 300));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
bdFailures.delete(cwd);
|
|
321
|
+
bdCache.set(cwd, { ts: Date.now(), data: parsed });
|
|
322
|
+
} catch (e) {
|
|
323
|
+
bdFailures.set(cwd, `bd output could not be parsed: ${e?.message || e}`.slice(0, 300));
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
} catch (e) {
|
|
327
|
+
refreshing.delete(cwd);
|
|
328
|
+
bdFailures.set(cwd, `bd could not be spawned: ${e?.message || e}`.slice(0, 300));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
|
|
333
|
+
const maxAge = Number.isFinite(opts.maxAgeMs) ? opts.maxAgeMs : BD_CACHE_TTL_MS;
|
|
147
334
|
const cached = bdCache.get(cwd);
|
|
148
|
-
|
|
335
|
+
const ttl = cached && Array.isArray(cached.data) && cached.data.length === 0
|
|
336
|
+
? Math.min(maxAge, EMPTY_TTL_MS)
|
|
337
|
+
: maxAge;
|
|
338
|
+
if (cached && Date.now() - cached.ts < ttl) return cached.data;
|
|
339
|
+
|
|
340
|
+
// Stale-while-revalidate. An entry that exists is served immediately, however
|
|
341
|
+
// old, and refreshed off the event loop. Only a directory with NO entry at all
|
|
342
|
+
// blocks — which after the boot warm-up means a project nobody has opened yet,
|
|
343
|
+
// once.
|
|
344
|
+
//
|
|
345
|
+
// The alternative was making this async and every caller with it: getTasks,
|
|
346
|
+
// getInbox, getPipeline, the metrics readers, the SSE broadcast, the alert
|
|
347
|
+
// sweeps. That refactor is the correct end state and is not what a board
|
|
348
|
+
// hanging today needs.
|
|
349
|
+
//
|
|
350
|
+
// The injected `runner` is how the tests drive this. When one is supplied the
|
|
351
|
+
// sync path is kept, so a test that stubs `bd` still observes the call it
|
|
352
|
+
// stubbed rather than a background spawn it cannot see.
|
|
353
|
+
if (cached && runner === bd) {
|
|
354
|
+
bdRefreshAsync(cwd);
|
|
355
|
+
return cached.data;
|
|
356
|
+
}
|
|
149
357
|
try {
|
|
358
|
+
lastBdRunAt.set(cwd, Date.now());
|
|
150
359
|
const result = runner(['list', '--json', '--all', '--include-gates'], { cwd });
|
|
360
|
+
// Stamped again on return: the touch happens DURING the call, and the call
|
|
361
|
+
// takes seconds. Stamping only before it leaves a window where our own echo
|
|
362
|
+
// arrives after the mark has already aged out.
|
|
363
|
+
lastBdRunAt.set(cwd, Date.now());
|
|
151
364
|
if (result.status !== 0) {
|
|
152
365
|
bdFailures.set(cwd, bdReason(result));
|
|
153
366
|
if (cached) return cached.data; // last-good data, cache untouched
|
|
@@ -449,8 +662,15 @@ function parseTasksMd(cwd) {
|
|
|
449
662
|
}
|
|
450
663
|
}
|
|
451
664
|
|
|
452
|
-
|
|
453
|
-
|
|
665
|
+
/**
|
|
666
|
+
* Sweeps that touch every registered project pass `{ maxAgeMs: SWEEP_MAX_AGE_MS }`.
|
|
667
|
+
* A gate that has been stale for hours is not less stale for having been read
|
|
668
|
+
* ten minutes ago, and no alert is worth making the board unanswerable.
|
|
669
|
+
*/
|
|
670
|
+
const SWEEP_MAX_AGE_MS = Number(process.env.GREAT_CTO_BD_SWEEP_MAX_AGE_MS || 15 * 60 * 1000);
|
|
671
|
+
|
|
672
|
+
function getTasks(cwd = process.cwd(), opts = {}) {
|
|
673
|
+
const all = bdList(cwd, bd, opts);
|
|
454
674
|
// Fallback to tasks.md when no Beads tasks (project not initialized with bd)
|
|
455
675
|
if (all.length === 0) {
|
|
456
676
|
const mdTasks = parseTasksMd(cwd);
|
|
@@ -511,7 +731,11 @@ function detectAgent(task) {
|
|
|
511
731
|
|
|
512
732
|
export {
|
|
513
733
|
bdCacheInvalidate,
|
|
734
|
+
bdCacheStale,
|
|
514
735
|
BD_CACHE_TTL_MS,
|
|
736
|
+
SWEEP_MAX_AGE_MS,
|
|
737
|
+
EMPTY_TTL_MS,
|
|
738
|
+
isSelfInflictedTouch,
|
|
515
739
|
BD_BIN,
|
|
516
740
|
bdEnv,
|
|
517
741
|
bd,
|
|
@@ -5,6 +5,10 @@ import { GREAT_CTO_DIR } from './config.mjs';
|
|
|
5
5
|
import { readFileSafe } from './util.mjs';
|
|
6
6
|
import { log } from './log.mjs';
|
|
7
7
|
import { readVerdicts } from './verdicts.mjs';
|
|
8
|
+
// Per-agent spending limits. The judge lives in scripts/lib because the pipeline
|
|
9
|
+
// dispatcher enforces the same verdict — one definition, so the board cannot
|
|
10
|
+
// show `within` while the dispatcher holds the stage.
|
|
11
|
+
import { parseAgentBudgets, judgeAgentBudget } from '../../../scripts/lib/agent-budget.mjs';
|
|
8
12
|
|
|
9
13
|
// ── Agent fleet view (DESIGN-agents-fleet-view §3) ─────────────────────────
|
|
10
14
|
//
|
|
@@ -86,6 +90,16 @@ function isFailure(verdict) {
|
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
function getAgentsFleet(projectCwd) {
|
|
93
|
+
// Read once per call, not per agent: sixty agents would otherwise re-read and
|
|
94
|
+
// re-parse the same PROJECT.md sixty times on every board refresh.
|
|
95
|
+
let agentBudgets = new Map();
|
|
96
|
+
try {
|
|
97
|
+
if (projectCwd) {
|
|
98
|
+
agentBudgets = parseAgentBudgets(
|
|
99
|
+
fs.readFileSync(path.join(projectCwd, '.great_cto', 'PROJECT.md'), 'utf8')).budgets;
|
|
100
|
+
}
|
|
101
|
+
} catch { /* no PROJECT.md, or unreadable — every agent reads as no-limit */ }
|
|
102
|
+
|
|
89
103
|
const agents = [];
|
|
90
104
|
let files = [];
|
|
91
105
|
try {
|
|
@@ -170,6 +184,17 @@ function getAgentsFleet(projectCwd) {
|
|
|
170
184
|
savings_x: savingsX,
|
|
171
185
|
health,
|
|
172
186
|
retired: isRetired(slug),
|
|
187
|
+
// Four states, and only `exceeded` can hold a dispatch — see
|
|
188
|
+
// scripts/lib/agent-budget.mjs. `llm_usd_30d_real` is null when nothing was
|
|
189
|
+
// measured, which is exactly what makes the difference between "under the
|
|
190
|
+
// cap" and "no idea" visible here instead of guessed.
|
|
191
|
+
budget: judgeAgentBudget({
|
|
192
|
+
agent: slug,
|
|
193
|
+
budgets: agentBudgets,
|
|
194
|
+
spend: realLlmUsd > 0
|
|
195
|
+
? { real_llm_usd: Math.round(realLlmUsd * 100) / 100, llm_usd: Math.round(estLlmUsd * 100) / 100 }
|
|
196
|
+
: { llm_usd: Math.round(estLlmUsd * 100) / 100 },
|
|
197
|
+
}),
|
|
173
198
|
});
|
|
174
199
|
}
|
|
175
200
|
|
|
@@ -178,7 +203,19 @@ function getAgentsFleet(projectCwd) {
|
|
|
178
203
|
const active30d = agents.filter(a => a.runs_30d > 0 && !a.retired).length;
|
|
179
204
|
const retireCandidates = agents.filter(a => a.runs_30d === 0 && !a.retired).length;
|
|
180
205
|
const failing = agents.filter(a => a.health === 'failing' && !a.retired).length;
|
|
181
|
-
|
|
206
|
+
// MEASURED spend, or none. This tile reported the estimate — verdict count
|
|
207
|
+
// times a hardcoded rate — under the label "LLM SPEND 30D", while the metrics
|
|
208
|
+
// page reported its own estimate, derived from TASKS, under the same words.
|
|
209
|
+
// The two disagreed by more than twofold ($3.90 against $1.65) and neither
|
|
210
|
+
// said it was estimating, so the board contradicted itself and sounded certain
|
|
211
|
+
// doing it.
|
|
212
|
+
//
|
|
213
|
+
// The estimate stays available per agent as `llm_usd_30d_est`, named as an
|
|
214
|
+
// estimate. The fleet total claims only what verdicts actually recorded, and
|
|
215
|
+
// is null when they recorded nothing — the same rule the metrics tile, the
|
|
216
|
+
// portfolio and the budgets all follow now.
|
|
217
|
+
const totalReal30d = agents.reduce((s, a) => s + (a.llm_usd_30d_real || 0), 0);
|
|
218
|
+
const measuredFor = agents.filter((a) => a.llm_usd_30d_real != null).length;
|
|
182
219
|
|
|
183
220
|
return {
|
|
184
221
|
agents,
|
|
@@ -188,7 +225,9 @@ function getAgentsFleet(projectCwd) {
|
|
|
188
225
|
active_30d: active30d,
|
|
189
226
|
retire_candidates: retireCandidates,
|
|
190
227
|
failing_7d: failing,
|
|
191
|
-
llm_usd_30d: Math.round(
|
|
228
|
+
llm_usd_30d: measuredFor ? Math.round(totalReal30d * 100) / 100 : null,
|
|
229
|
+
llm_usd_30d_measured_for: measuredFor,
|
|
230
|
+
llm_usd_30d_agents_with_runs: agents.filter((a) => a.runs_30d > 0).length,
|
|
192
231
|
},
|
|
193
232
|
};
|
|
194
233
|
}
|
|
@@ -160,7 +160,12 @@ function getMetrics(cwd = process.cwd(), days = 30) {
|
|
|
160
160
|
// Without this, "AI spend" stayed at lifetime $93 even when period=7D
|
|
161
161
|
// showed only 12 tasks worth ~$0.30 — making savings ratios nonsensical.
|
|
162
162
|
for (const v of verdicts) {
|
|
163
|
-
|
|
163
|
+
// A RECORDED zero is not a measurement. `log-verdict.sh` wrote `cost_usd: 0`
|
|
164
|
+
// whenever no cost was passed, for this repository's whole history, so
|
|
165
|
+
// `!= null` counted every one of them as a measured run costing nothing —
|
|
166
|
+
// and the board reported "$0.00 AI spend" over projects with dozens of
|
|
167
|
+
// agent runs. Same defect as the fleet's, on a second screen.
|
|
168
|
+
if (v.cost_usd == null || v.cost_usd === 0) continue;
|
|
164
169
|
if (!agentCostMap[v.agent]) continue;
|
|
165
170
|
if (v.ts && (now - new Date(v.ts).getTime()) > costWindowMs) continue;
|
|
166
171
|
agentCostMap[v.agent].real_llm_usd += v.cost_usd;
|
|
@@ -195,7 +200,7 @@ function getMetrics(cwd = process.cwd(), days = 30) {
|
|
|
195
200
|
const taskHumanTotal = agentsCost.reduce((s, a) => s + a.human_usd, 0);
|
|
196
201
|
// Filter verdicts to the same window for consistent total
|
|
197
202
|
const verdictLlmTotal = verdicts.reduce((s, v) => {
|
|
198
|
-
if (v.cost_usd == null) return s;
|
|
203
|
+
if (v.cost_usd == null || v.cost_usd === 0) return s;
|
|
199
204
|
if (v.ts && (now - new Date(v.ts).getTime()) > costWindowMs) return s;
|
|
200
205
|
return s + v.cost_usd;
|
|
201
206
|
}, 0);
|
|
@@ -206,7 +211,7 @@ function getMetrics(cwd = process.cwd(), days = 30) {
|
|
|
206
211
|
// Bar: enough windowed done-tasks carry a real verdict cost (coverage ≥ 50%,
|
|
207
212
|
// min 3), and the measured total is a real spend (≥ 1¢, not a synthetic $0).
|
|
208
213
|
const doneInWindowCount = done.filter(t => t.closed_at && (now - new Date(t.closed_at).getTime()) <= costWindowMs).length;
|
|
209
|
-
const verdictsWithCost = verdicts.filter(v => v.cost_usd != null && (!v.ts || (now - new Date(v.ts).getTime()) <= costWindowMs)).length;
|
|
214
|
+
const verdictsWithCost = verdicts.filter(v => v.cost_usd != null && v.cost_usd !== 0 && (!v.ts || (now - new Date(v.ts).getTime()) <= costWindowMs)).length;
|
|
210
215
|
const measuredTrustworthy = verdictLlmTotal >= 0.01
|
|
211
216
|
&& verdictsWithCost >= Math.max(3, Math.ceil(0.5 * doneInWindowCount));
|
|
212
217
|
|
|
@@ -269,9 +274,33 @@ function getMetrics(cwd = process.cwd(), days = 30) {
|
|
|
269
274
|
const verdictsInWindow = verdicts.filter(v => v.ts && (now - new Date(v.ts).getTime()) <= costWindowMs);
|
|
270
275
|
const acceptance = acceptanceMetrics(verdictsInWindow, cost.llm_usd);
|
|
271
276
|
|
|
277
|
+
// The window immediately before this one, same length — so a tile can say
|
|
278
|
+
// "12% fewer than the previous 30 days" instead of only "+3 this week".
|
|
279
|
+
//
|
|
280
|
+
// Three states, not two. `comparable: false` when the project has no history
|
|
281
|
+
// reaching back two full windows: a project that is three weeks old has no
|
|
282
|
+
// previous 30 days, and rendering that absence as 0% or as a fall from zero
|
|
283
|
+
// would be inventing a comparison. The tile shows nothing in that case.
|
|
284
|
+
const prevStart = now - 2 * costWindowMs;
|
|
285
|
+
const prevEnd = now - costWindowMs;
|
|
286
|
+
const inPrev = (ms) => ms > prevStart && ms <= prevEnd;
|
|
287
|
+
const oldestSignal = Math.min(
|
|
288
|
+
...[...done.map(t => t.closed_at), ...verdicts.map(v => v.ts)]
|
|
289
|
+
.filter(Boolean).map(x => new Date(x).getTime()).filter(Number.isFinite),
|
|
290
|
+
Infinity,
|
|
291
|
+
);
|
|
292
|
+
const previous = {
|
|
293
|
+
comparable: Number.isFinite(oldestSignal) && oldestSignal <= prevEnd,
|
|
294
|
+
done: done.filter(t => t.closed_at && inPrev(new Date(t.closed_at).getTime())).length,
|
|
295
|
+
llm_usd: Math.round(verdicts
|
|
296
|
+
.filter(v => v.ts && v.cost_usd != null && inPrev(new Date(v.ts).getTime()))
|
|
297
|
+
.reduce((a, v) => a + v.cost_usd, 0) * 10000) / 10000,
|
|
298
|
+
};
|
|
299
|
+
|
|
272
300
|
return {
|
|
273
301
|
window_days: days,
|
|
274
302
|
acceptance,
|
|
303
|
+
previous,
|
|
275
304
|
tasks: {
|
|
276
305
|
total: tasks.length,
|
|
277
306
|
done: done.length,
|
|
@@ -31,7 +31,8 @@ import path from 'node:path';
|
|
|
31
31
|
import { readVerdicts } from './verdicts.mjs';
|
|
32
32
|
|
|
33
33
|
/** A project's state, or an honest account of why it is unknown. */
|
|
34
|
-
export function projectRow(entry,
|
|
34
|
+
export function projectRow(entry, opts = {}) {
|
|
35
|
+
const { now = Date.now() } = opts;
|
|
35
36
|
const base = { slug: entry.slug, path: entry.path, archetype: entry.archetype || null, description: entry.description || '' };
|
|
36
37
|
|
|
37
38
|
if (!entry.path || !fs.existsSync(entry.path)) {
|
|
@@ -60,7 +61,20 @@ export function projectRow(entry, { now = Date.now() } = {}) {
|
|
|
60
61
|
// Spend is only claimed over verdicts that carry a cost. A verdict without one
|
|
61
62
|
// is not zero spend — it is spend nobody recorded, and adding it as zero is how
|
|
62
63
|
// a dashboard reports a number smaller than the invoice.
|
|
63
|
-
|
|
64
|
+
//
|
|
65
|
+
// The rule was right and the test for it was `typeof === 'number'`, which a
|
|
66
|
+
// RECORDED zero passes. `log-verdict.sh` wrote `cost_usd: 0` whenever no cost
|
|
67
|
+
// was passed to it, for the whole history of this repository, so the fleet read
|
|
68
|
+
// eight projects as "$0.00 spent" over 2 to 27 records each — the confident
|
|
69
|
+
// zero this file's own header exists to prevent, assembled out of
|
|
70
|
+
// non-measurements.
|
|
71
|
+
//
|
|
72
|
+
// A zero is not evidence of spend. It is counted separately and shown, never
|
|
73
|
+
// dropped and never summed: `booking` and `subs` prove the mixture is real, so
|
|
74
|
+
// silently discarding the zeros would hide how little of each figure is
|
|
75
|
+
// actually measured.
|
|
76
|
+
const priced = verdicts.filter((v) => typeof v.cost_usd === 'number' && v.cost_usd > 0);
|
|
77
|
+
const recordedZero = verdicts.filter((v) => v.cost_usd === 0).length;
|
|
64
78
|
const spend = priced.reduce((a, v) => a + v.cost_usd, 0);
|
|
65
79
|
|
|
66
80
|
const last = verdicts.length
|
|
@@ -76,8 +90,9 @@ export function projectRow(entry, { now = Date.now() } = {}) {
|
|
|
76
90
|
idleMs: newest ? now - newest : null,
|
|
77
91
|
spend: priced.length ? Number(spend.toFixed(4)) : null,
|
|
78
92
|
spendKnownFor: priced.length,
|
|
79
|
-
|
|
80
|
-
|
|
93
|
+
spendZeroFor: recordedZero,
|
|
94
|
+
spendUnknownFor: verdicts.length - priced.length - recordedZero,
|
|
95
|
+
needsYou: needsAttention(last, { transitions: opts.transitions || null }),
|
|
81
96
|
};
|
|
82
97
|
}
|
|
83
98
|
|
|
@@ -89,12 +104,45 @@ export function projectRow(entry, { now = Date.now() } = {}) {
|
|
|
89
104
|
* says "security-officer returned REJECTED", not a red dot the reader has to
|
|
90
105
|
* interpret.
|
|
91
106
|
*/
|
|
92
|
-
|
|
107
|
+
/**
|
|
108
|
+
* What is waiting on the CTO in this project, or null.
|
|
109
|
+
*
|
|
110
|
+
* This only looked at whether the LAST verdict was a failure, so across
|
|
111
|
+
* seventeen projects it returned null seventeen times — including for one with a
|
|
112
|
+
* blocked task and an open P0 visible on its own inbox. The column that exists
|
|
113
|
+
* to answer "what needs me" answered nothing, everywhere, and looked like calm.
|
|
114
|
+
*
|
|
115
|
+
* A stage that ended in a terminal APPROVED/DONE and has a gate declared after
|
|
116
|
+
* it is waiting on a person. That is what the module header promised: position
|
|
117
|
+
* from verdicts alone, erring toward "this may need you", which is the right
|
|
118
|
+
* direction to be wrong in for a decisions queue.
|
|
119
|
+
*
|
|
120
|
+
* Derived from verdicts and the pipeline map only. This module deliberately does
|
|
121
|
+
* not run `bd` — the header above says why, and a sweep of seventeen projects at
|
|
122
|
+
* 2-6 s each is the saturation this board was fixed for today.
|
|
123
|
+
*
|
|
124
|
+
* @param {object|null} last the newest verdict
|
|
125
|
+
* @param {object} [opts]
|
|
126
|
+
* @param {object} [opts.transitions] the pipeline map, for the gate question
|
|
127
|
+
*/
|
|
128
|
+
export function needsAttention(last, { transitions = null } = {}) {
|
|
93
129
|
if (!last) return null;
|
|
94
130
|
const v = String(last.verdict || '').toUpperCase();
|
|
95
131
|
if (['BLOCKED', 'FAIL', 'FAILED', 'REJECTED'].includes(v)) {
|
|
96
132
|
return `${last.agent || 'a stage'} returned ${v}`;
|
|
97
133
|
}
|
|
134
|
+
|
|
135
|
+
// A stage that finished successfully behind a declared gate is waiting on a
|
|
136
|
+
// person. Reported conservatively — the map says a gate is declared there, not
|
|
137
|
+
// that this particular gate is currently open — because for a decisions queue
|
|
138
|
+
// "this may need you" is the right direction to be wrong in.
|
|
139
|
+
if (transitions && ['APPROVED', 'DONE', 'PASS', 'PLAN_READY', 'BRIEF_READY'].includes(v)) {
|
|
140
|
+
const rule = transitions[last.agent];
|
|
141
|
+
const gates = rule?.gate ? (Array.isArray(rule.gate) ? rule.gate : [rule.gate]) : [];
|
|
142
|
+
if (gates.length) {
|
|
143
|
+
return `${last.agent} finished — ${gates.join(', ')} declared next`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
98
146
|
return null;
|
|
99
147
|
}
|
|
100
148
|
|
|
@@ -105,12 +153,15 @@ export function needsAttention(last) {
|
|
|
105
153
|
* unreadable registry used to render as a board with no projects, which is the
|
|
106
154
|
* same lie one level further out.
|
|
107
155
|
*/
|
|
108
|
-
export function portfolio(registry, { now = Date.now(), maxProjects = 100 } = {}) {
|
|
156
|
+
export function portfolio(registry, { now = Date.now(), maxProjects = 100, transitions = null } = {}) {
|
|
109
157
|
if (!registry || registry.unread) {
|
|
110
158
|
return { registryUnread: registry?.unread || 'the project registry could not be read', projects: [] };
|
|
111
159
|
}
|
|
160
|
+
// The map is read ONCE for the sweep, not once per project. It is the same map
|
|
161
|
+
// for every project now that it lives in the plugin, and seventeen reads of a
|
|
162
|
+
// file to answer one question each is how a glanceable screen stops being one.
|
|
112
163
|
const entries = (registry.projects || []).slice(0, maxProjects);
|
|
113
|
-
const projects = entries.map((e) => projectRow(e, { now }));
|
|
164
|
+
const projects = entries.map((e) => projectRow(e, { now, transitions }));
|
|
114
165
|
|
|
115
166
|
const readable = projects.filter((p) => !p.unread);
|
|
116
167
|
return {
|