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.
@@ -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.94.0",
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
- 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,
@@ -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
- 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();