great-cto 3.26.2 → 3.27.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/fleet.mjs +43 -1
- package/board/packages/board/lib/routes.mjs +147 -9
- package/board/packages/board/lib/view-counter.mjs +122 -0
- package/board/packages/board/public/index.html +946 -545
- package/board/scripts/lib/agent-posture.mjs +266 -0
- 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.0",
|
|
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 { postureOf } from '../../../scripts/lib/agent-posture.mjs';
|
|
3
4
|
import os from 'os';
|
|
4
5
|
import { GREAT_CTO_DIR } from './config.mjs';
|
|
5
6
|
import { readFileSafe } from './util.mjs';
|
|
@@ -128,12 +129,34 @@ function getAgentsFleet(projectCwd) {
|
|
|
128
129
|
const LLM_RATE_PER_HR = parseFloat(process.env.GREATCTO_LLM_RATE_PER_HR || '0.30');
|
|
129
130
|
const DEFAULT_TASK_MIN = 30;
|
|
130
131
|
|
|
132
|
+
/**
|
|
133
|
+
* The agent's tool grant, in the language of consequence.
|
|
134
|
+
*
|
|
135
|
+
* FOUR states, because three of them are not "no grant": an unreadable agent
|
|
136
|
+
* file, a file with no `tools:` line, a grant that is entirely routine, and a
|
|
137
|
+
* grant that holds something expensive to undo. The board rendered all of them
|
|
138
|
+
* identically before, because it never saw any of them.
|
|
139
|
+
*/
|
|
140
|
+
function posture(toolsLine, fileReadable) {
|
|
141
|
+
if (!fileReadable) return { state: 'unreadable', expensive: [], scopedInNameOnly: [], why: 'the agent file could not be read' };
|
|
142
|
+
if (toolsLine == null) return { state: 'undeclared', expensive: [], scopedInNameOnly: [], why: 'the agent declares no tools: line' };
|
|
143
|
+
const r = postureOf(toolsLine);
|
|
144
|
+
return {
|
|
145
|
+
state: r.unknownTools.length ? 'unclassified' : r.expensive.length ? 'expensive' : 'routine',
|
|
146
|
+
expensive: r.expensive,
|
|
147
|
+
scopedInNameOnly: r.scopedInNameOnly,
|
|
148
|
+
unknownTools: r.unknownTools,
|
|
149
|
+
why: r.unknownTools.length ? `unclassified grant(s): ${r.unknownTools.join(', ')}` : '',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
131
153
|
for (const f of files) {
|
|
132
154
|
const slug = f.replace(/^great_cto-/, '').replace(/\.md$/, '');
|
|
133
155
|
const fp = path.join(AGENTS_DIR, f);
|
|
134
156
|
const raw = readFileSafe(fp) || '';
|
|
135
157
|
const descM = raw.match(/^description:\s*"?([^"\n]+)"?/m);
|
|
136
158
|
const modelM = raw.match(/^model:\s*(\S+)/m);
|
|
159
|
+
const toolsM = raw.match(/^tools:\s*(.*)$/m);
|
|
137
160
|
const colorM = raw.match(/^color:\s*(\S+)/m);
|
|
138
161
|
|
|
139
162
|
const vs = byAgent.get(slug) || [];
|
|
@@ -155,6 +178,11 @@ function getAgentsFleet(projectCwd) {
|
|
|
155
178
|
// Estimated cost — DEFAULT_TASK_MIN per verdict (no real timing data here).
|
|
156
179
|
const estLlmUsd = (vs30d.length * DEFAULT_TASK_MIN / 60) * LLM_RATE_PER_HR;
|
|
157
180
|
const estHumanUsd = (vs30d.length * DEFAULT_TASK_MIN / 60) * HUMAN_RATE_PER_HR;
|
|
181
|
+
// NOT a measurement. Both sides are runs x DEFAULT_TASK_MIN x a rate, so this
|
|
182
|
+
// ratio is HUMAN_RATE/LLM_RATE for every agent that ran at all — 500 by
|
|
183
|
+
// construction. metrics.mjs nulls its equivalent for exactly this reason;
|
|
184
|
+
// this one shipped as a per-agent number and read like one. Kept, because
|
|
185
|
+
// removing a field breaks the board, but labelled at the source.
|
|
158
186
|
const savingsX = estLlmUsd > 0 ? Math.round(estHumanUsd / estLlmUsd) : null;
|
|
159
187
|
const realLlmUsd = vs30d.reduce((s, v) => s + (v.cost_usd || 0), 0);
|
|
160
188
|
|
|
@@ -167,7 +195,18 @@ function getAgentsFleet(projectCwd) {
|
|
|
167
195
|
agents.push({
|
|
168
196
|
slug,
|
|
169
197
|
description: descM?.[1]?.trim() || '',
|
|
170
|
-
model: modelM
|
|
198
|
+
// THREE states, not a default. `model: modelM || 'sonnet'` reported every
|
|
199
|
+
// agent with no `model:` line as pinned to sonnet, so "pinned to sonnet"
|
|
200
|
+
// and "not pinned at all" were the same string — and an unreadable agent
|
|
201
|
+
// file produced the same answer a third time. The fleet cannot show what
|
|
202
|
+
// it cannot distinguish.
|
|
203
|
+
model: raw ? (modelM?.[1]?.trim() ?? null) : null,
|
|
204
|
+
model_state: !raw ? 'unreadable' : modelM ? 'pinned' : 'undeclared',
|
|
205
|
+
// The tool grant, named in the language of consequence rather than listed.
|
|
206
|
+
// `/api/agents-installed` did not read `tools:` at all, so the board had
|
|
207
|
+
// nowhere to get it — scripts/lib/agent-posture.mjs has classified these
|
|
208
|
+
// since 3.24.0 and nothing was consuming it.
|
|
209
|
+
posture: posture(toolsM?.[1] ?? null, Boolean(raw)),
|
|
171
210
|
color: colorM?.[1]?.trim() || null,
|
|
172
211
|
domain: deriveDomain(slug),
|
|
173
212
|
runs_total: vs.length,
|
|
@@ -182,6 +221,9 @@ function getAgentsFleet(projectCwd) {
|
|
|
182
221
|
human_usd_30d_est: Math.round(estHumanUsd),
|
|
183
222
|
llm_usd_30d_real: realLlmUsd > 0 ? Math.round(realLlmUsd * 100) / 100 : null,
|
|
184
223
|
savings_x: savingsX,
|
|
224
|
+
// 'ratio' = the rate ratio, identical for every agent. 'measured' would
|
|
225
|
+
// require per-run timing, which no path produces today.
|
|
226
|
+
savings_source: savingsX == null ? null : 'ratio',
|
|
185
227
|
health,
|
|
186
228
|
retired: isRetired(slug),
|
|
187
229
|
// Four states, and only `exceeded` can hold a dispatch — see
|
|
@@ -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
|
|
|
@@ -1518,14 +1587,26 @@ async function dispatch(req, res, url, cwd) {
|
|
|
1518
1587
|
const tasks = getTasks(cwd);
|
|
1519
1588
|
const nowMs = Date.now();
|
|
1520
1589
|
const STUCK_H = 48;
|
|
1521
|
-
|
|
1522
|
-
|
|
1590
|
+
// `stuck` was ALWAYS EMPTY and had been since it was written. It read
|
|
1591
|
+
// `t.startedAt`, a field no code path in this repository produces — a task
|
|
1592
|
+
// carries created_at / updated_at / closed_at and nothing else. Every row
|
|
1593
|
+
// got `age_h: null` and was removed by the filter below, so the panel
|
|
1594
|
+
// reported "nothing is stuck" about a question it never asked. There are
|
|
1595
|
+
// seven in-progress tasks here as this is written.
|
|
1596
|
+
//
|
|
1597
|
+
// `updated_at` is the honest proxy: in progress, and unchanged for STUCK_H.
|
|
1598
|
+
// A task whose age cannot be determined is COUNTED, not dropped — an
|
|
1599
|
+
// unmeasurable task is not a healthy one.
|
|
1600
|
+
const inProgress = tasks.filter(t => t.status === 'in_progress');
|
|
1601
|
+
let stuckUnmeasurable = 0;
|
|
1602
|
+
const stuck = inProgress
|
|
1523
1603
|
.map(t => {
|
|
1524
|
-
const
|
|
1525
|
-
const
|
|
1526
|
-
|
|
1604
|
+
const since = t.updated_at || t.created_at || null;
|
|
1605
|
+
const ms = since ? new Date(since).getTime() : NaN;
|
|
1606
|
+
if (!Number.isFinite(ms)) { stuckUnmeasurable += 1; return null; }
|
|
1607
|
+
return { id: t.id, title: t.title, agent: t.agent, age_h: Math.round((nowMs - ms) / 3600000), since };
|
|
1527
1608
|
})
|
|
1528
|
-
.filter(t => t
|
|
1609
|
+
.filter(t => t && t.age_h > STUCK_H);
|
|
1529
1610
|
|
|
1530
1611
|
// Per-agent budgets from PROJECT.md
|
|
1531
1612
|
const projectMdPath = path.join(cwd, '.great_cto', 'PROJECT.md');
|
|
@@ -1583,7 +1664,8 @@ async function dispatch(req, res, url, cwd) {
|
|
|
1583
1664
|
// line the parser could not read is reported rather than dropped, because a
|
|
1584
1665
|
// budget silently ignored is a limit its author believes they have.
|
|
1585
1666
|
res.end(JSON.stringify({
|
|
1586
|
-
stuck,
|
|
1667
|
+
stuck, stuck_in_progress: inProgress.length, stuck_unmeasurable: stuckUnmeasurable,
|
|
1668
|
+
budgets, goal_ancestry: goalAncestry, tool_failure_rate_1h: toolFailureRate1h,
|
|
1587
1669
|
budgets_deprecated_key: budgetsDeprecatedKey,
|
|
1588
1670
|
budgets_malformed: budgetsMalformed,
|
|
1589
1671
|
// Three states, not two: read / absent / unreadable. Without these, a
|
|
@@ -1627,11 +1709,29 @@ async function dispatch(req, res, url, cwd) {
|
|
|
1627
1709
|
}
|
|
1628
1710
|
evidence = evidence.slice(-20).reverse();
|
|
1629
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
|
+
|
|
1630
1729
|
const reviewed = evidence.filter((r) => r.state === 'ok');
|
|
1631
1730
|
const summary = {
|
|
1632
1731
|
runs: evidence.length,
|
|
1633
1732
|
reviewed: reviewed.length,
|
|
1634
|
-
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,
|
|
1635
1735
|
blocked: reviewed.filter((r) => r.verdict === 'BLOCK').length,
|
|
1636
1736
|
unreadable_lines: unreadable,
|
|
1637
1737
|
};
|
|
@@ -1844,4 +1944,42 @@ async function dispatch(req, res, url, cwd) {
|
|
|
1844
1944
|
return false;
|
|
1845
1945
|
}
|
|
1846
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
|
+
|
|
1847
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 };
|