great-cto 2.94.0 → 2.96.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/beads.mjs +53 -5
- package/board/packages/board/lib/docs.mjs +137 -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 +146 -11
- package/board/packages/board/lib/verdicts.mjs +63 -9
- package/board/packages/board/public/index.html +376 -32
- package/board/scripts/lib/verdict-record.mjs +18 -1
- 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.96.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Great CTO",
|
|
8
8
|
"url": "https://github.com/avelikiy/great_cto"
|
|
@@ -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,
|
|
@@ -0,0 +1,137 @@
|
|
|
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 path from 'node:path';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Where a project keeps documentation, and what each place answers.
|
|
20
|
+
*
|
|
21
|
+
* Grouped by the QUESTION rather than by directory: `docs/architecture` and
|
|
22
|
+
* `docs/adr` sit next to each other on disk and answer different things — one
|
|
23
|
+
* says how the system is built, the other why it was decided that way. A reader
|
|
24
|
+
* arrives with one of those questions, not with a directory in mind.
|
|
25
|
+
*/
|
|
26
|
+
export const DOC_GROUPS = Object.freeze([
|
|
27
|
+
// Named files rather than the whole of `.great_cto`: that directory also holds
|
|
28
|
+
// session logs, verdict logs and lesson captures — a hundred and fourteen of
|
|
29
|
+
// them here. They are state the pipeline writes, not documentation someone
|
|
30
|
+
// would read, and burying four useful files under them is how a browser
|
|
31
|
+
// becomes something nobody opens.
|
|
32
|
+
// The .great_cto context files are listed by the "Agent context" group in the
|
|
33
|
+
// board, which knows the canonical set — including layers that are not written
|
|
34
|
+
// yet, which a directory walk cannot report because there is no file to find.
|
|
35
|
+
// Listing them here as well was the duplication between Docs and Memory.
|
|
36
|
+
{
|
|
37
|
+
key: 'state', label: 'This project',
|
|
38
|
+
files: ['README.md', 'CLAUDE.md'],
|
|
39
|
+
why: 'what it is, for a person arriving',
|
|
40
|
+
},
|
|
41
|
+
{ key: 'architecture', label: 'Architecture', dirs: ['docs/architecture'], why: 'how the system is built' },
|
|
42
|
+
{ key: 'decisions', label: 'Decisions', dirs: ['docs/adr', 'docs/decisions'], why: 'why it was built that way' },
|
|
43
|
+
{ key: 'plans', label: 'Plans', dirs: ['docs/plans'], why: 'what was going to be done' },
|
|
44
|
+
{ key: 'reviews', label: 'Reviews', dirs: ['docs/qa', 'docs/security', 'docs/quality'], why: 'what was checked, and what it found' },
|
|
45
|
+
{ key: 'design', label: 'Design', dirs: ['docs/design', 'docs/product'], why: 'what it should look like and for whom' },
|
|
46
|
+
{ key: 'other', label: 'Other', dirs: ['docs'], why: 'everything else the project wrote down' },
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/** Never walked: large, generated, or not this project's writing. */
|
|
50
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', 'screenshots', 'vendor']);
|
|
51
|
+
|
|
52
|
+
/** A cap, so a repository with thousands of markdown files cannot stall the board. */
|
|
53
|
+
export const MAX_DOCS = 500;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The title an author gave a document.
|
|
57
|
+
*
|
|
58
|
+
* From the first `# ` heading, because `ADR-010-pipeline-position-pull-view.md`
|
|
59
|
+
* is a filename and not what anyone called it. Read from a bounded head: a title
|
|
60
|
+
* is in the first few lines or it is not a title.
|
|
61
|
+
*/
|
|
62
|
+
export function titleOf(absPath, { read = fs.readFileSync } = {}) {
|
|
63
|
+
try {
|
|
64
|
+
const head = String(read(absPath, 'utf8')).slice(0, 2000);
|
|
65
|
+
const m = head.match(/^#\s+(.+?)\s*$/m);
|
|
66
|
+
return m ? m[1].replace(/\.md$/i, '').trim() : null;
|
|
67
|
+
} catch { return null; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function walk(dir, root, out, depth = 0) {
|
|
71
|
+
if (depth > 3 || out.length >= MAX_DOCS) return;
|
|
72
|
+
let entries;
|
|
73
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
74
|
+
for (const e of entries) {
|
|
75
|
+
if (out.length >= MAX_DOCS) return;
|
|
76
|
+
if (SKIP.has(e.name) || e.name.startsWith('.') && e.name !== '.great_cto') continue;
|
|
77
|
+
const abs = path.join(dir, e.name);
|
|
78
|
+
if (e.isDirectory()) { walk(abs, root, out, depth + 1); continue; }
|
|
79
|
+
if (!e.name.toLowerCase().endsWith('.md')) continue;
|
|
80
|
+
let st;
|
|
81
|
+
try { st = fs.statSync(abs); } catch { continue; }
|
|
82
|
+
out.push({ abs, rel: path.relative(root, abs), size: st.size, modified: st.mtime.toISOString() });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Which group a path belongs to — first match wins, so `other` catches the rest. */
|
|
87
|
+
export function groupFor(rel) {
|
|
88
|
+
const p = rel.split(path.sep).join('/');
|
|
89
|
+
for (const g of DOC_GROUPS) {
|
|
90
|
+
if ((g.files || []).includes(p)) return g.key;
|
|
91
|
+
if ((g.dirs || []).some((d) => p === d || p.startsWith(`${d}/`))) return g.key;
|
|
92
|
+
}
|
|
93
|
+
return 'other';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Every document in the project, grouped and newest first within each group.
|
|
98
|
+
*
|
|
99
|
+
* Newest first because a document written today and one written in March answer
|
|
100
|
+
* differently — the same reason the pipeline view labels a stale verdict rather
|
|
101
|
+
* than hiding it.
|
|
102
|
+
*/
|
|
103
|
+
export function listDocs(root, { max = MAX_DOCS } = {}) {
|
|
104
|
+
const found = [];
|
|
105
|
+
for (const g of DOC_GROUPS) {
|
|
106
|
+
for (const d of g.dirs || []) walk(path.join(root, d), root, found);
|
|
107
|
+
for (const f of g.files || []) {
|
|
108
|
+
const abs = path.join(root, f);
|
|
109
|
+
try {
|
|
110
|
+
const st = fs.statSync(abs);
|
|
111
|
+
if (st.isFile()) found.push({ abs, rel: f, size: st.size, modified: st.mtime.toISOString() });
|
|
112
|
+
} catch { /* absent */ }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
const docs = [];
|
|
118
|
+
for (const d of found) {
|
|
119
|
+
if (seen.has(d.rel) || docs.length >= max) continue;
|
|
120
|
+
seen.add(d.rel);
|
|
121
|
+
docs.push({
|
|
122
|
+
path: d.rel,
|
|
123
|
+
name: path.basename(d.rel),
|
|
124
|
+
title: titleOf(d.abs) || path.basename(d.rel, '.md'),
|
|
125
|
+
group: groupFor(d.rel),
|
|
126
|
+
size: d.size,
|
|
127
|
+
modified: d.modified,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const groups = DOC_GROUPS.map((g) => ({
|
|
132
|
+
key: g.key, label: g.label, why: g.why,
|
|
133
|
+
docs: docs.filter((d) => d.group === g.key).sort((a, b) => b.modified.localeCompare(a.modified)),
|
|
134
|
+
})).filter((g) => g.docs.length);
|
|
135
|
+
|
|
136
|
+
return { total: docs.length, truncated: docs.length >= max, groups };
|
|
137
|
+
}
|
|
@@ -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();
|