great-cto 3.26.4 → 3.27.1
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/routes.mjs +127 -2
- package/board/packages/board/lib/view-counter.mjs +122 -0
- package/board/packages/board/public/index.html +992 -589
- package/board/scripts/lib/cost-meter.mjs +236 -0
- package/board/scripts/lib/cross-model-review.mjs +225 -0
- package/board/scripts/lib/provider-exhaustion.mjs +152 -0
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "great-cto",
|
|
3
3
|
"description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 70 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.27.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Alexander Velikiy",
|
|
7
7
|
"url": "https://hashnode.com/@Greatcto"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import { execFileSync } from 'child_process';
|
|
3
4
|
import os from 'os';
|
|
4
5
|
import {
|
|
5
6
|
getVapidKeys,
|
|
@@ -27,6 +28,7 @@ import { upsertCapability, capabilitiesFromProjectMd } from '../../../scripts/li
|
|
|
27
28
|
import { getAgentsFleet, getAgentProfile, retireAgent, restoreAgent, appendDecisionLog, readDecisionsLog } from './fleet.mjs';
|
|
28
29
|
import { getResume, getShareState, toggleShare } from './share.mjs';
|
|
29
30
|
import { listSessions, readSession, editedFiles, searchSessions } from './transcripts.mjs';
|
|
31
|
+
import { recordView, summarizeViews } from './view-counter.mjs';
|
|
30
32
|
|
|
31
33
|
// ── HTTP router ────────────────────────────────────────────────────────────────
|
|
32
34
|
// dispatch(req, res, url, cwd, projInfo) handles every /api/* route plus /api/sse.
|
|
@@ -174,6 +176,65 @@ async function dispatch(req, res, url, cwd) {
|
|
|
174
176
|
return true;
|
|
175
177
|
}
|
|
176
178
|
|
|
179
|
+
// GET /api/views?since=<ISO>[&project=<name>]
|
|
180
|
+
//
|
|
181
|
+
// BRD-R9 (great_cto-ki1x.15): the only source for the K2/K3 kill-criteria in
|
|
182
|
+
// docs/product/BRIEF-board-redesign-2026-09.md and the 2026-09-20 kanban
|
|
183
|
+
// deep-link review already logged in .great_cto/decisions.md. Three states,
|
|
184
|
+
// not two — see lib/view-counter.mjs — mirroring the choice already made
|
|
185
|
+
// for /api/harnesses' evidence log just below: a line that fails to parse
|
|
186
|
+
// is counted in `unreadable_lines`, not silently dropped, and does not by
|
|
187
|
+
// itself flip the whole read to `unreadable`.
|
|
188
|
+
if (pathname === '/api/views' && req.method === 'GET') {
|
|
189
|
+
const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
|
|
190
|
+
const since = url.searchParams.get('since') || null;
|
|
191
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
192
|
+
res.end(JSON.stringify(summarizeViews({ root: c, since })));
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// POST /api/view { view: 'decisions'|'ledger'|'fleet'|'harness'|'settings'|'kanban' }
|
|
197
|
+
//
|
|
198
|
+
// One line per top-level view open, appended to the resolved project's own
|
|
199
|
+
// `.great_cto/view-counter.log` — never sent anywhere (docs/PRIVACY.md:
|
|
200
|
+
// telemetry is opt-in and off by default; this is not telemetry, it is a
|
|
201
|
+
// local file). Guarded by origin like every other state-changing POST on
|
|
202
|
+
// this server (the board listens on 127.0.0.1, and a page the user happens
|
|
203
|
+
// to be visiting can still issue a simple cross-origin POST to localhost).
|
|
204
|
+
//
|
|
205
|
+
// Called once from switchTab() when the redesigned tabs land
|
|
206
|
+
// (great_cto-ki1x.5 / .13) — see that task's dispatch note for the exact line.
|
|
207
|
+
if (pathname === '/api/view' && req.method === 'POST') {
|
|
208
|
+
if (!originAllowed(req)) {
|
|
209
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
210
|
+
res.end(JSON.stringify({ error: 'origin not allowed' }));
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
|
|
214
|
+
let body = '';
|
|
215
|
+
req.on('data', (ch) => { body += ch; if (body.length > 1024) req.destroy(); });
|
|
216
|
+
req.on('end', () => {
|
|
217
|
+
let parsed;
|
|
218
|
+
try { parsed = JSON.parse(body || '{}'); }
|
|
219
|
+
catch (e) {
|
|
220
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
221
|
+
res.end(JSON.stringify({ error: 'invalid_json', message: String(e.message || e) }));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const view = String(parsed.view || '');
|
|
225
|
+
try {
|
|
226
|
+
recordView({ root: c, view });
|
|
227
|
+
} catch (e) {
|
|
228
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
229
|
+
res.end(JSON.stringify({ error: String(e.message || e) }));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
233
|
+
res.end(JSON.stringify({ ok: true, view }));
|
|
234
|
+
});
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
177
238
|
// ── /api/notifications — email alerts via greatcto.systems/notify relay ──
|
|
178
239
|
// No API keys to manage — user enters only their email + verifies via
|
|
179
240
|
// 6-digit code sent by our Cloudflare Worker. The Worker rate-limits to
|
|
@@ -598,6 +659,14 @@ async function dispatch(req, res, url, cwd) {
|
|
|
598
659
|
// Inbox — what needs your attention right now
|
|
599
660
|
if (pathname === '/api/inbox') {
|
|
600
661
|
const inbox = getInbox(cwd);
|
|
662
|
+
// BRD-R3: the Decisions row shows both reviewers. The second opinion is a
|
|
663
|
+
// fact about the TREE, not about a gate — every pending gate on this tree
|
|
664
|
+
// shares it — so it is resolved once: the newest cross-review line whose
|
|
665
|
+
// `sha` is the current HEAD. Four states, none of which may read as a
|
|
666
|
+
// verdict: `not-run` (capability none / undeclared / unavailable),
|
|
667
|
+
// `unmeasured` (declared, no line for this sha), `unreadable` (only
|
|
668
|
+
// pre-join-key lines exist), `ok` (a paired verdict).
|
|
669
|
+
const second_opinion = secondOpinionForTree(cwd);
|
|
601
670
|
// What is waiting on the person in their OTHER projects. The headline and
|
|
602
671
|
// the badge are about the person, and the person is not scoped to `cwd`.
|
|
603
672
|
// If the registry itself cannot be walked, say so — `unreadable` is not
|
|
@@ -606,7 +675,7 @@ async function dispatch(req, res, url, cwd) {
|
|
|
606
675
|
try { elsewhere = inboxElsewhere(listProjects(), cwd, { readInbox: getInbox }); }
|
|
607
676
|
catch (e) { elsewhere = { state: 'unreadable', why: String(e?.message || e) }; }
|
|
608
677
|
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
609
|
-
res.end(JSON.stringify({ ...inbox, elsewhere }));
|
|
678
|
+
res.end(JSON.stringify({ ...inbox, elsewhere, second_opinion }));
|
|
610
679
|
return true;
|
|
611
680
|
}
|
|
612
681
|
|
|
@@ -1640,11 +1709,29 @@ async function dispatch(req, res, url, cwd) {
|
|
|
1640
1709
|
}
|
|
1641
1710
|
evidence = evidence.slice(-20).reverse();
|
|
1642
1711
|
} catch (e) { logState = e.code === 'ENOENT' ? 'absent' : 'unreadable'; }
|
|
1712
|
+
|
|
1713
|
+
// BRD-R2: a parsed row without a usable `sha` (BRD-R1's join key, additive
|
|
1714
|
+
// since a2f5d4e7 — scripts/lib/cross-model-review.mjs) cannot be paired
|
|
1715
|
+
// with the tree it reviewed. A verdict you cannot pair with a tree is not
|
|
1716
|
+
// a verdict about this tree, so it renders `unreadable` here — distinct
|
|
1717
|
+
// from `ok`/`skipped`/`BLOCK`, and from the whole-log `unreadable` state
|
|
1718
|
+
// above (that one means "could not read the file"; this one means "read
|
|
1719
|
+
// the line fine, but it predates the field"). `sha` must be a non-empty
|
|
1720
|
+
// string: missing, `null`, or `''` all mean "not supplied". Verdict/cost
|
|
1721
|
+
// are nulled in the response so neither renders as a real result; the
|
|
1722
|
+
// original line is kept under `raw` so nothing is lost.
|
|
1723
|
+
evidence = evidence.map((r) => (
|
|
1724
|
+
typeof r.sha === 'string' && r.sha !== ''
|
|
1725
|
+
? r
|
|
1726
|
+
: { ...r, state: 'unreadable', verdict: null, cost: null, raw: r }
|
|
1727
|
+
));
|
|
1728
|
+
|
|
1643
1729
|
const reviewed = evidence.filter((r) => r.state === 'ok');
|
|
1644
1730
|
const summary = {
|
|
1645
1731
|
runs: evidence.length,
|
|
1646
1732
|
reviewed: reviewed.length,
|
|
1647
|
-
skipped: evidence.filter((r) => r.state !== 'ok').length,
|
|
1733
|
+
skipped: evidence.filter((r) => r.state !== 'ok' && r.state !== 'unreadable').length,
|
|
1734
|
+
unreadable: evidence.filter((r) => r.state === 'unreadable').length,
|
|
1648
1735
|
blocked: reviewed.filter((r) => r.verdict === 'BLOCK').length,
|
|
1649
1736
|
unreadable_lines: unreadable,
|
|
1650
1737
|
};
|
|
@@ -1857,4 +1944,42 @@ async function dispatch(req, res, url, cwd) {
|
|
|
1857
1944
|
return false;
|
|
1858
1945
|
}
|
|
1859
1946
|
|
|
1947
|
+
|
|
1948
|
+
/**
|
|
1949
|
+
* The second opinion as it applies to the tree at HEAD — what the Decisions
|
|
1950
|
+
* row's Codex cell shows. Pure over the two files it reads (PROJECT.md and
|
|
1951
|
+
* cross-review.log) plus `git rev-parse HEAD`; every failure is a state, never
|
|
1952
|
+
* a throw, because "could not tell" is data for the row, not a reason to lose
|
|
1953
|
+
* the inbox.
|
|
1954
|
+
*/
|
|
1955
|
+
export function secondOpinionForTree(c) {
|
|
1956
|
+
let projectMd = null;
|
|
1957
|
+
try { projectMd = fs.readFileSync(path.join(c, '.great_cto', 'PROJECT.md'), 'utf8'); } catch { projectMd = null; }
|
|
1958
|
+
let declared = null;
|
|
1959
|
+
try { declared = projectMd == null ? null : capabilitiesFromProjectMd(projectMd).map.second_opinion; } catch { declared = null; }
|
|
1960
|
+
const tool = declared?.tool ?? null;
|
|
1961
|
+
const declaredState = declared?.state ?? (projectMd == null ? 'no-project-md' : 'undeclared');
|
|
1962
|
+
let head = null;
|
|
1963
|
+
try { head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: c, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null; } catch { head = null; }
|
|
1964
|
+
const base = { declared: declaredState, tool, head, verdict: null, findings: null, p0: null, sha: null, ts: null };
|
|
1965
|
+
if (declaredState !== 'declared' || !tool) {
|
|
1966
|
+
return { ...base, state: 'not-run', why: declaredState === 'none' ? 'second_opinion: none — deliberately off' : 'no second opinion declared in PROJECT.md' };
|
|
1967
|
+
}
|
|
1968
|
+
let lines = [];
|
|
1969
|
+
try { lines = fs.readFileSync(path.join(c, '.great_cto', 'cross-review.log'), 'utf8').trim().split('\n').filter(Boolean); }
|
|
1970
|
+
catch { return { ...base, state: 'unmeasured', why: `declared (${tool}), no review has been written yet` }; }
|
|
1971
|
+
const rows = [];
|
|
1972
|
+
for (const line of lines) { try { rows.push(JSON.parse(line)); } catch { /* counted by /api/harnesses; not a verdict either way */ } }
|
|
1973
|
+
const paired = rows.filter((r) => typeof r.sha === 'string' && r.sha !== '' && head && (r.sha === head || head.startsWith(r.sha) || r.sha.startsWith(head)) && r.state === 'ok');
|
|
1974
|
+
if (paired.length) {
|
|
1975
|
+
const r = paired[paired.length - 1];
|
|
1976
|
+
return { ...base, state: 'ok', verdict: r.verdict ?? null, findings: r.findings ?? null, p0: r.p0 ?? null, sha: r.sha, ts: r.ts ?? null, why: '' };
|
|
1977
|
+
}
|
|
1978
|
+
const anyKeyed = rows.some((r) => typeof r.sha === 'string' && r.sha !== '');
|
|
1979
|
+
if (!anyKeyed && rows.length) {
|
|
1980
|
+
return { ...base, state: 'unreadable', why: `${rows.length} review line(s) predate the join key — none can be paired with this tree` };
|
|
1981
|
+
}
|
|
1982
|
+
return { ...base, state: 'unmeasured', why: `declared (${tool}), no review line for ${head ? head.slice(0, 8) : 'this tree'}` };
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1860
1985
|
export { dispatch };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// BRD-R9 (great_cto-ki1x.15): a local, per-view request counter.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: every JTBD claim in the board redesign brief
|
|
4
|
+
// (docs/product/BRIEF-board-redesign-2026-09.md) is otherwise an assumption.
|
|
5
|
+
// This file is the one source the K2/K3 kill-criteria read from — Decisions
|
|
6
|
+
// opened <5 days in a 14-day window → pivot; Fleet opened <4 times in 60 days
|
|
7
|
+
// → delete — plus the 2026-09-20 kanban deep-link review already logged in
|
|
8
|
+
// .great_cto/decisions.md.
|
|
9
|
+
//
|
|
10
|
+
// What this is NOT: telemetry. docs/PRIVACY.md requires telemetry to be
|
|
11
|
+
// opt-in and off by default; this counter makes no network call, has no
|
|
12
|
+
// endpoint that leaves the machine, and writes to a file under the caller's
|
|
13
|
+
// OWN `.great_cto/` (project-scoped, same rule as decisionsLogPath in
|
|
14
|
+
// fleet.mjs / ADR-008 — never a global/home-dir file another project's
|
|
15
|
+
// agents could read).
|
|
16
|
+
//
|
|
17
|
+
// Kept pure on purpose (`root` and `at` are both injected): a test builds a
|
|
18
|
+
// temp root and a fixed clock instead of touching the real filesystem or
|
|
19
|
+
// wall clock, and the HTTP route in routes.mjs is the only caller that
|
|
20
|
+
// supplies real values.
|
|
21
|
+
import fs from 'fs';
|
|
22
|
+
import path from 'path';
|
|
23
|
+
|
|
24
|
+
// The only view names the redesigned IA renders (BRD-R8): Decisions, Ledger,
|
|
25
|
+
// Fleet, Harness, Settings, plus the demoted-to-deep-link `kanban`. Anything
|
|
26
|
+
// else is rejected outright, before any filesystem write — an unknown view
|
|
27
|
+
// name written to this file would silently corrupt the K2/K3 counts it
|
|
28
|
+
// exists to protect.
|
|
29
|
+
const VALID_VIEWS = ['decisions', 'ledger', 'fleet', 'harness', 'settings', 'kanban'];
|
|
30
|
+
|
|
31
|
+
function logFilePath(root) {
|
|
32
|
+
return path.join(root, '.great_cto', 'view-counter.log');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Append one line — `{"ts":<ISO>,"view":<name>}` — to `.great_cto/view-counter.log`.
|
|
37
|
+
* Append-only: never truncates, never rewrites a prior line. Creates the
|
|
38
|
+
* `.great_cto/` directory on first use.
|
|
39
|
+
*
|
|
40
|
+
* @param {{root:string, view:string, at?:Date}} args
|
|
41
|
+
* @throws if `view` is not one of VALID_VIEWS — nothing is written in that case.
|
|
42
|
+
*/
|
|
43
|
+
function recordView({ root, view, at = new Date() }) {
|
|
44
|
+
if (!VALID_VIEWS.includes(view)) {
|
|
45
|
+
throw new Error(`recordView: unknown view "${view}" — expected one of ${VALID_VIEWS.join(', ')}`);
|
|
46
|
+
}
|
|
47
|
+
const file = logFilePath(root);
|
|
48
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
49
|
+
fs.appendFileSync(file, JSON.stringify({ ts: at.toISOString(), view }) + '\n');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Summarize opens per view since `since` (inclusive; null/omitted = all time).
|
|
54
|
+
*
|
|
55
|
+
* Three states, not two (this repository's rule, e.g. routes.mjs `/api/harnesses`
|
|
56
|
+
* evidence log): a file that was never written is `absent` — DIFFERENT from
|
|
57
|
+
* `ok` with real zeros, which means "measured, and there were none". A file
|
|
58
|
+
* that exists but cannot even be opened (permission error, or a directory
|
|
59
|
+
* sitting where the file should be) is `unreadable`, carrying `why`.
|
|
60
|
+
*
|
|
61
|
+
* Within `ok`, an individual line that fails JSON.parse (or is missing a
|
|
62
|
+
* recognized `view`/`ts`) does NOT flip the file to `unreadable` — it is
|
|
63
|
+
* counted in `unreadable_lines` and skipped, same as the existing
|
|
64
|
+
* cross-review.log parser in routes.mjs: "unparseable lines are counted, not
|
|
65
|
+
* dropped, so a corrupted log does not read as a quiet one."
|
|
66
|
+
*
|
|
67
|
+
* @param {{root:string, since?:string|null}} args
|
|
68
|
+
* @returns {{state:'absent'}
|
|
69
|
+
* |{state:'unreadable', why:string}
|
|
70
|
+
* |{state:'ok', since:string|null, views:Record<string,{opens:number,days_with_opens:number,first:string|null,last:string|null}>, unreadable_lines:number}}
|
|
71
|
+
*/
|
|
72
|
+
function summarizeViews({ root, since = null }) {
|
|
73
|
+
const file = logFilePath(root);
|
|
74
|
+
let raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if (e && e.code === 'ENOENT') return { state: 'absent' };
|
|
79
|
+
return { state: 'unreadable', why: String(e?.message || e) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const sinceMs = since ? new Date(since).getTime() : -Infinity;
|
|
83
|
+
const views = {};
|
|
84
|
+
const daysSeen = {};
|
|
85
|
+
for (const v of VALID_VIEWS) {
|
|
86
|
+
views[v] = { opens: 0, days_with_opens: 0, first: null, last: null };
|
|
87
|
+
daysSeen[v] = new Set();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let unreadableLines = 0;
|
|
91
|
+
for (const line of raw.split('\n')) {
|
|
92
|
+
if (!line.trim()) continue;
|
|
93
|
+
let row;
|
|
94
|
+
try {
|
|
95
|
+
row = JSON.parse(line);
|
|
96
|
+
} catch {
|
|
97
|
+
unreadableLines += 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (!row || typeof row.view !== 'string' || !VALID_VIEWS.includes(row.view) || typeof row.ts !== 'string') {
|
|
101
|
+
unreadableLines += 1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const ms = new Date(row.ts).getTime();
|
|
105
|
+
if (!Number.isFinite(ms)) {
|
|
106
|
+
unreadableLines += 1;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (ms < sinceMs) continue; // outside the requested window — filtered, not unreadable
|
|
110
|
+
|
|
111
|
+
const bucket = views[row.view];
|
|
112
|
+
bucket.opens += 1;
|
|
113
|
+
if (bucket.first == null || ms < new Date(bucket.first).getTime()) bucket.first = row.ts;
|
|
114
|
+
if (bucket.last == null || ms > new Date(bucket.last).getTime()) bucket.last = row.ts;
|
|
115
|
+
daysSeen[row.view].add(row.ts.slice(0, 10)); // YYYY-MM-DD, UTC by construction (toISOString)
|
|
116
|
+
}
|
|
117
|
+
for (const v of VALID_VIEWS) views[v].days_with_opens = daysSeen[v].size;
|
|
118
|
+
|
|
119
|
+
return { state: 'ok', since: since ?? null, views, unreadable_lines: unreadableLines };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export { recordView, summarizeViews, VALID_VIEWS };
|