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.
- package/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/lib/alerts.mjs +11 -4
- package/board/packages/board/lib/beads.mjs +133 -6
- package/board/packages/board/lib/fleet.mjs +25 -0
- package/board/packages/board/lib/metrics.mjs +24 -0
- package/board/packages/board/lib/portfolio.mjs +58 -7
- package/board/packages/board/lib/routes.mjs +103 -9
- package/board/packages/board/lib/watchers.mjs +32 -1
- package/board/packages/board/public/index.html +595 -16
- 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.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
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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,
|
|
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 {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
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
|
-
|
|
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) {
|