great-cto 2.95.0 → 2.97.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 +5 -1
- package/board/packages/board/lib/beads.mjs +53 -5
- package/board/packages/board/lib/data-readers.mjs +53 -8
- package/board/packages/board/lib/docs.mjs +176 -0
- package/board/packages/board/lib/fleet.mjs +13 -2
- package/board/packages/board/lib/portfolio.mjs +128 -0
- package/board/packages/board/lib/projects.mjs +31 -0
- package/board/packages/board/lib/routes.mjs +253 -11
- package/board/packages/board/lib/verdicts.mjs +63 -9
- package/board/packages/board/public/index.html +568 -34
- package/board/scripts/lib/freshness.mjs +175 -0
- package/board/scripts/lib/gate-tier.mjs +279 -0
- package/board/scripts/lib/pipeline-wake.mjs +120 -0
- package/board/scripts/lib/receipt.mjs +386 -0
- package/board/scripts/lib/stand-down.mjs +147 -0
- package/board/scripts/lib/system-map.mjs +206 -0
- package/board/scripts/lib/verdict-record.mjs +18 -1
- package/dist/detect.js +1 -0
- package/dist/main.js +40 -2
- 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": "2.
|
|
5
|
+
"version": "2.97.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Great CTO",
|
|
8
8
|
"url": "https://github.com/avelikiy/great_cto"
|
|
@@ -195,6 +195,10 @@
|
|
|
195
195
|
"command": "PLUGIN_DIR=$(ls -d ~/.claude/plugins/cache/local/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||'); node \"${PLUGIN_DIR}/scripts/hooks/reviewer-nudge.mjs\" 2>/dev/null || true",
|
|
196
196
|
"timeout": 5,
|
|
197
197
|
"statusMessage": "Checking reviewer rules for this file..."
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
"type": "command",
|
|
201
|
+
"command": "PLUGIN_DIR=$(ls -d ~/.claude/plugins/cache/local/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||'); node \"${PLUGIN_DIR}/scripts/hooks/lesson-rules-check.mjs\" 2>/dev/null; true"
|
|
198
202
|
}
|
|
199
203
|
]
|
|
200
204
|
},
|
|
@@ -100,20 +100,64 @@ function bdWriteSerialised(fn) {
|
|
|
100
100
|
// populated board on every SSE push (great_cto-e2ew). We deliberately do NOT
|
|
101
101
|
// refresh cached.ts on failure, so the next call retries bd immediately
|
|
102
102
|
// rather than being TTL-gated on a failed read.
|
|
103
|
+
//
|
|
104
|
+
// Why the failure is also RECORDED
|
|
105
|
+
// --------------------------------
|
|
106
|
+
// Falling back to `[]` keeps the board up, but `[]` is the same value a project
|
|
107
|
+
// with no tasks returns, and the reader cannot tell them apart. A project whose
|
|
108
|
+
// directory name contains a dot — `<private-project>.ai` — makes bd refuse to open its
|
|
109
|
+
// database at all ("invalid database name"), and the board answered that with a
|
|
110
|
+
// clean empty board: no tasks, no metrics, no explanation. Switching to it looked
|
|
111
|
+
// exactly like a project nobody had started.
|
|
112
|
+
//
|
|
113
|
+
// So the reason is kept per-cwd and handed up to the API, which reports it in
|
|
114
|
+
// `X-Board-Degraded`. The empty list is still returned — the board stays usable —
|
|
115
|
+
// but it now arrives labelled.
|
|
116
|
+
const bdFailures = new Map();
|
|
117
|
+
|
|
118
|
+
/** Why this project's tasks could not be read, or null if they could. */
|
|
119
|
+
function bdFailureFor(cwd = process.cwd()) {
|
|
120
|
+
return bdFailures.get(cwd) || null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The first meaningful line of bd's complaint, short enough for a header. */
|
|
124
|
+
function bdReason(result) {
|
|
125
|
+
const text = String(result?.stderr || result?.stdout || '').trim();
|
|
126
|
+
// bd reports some failures as JSON on stdout with status 0-adjacent shapes.
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(text);
|
|
129
|
+
if (parsed && parsed.error) return String(parsed.error).slice(0, 300);
|
|
130
|
+
} catch { /* not JSON — use the raw first line */ }
|
|
131
|
+
const line = text.split('\n').map((s) => s.trim()).find(Boolean);
|
|
132
|
+
return (line || 'bd exited non-zero without a message').slice(0, 300);
|
|
133
|
+
}
|
|
134
|
+
|
|
103
135
|
function bdList(cwd = process.cwd(), runner = bd) {
|
|
104
136
|
const cached = bdCache.get(cwd);
|
|
105
137
|
if (cached && Date.now() - cached.ts < BD_CACHE_TTL_MS) return cached.data;
|
|
106
138
|
try {
|
|
107
139
|
const result = runner(['list', '--json', '--all', '--include-gates'], { cwd });
|
|
108
140
|
if (result.status !== 0) {
|
|
141
|
+
bdFailures.set(cwd, bdReason(result));
|
|
109
142
|
if (cached) return cached.data; // last-good data, cache untouched
|
|
110
143
|
bdCache.set(cwd, { ts: Date.now(), data: [] });
|
|
111
144
|
return [];
|
|
112
145
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
146
|
+
// bd 0.6x reports some open failures as a JSON object on stdout with exit 0.
|
|
147
|
+
// `JSON.parse` succeeds, the result is not an array, and the board rendered
|
|
148
|
+
// it as no tasks — the same silent zero one layer further in.
|
|
149
|
+
const parsed = JSON.parse(result.stdout || '[]');
|
|
150
|
+
if (!Array.isArray(parsed)) {
|
|
151
|
+
bdFailures.set(cwd, String(parsed?.error || 'bd returned something that is not a task list').slice(0, 300));
|
|
152
|
+
if (cached) return cached.data;
|
|
153
|
+
bdCache.set(cwd, { ts: Date.now(), data: [] });
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
bdFailures.delete(cwd);
|
|
157
|
+
bdCache.set(cwd, { ts: Date.now(), data: parsed });
|
|
158
|
+
return parsed;
|
|
159
|
+
} catch (e) {
|
|
160
|
+
bdFailures.set(cwd, `bd could not be run: ${e?.message || e}`.slice(0, 300));
|
|
117
161
|
if (cached) return cached.data; // last-good data, cache untouched
|
|
118
162
|
bdCache.set(cwd, { ts: Date.now(), data: [] });
|
|
119
163
|
return [];
|
|
@@ -322,7 +366,10 @@ const readDegradation = new Map();
|
|
|
322
366
|
|
|
323
367
|
/** Degradation reason for a project's task sources, or null when healthy. */
|
|
324
368
|
function getReadDegradation(cwd = process.cwd()) {
|
|
325
|
-
|
|
369
|
+
// tasks.md first: if that file exists and is broken, that is the specific
|
|
370
|
+
// problem. Otherwise report bd's failure, which until now was swallowed — the
|
|
371
|
+
// board answered "no tasks" for a project whose database bd refused to open.
|
|
372
|
+
return readDegradation.get(cwd) || bdFailureFor(cwd) || null;
|
|
326
373
|
}
|
|
327
374
|
|
|
328
375
|
function parseTasksMd(cwd) {
|
|
@@ -460,6 +507,7 @@ export {
|
|
|
460
507
|
checkBeadsAvailable,
|
|
461
508
|
bdWriteSerialised,
|
|
462
509
|
bdList,
|
|
510
|
+
bdFailureFor,
|
|
463
511
|
parseTasksMd,
|
|
464
512
|
getReadDegradation,
|
|
465
513
|
setTaskStatusInTasksMd,
|
|
@@ -322,17 +322,62 @@ function getInbox(cwd = process.cwd()) {
|
|
|
322
322
|
return ageH > 48;
|
|
323
323
|
});
|
|
324
324
|
const sec = readSecStats(cwd);
|
|
325
|
+
|
|
326
|
+
// One object, one section.
|
|
327
|
+
//
|
|
328
|
+
// These four filters overlap — a P0 that is also blocked matched two of them,
|
|
329
|
+
// so it rendered as two rows and the nav badge (gates + p0 + blocked) counted
|
|
330
|
+
// it twice. "Two things need you" when one thing does is a small lie that
|
|
331
|
+
// costs real attention, and it is the same defect as any other count that
|
|
332
|
+
// measures the query rather than the world.
|
|
333
|
+
//
|
|
334
|
+
// Each task lands in its strongest section only, strongest first: a gate is
|
|
335
|
+
// waiting on a signature, a P0 is an emergency, blocked is a state, stale is
|
|
336
|
+
// an observation. The other states it is in are kept on the row as `also`, so
|
|
337
|
+
// deduplicating loses nothing — the row can still say "and it is blocked".
|
|
338
|
+
const ORDER = [
|
|
339
|
+
['gate', pendingGates],
|
|
340
|
+
['p0', p0],
|
|
341
|
+
['blocked', blocked],
|
|
342
|
+
['stale', stale],
|
|
343
|
+
];
|
|
344
|
+
const homeOf = new Map(); // task id → the section that owns it
|
|
345
|
+
const alsoOf = new Map(); // task id → the other sections it matched
|
|
346
|
+
for (const [name, list] of ORDER) {
|
|
347
|
+
for (const t of list) {
|
|
348
|
+
const id = t?.id ?? t?.title;
|
|
349
|
+
if (id === undefined) continue;
|
|
350
|
+
if (homeOf.has(id)) { alsoOf.set(id, [...(alsoOf.get(id) || []), name]); continue; }
|
|
351
|
+
homeOf.set(id, name);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const own = (name, list, limit) => list
|
|
355
|
+
.filter((t) => homeOf.get(t?.id ?? t?.title) === name)
|
|
356
|
+
.map((t) => ({ ...t, also: alsoOf.get(t?.id ?? t?.title) || [] }))
|
|
357
|
+
.slice(0, limit);
|
|
358
|
+
|
|
359
|
+
const ownedGates = own('gate', pendingGates, 20);
|
|
360
|
+
const ownedP0 = own('p0', p0, 10);
|
|
361
|
+
const ownedBlocked = own('blocked', blocked, 10);
|
|
362
|
+
const ownedStale = own('stale', stale, 10);
|
|
363
|
+
|
|
325
364
|
return {
|
|
326
|
-
pending_gates:
|
|
327
|
-
blocked:
|
|
328
|
-
p0_open:
|
|
329
|
-
stale_in_progress:
|
|
365
|
+
pending_gates: ownedGates,
|
|
366
|
+
blocked: ownedBlocked,
|
|
367
|
+
p0_open: ownedP0,
|
|
368
|
+
stale_in_progress: ownedStale,
|
|
330
369
|
security: { blocked: sec.blocked, approved: sec.approved },
|
|
331
370
|
summary: {
|
|
332
|
-
gates
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
371
|
+
// Counts of distinct objects, so gates + p0 + blocked is a real total
|
|
372
|
+
// rather than a sum over overlapping sets.
|
|
373
|
+
gates: [...homeOf.values()].filter((s) => s === 'gate').length,
|
|
374
|
+
blocked: [...homeOf.values()].filter((s) => s === 'blocked').length,
|
|
375
|
+
p0: [...homeOf.values()].filter((s) => s === 'p0').length,
|
|
376
|
+
stale: [...homeOf.values()].filter((s) => s === 'stale').length,
|
|
377
|
+
// How many distinct things want attention at all — the number the nav
|
|
378
|
+
// badge means, stated once here rather than re-derived by every caller
|
|
379
|
+
// that might re-derive it wrongly.
|
|
380
|
+
needs_you: [...homeOf.values()].filter((s) => s !== 'stale').length,
|
|
336
381
|
},
|
|
337
382
|
};
|
|
338
383
|
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// The project's own documentation, as something you can browse.
|
|
2
|
+
//
|
|
3
|
+
// A great_cto project accumulates a lot of it — this repository has twenty
|
|
4
|
+
// documents at the root of `docs/`, thirty-one plans, twenty architecture
|
|
5
|
+
// documents, ten ADRs — and the board showed none of it. Agents write ARCH docs,
|
|
6
|
+
// ADRs, QA and security reports, and the only way to read one was to know its
|
|
7
|
+
// filename and open an editor.
|
|
8
|
+
//
|
|
9
|
+
// `/api/doc` could already fetch a document by path. What was missing is the
|
|
10
|
+
// question before that one: which documents exist, and which of them is about
|
|
11
|
+
// the thing I am looking at now.
|
|
12
|
+
//
|
|
13
|
+
// Zero dependencies, like the rest of the board.
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import { judgeFreshness } from '../../../scripts/lib/freshness.mjs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Where a project keeps documentation, and what each place answers.
|
|
21
|
+
*
|
|
22
|
+
* Grouped by the QUESTION rather than by directory: `docs/architecture` and
|
|
23
|
+
* `docs/adr` sit next to each other on disk and answer different things — one
|
|
24
|
+
* says how the system is built, the other why it was decided that way. A reader
|
|
25
|
+
* arrives with one of those questions, not with a directory in mind.
|
|
26
|
+
*/
|
|
27
|
+
export const DOC_GROUPS = Object.freeze([
|
|
28
|
+
// Named files rather than the whole of `.great_cto`: that directory also holds
|
|
29
|
+
// session logs, verdict logs and lesson captures — a hundred and fourteen of
|
|
30
|
+
// them here. They are state the pipeline writes, not documentation someone
|
|
31
|
+
// would read, and burying four useful files under them is how a browser
|
|
32
|
+
// becomes something nobody opens.
|
|
33
|
+
// The .great_cto context files are listed by the "Agent context" group in the
|
|
34
|
+
// board, which knows the canonical set — including layers that are not written
|
|
35
|
+
// yet, which a directory walk cannot report because there is no file to find.
|
|
36
|
+
// Listing them here as well was the duplication between Docs and Memory.
|
|
37
|
+
{
|
|
38
|
+
key: 'state', label: 'This project',
|
|
39
|
+
files: ['README.md', 'CLAUDE.md'],
|
|
40
|
+
why: 'what it is, for a person arriving',
|
|
41
|
+
},
|
|
42
|
+
{ key: 'architecture', label: 'Architecture', dirs: ['docs/architecture'], why: 'how the system is built' },
|
|
43
|
+
{ key: 'decisions', label: 'Decisions', dirs: ['docs/adr', 'docs/decisions'], why: 'why it was built that way' },
|
|
44
|
+
{ key: 'plans', label: 'Plans', dirs: ['docs/plans'], why: 'what was going to be done' },
|
|
45
|
+
{ key: 'reviews', label: 'Reviews', dirs: ['docs/qa', 'docs/security', 'docs/quality'], why: 'what was checked, and what it found' },
|
|
46
|
+
{ key: 'design', label: 'Design', dirs: ['docs/design', 'docs/product'], why: 'what it should look like and for whom' },
|
|
47
|
+
{ key: 'other', label: 'Other', dirs: ['docs'], why: 'everything else the project wrote down' },
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
/** Never walked: large, generated, or not this project's writing. */
|
|
51
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', 'screenshots', 'vendor']);
|
|
52
|
+
|
|
53
|
+
/** A cap, so a repository with thousands of markdown files cannot stall the board. */
|
|
54
|
+
export const MAX_DOCS = 500;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The title an author gave a document.
|
|
58
|
+
*
|
|
59
|
+
* From the first `# ` heading, because `ADR-010-pipeline-position-pull-view.md`
|
|
60
|
+
* is a filename and not what anyone called it. Read from a bounded head: a title
|
|
61
|
+
* is in the first few lines or it is not a title.
|
|
62
|
+
*/
|
|
63
|
+
export function titleOf(absPath, { read = fs.readFileSync } = {}) {
|
|
64
|
+
try {
|
|
65
|
+
const head = String(read(absPath, 'utf8')).slice(0, 2000);
|
|
66
|
+
const m = head.match(/^#\s+(.+?)\s*$/m);
|
|
67
|
+
return m ? m[1].replace(/\.md$/i, '').trim() : null;
|
|
68
|
+
} catch { return null; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function walk(dir, root, out, depth = 0) {
|
|
72
|
+
if (depth > 3 || out.length >= MAX_DOCS) return;
|
|
73
|
+
let entries;
|
|
74
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
75
|
+
for (const e of entries) {
|
|
76
|
+
if (out.length >= MAX_DOCS) return;
|
|
77
|
+
if (SKIP.has(e.name) || e.name.startsWith('.') && e.name !== '.great_cto') continue;
|
|
78
|
+
const abs = path.join(dir, e.name);
|
|
79
|
+
if (e.isDirectory()) { walk(abs, root, out, depth + 1); continue; }
|
|
80
|
+
if (!e.name.toLowerCase().endsWith('.md')) continue;
|
|
81
|
+
let st;
|
|
82
|
+
try { st = fs.statSync(abs); } catch { continue; }
|
|
83
|
+
out.push({ abs, rel: path.relative(root, abs), size: st.size, modified: st.mtime.toISOString() });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Which group a path belongs to — first match wins, so `other` catches the rest. */
|
|
88
|
+
export function groupFor(rel) {
|
|
89
|
+
const p = rel.split(path.sep).join('/');
|
|
90
|
+
for (const g of DOC_GROUPS) {
|
|
91
|
+
if ((g.files || []).includes(p)) return g.key;
|
|
92
|
+
if ((g.dirs || []).some((d) => p === d || p.startsWith(`${d}/`))) return g.key;
|
|
93
|
+
}
|
|
94
|
+
return 'other';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Every document in the project, grouped and newest first within each group.
|
|
99
|
+
*
|
|
100
|
+
* Newest first because a document written today and one written in March answer
|
|
101
|
+
* differently — the same reason the pipeline view labels a stale verdict rather
|
|
102
|
+
* than hiding it.
|
|
103
|
+
*/
|
|
104
|
+
/**
|
|
105
|
+
* One document's freshness, as three states rather than a date.
|
|
106
|
+
*
|
|
107
|
+
* `unknown` covers two different absences and says which: a file we could not
|
|
108
|
+
* read at all, and a file that simply declares no date. Neither may render as
|
|
109
|
+
* fresh — the whole reason `stale_after` exists is that a document nobody can
|
|
110
|
+
* judge must not look like one that passed.
|
|
111
|
+
*/
|
|
112
|
+
function freshnessOf(abs, nowMs = Date.now(), staleDays = 180) {
|
|
113
|
+
let text;
|
|
114
|
+
try { text = fs.readFileSync(abs, 'utf8'); }
|
|
115
|
+
catch (e) {
|
|
116
|
+
return { freshness: 'unknown', freshnessBasis: 'unreadable', staleAfter: null,
|
|
117
|
+
freshnessWhy: `could not read this file: ${String(e?.message || e)}` };
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const j = judgeFreshness({ text, dateType: 'any', nowMs, staleDays });
|
|
121
|
+
return {
|
|
122
|
+
freshness: j.verdict,
|
|
123
|
+
freshnessBasis: j.basis,
|
|
124
|
+
staleAfter: j.staleAfter,
|
|
125
|
+
freshnessWhy: j.basis === 'declared'
|
|
126
|
+
? `the author declared it good until ${j.staleAfter}`
|
|
127
|
+
: (j.date ? `judged by its own date ${j.date} (${j.ageDays}d, threshold ${staleDays}d)`
|
|
128
|
+
: 'no stale_after and no date — nothing to judge it by'),
|
|
129
|
+
};
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return { freshness: 'unknown', freshnessBasis: 'unreadable', staleAfter: null,
|
|
132
|
+
freshnessWhy: String(e?.message || e) };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function listDocs(root, { max = MAX_DOCS } = {}) {
|
|
137
|
+
const found = [];
|
|
138
|
+
for (const g of DOC_GROUPS) {
|
|
139
|
+
for (const d of g.dirs || []) walk(path.join(root, d), root, found);
|
|
140
|
+
for (const f of g.files || []) {
|
|
141
|
+
const abs = path.join(root, f);
|
|
142
|
+
try {
|
|
143
|
+
const st = fs.statSync(abs);
|
|
144
|
+
if (st.isFile()) found.push({ abs, rel: f, size: st.size, modified: st.mtime.toISOString() });
|
|
145
|
+
} catch { /* absent */ }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const seen = new Set();
|
|
150
|
+
const docs = [];
|
|
151
|
+
for (const d of found) {
|
|
152
|
+
if (seen.has(d.rel) || docs.length >= max) continue;
|
|
153
|
+
seen.add(d.rel);
|
|
154
|
+
docs.push({
|
|
155
|
+
path: d.rel,
|
|
156
|
+
name: path.basename(d.rel),
|
|
157
|
+
title: titleOf(d.abs) || path.basename(d.rel, '.md'),
|
|
158
|
+
group: groupFor(d.rel),
|
|
159
|
+
size: d.size,
|
|
160
|
+
modified: d.modified,
|
|
161
|
+
// A modification time answers "when was this file last touched", which is
|
|
162
|
+
// a different question from "is this still true". A typo fix rejuvenates a
|
|
163
|
+
// document that stopped being true months earlier, and the list showed
|
|
164
|
+
// only the former. `judgeFreshness` gives three verdicts and names which
|
|
165
|
+
// rule produced each — see scripts/lib/freshness.mjs.
|
|
166
|
+
...freshnessOf(d.abs),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const groups = DOC_GROUPS.map((g) => ({
|
|
171
|
+
key: g.key, label: g.label, why: g.why,
|
|
172
|
+
docs: docs.filter((d) => d.group === g.key).sort((a, b) => b.modified.localeCompare(a.modified)),
|
|
173
|
+
})).filter((g) => g.docs.length);
|
|
174
|
+
|
|
175
|
+
return { total: docs.length, truncated: docs.length >= max, groups };
|
|
176
|
+
}
|
|
@@ -193,7 +193,18 @@ function getAgentsFleet(projectCwd) {
|
|
|
193
193
|
};
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
|
|
196
|
+
/**
|
|
197
|
+
* One agent's profile and its recent record.
|
|
198
|
+
*
|
|
199
|
+
* `cwd` scopes the statistics to a project. Without it the counts came from
|
|
200
|
+
* every project's verdicts at once, so opening an agent while looking at
|
|
201
|
+
* one project showed its behaviour across twenty-two projects — a number that is
|
|
202
|
+
* about the fleet answering a question about this project.
|
|
203
|
+
*
|
|
204
|
+
* Omitting `cwd` still means "the whole fleet", which is what the fleet view
|
|
205
|
+
* itself wants; the defect was the caller, not the default.
|
|
206
|
+
*/
|
|
207
|
+
function getAgentProfile(slug, cwd = null) {
|
|
197
208
|
const fp = path.join(AGENTS_DIR, `great_cto-${slug}.md`);
|
|
198
209
|
if (!fs.existsSync(fp)) return null;
|
|
199
210
|
|
|
@@ -222,7 +233,7 @@ function getAgentProfile(slug) {
|
|
|
222
233
|
const skillsM = raw.match(/^skills:\s*\n((?:[ \t]*-[ \t]*.+\n?)+)/m);
|
|
223
234
|
if (skillsM) for (const line of skillsM[1].split('\n')) { const v = (line.match(/^[ \t]*-[ \t]*(.+)$/) || [])[1]; const t = v && v.trim(); if (t && !/^-+$/.test(t)) skills.push(t); }
|
|
224
235
|
|
|
225
|
-
const verdicts = readVerdicts();
|
|
236
|
+
const verdicts = readVerdicts(cwd);
|
|
226
237
|
const all = verdicts.filter(v => v.agent === slug);
|
|
227
238
|
const now = Date.now();
|
|
228
239
|
const day30Ms = 30 * 86400_000;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// One screen for the whole fleet.
|
|
2
|
+
//
|
|
3
|
+
// The board answers a CTO's questions one project at a time. With twenty-two in
|
|
4
|
+
// the registry, finding out where you are needed means switching to each of them
|
|
5
|
+
// by hand — so the question "what needs me right now, across everything" has no
|
|
6
|
+
// answer at all, and the switcher is a poor substitute for one.
|
|
7
|
+
//
|
|
8
|
+
// Three columns, one row per project: what is waiting on a decision, when the
|
|
9
|
+
// project last moved, what it has cost. No sorting, no drill-down, no charts —
|
|
10
|
+
// if this page is worth having, that will show without them.
|
|
11
|
+
//
|
|
12
|
+
// Every cell can say `unread`
|
|
13
|
+
// ---------------------------
|
|
14
|
+
// This is the point rather than a detail. A fleet view built on readers that
|
|
15
|
+
// return zero when they fail multiplies one silent lie by twenty-two, and the
|
|
16
|
+
// screen most likely to be trusted becomes the one most likely to mislead. A
|
|
17
|
+
// project whose state could not be read says so; it never contributes a
|
|
18
|
+
// confident zero to a total.
|
|
19
|
+
//
|
|
20
|
+
// Why no `bd`
|
|
21
|
+
// -----------
|
|
22
|
+
// Gate approval is authoritative but costs about 530ms per project — twelve
|
|
23
|
+
// seconds for the fleet, on a screen meant to be glanced at. The pipeline
|
|
24
|
+
// position computed from verdict files alone reports `awaiting-gate`
|
|
25
|
+
// conservatively, which for a decisions queue errs toward "this may need you".
|
|
26
|
+
// That is the right direction to be wrong in here, and it is stated rather than
|
|
27
|
+
// hidden.
|
|
28
|
+
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { readVerdicts } from './verdicts.mjs';
|
|
32
|
+
|
|
33
|
+
/** A project's state, or an honest account of why it is unknown. */
|
|
34
|
+
export function projectRow(entry, { now = Date.now() } = {}) {
|
|
35
|
+
const base = { slug: entry.slug, path: entry.path, archetype: entry.archetype || null, description: entry.description || '' };
|
|
36
|
+
|
|
37
|
+
if (!entry.path || !fs.existsSync(entry.path)) {
|
|
38
|
+
return { ...base, unread: 'the project directory no longer exists' };
|
|
39
|
+
}
|
|
40
|
+
const gc = path.join(entry.path, '.great_cto');
|
|
41
|
+
if (!fs.existsSync(gc)) {
|
|
42
|
+
return { ...base, unread: 'not initialised — no .great_cto here' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let verdicts;
|
|
46
|
+
try {
|
|
47
|
+
verdicts = readVerdicts(entry.path);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
return { ...base, unread: `verdicts could not be read: ${e.message}` };
|
|
50
|
+
}
|
|
51
|
+
if (!Array.isArray(verdicts)) {
|
|
52
|
+
return { ...base, unread: 'verdicts could not be read' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const withTs = verdicts.filter((v) => v.ts && !Number.isNaN(Date.parse(v.ts)));
|
|
56
|
+
const newest = withTs.length
|
|
57
|
+
? Math.max(...withTs.map((v) => Date.parse(v.ts)))
|
|
58
|
+
: null;
|
|
59
|
+
|
|
60
|
+
// Spend is only claimed over verdicts that carry a cost. A verdict without one
|
|
61
|
+
// is not zero spend — it is spend nobody recorded, and adding it as zero is how
|
|
62
|
+
// a dashboard reports a number smaller than the invoice.
|
|
63
|
+
const priced = verdicts.filter((v) => typeof v.cost_usd === 'number');
|
|
64
|
+
const spend = priced.reduce((a, v) => a + v.cost_usd, 0);
|
|
65
|
+
|
|
66
|
+
const last = verdicts.length
|
|
67
|
+
? verdicts.reduce((a, b) => (Date.parse(b.ts || 0) > Date.parse(a.ts || 0) ? b : a))
|
|
68
|
+
: null;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
...base,
|
|
72
|
+
stages: verdicts.length,
|
|
73
|
+
lastAgent: last?.agent || null,
|
|
74
|
+
lastVerdict: last?.verdict || null,
|
|
75
|
+
lastMovedAt: newest ? new Date(newest).toISOString() : null,
|
|
76
|
+
idleMs: newest ? now - newest : null,
|
|
77
|
+
spend: priced.length ? Number(spend.toFixed(4)) : null,
|
|
78
|
+
spendKnownFor: priced.length,
|
|
79
|
+
spendUnknownFor: verdicts.length - priced.length,
|
|
80
|
+
needsYou: needsAttention(last),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Does this project look like it is waiting on a human?
|
|
86
|
+
*
|
|
87
|
+
* A blocking verdict is unambiguous. Everything else is a guess made without
|
|
88
|
+
* reading gate state, so it is reported as a reason rather than a flag — the row
|
|
89
|
+
* says "security-officer returned REJECTED", not a red dot the reader has to
|
|
90
|
+
* interpret.
|
|
91
|
+
*/
|
|
92
|
+
export function needsAttention(last) {
|
|
93
|
+
if (!last) return null;
|
|
94
|
+
const v = String(last.verdict || '').toUpperCase();
|
|
95
|
+
if (['BLOCKED', 'FAIL', 'FAILED', 'REJECTED'].includes(v)) {
|
|
96
|
+
return `${last.agent || 'a stage'} returned ${v}`;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The fleet.
|
|
103
|
+
*
|
|
104
|
+
* `registryUnread` carries a broken projects.json up to the reader: an
|
|
105
|
+
* unreadable registry used to render as a board with no projects, which is the
|
|
106
|
+
* same lie one level further out.
|
|
107
|
+
*/
|
|
108
|
+
export function portfolio(registry, { now = Date.now(), maxProjects = 100 } = {}) {
|
|
109
|
+
if (!registry || registry.unread) {
|
|
110
|
+
return { registryUnread: registry?.unread || 'the project registry could not be read', projects: [] };
|
|
111
|
+
}
|
|
112
|
+
const entries = (registry.projects || []).slice(0, maxProjects);
|
|
113
|
+
const projects = entries.map((e) => projectRow(e, { now }));
|
|
114
|
+
|
|
115
|
+
const readable = projects.filter((p) => !p.unread);
|
|
116
|
+
return {
|
|
117
|
+
projects,
|
|
118
|
+
total: projects.length,
|
|
119
|
+
unreadable: projects.length - readable.length,
|
|
120
|
+
// Totals are over what could be read, and say so. A sum that silently
|
|
121
|
+
// excludes four projects is a different number from the one it claims to be.
|
|
122
|
+
spend: readable.some((p) => p.spend != null)
|
|
123
|
+
? Number(readable.reduce((a, p) => a + (p.spend || 0), 0).toFixed(4))
|
|
124
|
+
: null,
|
|
125
|
+
spendCoveredProjects: readable.filter((p) => p.spend != null).length,
|
|
126
|
+
needsYou: readable.filter((p) => p.needsYou).length,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -205,6 +205,37 @@ function getChangeTier(dir) {
|
|
|
205
205
|
}
|
|
206
206
|
}
|
|
207
207
|
function autoRegisterProject(dir) {
|
|
208
|
+
// The home directory is never a project.
|
|
209
|
+
//
|
|
210
|
+
// `~/.great_cto/` is the GLOBAL store — cross-project verdicts, decisions,
|
|
211
|
+
// secrets — and it is shaped exactly like a project's own `.great_cto/`, same
|
|
212
|
+
// name and same contents. So the home directory reads as a project, and
|
|
213
|
+
// auto-registration kept adding it: deleted from the registry, it was back two
|
|
214
|
+
// minutes later, contributing 124 stages and $93 of $95 to a fleet view that
|
|
215
|
+
// has 22 real projects.
|
|
216
|
+
//
|
|
217
|
+
// Nothing downstream can tell these apart, because they are the same shape.
|
|
218
|
+
// The distinction is the location, so that is where it belongs.
|
|
219
|
+
//
|
|
220
|
+
// The installed plugin is not a project either, for the same reason and by the
|
|
221
|
+
// same mechanism: `~/.claude/plugins/cache/local/great_cto/2.95.0` ships a
|
|
222
|
+
// `.great_cto/` because it IS great_cto, so running the board from there
|
|
223
|
+
// registered the plugin's own copy as a twenty-third project — a version
|
|
224
|
+
// number in the switcher next to real work.
|
|
225
|
+
//
|
|
226
|
+
// Nor is a directory inside a project already registered. `packages/cli` ships
|
|
227
|
+
// its own `.great_cto/` because the published package carries one, so opening
|
|
228
|
+
// the board there added it as a separate project reading great_cto's beads —
|
|
229
|
+
// 228 tasks counted twice, under two names, in a fleet of seventeen.
|
|
230
|
+
try {
|
|
231
|
+
const resolved = path.resolve(dir);
|
|
232
|
+
if (resolved === path.resolve(os.homedir())) return null;
|
|
233
|
+
if (isInsideDir(path.join(os.homedir(), '.claude', 'plugins'), resolved)) return null;
|
|
234
|
+
const enclosing = readProjectsRegistry().projects
|
|
235
|
+
.find((e) => e.path && path.resolve(e.path) !== resolved && isInsideDir(path.resolve(e.path), resolved));
|
|
236
|
+
if (enclosing) return null;
|
|
237
|
+
} catch { /* if we cannot resolve it, fall through to the normal checks */ }
|
|
238
|
+
|
|
208
239
|
const meta = readProjectMd(dir);
|
|
209
240
|
if (!meta) return null;
|
|
210
241
|
const reg = readProjectsRegistry();
|