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.
@@ -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": "2.99.0",
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
- import { getTasks } from './beads.mjs';
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
- const BD_CACHE_TTL_MS = Number(process.env.GREAT_CTO_BD_CACHE_TTL_MS || 30000);
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
- function bdCacheInvalidate(cwd) { bdCache.delete(cwd); }
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
- function bdList(cwd = process.cwd(), runner = bd) {
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
- if (cached && Date.now() - cached.ts < BD_CACHE_TTL_MS) return cached.data;
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
- function getTasks(cwd = process.cwd()) {
453
- const all = bdList(cwd);
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
- const totalLlm30d = agents.reduce((s, a) => s + (a.llm_usd_30d_est || 0), 0);
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(totalLlm30d * 100) / 100,
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
- if (v.cost_usd == null) continue;
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, { now = Date.now() } = {}) {
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
- const priced = verdicts.filter((v) => typeof v.cost_usd === 'number');
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
- spendUnknownFor: verdicts.length - priced.length,
80
- needsYou: needsAttention(last),
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
- export function needsAttention(last) {
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 {