great-cto 2.91.0 → 2.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "name": "great_cto",
3
3
  "id": "great_cto",
4
4
  "description": "Engineering process for solo founders and teams up to 50 engineers. Agents do architecture, code review, QA, and security. You make two decisions per feature.",
5
- "version": "2.91.0",
5
+ "version": "2.93.0",
6
6
  "author": {
7
7
  "name": "Great CTO",
8
8
  "url": "https://github.com/avelikiy/great_cto"
@@ -56,7 +56,7 @@
56
56
  "hooks": [
57
57
  {
58
58
  "type": "command",
59
- "command": "PLUGIN_DIR=$(ls -d ~/.claude/plugins/cache/local/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||'); if [ -n \"$PLUGIN_DIR\" ] && [ -f \"${PLUGIN_DIR}/shared/orchestrator.toml\" ]; then mkdir -p shared; cp \"${PLUGIN_DIR}/shared/orchestrator.toml\" shared/orchestrator.toml 2>/dev/null || true; cp \"${PLUGIN_DIR}/shared/pipeline.toml\" shared/pipeline.toml 2>/dev/null || true; fi",
59
+ "command": "PLUGIN_DIR=$(ls -d ~/.claude/plugins/cache/local/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||'); if [ -f .claude-plugin/plugin.json ] && grep -q '\"name\": *\"great_cto\"' .claude-plugin/plugin.json 2>/dev/null; then : ; elif [ -n \"$PLUGIN_DIR\" ] && [ -f \"${PLUGIN_DIR}/shared/orchestrator.toml\" ]; then mkdir -p shared; cp \"${PLUGIN_DIR}/shared/orchestrator.toml\" shared/orchestrator.toml 2>/dev/null || true; cp \"${PLUGIN_DIR}/shared/pipeline.toml\" shared/pipeline.toml 2>/dev/null || true; fi",
60
60
  "timeout": 5,
61
61
  "statusMessage": "Refreshing orchestrator contract..."
62
62
  },
@@ -262,6 +262,16 @@
262
262
  }
263
263
  ]
264
264
  }
265
+ ],
266
+ "Stop": [
267
+ {
268
+ "hooks": [
269
+ {
270
+ "type": "command",
271
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/pipeline-stall-guard.mjs\" 2>/dev/null || true"
272
+ }
273
+ ]
274
+ }
265
275
  ]
266
276
  }
267
277
  }
@@ -132,7 +132,14 @@ function tasksMdStatus(rawStatus, id, title) {
132
132
  else if (s === 'in_progress' || s === 'in-progress' || s === 'wip' || s === 'doing') status = 'in_progress';
133
133
  else if (s === 'blocked') status = 'blocked';
134
134
  else status = isGate ? 'gate' : 'backlog';
135
- return { status, raw_status: status === 'done' ? 'closed' : 'open', isGate };
135
+ // raw_status is what the inbox filters on, because mapStatus() rewrites any
136
+ // gate-labelled task to 'gate' and filtering on the mapped value would leave
137
+ // closed gates in the inbox forever. That fix covered `done`; it did not cover
138
+ // `blocked`, so a gate marked blocked in tasks.md reported raw_status 'open'
139
+ // and never left the inbox — for exactly the bd-less projects this parser
140
+ // exists to serve.
141
+ const raw = status === 'done' ? 'closed' : status === 'blocked' ? 'blocked' : 'open';
142
+ return { status, raw_status: raw, isGate };
136
143
  }
137
144
 
138
145
  // Build the full task record both parsers emit — one shape so getTasks can
@@ -17,13 +17,26 @@ import { readVerdicts } from './verdicts.mjs';
17
17
  // frontmatter, to avoid pipeline-wide migration). Founder may flip to
18
18
  // frontmatter-driven later — encapsulated in this function only.
19
19
 
20
- const AGENTS_DIR = path.join(os.homedir(), '.claude', 'agents');
20
+ // The agents directory, overridable for tests.
21
+ //
22
+ // getAgentsFleet and getAgentProfile read the real ~/.claude/agents, which left
23
+ // them on the integration path and untested — and the untested part is where the
24
+ // classification bug lived that put 40 of 69 agents in one bucket. An env
25
+ // override is enough to exercise them against a fixture; mocking `fs` would test
26
+ // the mock.
27
+ const AGENTS_DIR = process.env.GREAT_CTO_AGENTS_DIR
28
+ || path.join(os.homedir(), '.claude', 'agents');
21
29
 
22
30
  function deriveDomain(slug) {
23
31
  const s = slug.toLowerCase();
24
32
  if (/architect|adr|design|prompt/.test(s)) return 'arch';
25
33
  if (/security|sec-|threat|pci|gdpr|hipaa/.test(s)) return 'security';
26
- if (/qa|test|eval|review/.test(s)) return 'qa';
34
+ // `review` used to be in this alternation, and it matched every `*-reviewer`
35
+ // slug — so 37 of the 40 agents in `qa` were domain reviewers, the `domain`
36
+ // bucket below was permanently empty, and the panel grouped 58% of the fleet
37
+ // under one heading. The rule that was supposed to separate them sat two lines
38
+ // later and could never be reached.
39
+ if (/\bqa\b|test|eval|^code-reviewer$/.test(s)) return 'qa';
27
40
  if (/devops|deploy|infra|l3|support|oncall/.test(s)) return 'ops';
28
41
  if (/reviewer$/.test(s)) return 'domain';
29
42
  if (/pm|plan|product/.test(s)) return 'pm';
@@ -19,6 +19,7 @@ import { getMetrics } from './metrics.mjs';
19
19
  import { readVerdicts } from './verdicts.mjs';
20
20
  import { getAgentsFleet, getAgentProfile, retireAgent, restoreAgent, appendDecisionLog, readDecisionsLog } from './fleet.mjs';
21
21
  import { getResume, getShareState, toggleShare } from './share.mjs';
22
+ import { listSessions, readSession, editedFiles, searchSessions } from './transcripts.mjs';
22
23
 
23
24
  // ── HTTP router ────────────────────────────────────────────────────────────────
24
25
  // dispatch(req, res, url, cwd, projInfo) handles every /api/* route plus /api/sse.
@@ -855,6 +856,53 @@ async function dispatch(req, res, url, cwd) {
855
856
  return true;
856
857
  }
857
858
 
859
+ // ── session transcripts ───────────────────────────────────────────────
860
+ //
861
+ // The board reports outcomes. These four report HOW an outcome was reached,
862
+ // which is the only thing that separates a weak agent from a broken harness —
863
+ // a distinction this repo got wrong three times in one week by reading a score
864
+ // without reading the run.
865
+ //
866
+ // Read-only by construction: no writes, no index, no cache. A transcript is
867
+ // evidence, and a tool that edits evidence is not one.
868
+
869
+ if (pathname === '/api/sessions') {
870
+ const sessions = listSessions(cwd);
871
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
872
+ res.end(JSON.stringify({ project: cwd, count: sessions.length,
873
+ // The absolute path stays on the server. A browser has no use for it and
874
+ // it names the operator's home directory.
875
+ sessions: sessions.map(({ file, ...rest }) => rest) }));
876
+ return true;
877
+ }
878
+
879
+ if (pathname.startsWith('/api/sessions/') && pathname.endsWith('/edits')) {
880
+ const id = pathname.slice('/api/sessions/'.length, -'/edits'.length);
881
+ const s = listSessions(cwd).find((x) => x.id === id);
882
+ if (!s) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end('{"error":"no such session"}'); return true; }
883
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
884
+ res.end(JSON.stringify({ session: id, files: editedFiles(s.file) }));
885
+ return true;
886
+ }
887
+
888
+ if (pathname.startsWith('/api/sessions/')) {
889
+ // The id comes from the URL and is used to pick from a list the server
890
+ // built — never to build a path. A traversal in the id matches nothing.
891
+ const id = pathname.slice('/api/sessions/'.length);
892
+ const s = listSessions(cwd).find((x) => x.id === id);
893
+ if (!s) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end('{"error":"no such session"}'); return true; }
894
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
895
+ res.end(JSON.stringify({ session: id, title: s.title, modified: s.modified, turns: readSession(s.file) }));
896
+ return true;
897
+ }
898
+
899
+ if (pathname === '/api/session-search') {
900
+ const q = url.searchParams.get('q') || '';
901
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
902
+ res.end(JSON.stringify(searchSessions(cwd, q)));
903
+ return true;
904
+ }
905
+
858
906
  // Build version — so the board shows which great_cto version it's running.
859
907
  if (pathname === '/api/version') {
860
908
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
@@ -0,0 +1,198 @@
1
+ // Session transcripts — what the agent actually did, as opposed to what it
2
+ // concluded.
3
+ //
4
+ // The board reports outcomes: verdicts, gates, cost, tasks. It has never touched
5
+ // the 2 GB of session transcripts sitting in ~/.claude/projects, which are the
6
+ // only record of HOW an outcome was reached — the tool calls, the retries, the
7
+ // edits, the turn where it went wrong.
8
+ //
9
+ // The value of that record was demonstrated the hard way this week. Three
10
+ // separate times a low eval score was read as an agent gap and turned out to be
11
+ // the harness: a token cap that truncated every answer, a one-shot actor that
12
+ // could not look anything up, a fixture with no approved gate. Each was found by
13
+ // reading what the agent did, and each cost a day of the wrong repair first.
14
+ //
15
+ // Deliberately narrow, and deliberately read-only:
16
+ //
17
+ // - one format, ours. agentsview parses fifty; we have Claude Code's JSONL and
18
+ // no reason to guess at the others.
19
+ // - no index, no database. A session file is read when someone opens it. The
20
+ // board is zero-dependency and stays that way.
21
+ // - nothing is written back. A transcript is evidence; a tool that edits
22
+ // evidence is not one.
23
+
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import os from 'node:os';
27
+
28
+ /** Claude Code's per-project session directory, keyed by a slugified cwd. */
29
+ export function projectsRoot() {
30
+ return path.join(os.homedir(), '.claude', 'projects');
31
+ }
32
+
33
+ /**
34
+ * cwd → the directory name Claude Code derives from it.
35
+ *
36
+ * Every character outside [A-Za-z0-9-] becomes a hyphen, underscores included:
37
+ * `/Users/x/development/Personal/great_cto` is stored as
38
+ * `-Users-x-development-Personal-great-cto`. Deriving this from the observed
39
+ * directory names rather than guessing, because a slug that is nearly right
40
+ * silently lists zero sessions and looks like a project with no history.
41
+ */
42
+ export function slugForCwd(cwd) {
43
+ return String(cwd || '').replace(/[^A-Za-z0-9-]/g, '-');
44
+ }
45
+
46
+ function readJsonl(file, { limit = Infinity } = {}) {
47
+ let text;
48
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
49
+ const out = [];
50
+ for (const line of text.split('\n')) {
51
+ if (out.length >= limit) break;
52
+ const t = line.trim();
53
+ if (!t || t[0] !== '{') continue;
54
+ // A truncated append is the normal state of a file being written to right
55
+ // now. Skipping the line is right; failing the read is not.
56
+ try { out.push(JSON.parse(t)); } catch { /* partial write */ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * Sessions for a project, newest first.
63
+ *
64
+ * Metadata only — each entry costs one stat and a bounded head-read, so listing
65
+ * a project with a hundred sessions does not read a hundred megabytes.
66
+ */
67
+ export function listSessions(cwd, { root = projectsRoot() } = {}) {
68
+ const dir = path.join(root, slugForCwd(cwd));
69
+ let files;
70
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')); } catch { return []; }
71
+
72
+ return files.map((f) => {
73
+ const full = path.join(dir, f);
74
+ let stat;
75
+ try { stat = fs.statSync(full); } catch { return null; }
76
+
77
+ // The title lives in a `custom-title` record that can appear anywhere, and
78
+ // rewriting it appends a new one — so the LAST is current. Read a bounded
79
+ // head and accept not finding it rather than scanning a 40 MB file for a
80
+ // label.
81
+ const head = readJsonl(full, { limit: 400 });
82
+ const titles = head.filter((r) => r.type === 'custom-title' && r.customTitle);
83
+ const firstUser = head.find((r) => r.type === 'user' && !r.isCompactSummary);
84
+
85
+ return {
86
+ id: f.replace(/\.jsonl$/, ''),
87
+ file: full,
88
+ title: titles.length ? titles[titles.length - 1].customTitle : null,
89
+ // Falling back to the opening prompt: a session with no title is still
90
+ // recognisable by what it was asked to do.
91
+ opened_with: firstUser ? messageText(firstUser).slice(0, 120) : null,
92
+ size_bytes: stat.size,
93
+ modified: stat.mtime.toISOString(),
94
+ };
95
+ }).filter(Boolean).sort((a, b) => b.modified.localeCompare(a.modified));
96
+ }
97
+
98
+ /** A message record → plain text, whatever content shape it carries. */
99
+ export function messageText(rec) {
100
+ const c = rec?.message?.content;
101
+ if (typeof c === 'string') return c;
102
+ if (!Array.isArray(c)) return '';
103
+ return c.filter((b) => b?.type === 'text').map((b) => b.text || '').join('\n').trim();
104
+ }
105
+
106
+ /** Tool calls in an assistant record, as {name, input}. */
107
+ export function toolCalls(rec) {
108
+ const c = rec?.message?.content;
109
+ if (!Array.isArray(c)) return [];
110
+ return c.filter((b) => b?.type === 'tool_use').map((b) => ({ name: b.name, input: b.input ?? {} }));
111
+ }
112
+
113
+ /**
114
+ * One session as a readable turn list.
115
+ *
116
+ * Bookkeeping records — `mode`, `queue-operation`, `last-prompt`, `custom-title`
117
+ * — are dropped: they describe the client's state, not the work. `system` is
118
+ * kept, because a compaction or an error is part of how the session went.
119
+ */
120
+ export function readSession(file, { limit = 2000 } = {}) {
121
+ const KEEP = new Set(['user', 'assistant', 'system']);
122
+ const turns = [];
123
+ for (const rec of readJsonl(file)) {
124
+ if (!KEEP.has(rec.type)) continue;
125
+ const tools = toolCalls(rec);
126
+ const text = messageText(rec);
127
+ if (!text && !tools.length && rec.type !== 'system') continue;
128
+ turns.push({
129
+ role: rec.type,
130
+ ts: rec.timestamp ?? null,
131
+ text,
132
+ tools: tools.map((t) => t.name),
133
+ // The whole input is not carried — a file write's content can be
134
+ // megabytes, and the panel wants to show what was called, not replay it.
135
+ tool_detail: tools.map((t) => ({ name: t.name, target: t.input?.file_path ?? t.input?.path ?? t.input?.command ?? null })),
136
+ subtype: rec.subtype ?? null,
137
+ });
138
+ if (turns.length >= limit) break;
139
+ }
140
+ return turns;
141
+ }
142
+
143
+ /**
144
+ * Files this session edited, newest first.
145
+ *
146
+ * Read from the Edit/Write tool calls rather than from git, so it shows what the
147
+ * agent touched even when nothing was committed — which is exactly the case
148
+ * where a reader is trying to work out what happened.
149
+ */
150
+ export function editedFiles(file) {
151
+ const EDIT = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit']);
152
+ const seen = new Map();
153
+ for (const rec of readJsonl(file)) {
154
+ if (rec.type !== 'assistant') continue;
155
+ for (const t of toolCalls(rec)) {
156
+ if (!EDIT.has(t.name)) continue;
157
+ const p = t.input?.file_path ?? t.input?.path;
158
+ if (!p) continue;
159
+ const prev = seen.get(p);
160
+ seen.set(p, { path: p, tool: t.name, edits: (prev?.edits ?? 0) + 1, last: rec.timestamp ?? prev?.last ?? null });
161
+ }
162
+ }
163
+ return [...seen.values()].sort((a, b) => String(b.last).localeCompare(String(a.last)));
164
+ }
165
+
166
+ /**
167
+ * Search a project's transcripts for a string.
168
+ *
169
+ * Plain substring, case-insensitive, capped. No index and no embedding: the
170
+ * question this answers is "where did I see that", and grep answers it at a
171
+ * scale of gigabytes without a database to keep in sync.
172
+ */
173
+ export function searchSessions(cwd, query, { root = projectsRoot(), limit = 50 } = {}) {
174
+ const q = String(query || '').trim().toLowerCase();
175
+ if (q.length < 3) return { query: q, hits: [], note: 'a query under 3 characters matches everything' };
176
+
177
+ const hits = [];
178
+ for (const s of listSessions(cwd, { root })) {
179
+ for (const rec of readJsonl(s.file)) {
180
+ if (hits.length >= limit) break;
181
+ const text = messageText(rec);
182
+ if (!text) continue;
183
+ const at = text.toLowerCase().indexOf(q);
184
+ if (at === -1) continue;
185
+ hits.push({
186
+ session: s.id,
187
+ title: s.title,
188
+ role: rec.type,
189
+ ts: rec.timestamp ?? null,
190
+ // A window around the match, not the whole turn — a turn can be a
191
+ // thousand lines and the reader wants to know whether to open it.
192
+ excerpt: text.slice(Math.max(0, at - 60), at + q.length + 120).replace(/\s+/g, ' ').trim(),
193
+ });
194
+ }
195
+ if (hits.length >= limit) break;
196
+ }
197
+ return { query: q, hits, truncated: hits.length >= limit };
198
+ }
@@ -2194,6 +2194,11 @@ button { font-family: inherit; cursor: pointer; }
2194
2194
  <span>Memory</span>
2195
2195
  <span class="count" id="nav-memory-count">0</span>
2196
2196
  </div>
2197
+ <div class="nav-item" data-tab="sessions" onclick="switchTab('sessions', this)">
2198
+ <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
2199
+ <span>Sessions</span>
2200
+ <span class="count" id="nav-sessions-count">0</span>
2201
+ </div>
2197
2202
  <div class="nav-item" data-tab="logs" onclick="switchTab('logs', this)">
2198
2203
  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>
2199
2204
  <span>Logs</span>
@@ -2464,6 +2469,19 @@ button { font-family: inherit; cursor: pointer; }
2464
2469
  </div>
2465
2470
 
2466
2471
  <!-- Memory -->
2472
+ <!-- Sessions: what the agent DID, as opposed to what it concluded.
2473
+ Every other panel reports an outcome. Three times this week a low eval
2474
+ score was read as an agent gap and turned out to be the harness, and
2475
+ each was found by reading the run rather than the number. -->
2476
+ <div class="panel" id="panel-sessions">
2477
+ <div class="panel-head">
2478
+ <h2>Sessions</h2>
2479
+ <input id="session-q" class="input" placeholder="search transcripts…" style="max-width:280px"
2480
+ onkeydown="if(event.key==='Enter')searchTranscripts()">
2481
+ </div>
2482
+ <div id="sessions-body" class="muted">Loading…</div>
2483
+ </div>
2484
+
2467
2485
  <div class="panel" id="panel-memory">
2468
2486
  <div class="memory-page">
2469
2487
  <div class="memory-side">
@@ -3284,6 +3302,104 @@ function mdToHtml(md) {
3284
3302
  });
3285
3303
  }
3286
3304
 
3305
+ // ── Sessions ────────────────────────────────────────────────────────────────
3306
+ //
3307
+ // Every other panel answers "what happened". This one answers "how", which is
3308
+ // the only thing that separates a weak agent from a broken harness — a
3309
+ // distinction this repo got wrong three times in one week by reading a score
3310
+ // without reading the run.
3311
+
3312
+ const fmtMB = (b) => b >= 1e6 ? (b / 1e6).toFixed(1) + ' MB' : Math.round(b / 1e3) + ' KB';
3313
+ const ago = (iso) => {
3314
+ const h = (Date.now() - new Date(iso)) / 36e5;
3315
+ if (!Number.isFinite(h)) return '';
3316
+ return h < 1 ? `${Math.round(h * 60)}m ago` : h < 48 ? `${Math.round(h)}h ago` : `${Math.round(h / 24)}d ago`;
3317
+ };
3318
+
3319
+ async function loadSessions() {
3320
+ const body = document.getElementById('sessions-body');
3321
+ const d = await api(`/api/sessions${pqs()}`);
3322
+ // Never leave the panel on "Loading…" — an endpoint that failed must say so,
3323
+ // because a spinner and an empty project look identical to the reader.
3324
+ if (!d) { body.innerHTML = '<div class="muted">Could not read sessions.</div>'; return; }
3325
+ const c = document.getElementById('nav-sessions-count'); if (c) c.textContent = d.count ?? 0;
3326
+ if (!d.sessions?.length) {
3327
+ body.innerHTML = '<div class="muted">No transcripts for this project yet.</div>';
3328
+ return;
3329
+ }
3330
+ body.innerHTML = d.sessions.map(s => `
3331
+ <div class="card" style="margin-bottom:8px">
3332
+ <div style="display:flex;justify-content:space-between;gap:12px;align-items:baseline">
3333
+ <strong>${esc(s.title || s.opened_with || s.id)}</strong>
3334
+ <span class="muted" style="white-space:nowrap">${ago(s.modified)} · ${fmtMB(s.size_bytes)}</span>
3335
+ </div>
3336
+ <div style="margin-top:6px">
3337
+ <button class="btn-sm" onclick="openSession('${esc(s.id)}')">transcript</button>
3338
+ <button class="btn-sm" onclick="openEdits('${esc(s.id)}')">edits</button>
3339
+ </div>
3340
+ <div id="sess-${esc(s.id)}" style="margin-top:8px"></div>
3341
+ </div>`).join('');
3342
+ }
3343
+
3344
+ async function openEdits(id) {
3345
+ const slot = document.getElementById(`sess-${id}`);
3346
+ slot.innerHTML = '<span class="muted">Loading…</span>';
3347
+ const d = await api(`/api/sessions/${encodeURIComponent(id)}/edits${pqs()}`);
3348
+ if (!d?.files) { slot.innerHTML = '<span class="muted">Could not read edits.</span>'; return; }
3349
+ if (!d.files.length) { slot.innerHTML = '<span class="muted">No files edited in this session.</span>'; return; }
3350
+ slot.innerHTML = `<table class="tbl"><tbody>${d.files.map(f => `
3351
+ <tr><td style="width:3em;text-align:right" class="muted">${f.edits}×</td>
3352
+ <td style="width:5em" class="muted">${esc(f.tool)}</td>
3353
+ <td><code>${esc(f.path)}</code></td></tr>`).join('')}</tbody></table>`;
3354
+ }
3355
+
3356
+ async function openSession(id) {
3357
+ const slot = document.getElementById(`sess-${id}`);
3358
+ slot.innerHTML = '<span class="muted">Loading…</span>';
3359
+ const d = await api(`/api/sessions/${encodeURIComponent(id)}${pqs()}`);
3360
+ if (!d?.turns) { slot.innerHTML = '<span class="muted">Could not read transcript.</span>'; return; }
3361
+ // A session runs to thousands of turns. Showing the tail is what a reader
3362
+ // wants — the question is almost always "what happened at the end".
3363
+ const turns = d.turns.slice(-60);
3364
+ slot.innerHTML =
3365
+ `<div class="muted" style="margin-bottom:6px">last ${turns.length} of ${d.turns.length} turns</div>` +
3366
+ `<div style="max-height:420px;overflow:auto;border-left:2px solid var(--border);padding-left:10px">` +
3367
+ turns.map(t => {
3368
+ const tools = t.tool_detail?.length
3369
+ ? `<div class="muted" style="font-size:.85em">${t.tool_detail.map(x =>
3370
+ `${esc(x.name)}${x.target ? ' → ' + esc(String(x.target).slice(0, 70)) : ''}`).join(' · ')}</div>`
3371
+ : '';
3372
+ const text = (t.text || '').slice(0, 400);
3373
+ return `<div style="margin-bottom:10px">
3374
+ <span class="muted" style="font-size:.8em">${esc(t.role)}${t.subtype ? '/' + esc(t.subtype) : ''} ${t.ts ? ago(t.ts) : ''}</span>
3375
+ ${text ? `<div>${esc(text)}${(t.text || '').length > 400 ? '…' : ''}</div>` : ''}
3376
+ ${tools}
3377
+ </div>`;
3378
+ }).join('') + '</div>';
3379
+ }
3380
+
3381
+ async function searchTranscripts() {
3382
+ const q = document.getElementById('session-q').value.trim();
3383
+ const body = document.getElementById('sessions-body');
3384
+ if (q.length < 3) { loadSessions(); return; }
3385
+ body.innerHTML = '<div class="muted">Searching…</div>';
3386
+ const d = await api(`/api/session-search${pqs()}&q=${encodeURIComponent(q)}`);
3387
+ if (!d) { body.innerHTML = '<div class="muted">Search failed.</div>'; return; }
3388
+ if (!d.hits?.length) {
3389
+ body.innerHTML = `<div class="muted">No match for “${esc(q)}”.${d.note ? ' ' + esc(d.note) : ''}
3390
+ <button class="btn-sm" onclick="loadSessions()">back</button></div>`;
3391
+ return;
3392
+ }
3393
+ body.innerHTML =
3394
+ `<div class="muted" style="margin-bottom:8px">${d.hits.length} match(es)${d.truncated ? ', capped' : ''}
3395
+ <button class="btn-sm" onclick="loadSessions()">back</button></div>` +
3396
+ d.hits.map(h => `
3397
+ <div class="card" style="margin-bottom:6px">
3398
+ <div class="muted" style="font-size:.8em">${esc(h.title || h.session)} · ${esc(h.role)} · ${h.ts ? ago(h.ts) : ''}</div>
3399
+ <div>${esc(h.excerpt)}</div>
3400
+ </div>`).join('');
3401
+ }
3402
+
3287
3403
  async function loadMemory() {
3288
3404
  const d = await api(`/api/memory${pqs()}`);
3289
3405
  memoryData = d || { layers: [], patterns: [] };
@@ -4592,7 +4708,7 @@ function switchTab(id, el) {
4592
4708
  // crumb — `labels` used to be referenced here but is only ever a function-local in
4593
4709
  // this file, so this threw ReferenceError on every tab switch and skipped the per-tab
4594
4710
  // loaders below (e.g. logs stuck on "Loading…"). Use a local label map.
4595
- const TAB_LABELS = { dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', memory: 'Memory', logs: 'Logs', notifications: 'Notifications', metrics: 'Metrics' };
4711
+ const TAB_LABELS = { dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', memory: 'Memory', sessions: 'Sessions', logs: 'Logs', notifications: 'Notifications', metrics: 'Metrics' };
4596
4712
  const crumb = document.getElementById('crumb-here'); if (crumb) crumb.textContent = TAB_LABELS[id] || 'Board';
4597
4713
  if (id === 'dashboard' || id === 'agents') {
4598
4714
  api(`/api/metrics${pqs()}`).then(m => { if (m) { metrics = m; renderDashboard(m); } });
@@ -4601,6 +4717,7 @@ function switchTab(id, el) {
4601
4717
  }
4602
4718
  if (id === 'inbox') refreshInbox();
4603
4719
  if (id === 'memory') loadMemory();
4720
+ if (id === 'sessions') loadSessions();
4604
4721
  if (id === 'logs') refreshLogs();
4605
4722
  if (id === 'notifications') loadNotifications();
4606
4723
  }
package/dist/main.js CHANGED
@@ -1196,6 +1196,23 @@ async function runSelfUpgrade() {
1196
1196
  return result.exitCode;
1197
1197
  }
1198
1198
  success(result.message);
1199
+ // The CLI is upgraded; the plugin Claude Code actually loads may not be.
1200
+ // `npm i -g` never touches the plugin cache, so without this the user is told
1201
+ // the upgrade succeeded and keeps running the previous release's hooks and
1202
+ // agent prompts with nothing anywhere saying so.
1203
+ try {
1204
+ const { pluginCacheLagWarning } = await import("./self-upgrade.js");
1205
+ const { readdirSync } = await import("node:fs");
1206
+ const { join } = await import("node:path");
1207
+ const cacheRoot = join(homedir(), ".claude", "plugins", "cache", "local", "great_cto");
1208
+ const dirs = readdirSync(cacheRoot);
1209
+ const warning = pluginCacheLagWarning(result.newVersion ?? "", dirs);
1210
+ if (warning) {
1211
+ log("");
1212
+ log(dim(warning));
1213
+ }
1214
+ }
1215
+ catch { /* no plugin cache is not a lag — the plugin is simply not installed */ }
1199
1216
  return 0;
1200
1217
  }
1201
1218
  async function runUpgrade(rawArgv, args) {
@@ -1242,6 +1259,10 @@ async function main() {
1242
1259
  // is the single exit funnel: it fires one fire-and-forget event (command + node + os
1243
1260
  // + exit_code + duration, no PII) only when the user has opted in, then exits.
1244
1261
  const __tStart = Date.now();
1262
+ // Promise<never>, not Promise<void>: this always exits, and typing it as such
1263
+ // is what lets `await finish(2)` narrow a nullable the way `process.exit(2)`
1264
+ // did. Without it, routing an exit through the update check would silently
1265
+ // widen types at every call site.
1245
1266
  const finish = async (code) => {
1246
1267
  try {
1247
1268
  await sendUsagePing({
@@ -1270,7 +1291,7 @@ async function main() {
1270
1291
  if (args.command === "telemetry") {
1271
1292
  const { exitCode, output } = telemetrySubcommand(args.positional[0]);
1272
1293
  process.stdout.write(output);
1273
- process.exit(exitCode);
1294
+ await finish(exitCode);
1274
1295
  }
1275
1296
  if (args.command === "help") {
1276
1297
  printHelp();
@@ -1281,7 +1302,7 @@ async function main() {
1281
1302
  error(`great-cto: unknown command or flag '${tok}'`);
1282
1303
  log("");
1283
1304
  log(`Run ${cyan("great-cto --help")} for usage.`);
1284
- process.exit(2);
1305
+ await finish(2);
1285
1306
  }
1286
1307
  if (args.command === "board") {
1287
1308
  try {
@@ -1304,7 +1325,7 @@ async function main() {
1304
1325
  }
1305
1326
  catch (e) {
1306
1327
  error(e.message);
1307
- process.exit(1);
1328
+ await finish(1);
1308
1329
  }
1309
1330
  }
1310
1331
  if (args.command === "console") {
@@ -1314,7 +1335,7 @@ async function main() {
1314
1335
  }
1315
1336
  catch (e) {
1316
1337
  error(e.message);
1317
- process.exit(1);
1338
+ await finish(1);
1318
1339
  }
1319
1340
  }
1320
1341
  if (args.command === "register") {
@@ -1324,7 +1345,7 @@ async function main() {
1324
1345
  }
1325
1346
  catch (e) {
1326
1347
  error(e.message);
1327
- process.exit(1);
1348
+ await finish(1);
1328
1349
  }
1329
1350
  }
1330
1351
  if (args.command === "ci") {
@@ -1335,7 +1356,7 @@ async function main() {
1335
1356
  }
1336
1357
  catch (e) {
1337
1358
  error(e.message);
1338
- process.exit(2);
1359
+ await finish(2);
1339
1360
  }
1340
1361
  }
1341
1362
  if (args.command === "mcp") {
@@ -1349,7 +1370,7 @@ async function main() {
1349
1370
  }
1350
1371
  catch (e) {
1351
1372
  error(e.message);
1352
- process.exit(2);
1373
+ await finish(2);
1353
1374
  }
1354
1375
  }
1355
1376
  if (args.command === "adapt") {
@@ -1364,16 +1385,16 @@ async function main() {
1364
1385
  }
1365
1386
  catch (e) {
1366
1387
  error(e.message);
1367
- process.exit(2);
1388
+ await finish(2);
1368
1389
  }
1369
1390
  }
1370
1391
  if (args.command === "task") {
1371
1392
  const { runTask } = await import("./worker.js");
1372
- process.exit(await runTask(args.taskArgs ?? []));
1393
+ await finish(await runTask(args.taskArgs ?? []));
1373
1394
  }
1374
1395
  if (args.command === "worker") {
1375
1396
  const { runWorker } = await import("./worker.js");
1376
- process.exit(await runWorker(args.taskArgs ?? []));
1397
+ await finish(await runWorker(args.taskArgs ?? []));
1377
1398
  }
1378
1399
  if (args.command === "serve") {
1379
1400
  try {
@@ -1390,7 +1411,7 @@ async function main() {
1390
1411
  }
1391
1412
  catch (e) {
1392
1413
  error(e.message);
1393
- process.exit(2);
1414
+ await finish(2);
1394
1415
  }
1395
1416
  }
1396
1417
  if (args.command === "webhook") {
@@ -1399,14 +1420,14 @@ async function main() {
1399
1420
  const parsed = parseWebhookArgs(rawArgv);
1400
1421
  if (!parsed) {
1401
1422
  error("usage: great-cto webhook list | add-incoming <name> --secret <s> | add-outgoing <name> --url <u> --format <f> --triggers <t1,t2> | remove <name> | test <name>");
1402
- process.exit(2);
1423
+ return finish(2);
1403
1424
  }
1404
1425
  const code = await runWebhookCli(parsed);
1405
1426
  await finish(code);
1406
1427
  }
1407
1428
  catch (e) {
1408
1429
  error(e.message);
1409
- process.exit(2);
1430
+ await finish(2);
1410
1431
  }
1411
1432
  }
1412
1433
  if (args.command === "upgrade") {
@@ -1416,7 +1437,7 @@ async function main() {
1416
1437
  }
1417
1438
  catch (e) {
1418
1439
  error(e.message);
1419
- process.exit(2);
1440
+ await finish(2);
1420
1441
  }
1421
1442
  }
1422
1443
  if (args.command === "chat-only-hint") {
@@ -1433,21 +1454,21 @@ async function main() {
1433
1454
  log(` ${cyan("serve")} · ${cyan("webhook")} · ${cyan("report")} · ${cyan("board")} · ${cyan("register")} · ${cyan("upgrade")}`);
1434
1455
  log("");
1435
1456
  log(`Run ${cyan("npx great-cto --help")} for the full CLI reference.`);
1436
- process.exit(2);
1457
+ await finish(2);
1437
1458
  }
1438
1459
  if (args.command === "report") {
1439
1460
  try {
1440
1461
  const { runReport, parseReportArgs } = await import("./report.js");
1441
1462
  const parsed = parseReportArgs(rawArgv, args.dir);
1442
1463
  if (!parsed) {
1443
- process.exit(2);
1464
+ return finish(2);
1444
1465
  }
1445
1466
  const code = await runReport(parsed);
1446
1467
  await finish(code);
1447
1468
  }
1448
1469
  catch (e) {
1449
1470
  error(e.message);
1450
- process.exit(2);
1471
+ await finish(2);
1451
1472
  }
1452
1473
  }
1453
1474
  if (args.command === "version") {
@@ -1464,7 +1485,7 @@ async function main() {
1464
1485
  const pkg = JSON.parse(readFileSync(p, "utf-8"));
1465
1486
  if (pkg.name === "great-cto" && pkg.version) {
1466
1487
  log(pkg.version);
1467
- process.exit(0);
1488
+ await finish(0);
1468
1489
  }
1469
1490
  }
1470
1491
  catch { /* keep searching */ }
@@ -1474,7 +1495,7 @@ async function main() {
1474
1495
  catch {
1475
1496
  log("0.0.0");
1476
1497
  }
1477
- process.exit(0);
1498
+ await finish(0);
1478
1499
  }
1479
1500
  try {
1480
1501
  const code = await runInit(args);
@@ -154,3 +154,57 @@ export function performSelfUpgrade(opts) {
154
154
  }
155
155
  // Re-export for callers that only need the prefix-derivation logic directly.
156
156
  export { derivePrefixFromBinPath as _derivePrefixFromBinPath };
157
+ /**
158
+ * Newest version present in the Claude Code plugin cache, or null.
159
+ *
160
+ * Takes the directory names so the rule is testable without a filesystem.
161
+ */
162
+ export function newestCachedPlugin(dirNames) {
163
+ const versions = dirNames.filter((d) => /^\d+\.\d+\.\d+$/.test(d));
164
+ if (!versions.length)
165
+ return null;
166
+ const key = (v) => v.split(".").map(Number);
167
+ return versions.sort((a, b) => {
168
+ const [A, B] = [key(a), key(b)];
169
+ for (let i = 0; i < 3; i++)
170
+ if (A[i] !== B[i])
171
+ return A[i] - B[i];
172
+ return 0;
173
+ })[versions.length - 1];
174
+ }
175
+ /**
176
+ * Warn when the npm package has moved ahead of the plugin Claude Code loads.
177
+ *
178
+ * `npm i -g` replaces the CLI. It does not touch
179
+ * ~/.claude/plugins/cache/local/great_cto/<version>/, which is where the hooks
180
+ * and agent prompts are actually read from — so a user can take the update
181
+ * prompt, be told the upgrade succeeded, and keep running the previous release's
182
+ * agents with nothing anywhere saying so. That is the same defect this codebase
183
+ * keeps finding: a step reported as done while the thing it was meant to change
184
+ * stayed as it was.
185
+ *
186
+ * Returns null when they match, when nothing is cached (the plugin is simply not
187
+ * installed), or when the cache is somehow ahead — none of those are the failure
188
+ * this exists to catch.
189
+ */
190
+ export function pluginCacheLagWarning(npmVersion, cachedDirs) {
191
+ const cached = newestCachedPlugin(cachedDirs);
192
+ if (!cached || !npmVersion)
193
+ return null;
194
+ if (cached === npmVersion)
195
+ return null;
196
+ const key = (v) => v.split(".").map(Number);
197
+ const [c, n] = [key(cached), key(npmVersion)];
198
+ for (let i = 0; i < 3; i++) {
199
+ if (c[i] > n[i])
200
+ return null; // cache ahead — not the case we guard
201
+ if (c[i] < n[i])
202
+ break;
203
+ }
204
+ return [
205
+ `note: the npm package is ${npmVersion}, but the plugin Claude Code loads is ${cached}.`,
206
+ " Hooks and agent prompts come from the plugin cache, not from npm — until it is",
207
+ " refreshed, this session keeps running the previous release's agents.",
208
+ " Refresh it with: bash scripts/install-local.sh --prune",
209
+ ].join("\n");
210
+ }
@@ -58,6 +58,29 @@ export function cachePath() {
58
58
  }
59
59
  /** Pure function: is a suppression condition active? Fail-open to "suppressed" on any ambiguity. */
60
60
  export function isSuppressed(opts = {}) {
61
+ if (isRefreshSuppressed(opts))
62
+ return true;
63
+ // Nothing may be printed when stderr is not a terminal — the output is being
64
+ // read by something. This is about PRINTING only; see isRefreshSuppressed.
65
+ if (opts.stderrIsTTY === false)
66
+ return true;
67
+ return false;
68
+ }
69
+ /**
70
+ * Should the background cache refresh be suppressed too?
71
+ *
72
+ * Deliberately narrower than isSuppressed. Not printing and not learning are
73
+ * different things, and conflating them had a consequence: the refresh sits
74
+ * behind the same non-TTY check as the hint, so a user whose runs are mostly
75
+ * non-interactive never warmed the cache — and their occasional interactive run
76
+ * therefore had no cache to read and showed nothing, every time. The cache file
77
+ * was simply absent on this machine, which is how this was found.
78
+ *
79
+ * A detached refresh with stdio ignored pollutes no output. What genuinely must
80
+ * not spawn: the protocol commands, whose children would outlive a parsed
81
+ * stream, and anyone who asked us not to check at all.
82
+ */
83
+ export function isRefreshSuppressed(opts = {}) {
61
84
  const env = opts.env ?? process.env;
62
85
  if (env.CI != null && env.CI !== "" && env.CI !== "0" && env.CI !== "false")
63
86
  return true;
@@ -65,8 +88,6 @@ export function isSuppressed(opts = {}) {
65
88
  return true;
66
89
  if (opts.command && PROTOCOL_SENSITIVE_COMMANDS.has(opts.command))
67
90
  return true;
68
- if (opts.stderrIsTTY === false)
69
- return true;
70
91
  return false;
71
92
  }
72
93
  /**
@@ -213,10 +234,15 @@ export async function checkForUpdate(opts) {
213
234
  const env = opts.env ?? process.env;
214
235
  const stderrIsTTY = opts.stderrIsTTY ?? Boolean(process.stderr.isTTY);
215
236
  const stdinIsTTY = opts.stdinIsTTY ?? Boolean(process.stdin.isTTY);
216
- if (isSuppressed({ env, command: opts.command, stderrIsTTY }))
237
+ // Warm the cache even when nothing may be printed. A run that cannot show a
238
+ // hint can still learn the version for the run that can.
239
+ if (isRefreshSuppressed({ env, command: opts.command }))
217
240
  return;
241
+ const quiet = isSuppressed({ env, command: opts.command, stderrIsTTY });
218
242
  const cache = readCache();
219
243
  if (isCacheFresh(cache, opts.now)) {
244
+ if (quiet)
245
+ return; // fresh cache, but this run may not speak
220
246
  if (!cache || !isNewerVersion(opts.currentVersion, cache.latest))
221
247
  return;
222
248
  if (shouldPrompt({ currentVersion: opts.currentVersion, cache, env, command: opts.command, stderrIsTTY, stdinIsTTY })) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "2.91.0",
3
+ "version": "2.93.0",
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",