great-cto 3.57.0 → 3.58.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.
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * great_cto status line — your status line, plus a record of Claude's plan use.
4
+ *
5
+ * Claude Code hands the status line command a JSON object on stdin, and for a
6
+ * subscription it carries `rate_limits`: the 5-hour window, the week, the
7
+ * per-model weeks, each with a used percentage and a reset time. Nothing else
8
+ * on the machine records them — not the transcripts, not any hook payload — so
9
+ * the board could only say "Claude Code does not record plan use". This script
10
+ * keeps one line per CHANGE of those numbers in ~/.great_cto/claude-limits.jsonl
11
+ * and then prints the status line you had before (`great-cto statusline
12
+ * install` saved it), or a short one of its own.
13
+ *
14
+ * Installed by copying this file to ~/.great_cto/statusline.mjs: the status line
15
+ * runs often, and `npx` would add its start-up to every refresh. Zero
16
+ * dependencies; never fails the status line over the record.
17
+ */
18
+ import { readFileSync, appendFileSync, writeFileSync, mkdirSync, statSync, renameSync, realpathSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { basename, join } from 'node:path';
21
+ import { spawnSync } from 'node:child_process';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { resolve } from 'node:path';
24
+
25
+ const DIR = process.env.GREAT_CTO_HOME || join(homedir(), '.great_cto');
26
+ const LOG = join(DIR, 'claude-limits.jsonl');
27
+ const LAST = join(DIR, '.claude-limits-last');
28
+ const CONF = join(DIR, 'statusline.json');
29
+ const MAX_BYTES = 5 * 1024 * 1024;
30
+
31
+ /** Seconds since the epoch, from a number (seconds or milliseconds) or a date string. */
32
+ export function resetSeconds(v) {
33
+ if (typeof v === 'number' && Number.isFinite(v)) return Math.round(v > 1e12 ? v / 1000 : v);
34
+ if (typeof v === 'string' && v.trim()) {
35
+ const n = Number(v);
36
+ if (Number.isFinite(n)) return resetSeconds(n);
37
+ const t = Date.parse(v);
38
+ if (Number.isFinite(t)) return Math.round(t / 1000);
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /** `rate_limits` → { window: { used, resets } }, only windows that carry a number. */
44
+ export function windowsOf(rateLimits) {
45
+ const out = {};
46
+ if (!rateLimits || typeof rateLimits !== 'object') return out;
47
+ for (const [name, w] of Object.entries(rateLimits)) {
48
+ if (!w || typeof w !== 'object') continue;
49
+ const used = Number(w.used_percentage ?? w.utilization ?? w.used_percent);
50
+ if (!Number.isFinite(used)) continue;
51
+ out[name] = { used: Math.round(used * 10) / 10, resets: resetSeconds(w.resets_at ?? w.resetsAt) };
52
+ }
53
+ return out;
54
+ }
55
+
56
+ export function record(rateLimits, { now = Date.now() } = {}) {
57
+ const windows = windowsOf(rateLimits);
58
+ if (!Object.keys(windows).length) return false;
59
+ const key = JSON.stringify(windows);
60
+ let last = '';
61
+ try { last = readFileSync(LAST, 'utf8'); } catch { /* first reading */ }
62
+ if (last === key) return false;
63
+ mkdirSync(DIR, { recursive: true });
64
+ try { if (statSync(LOG).size > MAX_BYTES) renameSync(LOG, `${LOG}.1`); } catch { /* no log yet */ }
65
+ appendFileSync(LOG, `${JSON.stringify({ ts: new Date(now).toISOString(), windows })}\n`);
66
+ writeFileSync(LAST, key);
67
+ return true;
68
+ }
69
+
70
+ export function defaultLine(d) {
71
+ const parts = [];
72
+ const model = d?.model?.display_name || d?.model?.id;
73
+ if (model) parts.push(model);
74
+ const dir = d?.workspace?.current_dir || d?.cwd;
75
+ if (dir) parts.push(basename(dir));
76
+ const w = windowsOf(d?.rate_limits);
77
+ if (w.five_hour) parts.push(`5h ${Math.round(w.five_hour.used)}%`);
78
+ if (w.seven_day) parts.push(`7d ${Math.round(w.seven_day.used)}%`);
79
+ return parts.join(' · ');
80
+ }
81
+
82
+ function main() {
83
+ let raw = '';
84
+ try { raw = readFileSync(0, 'utf8'); } catch { /* no input */ }
85
+ let d = {};
86
+ try { d = JSON.parse(raw || '{}'); } catch { /* not JSON */ }
87
+ try { record(d.rate_limits); } catch { /* the record never costs the status line */ }
88
+ let conf = {};
89
+ try { conf = JSON.parse(readFileSync(CONF, 'utf8')); } catch { /* none saved */ }
90
+ if (typeof conf.chain === 'string' && conf.chain.trim()) {
91
+ const r = spawnSync('/bin/sh', ['-c', conf.chain], { input: raw, encoding: 'utf8', timeout: 5000 });
92
+ process.stdout.write(r.stdout || '');
93
+ return;
94
+ }
95
+ process.stdout.write(`${defaultLine(d)}\n`);
96
+ }
97
+
98
+ // Real paths on both sides: the module URL is resolved through symlinks (macOS
99
+ // /var → /private/var, a symlinked home), the command line is not.
100
+ const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
101
+ if (process.argv[1] && real(fileURLToPath(import.meta.url)) === real(process.argv[1])) main();
@@ -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 72 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.57.0",
4
+ "version": "3.58.1",
5
5
  "author": {
6
6
  "name": "Alexander Velikiy",
7
7
  "url": "https://hashnode.com/@Greatcto"
@@ -13,7 +13,7 @@ import { sseClients, notifHistory } from './state.mjs';
13
13
  import { autoRegisterProject, listProjects, resolveProjectCwd, resolveProjectInfo, getChangeTier, readProjectsRegistry, getRegistryDegradation } from './projects.mjs';
14
14
  import { readVerdictsWithHealth } from './verdicts.mjs';
15
15
  import { agentUsage, usageSnapshot } from '../../../scripts/lib/agent-usage.mjs';
16
- import { usageIndexSnapshot, summarizeUsage, readCodexTitles } from '../../../scripts/lib/session-usage.mjs';
16
+ import { usageIndexSnapshot, summarizeUsage, readCodexTitles, readClaudeLimits, claudeRecorder } from '../../../scripts/lib/session-usage.mjs';
17
17
  import { outcomes as computeOutcomes } from '../../../scripts/lib/outcomes.mjs';
18
18
  import { reviewerStatus } from '../../../scripts/lib/required-reviewers.mjs';
19
19
  import { readSessionStatus } from '../../../scripts/lib/session-status.mjs';
@@ -1507,7 +1507,7 @@ async function dispatch(req, res, url, cwd) {
1507
1507
  if (snap.state !== 'ready') {
1508
1508
  body = { state: snap.state, why: snap.why };
1509
1509
  } else {
1510
- const sum = summarizeUsage(snap.index, { days, codexTitles: readCodexTitles() });
1510
+ const sum = summarizeUsage(snap.index, { days, codexTitles: readCodexTitles(), claudeReadings: readClaudeLimits(), claudeRecorderState: claudeRecorder() });
1511
1511
  const ours = new Set(boardAgentNames());
1512
1512
  for (const host of Object.keys(sum.lists)) {
1513
1513
  sum.lists[host].agents = sum.lists[host].agents.map((a) => ({ ...a, ours: ours.has(a.name) }));
@@ -2443,6 +2443,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2443
2443
  .usage-kinds b { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--text); font-weight: 500; margin-right: 4px; }
2444
2444
  .usage-quote { font-size: var(--fs-small); color: var(--text2); border-left: 2px solid var(--border-strong); padding: 2px 0 2px 10px; margin: 10px 0 0; overflow-wrap: anywhere; }
2445
2445
  .usage-pace { font-size: var(--fs-small); color: var(--text); margin: 4px 0 8px; }
2446
+ .usage-card code, .usage-note code, .usage-sub code { font-family: var(--mono); font-size: var(--fs-caption); color: var(--text); background: var(--bg-muted); padding: 1px 4px; }
2446
2447
  .usage-pace.bad { color: var(--status-blocked-ink); }
2447
2448
  .usage-prios { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 12px; }
2448
2449
  .usage-prio { border: 1px solid var(--border); padding: 8px 10px; min-width: 0; }
@@ -6077,8 +6078,21 @@ function usageHostCard(d, host) {
6077
6078
  : `<div class="usage-limit"><div class="usage-limit-head"><span>Plan limit</span>${absent('unloaded', 'no Codex session has reported its rate-limit window yet')}</div></div>`;
6078
6079
  } else {
6079
6080
  const hits = h.limitHits || { plan: 0, server: 0 };
6080
- limit = `<div class="usage-limit"><div class="usage-limit-head"><span>Plan limit</span>${absent('uncomputable', 'Claude Code does not write how much of the plan is used — only when a request is refused')}</div>
6081
- <div class="usage-sub">${hits.plan} refused at the plan limit · ${hits.server} server-side throttles (not your limit) in ${d.window.days} days</div></div>`;
6081
+ const P = d.limits && d.limits.claude && d.limits.claude.plan;
6082
+ const meter = (lane) => {
6083
+ const c = lane && lane.current;
6084
+ if (!c || !c.open) return '';
6085
+ const used = Math.max(0, Math.min(100, Number(c.used) || 0));
6086
+ const cls = used >= 90 ? 'full' : used >= 70 ? 'warn' : '';
6087
+ return `<div class="usage-limit-head"><span>${usageLimitWindow(lane.minutes)}</span><b>${used}% used</b></div>
6088
+ <div class="usage-meter ${cls}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${used}" aria-label="${esc(usageLimitWindow(lane.minutes))}"><span style="width:${used}%"></span></div>
6089
+ <div class="usage-sub">resets ${esc(fmtWhen(c.resets))}</div>`;
6090
+ };
6091
+ const meters = P ? meter(P.fiveHour) + meter(P.weekly) : '';
6092
+ limit = meters
6093
+ ? `<div class="usage-limit">${meters}<div class="usage-sub">as Claude Code reported it to the status line, ${esc(relTime(new Date(Math.max(...Object.values(P).map((l) => (l.current && l.current.readAt) || 0))).toISOString()))} ago · ${hits.plan} refused at a limit in ${d.window.days} days</div></div>`
6094
+ : `<div class="usage-limit"><div class="usage-limit-head"><span>Plan limit</span>${absent('unloaded', usageRecorderHint(d).short)}</div>
6095
+ <div class="usage-sub">${hits.plan} refused at the plan limit · ${hits.server} server-side throttles (not your limit) in ${d.window.days} days · plan percentage: ${d.limits && d.limits.claude && d.limits.claude.recorder === 'on' ? 'from <code>claude</code> in a terminal' : '<code>great-cto statusline install</code>'}</div></div>`;
6082
6096
  }
6083
6097
  return `<section class="usage-card" data-host="${host}">
6084
6098
  <h3><span>${name}</span><span class="muted">${esc(d.window.from)} → ${esc(d.window.to)}</span></h3>
@@ -6191,8 +6205,21 @@ function usageLineSvg({ from, to, points, max, marks = [], ceiling = null, label
6191
6205
  <line class="grid" x1="0" x2="${W}" y1="${H}" y2="${H}"></line>${ceil}${ticks}<path class="ln" d="${path}"></path></svg>`;
6192
6206
  }
6193
6207
 
6208
+ // Plan percentage reaches only a status line Claude Code draws: the terminal
6209
+ // `claude` draws one, the desktop app's Code tab does not. So "recorder on, no
6210
+ // reading yet" gets its own words — sending the operator to install what they
6211
+ // already installed is the hint that misled on the first day.
6212
+ function usageRecorderHint(d) {
6213
+ const on = d.limits && d.limits.claude && d.limits.claude.recorder === 'on';
6214
+ return on
6215
+ ? { short: 'recorder on, no reading yet — readings come from claude in a terminal', long: 'The recorder is on and has no reading yet. Claude Code passes its plan percentage only to a status line it draws: the terminal <code>claude</code> does, the desktop app\'s Code tab does not. One terminal session fills this in.' }
6216
+ : { short: 'Claude Code reports plan use only to the status line — turn on the recorder: great-cto statusline install', long: 'Claude Code hands its plan percentage to the status line and to nothing else. <code>great-cto statusline install</code> records it here and keeps your own status line; readings come from <code>claude</code> in a terminal.' };
6217
+ }
6218
+
6194
6219
  function usageClaudeLimits(d) {
6195
- const head = `<h3><span>Claude Code limits</span><span class="muted">from refusals — no plan percentage is recorded</span></h3>`;
6220
+ const L0 = d.limits && d.limits.claude;
6221
+ const plan = L0 && L0.plan;
6222
+ const head = `<h3><span>Claude Code limits</span><span class="muted">${plan ? 'as Claude Code reported them to the status line' : L0 && L0.recorder === 'on' ? 'from refusals — plan percentages arrive from claude in a terminal' : 'from refusals — turn on the status line recorder for plan percentages'}</span></h3>`;
6196
6223
  if (!d.seen || !d.seen.claude) return `<section class="usage-card" data-limits="claude">${head}<p class="usage-note">No Claude Code logs on this machine.</p></section>`;
6197
6224
  const L = d.limits && d.limits.claude;
6198
6225
  if (!L) return `<section class="usage-card" data-limits="claude">${head}<p class="usage-note">Not computed.</p></section>`;
@@ -6211,7 +6238,12 @@ function usageClaudeLimits(d) {
6211
6238
  const chart = pts.length > 1 ? `<div class="usage-line">${usageLineSvg({ from: s.from, to, points: pts, max, marks, ceiling: L.ceilings.session ? L.ceilings.session.median : null, label: 'Claude Code spend in the trailing 5 hours, with refusals marked' })}</div>
6212
6239
  <div class="usage-axis"><span>${esc(d.window.from)}</span><span>$ in the trailing 5 hours · peak ${esc(fmtUsdEq(max))}</span><span>${esc(d.window.to)}</span></div>` : '';
6213
6240
  const last = L.lastHit ? `<p class="usage-quote" title="${esc(fmtDate(L.lastHit.at))}">${esc(L.lastHit.text)} <span class="muted">— ${esc(relTime(L.lastHit.at))} ago</span></p>` : '';
6241
+ const CLAUDE_LANE = { fiveHour: '5-hour limit', weekly: 'Weekly limit', seven_day_opus: 'Weekly limit · Opus', seven_day_sonnet: 'Weekly limit · Sonnet', seven_day_oauth_apps: 'Weekly limit · apps' };
6242
+ const lanes = plan
6243
+ ? ['fiveHour', 'weekly', ...Object.keys(plan).filter((k) => k !== 'fiveHour' && k !== 'weekly')].filter((k) => plan[k]).map((k) => usageCodexLane(k, plan[k], d, CLAUDE_LANE[k] || k)).join('')
6244
+ : `<p class="usage-note">${usageRecorderHint(d).long}</p>`;
6214
6245
  return `<section class="usage-card" data-limits="claude">${head}
6246
+ ${lanes}
6215
6247
  <div class="usage-stats">${stat(esc(fmtUsdEq(L.fiveHour.usd)), 'last 5 hours, $ list')}${stat(esc(fmtUsdEq(L.sevenDay.usd)), 'last 7 days, $ list')}${stat(esc(String(hits.plan || 0)), `refusals in ${d.window.days} days`)}</div>
6216
6248
  ${kinds ? `<div class="usage-kinds">${kinds}</div>` : `<p class="usage-note">No request was refused by a limit in this period.</p>`}
6217
6249
  ${ceilings.length ? `<p class="usage-note">${ceilings.join('<br>')}. <span class="muted">An estimate from what had been spent when Claude Code refused — not a published limit.</span></p>` : ''}
@@ -6221,8 +6253,8 @@ function usageClaudeLimits(d) {
6221
6253
  </section>`;
6222
6254
  }
6223
6255
 
6224
- function usageCodexLane(name, lane, d) {
6225
- const title = usageLimitWindow(lane.minutes);
6256
+ function usageCodexLane(name, lane, d, label = null) {
6257
+ const title = label || usageLimitWindow(lane.minutes);
6226
6258
  const P = lane.projection;
6227
6259
  const lastRead = lane.series.length ? lane.series[lane.series.length - 1][0] : null;
6228
6260
  let pace = `<p class="usage-pace muted">No open window${lastRead ? ` — the last reading of it is from ${esc(fmtWhen(lastRead))}; the plan may no longer have it` : ''}.</p>`;
@@ -695,28 +695,98 @@ export function laneName(minutes) {
695
695
  * it is grouped to ten minutes — unrounded, one week read as forty windows.
696
696
  */
697
697
  function codexLimitDetail(points, { fromMs, now }) {
698
- if (!points.length) return null;
699
- const pts = [...points].sort((a, b) => a[0] - b[0]);
700
- const lanes = {};
701
- for (const p of pts) {
698
+ const samples = [];
699
+ for (const p of points) {
702
700
  for (const [iu, im, ir] of [[1, 2, 3], [4, 5, 6]]) {
703
- const used = p[iu]; const minutes = p[im]; const resets = p[ir];
704
- if (used == null || !minutes || !resets) continue;
705
- const resetMs = resets * 1000;
706
- if (resetMs < fromMs) continue;
707
- const lane = lanes[laneName(minutes)] || (lanes[laneName(minutes)] = { minutes, windows: new Map(), series: [] });
708
- if (p[0] >= fromMs) {
709
- const prev = lane.series[lane.series.length - 1];
710
- if (!prev || prev[1] !== used) lane.series.push([p[0], used]);
711
- }
712
- const key = Math.round(resets / 600);
713
- const w = lane.windows.get(key) || { start: resetMs - minutes * 60000, resets: resetMs, peak: 0, fullAt: null, last: null };
714
- w.resets = Math.max(w.resets, resetMs);
715
- w.peak = Math.max(w.peak, used);
716
- if (used >= 100 && !w.fullAt) w.fullAt = p[0];
717
- if (!w.last || p[0] >= w.last[0]) w.last = [p[0], used];
718
- lane.windows.set(key, w);
701
+ if (p[iu] == null || !p[im] || !p[ir]) continue;
702
+ samples.push({ t: p[0], lane: laneName(p[im]), minutes: p[im], used: p[iu], resets: p[ir] });
703
+ }
704
+ }
705
+ return laneDetail(samples, { fromMs, now });
706
+ }
707
+
708
+ /** Window lengths of the plan windows Claude Code reports, by the name it gives them. */
709
+ const CLAUDE_WINDOWS = { five_hour: 300 };
710
+ export function claudeWindowMinutes(name) {
711
+ return CLAUDE_WINDOWS[name] ?? (/^seven_day/.test(name) ? 10080 : null);
712
+ }
713
+
714
+ /**
715
+ * Claude's plan windows, from the readings the great_cto status line recorded
716
+ * (~/.great_cto/claude-limits.jsonl — Claude Code hands plan use to the status
717
+ * line and to nothing else). Same shape as Codex's.
718
+ */
719
+ export function claudePlanDetail(readings, { fromMs, now }) {
720
+ const samples = [];
721
+ for (const r of readings) {
722
+ const t = Date.parse(r?.ts);
723
+ if (!Number.isFinite(t)) continue;
724
+ for (const [name, w] of Object.entries(r.windows || {})) {
725
+ const minutes = claudeWindowMinutes(name);
726
+ if (!minutes || !Number.isFinite(w?.used) || !w?.resets) continue;
727
+ samples.push({ t, lane: name === 'five_hour' ? 'fiveHour' : name === 'seven_day' ? 'weekly' : name, minutes, used: w.used, resets: w.resets });
728
+ }
729
+ }
730
+ return laneDetail(samples, { fromMs, now });
731
+ }
732
+
733
+ /** The readings file, oldest first; [] when the status line was never installed. */
734
+ export function readClaudeLimits(file = path.join(home(), '.great_cto', 'claude-limits.jsonl')) {
735
+ const out = [];
736
+ for (const f of [`${file}.1`, file]) {
737
+ let text = '';
738
+ try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
739
+ for (const line of text.split('\n')) {
740
+ if (!line.trim()) continue;
741
+ try { out.push(JSON.parse(line)); } catch { /* partial line */ }
742
+ }
743
+ }
744
+ return out;
745
+ }
746
+
747
+ /**
748
+ * Is the plan-use recorder on? `great-cto statusline install` points Claude Code's
749
+ * statusLine at ~/.great_cto/statusline.mjs. On with no reading yet is not the
750
+ * same as never installed: only a status line Claude Code DRAWS gets the plan
751
+ * percentage — the terminal `claude` draws one, the desktop app's Code tab does
752
+ * not — so the board must say which of the two the operator is in.
753
+ */
754
+ export function claudeRecorder(homeDir = home()) {
755
+ try {
756
+ const s = JSON.parse(fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf8'));
757
+ const cmd = s && s.statusLine && s.statusLine.command;
758
+ return typeof cmd === 'string' && /\.great_cto[\\/]statusline\.mjs/.test(cmd) ? 'on' : 'off';
759
+ } catch { return 'off'; }
760
+ }
761
+
762
+ /**
763
+ * Plan windows from readings [{t, lane, minutes, used, resets}]: every window the
764
+ * period touched with its peak and when it filled, the readings to draw, and —
765
+ * for the window still open — where the current pace ends up by the reset.
766
+ *
767
+ * `resets` drifts by seconds between readings of one window, so it is grouped to
768
+ * ten minutes — unrounded, one week read as forty windows.
769
+ */
770
+ function laneDetail(samples, { fromMs, now }) {
771
+ if (!samples.length) return null;
772
+ const sorted = [...samples].sort((a, b) => a.t - b.t);
773
+ const lanes = {};
774
+ for (const s of sorted) {
775
+ const { t, minutes, used, resets } = s;
776
+ const resetMs = resets * 1000;
777
+ if (resetMs < fromMs) continue;
778
+ const lane = lanes[s.lane] || (lanes[s.lane] = { minutes, windows: new Map(), series: [] });
779
+ if (t >= fromMs) {
780
+ const prev = lane.series[lane.series.length - 1];
781
+ if (!prev || prev[1] !== used) lane.series.push([t, used]);
719
782
  }
783
+ const key = Math.round(resets / 600);
784
+ const w = lane.windows.get(key) || { start: resetMs - minutes * 60000, resets: resetMs, peak: 0, fullAt: null, last: null };
785
+ w.resets = Math.max(w.resets, resetMs);
786
+ w.peak = Math.max(w.peak, used);
787
+ if (used >= 100 && !w.fullAt) w.fullAt = t;
788
+ if (!w.last || t >= w.last[0]) w.last = [t, used];
789
+ lane.windows.set(key, w);
720
790
  }
721
791
  const out = {};
722
792
  for (const [name, lane] of Object.entries(lanes)) {
@@ -742,6 +812,7 @@ function codexLimitDetail(points, { fromMs, now }) {
742
812
  filled: list.filter((w) => w.fullAt).length,
743
813
  series: lane.series,
744
814
  projection,
815
+ current: latest ? { used: latest.last[1], readAt: latest.last[0], resets: latest.resets, open: latest.resets > now } : null,
745
816
  };
746
817
  }
747
818
  return Object.keys(out).length ? out : null;
@@ -752,7 +823,7 @@ function codexLimitDetail(points, { fromMs, now }) {
752
823
  * @param {object} index from scanUsage
753
824
  * @param {{days?:number, now?:number, codexTitles?:Record<string,string>, prices?:object, top?:number}} opts
754
825
  */
755
- export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles = {}, prices = effectivePrices(), top = 10 } = {}) {
826
+ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles = {}, prices = effectivePrices(), top = 10, claudeReadings = [], claudeRecorderState = 'off' } = {}) {
756
827
  const span = Math.max(1, Math.min(365, Math.floor(days) || 30));
757
828
  const dates = [];
758
829
  for (let i = span - 1; i >= 0; i--) dates.push(dayOf(now - i * 86400000));
@@ -887,7 +958,7 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
887
958
  }])),
888
959
  limits: {
889
960
  codex: codexLimits ? { ...codexLimits, plan: codexLimits.plan || codexPlan?.plan || null, detail: codexLimitDetail(codexPoints, { fromMs, now }) } : null,
890
- claude: claudeLimits(hourly.claude, { fromMs, now, lastHit }),
961
+ claude: { ...claudeLimits(hourly.claude, { fromMs, now, lastHit }), plan: claudePlanDetail(claudeReadings, { fromMs, now }), recorder: claudeRecorderState },
891
962
  },
892
963
  unpricedModels: [...unpriced].sort(),
893
964
  // Claude Code only: Codex does not write hook outcomes to its session logs.
package/dist/main.js CHANGED
@@ -139,6 +139,11 @@ function parseArgs(argv) {
139
139
  args.command = "upgrade";
140
140
  else if (a === "uninstall")
141
141
  args.command = "uninstall";
142
+ else if (a === "statusline") {
143
+ args.command = "statusline";
144
+ args.taskArgs = argv.slice(i + 1);
145
+ break;
146
+ }
142
147
  else if (a === "--purge-data")
143
148
  args.purgeData = true;
144
149
  else if (a === "--projects")
@@ -767,6 +772,8 @@ ${bold("Usage:")}
767
772
  npx great-cto serve [--port 3142]
768
773
  npx great-cto upgrade [superpowers|beads] Re-clone companions to latest tag + re-apply overlays
769
774
  npx great-cto upgrade --self Upgrade the great-cto CLI itself, in place
775
+ npx great-cto statusline install Record Claude's plan use (5h / weekly %) for the board; keeps your status line
776
+ npx great-cto statusline uninstall Put your previous status line back
770
777
  npx great-cto uninstall [--yes] Show what great_cto installed; --yes removes it (your data stays)
771
778
  npx great-cto help
772
779
  npx great-cto version
@@ -1718,6 +1725,10 @@ async function main() {
1718
1725
  await finish(2);
1719
1726
  }
1720
1727
  }
1728
+ if (args.command === "statusline") {
1729
+ const { runStatuslineCommand } = await import("./statusline.js");
1730
+ await finish(runStatuslineCommand(args.taskArgs ?? []));
1731
+ }
1721
1732
  if (args.command === "upgrade") {
1722
1733
  try {
1723
1734
  const code = await runUpgrade(rawArgv, args);
@@ -0,0 +1,145 @@
1
+ // `great-cto statusline install | uninstall | status`
2
+ //
3
+ // Claude Code gives the status line command its plan usage (`rate_limits`: the
4
+ // 5-hour window, the week, per-model weeks) and gives it to nothing else. This
5
+ // installs a small status line (assets/statusline.mjs → ~/.great_cto/statusline.mjs)
6
+ // that records those numbers for the board and then prints the status line the
7
+ // user already had. Opt-in, reversible: the previous setting is kept and put back.
8
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, renameSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join, dirname, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { getSettingsPath } from "./settings.js";
13
+ import { dim, success, warn } from "./ui.js";
14
+ export function statuslinePaths(home = homedir()) {
15
+ const gctoDir = process.env["GREAT_CTO_HOME"] || join(home, ".great_cto");
16
+ return {
17
+ settings: process.env["GREAT_CTO_HOME"] ? join(home, ".claude", "settings.json") : getSettingsPath(),
18
+ gctoDir,
19
+ script: join(gctoDir, "statusline.mjs"),
20
+ conf: join(gctoDir, "statusline.json"),
21
+ log: join(gctoDir, "claude-limits.jsonl"),
22
+ asset: resolve(dirname(fileURLToPath(import.meta.url)), "..", "assets", "statusline.mjs"),
23
+ };
24
+ }
25
+ function readJson(path) {
26
+ if (!existsSync(path))
27
+ return null;
28
+ try {
29
+ const raw = readFileSync(path, "utf-8");
30
+ return raw.trim() ? JSON.parse(raw) : {};
31
+ }
32
+ catch {
33
+ return "invalid";
34
+ }
35
+ }
36
+ function writeJsonAtomic(path, value) {
37
+ mkdirSync(dirname(path), { recursive: true });
38
+ const tmp = `${path}.tmp-${Date.now()}`;
39
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", "utf-8");
40
+ renameSync(tmp, path);
41
+ }
42
+ export function ourCommand(p) {
43
+ return `node "${p.script}"`;
44
+ }
45
+ function isOurs(statusLine, p) {
46
+ const cmd = statusLine?.command;
47
+ return typeof cmd === "string" && cmd.includes(p.script);
48
+ }
49
+ export function installStatusline(p = statuslinePaths()) {
50
+ const settings = readJson(p.settings);
51
+ if (settings === "invalid")
52
+ return { ok: false, message: `${p.settings} is not valid JSON — left alone` };
53
+ const s = settings ?? {};
54
+ if (!existsSync(p.asset))
55
+ return { ok: false, message: `the status line script is missing from this install (${p.asset})` };
56
+ mkdirSync(p.gctoDir, { recursive: true });
57
+ copyFileSync(p.asset, p.script);
58
+ const current = s["statusLine"];
59
+ if (!isOurs(current, p)) {
60
+ // What the user had is kept twice: run by ours on every refresh (chain), and
61
+ // stored whole so uninstall can put it back exactly.
62
+ const chain = typeof current?.command === "string"
63
+ ? String(current.command) : null;
64
+ writeJsonAtomic(p.conf, { chain, previous: current ?? null, installedAt: new Date().toISOString() });
65
+ }
66
+ let backup = null;
67
+ if (existsSync(p.settings)) {
68
+ backup = `${p.settings}.bak-${Date.now()}`;
69
+ copyFileSync(p.settings, backup);
70
+ }
71
+ const prevPadding = current?.padding;
72
+ s["statusLine"] = { type: "command", command: ourCommand(p), ...(typeof prevPadding === "number" ? { padding: prevPadding } : {}) };
73
+ writeJsonAtomic(p.settings, s);
74
+ return { ok: true, message: isOurs(current, p) ? "status line script refreshed" : "status line installed", backup };
75
+ }
76
+ export function uninstallStatusline(p = statuslinePaths()) {
77
+ const settings = readJson(p.settings);
78
+ if (settings === "invalid")
79
+ return { ok: false, message: `${p.settings} is not valid JSON — left alone` };
80
+ const s = settings ?? {};
81
+ if (!isOurs(s["statusLine"], p))
82
+ return { ok: true, message: "the great_cto status line is not installed — nothing changed" };
83
+ const conf = readJson(p.conf);
84
+ const previous = conf && conf !== "invalid" ? conf["previous"] : null;
85
+ let backup = null;
86
+ if (existsSync(p.settings)) {
87
+ backup = `${p.settings}.bak-${Date.now()}`;
88
+ copyFileSync(p.settings, backup);
89
+ }
90
+ if (previous && typeof previous === "object")
91
+ s["statusLine"] = previous;
92
+ else
93
+ delete s["statusLine"];
94
+ writeJsonAtomic(p.settings, s);
95
+ return { ok: true, message: previous ? "your previous status line is back" : "status line removed", backup };
96
+ }
97
+ export function statuslineStatus(p = statuslinePaths()) {
98
+ const settings = readJson(p.settings);
99
+ const installed = !!settings && settings !== "invalid" && isOurs(settings["statusLine"], p);
100
+ const conf = readJson(p.conf);
101
+ const chain = conf && conf !== "invalid" && typeof conf["chain"] === "string" ? String(conf["chain"]) : null;
102
+ let lastReading = null;
103
+ try {
104
+ const lines = readFileSync(p.log, "utf-8").trim().split("\n");
105
+ lastReading = JSON.parse(lines[lines.length - 1] || "null");
106
+ }
107
+ catch { /* nothing recorded yet */ }
108
+ return { installed, chain, lastReading };
109
+ }
110
+ export function runStatuslineCommand(argv) {
111
+ const sub = argv[0] || "status";
112
+ if (sub === "install") {
113
+ const r = installStatusline();
114
+ if (!r.ok) {
115
+ warn(r.message);
116
+ return 1;
117
+ }
118
+ success(`${r.message} ${dim(r.backup ? `(settings backup: ${r.backup})` : "")}`);
119
+ console.log(" Claude's plan use is recorded on the next status line refresh; the board shows it under Usage → Limits.");
120
+ console.log(" Readings come from `claude` in a terminal: the desktop app's Code tab does not draw a status line.");
121
+ console.log(` Undo: ${dim("great-cto statusline uninstall")}`);
122
+ return 0;
123
+ }
124
+ if (sub === "uninstall") {
125
+ const r = uninstallStatusline();
126
+ if (!r.ok) {
127
+ warn(r.message);
128
+ return 1;
129
+ }
130
+ success(`${r.message} ${dim(r.backup ? `(settings backup: ${r.backup})` : "")}`);
131
+ return 0;
132
+ }
133
+ if (sub === "status") {
134
+ const st = statuslineStatus();
135
+ console.log(`installed: ${st.installed ? "yes" : "no"}`);
136
+ if (st.chain)
137
+ console.log(`your status line (shown through it): ${st.chain}`);
138
+ console.log(`last reading: ${st.lastReading ? JSON.stringify(st.lastReading) : "none yet"}`);
139
+ if (st.installed && !st.lastReading)
140
+ console.log(" (readings come from `claude` in a terminal; the desktop app's Code tab does not draw a status line)");
141
+ return 0;
142
+ }
143
+ warn(`unknown: great-cto statusline ${sub} — use install, uninstall or status`);
144
+ return 1;
145
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.57.0",
3
+ "version": "3.58.1",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",