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.
@@ -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.95.0",
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
- const data = JSON.parse(result.stdout || '[]');
114
- bdCache.set(cwd, { ts: Date.now(), data });
115
- return data;
116
- } catch {
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
- return readDegradation.get(cwd) || null;
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: pendingGates.slice(0, 20),
327
- blocked: blocked.slice(0, 10),
328
- p0_open: p0.slice(0, 10),
329
- stale_in_progress: stale.slice(0, 10),
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: pendingGates.length,
333
- blocked: blocked.length,
334
- p0: p0.length,
335
- stale: stale.length,
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
- function getAgentProfile(slug) {
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();