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.
@@ -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.26.2",
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?.[1]?.trim() || 'sonnet',
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
- const stuck = tasks
1522
- .filter(t => t.status === 'in_progress')
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 startedAt = t.startedAt ? new Date(t.startedAt).getTime() : null;
1525
- const ageH = startedAt ? (nowMs - startedAt) / 3600000 : null;
1526
- return { id: t.id, title: t.title, agent: t.agent, age_h: ageH ? Math.round(ageH) : null };
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.age_h !== null && t.age_h > STUCK_H);
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, budgets, goal_ancestry: goalAncestry, tool_failure_rate_1h: toolFailureRate1h,
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 };