great-cto 2.99.0 → 3.0.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.0.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;
@@ -22,9 +22,26 @@ 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);
26
40
 
27
- function bdCacheInvalidate(cwd) { bdCache.delete(cwd); }
41
+ function bdCacheInvalidate(cwd) {
42
+ clearSelfTouch(cwd);
43
+ bdCache.delete(cwd);
44
+ }
28
45
 
29
46
  // ── bd binary resolution (BH-32) ────────────────────────────────────────────
30
47
  // A board launched from a GUI / launchd / a login shell that didn't source the
@@ -143,11 +160,111 @@ function bdReason(result) {
143
160
  return (line || 'bd exited non-zero without a message').slice(0, 300);
144
161
  }
145
162
 
146
- function bdList(cwd = process.cwd(), runner = bd) {
163
+ /**
164
+ * @param {object} [opts]
165
+ * @param {number} [opts.maxAgeMs] How stale an entry may be before it is
166
+ * refreshed. Defaults to BD_CACHE_TTL_MS — the interactive freshness a person
167
+ * looking at one project expects.
168
+ *
169
+ * Background sweeps pass a much larger value, and the reason is arithmetic
170
+ * rather than taste. `bd list` costs 2-6 s per project; this machine has 16
171
+ * registered. One sweep is therefore ~60 s of SYNCHRONOUS work — `spawnSync`
172
+ * holds the event loop for the whole of it — while the TTL was 30 s. The first
173
+ * entries expired before the sweep that filled them had finished, so the next
174
+ * sweep re-ran all sixteen. Three of the alert crons do this every five
175
+ * minutes: ~190 s of blocking per 300 s.
176
+ *
177
+ * Measured while it was happening: a `bd` child present in 9 of 10 samples
178
+ * over 20 s, and `/api/version` — a single readdirSync — answering in 1-10 s
179
+ * because it was queued behind the sweep. The board rendered empty, said
180
+ * "live · synced just now", and was telling the truth: SSE was connected and
181
+ * every data request had timed out.
182
+ *
183
+ * A cache whose TTL is shorter than the sweep that fills it never hits. Same
184
+ * defect as the 2 s TTL fixed yesterday, one level up: that one was measured
185
+ * against a single call, this one has to be measured against the whole sweep.
186
+ */
187
+ /**
188
+ * When we last ran `bd` ourselves in a directory.
189
+ *
190
+ * `bd list` is a READ, and dolt still writes: it touches
191
+ * `.dolt/noms/manifest` and `.dolt/noms/journal.idx` on every invocation. The
192
+ * file watcher watches exactly those files — deliberately, because `bd create`
193
+ * writes only to dolt and never to interactions.jsonl, so they are the only
194
+ * signal for a new issue.
195
+ *
196
+ * The two together form a loop: a request runs `bd list`, dolt touches the
197
+ * journal, the watcher fires, the cache entry is invalidated, and the next
198
+ * request runs `bd list` again. The read destroyed the cache that existed to
199
+ * make the read unnecessary. This is why raising the TTL never worked — not at
200
+ * 2 s, not at 30 s, not at 5 minutes. The entry was never expiring; it was being
201
+ * deleted.
202
+ *
203
+ * A touch within `SELF_TOUCH_WINDOW_MS` of our own run is ours. A real external
204
+ * write inside that window is missed by the watcher and picked up by the next
205
+ * event or by the TTL — the alternative is the loop, which costs every read.
206
+ */
207
+ const lastBdRunAt = new Map();
208
+ const SELF_TOUCH_WINDOW_MS = 3000;
209
+
210
+ /** True when a file event under `cwd` is the echo of a `bd` we just ran. */
211
+ function isSelfInflictedTouch(cwd) {
212
+ const at = lastBdRunAt.get(cwd);
213
+ return at != null && Date.now() - at < SELF_TOUCH_WINDOW_MS;
214
+ }
215
+
216
+ /**
217
+ * A DELIBERATE invalidation means something really changed, so the file event it
218
+ * is about to produce is not ours to ignore.
219
+ *
220
+ * Without this the self-touch window swallowed real writes. Approving a gate
221
+ * goes: read the inbox (our `bd list`, stamped), approve (a `bd update`), dolt
222
+ * touches the journal, watcher fires — and the touch lands inside the 3 s window
223
+ * opened by our own read, so it was skipped and the SSE broadcast never went out.
224
+ * Live updates stopped, and the comment above said the write would be "picked up
225
+ * by the next event", which for a broadcast means never.
226
+ *
227
+ * Every write path already calls `bdCacheInvalidate`. Clearing the mark there
228
+ * costs nothing and makes the window mean what it says: it suppresses the echo
229
+ * of a READ, never the consequence of a write.
230
+ */
231
+ function clearSelfTouch(cwd) { lastBdRunAt.delete(cwd); }
232
+
233
+ /**
234
+ * An EMPTY answer is cached briefly, whatever the TTL says.
235
+ *
236
+ * Empty is the one result most likely to be premature: a board that starts while
237
+ * a project is still being written reads no tasks, and that answer is
238
+ * indistinguishable from a project that genuinely has none. At a 2 s TTL the
239
+ * mistake healed before anyone noticed. At 5 minutes it does not — the board
240
+ * served `{"gates":0,"blocked":0,"p0":0,"stale":0}` for longer than any test
241
+ * runs, and longer than a person will wait before deciding the board is broken.
242
+ *
243
+ * That is exactly what raising the TTL did to this suite: the gate tests create
244
+ * a task, start a board, and poll. The poll used to recover. It stopped
245
+ * recovering, and the failures moved around enough between runs to read as the
246
+ * flakiness that had been there all day — which is why I spent three runs
247
+ * blaming the machine.
248
+ *
249
+ * A short window for empty costs one `bd list` on a project that really is
250
+ * empty, and nothing at all on one that is not.
251
+ */
252
+ const EMPTY_TTL_MS = Number(process.env.GREAT_CTO_BD_EMPTY_TTL_MS || 5000);
253
+
254
+ function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
255
+ const maxAge = Number.isFinite(opts.maxAgeMs) ? opts.maxAgeMs : BD_CACHE_TTL_MS;
147
256
  const cached = bdCache.get(cwd);
148
- if (cached && Date.now() - cached.ts < BD_CACHE_TTL_MS) return cached.data;
257
+ const ttl = cached && Array.isArray(cached.data) && cached.data.length === 0
258
+ ? Math.min(maxAge, EMPTY_TTL_MS)
259
+ : maxAge;
260
+ if (cached && Date.now() - cached.ts < ttl) return cached.data;
149
261
  try {
262
+ lastBdRunAt.set(cwd, Date.now());
150
263
  const result = runner(['list', '--json', '--all', '--include-gates'], { cwd });
264
+ // Stamped again on return: the touch happens DURING the call, and the call
265
+ // takes seconds. Stamping only before it leaves a window where our own echo
266
+ // arrives after the mark has already aged out.
267
+ lastBdRunAt.set(cwd, Date.now());
151
268
  if (result.status !== 0) {
152
269
  bdFailures.set(cwd, bdReason(result));
153
270
  if (cached) return cached.data; // last-good data, cache untouched
@@ -449,8 +566,15 @@ function parseTasksMd(cwd) {
449
566
  }
450
567
  }
451
568
 
452
- function getTasks(cwd = process.cwd()) {
453
- const all = bdList(cwd);
569
+ /**
570
+ * Sweeps that touch every registered project pass `{ maxAgeMs: SWEEP_MAX_AGE_MS }`.
571
+ * A gate that has been stale for hours is not less stale for having been read
572
+ * ten minutes ago, and no alert is worth making the board unanswerable.
573
+ */
574
+ const SWEEP_MAX_AGE_MS = Number(process.env.GREAT_CTO_BD_SWEEP_MAX_AGE_MS || 15 * 60 * 1000);
575
+
576
+ function getTasks(cwd = process.cwd(), opts = {}) {
577
+ const all = bdList(cwd, bd, opts);
454
578
  // Fallback to tasks.md when no Beads tasks (project not initialized with bd)
455
579
  if (all.length === 0) {
456
580
  const mdTasks = parseTasksMd(cwd);
@@ -512,6 +636,9 @@ function detectAgent(task) {
512
636
  export {
513
637
  bdCacheInvalidate,
514
638
  BD_CACHE_TTL_MS,
639
+ SWEEP_MAX_AGE_MS,
640
+ EMPTY_TTL_MS,
641
+ isSelfInflictedTouch,
515
642
  BD_BIN,
516
643
  bdEnv,
517
644
  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
 
@@ -269,9 +269,33 @@ function getMetrics(cwd = process.cwd(), days = 30) {
269
269
  const verdictsInWindow = verdicts.filter(v => v.ts && (now - new Date(v.ts).getTime()) <= costWindowMs);
270
270
  const acceptance = acceptanceMetrics(verdictsInWindow, cost.llm_usd);
271
271
 
272
+ // The window immediately before this one, same length — so a tile can say
273
+ // "12% fewer than the previous 30 days" instead of only "+3 this week".
274
+ //
275
+ // Three states, not two. `comparable: false` when the project has no history
276
+ // reaching back two full windows: a project that is three weeks old has no
277
+ // previous 30 days, and rendering that absence as 0% or as a fall from zero
278
+ // would be inventing a comparison. The tile shows nothing in that case.
279
+ const prevStart = now - 2 * costWindowMs;
280
+ const prevEnd = now - costWindowMs;
281
+ const inPrev = (ms) => ms > prevStart && ms <= prevEnd;
282
+ const oldestSignal = Math.min(
283
+ ...[...done.map(t => t.closed_at), ...verdicts.map(v => v.ts)]
284
+ .filter(Boolean).map(x => new Date(x).getTime()).filter(Number.isFinite),
285
+ Infinity,
286
+ );
287
+ const previous = {
288
+ comparable: Number.isFinite(oldestSignal) && oldestSignal <= prevEnd,
289
+ done: done.filter(t => t.closed_at && inPrev(new Date(t.closed_at).getTime())).length,
290
+ llm_usd: Math.round(verdicts
291
+ .filter(v => v.ts && v.cost_usd != null && inPrev(new Date(v.ts).getTime()))
292
+ .reduce((a, v) => a + v.cost_usd, 0) * 10000) / 10000,
293
+ };
294
+
272
295
  return {
273
296
  window_days: days,
274
297
  acceptance,
298
+ previous,
275
299
  tasks: {
276
300
  total: tasks.length,
277
301
  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 {
@@ -18,6 +18,7 @@ import { log } from './log.mjs';
18
18
  import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks, setTaskStatusInTasksMd, getReadDegradation } from './beads.mjs';
19
19
  import { getMetrics } from './metrics.mjs';
20
20
  import { readVerdicts } from './verdicts.mjs';
21
+ import { parseAgentBudgets, upsertAgentBudget, removeAgentBudget } from '../../../scripts/lib/agent-budget.mjs';
21
22
  import { getAgentsFleet, getAgentProfile, retireAgent, restoreAgent, appendDecisionLog, readDecisionsLog } from './fleet.mjs';
22
23
  import { getResume, getShareState, toggleShare } from './share.mjs';
23
24
  import { listSessions, readSession, editedFiles, searchSessions } from './transcripts.mjs';
@@ -363,7 +364,18 @@ async function dispatch(req, res, url, cwd) {
363
364
  const degraded = getRegistryDegradation();
364
365
  // A registry that could not be read used to render as a board with no
365
366
  // projects — the same silence this screen exists to remove, one level out.
366
- const out = portfolio(degraded ? { unread: degraded } : reg);
367
+ // The pipeline map, so the fleet can say which projects are waiting on a
368
+ // decision rather than only which ones failed. Read once for the sweep;
369
+ // unreadable means the gate question is simply not answered, never
370
+ // answered as "nothing is waiting".
371
+ let transitions = null;
372
+ try {
373
+ const { pipelineMapFor } = await import('../../../scripts/lib/pipeline-health.mjs');
374
+ const { parsePipelineToml } = await import('../../../scripts/hooks/pipeline-dispatcher.mjs');
375
+ const map = pipelineMapFor(cwd);
376
+ if (map.path) transitions = parsePipelineToml(fs.readFileSync(map.path, 'utf8'));
377
+ } catch { transitions = null; }
378
+ const out = portfolio(degraded ? { unread: degraded } : reg, { transitions });
367
379
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
368
380
  res.end(JSON.stringify(out));
369
381
  } catch (e) {
@@ -1343,16 +1355,20 @@ async function dispatch(req, res, url, cwd) {
1343
1355
  // Per-agent budgets from PROJECT.md
1344
1356
  const projectMdPath = path.join(cwd, '.great_cto', 'PROJECT.md');
1345
1357
  let budgets = {};
1358
+ let budgetsDeprecatedKey = null;
1359
+ const budgetsMalformed = [];
1346
1360
  let goalAncestry = null;
1347
1361
  try {
1348
1362
  const projectTxt = fs.readFileSync(projectMdPath, 'utf8');
1349
- const sectionMatch = projectTxt.match(/^agent-budget:\s*\n((?:[ \t]+\S[^\n]*\n?)*)/m);
1350
- if (sectionMatch) {
1351
- for (const line of sectionMatch[1].split('\n')) {
1352
- const m = line.match(/^\s+([a-z][a-z0-9-]*):\s*(\d+(?:\.\d+)?)/);
1353
- if (m) budgets[m[1]] = parseFloat(m[2]);
1354
- }
1355
- }
1363
+ // One parser, in scripts/lib/agent-budget.mjs — the same one the pipeline
1364
+ // dispatcher enforces with and the fleet view judges with. This was an
1365
+ // inline regex reading a different key (`agent-budget:`) with a different
1366
+ // meaning ("$X/run"), so the board could display a cap the dispatcher had
1367
+ // never heard of, and vice versa.
1368
+ const parsed = parseAgentBudgets(projectTxt);
1369
+ for (const [agent, cap] of parsed.budgets) budgets[agent] = cap;
1370
+ if (parsed.deprecatedKey) budgetsDeprecatedKey = parsed.deprecatedKey;
1371
+ for (const bad of parsed.malformed) budgetsMalformed.push(bad);
1356
1372
  // Goal ancestry
1357
1373
  const archetype = (projectTxt.match(/^(?:archetype|primary):\s*(\S+)/m) || [])[1] || null;
1358
1374
  const compliance = (projectTxt.match(/^compliance:\s*(.+)$/m) || [])[1] || null;
@@ -1370,7 +1386,85 @@ async function dispatch(req, res, url, cwd) {
1370
1386
  } catch { /* no log */ }
1371
1387
 
1372
1388
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1373
- res.end(JSON.stringify({ stuck, budgets, goal_ancestry: goalAncestry, tool_failure_rate_1h: toolFailureRate1h }));
1389
+ // `budgets_deprecated_key` and `budgets_malformed` travel with the caps.
1390
+ // A limit somebody wrote under the old key still works and they are told; a
1391
+ // line the parser could not read is reported rather than dropped, because a
1392
+ // budget silently ignored is a limit its author believes they have.
1393
+ res.end(JSON.stringify({
1394
+ stuck, budgets, goal_ancestry: goalAncestry, tool_failure_rate_1h: toolFailureRate1h,
1395
+ budgets_deprecated_key: budgetsDeprecatedKey,
1396
+ budgets_malformed: budgetsMalformed,
1397
+ }));
1398
+ return true;
1399
+ }
1400
+
1401
+ // Set or clear one agent's spending cap, by writing PROJECT.md.
1402
+ //
1403
+ // POST /api/agent-budgets { agent, limit_usd } set / replace
1404
+ // POST /api/agent-budgets { agent, remove: true } clear
1405
+ //
1406
+ // This writes a file the operator owns and git tracks, from a browser. Same
1407
+ // two gates as /api/projects/register, for the same reason: the board listens
1408
+ // on 127.0.0.1, and a page the user happens to be visiting can still issue a
1409
+ // simple cross-origin POST to localhost.
1410
+ //
1411
+ // The reply always reports the PREVIOUS value. A cap that silently replaced
1412
+ // another is a change the operator cannot see they made.
1413
+ if (pathname === '/api/agent-budgets' && req.method === 'POST') {
1414
+ if (!originAllowed(req)) {
1415
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1416
+ res.end(JSON.stringify({ error: 'origin not allowed' }));
1417
+ return true;
1418
+ }
1419
+ let body = '';
1420
+ req.on('data', (c) => { body += c; if (body.length > 4096) req.destroy(); });
1421
+ req.on('end', () => {
1422
+ let parsed;
1423
+ try { parsed = JSON.parse(body || '{}'); }
1424
+ catch (e) {
1425
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1426
+ res.end(JSON.stringify({ error: 'invalid_json', message: String(e.message || e) }));
1427
+ return;
1428
+ }
1429
+ const mdPath = path.join(cwd, '.great_cto', 'PROJECT.md');
1430
+ let before;
1431
+ try { before = fs.readFileSync(mdPath, 'utf8'); }
1432
+ catch (e) {
1433
+ // No PROJECT.md is not a project we may create one for from a browser.
1434
+ res.writeHead(409, { 'Content-Type': 'application/json' });
1435
+ res.end(JSON.stringify({ error: 'no PROJECT.md to write to', detail: String(e.message || e) }));
1436
+ return;
1437
+ }
1438
+ let out;
1439
+ try {
1440
+ out = parsed.remove
1441
+ ? removeAgentBudget(before, parsed.agent)
1442
+ : upsertAgentBudget(before, parsed.agent, parsed.limit_usd);
1443
+ } catch (e) {
1444
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1445
+ res.end(JSON.stringify({ error: String(e.message || e) }));
1446
+ return;
1447
+ }
1448
+ try {
1449
+ // Write through a temp file in the same directory, then rename: a
1450
+ // half-written PROJECT.md is the project's own identity truncated.
1451
+ const tmp = `${mdPath}.tmp-${process.pid}`;
1452
+ fs.writeFileSync(tmp, out.text);
1453
+ fs.renameSync(tmp, mdPath);
1454
+ } catch (e) {
1455
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1456
+ res.end(JSON.stringify({ error: `could not write PROJECT.md: ${String(e.message || e)}` }));
1457
+ return;
1458
+ }
1459
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1460
+ res.end(JSON.stringify({
1461
+ ok: true,
1462
+ agent: String(parsed.agent || '').toLowerCase(),
1463
+ previous_usd: out.previousUsd,
1464
+ removed: out.removed === true,
1465
+ created_block: out.created === true,
1466
+ }));
1467
+ });
1374
1468
  return true;
1375
1469
  }
1376
1470
 
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import { GREAT_CTO_DIR } from './config.mjs';
4
4
  import { sseClients } from './state.mjs';
5
5
  import { listProjects } from './projects.mjs';
6
- import { bdCacheInvalidate, getTasks } from './beads.mjs';
6
+ import { bdCacheInvalidate, getTasks, isSelfInflictedTouch } from './beads.mjs';
7
7
  import { getPipeline, getInbox } from './data-readers.mjs';
8
8
 
9
9
  // ── File watcher ───────────────────────────────────────────────────────────────
@@ -17,6 +17,37 @@ function watchBeads() {
17
17
  if (!dirs.includes(process.cwd())) dirs.push(process.cwd());
18
18
 
19
19
  const broadcast = (dir) => {
20
+ // Invalidate ONLY for a project somebody is looking at.
21
+ //
22
+ // This ran unconditionally, for all sixteen registered projects, on every
23
+ // file event in any of them — and an invalidated entry is deleted, so no
24
+ // later read can be served from cache no matter how stale it is willing to
25
+ // accept. The alert crons then re-ran `bd list` for all sixteen every five
26
+ // minutes, at 2-6 s each, synchronously. That is the pair that made the
27
+ // board unanswerable: watchers emptying the cache as fast as sweeps filled
28
+ // it, with the event loop held by `spawnSync` throughout.
29
+ //
30
+ // A project nobody is watching serves data up to BD_CACHE_TTL_MS old, which
31
+ // is the freshness contract everywhere else, and the read that refreshes it
32
+ // happens when someone actually opens it.
33
+ const watched = [...sseClients].some((r) => r._gctoCwd === dir);
34
+ if (!watched) return;
35
+ // Do not react at all to the echo of our own read. `bd list` touches the dolt
36
+ // journal this watcher watches, so every read was dropping the entry it had
37
+ // just filled — a loop that made the cache useless at any TTL.
38
+ //
39
+ // Return, rather than merely skipping the invalidation. I tried the narrower
40
+ // version — suppress the cache drop, still broadcast — reasoning that a
41
+ // watcher event is still an event. It is not: the broadcast reads
42
+ // getTasks(dir), and on a self-touch the entry was deliberately NOT
43
+ // invalidated, so the event carries the cache's older answer. Clients were
44
+ // pushed stale tasks, and `gate: SSE broadcasts updated tasks after
45
+ // approval` failed on exactly that.
46
+ //
47
+ // Nothing is lost by staying silent here. A write through the board calls
48
+ // broadcastTasks itself; a write from outside is not a self-touch and lands
49
+ // in the branch below.
50
+ if (isSelfInflictedTouch(dir)) return;
20
51
  bdCacheInvalidate(dir);
21
52
  for (const res of sseClients) {
22
53
  if (res._gctoCwd === dir) {