great-cto 2.92.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.92.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
  }
@@ -17,7 +17,15 @@ 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();
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "2.92.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",