great-cto 3.59.1 → 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "great-cto",
3
3
  "description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 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.59.1",
4
+ "version": "3.61.0",
5
5
  "author": {
6
6
  "name": "Alexander Velikiy",
7
7
  "url": "https://hashnode.com/@Greatcto"
@@ -436,7 +436,8 @@ function resolveProjectInfo(slugOrPath) {
436
436
  // choose between candidates.
437
437
  const byDerived = reg.projects.filter((p) => {
438
438
  if (!p?.path) return false;
439
- return path.basename(p.path) === slugOrPath;
439
+ try { return path.basename(p.path) === slugOrPath || readProjectMd(p.path)?.slug === slugOrPath; }
440
+ catch { return false; }
440
441
  });
441
442
  const derived = pickBestBySlug(byDerived);
442
443
  if (derived) return { cwd: derived.path, resolved: 'slug' };
@@ -67,6 +67,7 @@ import { getAgentsFleet, getAgentProfile, retireAgent, restoreAgent, appendDecis
67
67
  import { getResume, getShareState, toggleShare } from './share.mjs';
68
68
  import { listSessions, readSession, editedFiles, searchSessions } from './transcripts.mjs';
69
69
  import { recordView, summarizeViews } from './view-counter.mjs';
70
+ import { getSkillsInventory } from './skills-inventory.mjs';
70
71
 
71
72
  // ── HTTP router ────────────────────────────────────────────────────────────────
72
73
  // dispatch(req, res, url, cwd, projInfo) handles every /api/* route plus /api/sse.
@@ -91,6 +92,7 @@ function verdictHeaders(cwd, base = { 'Content-Type': 'application/json', 'Cache
91
92
 
92
93
  async function dispatch(req, res, url, cwd) {
93
94
  const pathname = url.pathname;
95
+ const serverCwd = cwd;
94
96
 
95
97
  // The selected project, resolved ONCE for every route below.
96
98
  //
@@ -117,6 +119,11 @@ async function dispatch(req, res, url, cwd) {
117
119
  }
118
120
  }
119
121
 
122
+ // These read models accept registered projects or the server's own root,
123
+ // never an arbitrary HOME directory supplied as a query parameter.
124
+ const scopedProject = ['/api/skills', '/api/usage', '/api/outcomes'].includes(pathname)
125
+ ? scopedReadProject(cwd, serverCwd, requestedProject) : null;
126
+
120
127
  if (pathname === '/api/work/publication') {
121
128
  const respond = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); };
122
129
  if (requestedProject && resolveProjectInfo(requestedProject).resolved === 'fallback') { respond(404, { error: 'Unknown project' }); return true; }
@@ -171,6 +178,30 @@ async function dispatch(req, res, url, cwd) {
171
178
  return true;
172
179
  }
173
180
 
181
+ // Inventory is a read-only local observation. Query parameters cannot choose
182
+ // filesystem roots, install skills or ask for network/upstream checks.
183
+ if (pathname === '/api/skills') {
184
+ const headers = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' };
185
+ if (req.method !== 'GET') {
186
+ res.writeHead(405, { ...headers, Allow: 'GET' });
187
+ res.end(JSON.stringify({ error: 'Skill inventory is read-only' }));
188
+ return true;
189
+ }
190
+ if (!scopedProject) {
191
+ res.writeHead(404, headers);
192
+ res.end(JSON.stringify({ error: 'Unknown project; skill inventory was not read' }));
193
+ return true;
194
+ }
195
+ try {
196
+ const snapshot = await getSkillsInventory(cwd);
197
+ res.writeHead(200, headers); res.end(JSON.stringify(snapshot));
198
+ } catch {
199
+ res.writeHead(503, headers);
200
+ res.end(JSON.stringify({ error: 'Skill inventory could not be read' }));
201
+ }
202
+ return true;
203
+ }
204
+
174
205
  // New read model refuses project fallback rather than relabeling another project.
175
206
  if (pathname === '/api/work') {
176
207
  if (requestedProject && resolveProjectInfo(requestedProject).resolved === 'fallback') {
@@ -1570,15 +1601,19 @@ async function dispatch(req, res, url, cwd) {
1570
1601
  return true;
1571
1602
  }
1572
1603
 
1573
- // What Claude Code and Codex consumed on this machine — tokens, models, the
1604
+ // What Claude Code and Codex consumed in the selected project — tokens, models, the
1574
1605
  // heaviest conversations, tools, skills, agents, cache, Codex's plan window —
1575
1606
  // read from the hosts' own session logs (scripts/lib/session-usage.mjs).
1576
- // Machine-wide like /api/agent-usage: the logs are one history. Served on the
1607
+ // Filter before aggregation, including linked worktrees. Served on the
1577
1608
  // board's own host only; nothing here is sent anywhere.
1578
1609
  if (pathname === '/api/usage') {
1610
+ if (!scopedProject) {
1611
+ res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1612
+ res.end(JSON.stringify({ error: 'Unknown project', state: 'unavailable' })); return true;
1613
+ }
1579
1614
  const rawDays = parseInt(url.searchParams.get('days') || '30', 10);
1580
1615
  const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 30;
1581
- const snap = boardSessionUsage().get(days);
1616
+ const snap = boardSessionUsage().get(days, cwd);
1582
1617
  let body;
1583
1618
  if (snap.state !== 'counted') {
1584
1619
  body = { state: snap.state, why: snap.why };
@@ -1603,15 +1638,19 @@ async function dispatch(req, res, url, cwd) {
1603
1638
  return true;
1604
1639
  }
1605
1640
 
1606
- // What the agents concluded and what reviews found, across every registered
1607
- // project (scripts/lib/outcomes.mjs): verdicts per agent, Beads bugs by
1641
+ // What the agents concluded and what reviews found in this project
1642
+ // (scripts/lib/outcomes.mjs): verdicts per agent, Beads bugs by
1608
1643
  // priority. Counts only — no bug title leaves the server. Answers at once:
1609
- // `computing` until the first read of every project's Beads lands.
1644
+ // `computing` until the first read of this project's Beads lands.
1610
1645
  if (pathname === '/api/outcomes') {
1646
+ if (!scopedProject) {
1647
+ res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1648
+ res.end(JSON.stringify({ error: 'Unknown project', state: 'unavailable' })); return true;
1649
+ }
1611
1650
  const rawDays = parseInt(url.searchParams.get('days') || '30', 10);
1612
1651
  const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 30;
1613
1652
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1614
- res.end(JSON.stringify(boardOutcomes(days)));
1653
+ res.end(JSON.stringify(boardOutcomes(days, scopedProject)));
1615
1654
  return true;
1616
1655
  }
1617
1656
 
@@ -2304,16 +2343,45 @@ function boardHookNames() {
2304
2343
 
2305
2344
  const _outcomes = new Map();
2306
2345
  const OUTCOMES_TTL = 5 * 60 * 1000;
2307
- function boardOutcomes(days) {
2308
- const slot = _outcomes.get(days) || {};
2346
+ function scopedReadProject(cwd, serverCwd, requested) {
2347
+ const canonical = dir => { try { return fs.realpathSync(dir); } catch { return path.resolve(dir); } };
2348
+ const projectPath = canonical(cwd);
2349
+ const entries = (readProjectsRegistry().projects || []).filter(p => p?.path);
2350
+ const own = entries.filter(p => canonical(p.path) === projectPath);
2351
+ if (requested && (resolveProjectInfo(requested).resolved === 'fallback'
2352
+ || (!own.length && projectPath !== canonical(serverCwd)))) return null;
2353
+ const labels = p => {
2354
+ const result = [p.slug, p.name, p.path, path.basename(p.path)];
2355
+ let fd;
2356
+ try {
2357
+ fd = fs.openSync(path.join(p.path, '.great_cto', 'PROJECT.md'), 'r');
2358
+ const bytes = Buffer.alloc(65536);
2359
+ const text = bytes.subarray(0, fs.readSync(fd, bytes, 0, bytes.length, 0)).toString('utf8');
2360
+ for (const key of ['slug', 'project', 'name']) result.push(text.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1]?.trim());
2361
+ } catch { /* aliases absent; canonical path still identifies the root */ }
2362
+ finally { if (fd !== undefined) fs.closeSync(fd); }
2363
+ return result.filter(Boolean);
2364
+ };
2365
+ const foreign = new Set(entries.filter(p => canonical(p.path) !== projectPath).flatMap(labels));
2366
+ const aliases = [...new Set([...own, { path: projectPath }].flatMap(labels))].filter(label => !foreign.has(label)).sort();
2367
+ return { name: own[0]?.slug || own[0]?.name || path.basename(projectPath), path: projectPath, aliases };
2368
+ }
2369
+ function boardOutcomes(days, project) {
2370
+ const projectPath = project.path;
2371
+ const key = JSON.stringify([days, projectPath, project.aliases]);
2372
+ const slot = _outcomes.get(key) || {};
2309
2373
  const fresh = slot.value && Date.now() - slot.at < OUTCOMES_TTL;
2310
2374
  if (!fresh && !slot.running) {
2311
- slot.running = computeOutcomes({ days, roster: boardAgentNames() })
2312
- .then((v) => { slot.value = v; }, (e) => { slot.value = slot.value || { state: 'unavailable', why: `outcomes could not be read: ${e?.message || e}` }; })
2375
+ slot.running = computeOutcomes({ days, roster: boardAgentNames(), projects: [project], projectScope: true })
2376
+ .then((v) => { slot.value = { ...v, scope: { kind: 'project', path: projectPath } }; }, () => { slot.value = slot.value || { state: 'unavailable', why: 'Outcomes could not be read.' }; })
2313
2377
  .finally(() => { slot.at = Date.now(); slot.running = null; });
2314
- _outcomes.set(days, slot);
2378
+ if (_outcomes.size >= 32 && !_outcomes.has(key)) {
2379
+ const evict = [..._outcomes].find(([, s]) => !s.running);
2380
+ if (evict) _outcomes.delete(evict[0]);
2381
+ }
2382
+ _outcomes.set(key, slot);
2315
2383
  }
2316
- return slot.value || { state: 'computing', why: 'reading every project\'s verdicts and Beads' };
2384
+ return slot.value || { state: 'computing', why: 'reading this project\'s verdicts and Beads' };
2317
2385
  }
2318
2386
 
2319
2387
  let _sessionUsageSnap = null;
@@ -0,0 +1,228 @@
1
+ import fs from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { createHash } from 'node:crypto';
6
+ import { parseFrontmatter, strictYamlIssues, SIZE_BUDGET, DESC_MIN, DESC_MAX } from '../../../scripts/skill-lint.mjs';
7
+
8
+ // Discovery is not host activation, trust verification or a quality evaluation.
9
+ // Never execute a discovered script or fetch its upstream. No caller-supplied
10
+ // roots: only HOME, the server-resolved project, and fixed local registry tiers.
11
+ export const INVENTORY_LIMITS = Object.freeze({
12
+ entries: 12000, skills: 2000, depth: 10, fileBytes: 128 * 1024,
13
+ totalBytes: 8 * 1024 * 1024, registryBytes: 2 * 1024 * 1024, milliseconds: 1500,
14
+ cacheEntries: 2500, cacheSkills: 300, cacheMilliseconds: 450,
15
+ });
16
+ const inside = (root, file) => file === root || file.startsWith(root + path.sep);
17
+ const digest = bytes => createHash('sha256').update(bytes).digest('hex');
18
+ const safeDeclaration = value => typeof value === 'string' && !/\/(?:Users|home)\//.test(value)
19
+ ? value.slice(0, 160) : null;
20
+
21
+ async function readBounded(file, max, root) {
22
+ let handle;
23
+ try {
24
+ if (!inside(root, await fs.realpath(file))) return { state: 'outside-root' };
25
+ handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
26
+ const before = await handle.stat();
27
+ if (!before.isFile()) return { state: 'unreadable' };
28
+ if (before.size > max) return { state: 'too-large' };
29
+ const bytes = Buffer.alloc(max + 1);
30
+ let size = 0;
31
+ while (size <= max) {
32
+ const read = await handle.read(bytes, size, bytes.length - size, null);
33
+ if (!read.bytesRead) break;
34
+ size += read.bytesRead;
35
+ }
36
+ const after = await handle.stat();
37
+ if (size > max) return { state: 'too-large' };
38
+ if (before.size !== size || before.size !== after.size || before.mtimeMs !== after.mtimeMs
39
+ || before.ctimeMs !== after.ctimeMs) return { state: 'changed-during-read' };
40
+ return { state: 'observed', bytes: bytes.subarray(0, size) };
41
+ } catch (e) {
42
+ return { state: e.code === 'ENOENT' ? 'missing' : 'unreadable' };
43
+ } finally { await handle?.close(); }
44
+ }
45
+
46
+ export async function skillInventory({ home = os.homedir(), cwd, limits = {} } = {}) {
47
+ const started = Date.now();
48
+ // macOS /tmp is a system alias for /private/tmp, not a skill symlink.
49
+ // Normalize only the trusted base once, rejecting symlinks below that base.
50
+ const originalHome = path.resolve(home), originalCwd = cwd && path.resolve(cwd);
51
+ home = await fs.realpath(home);
52
+ if (cwd) cwd = await fs.realpath(cwd);
53
+ const cap = Object.fromEntries(Object.entries(INVENTORY_LIMITS).map(([key, value]) =>
54
+ [key, Number.isInteger(limits[key]) && limits[key] > 0 ? Math.min(value, limits[key]) : value]));
55
+ const roots = [
56
+ ['.claude/skills', 'Claude Code'], ['.codex/skills', 'Codex'], ['.agents/skills', 'Shared'],
57
+ ['.claude/plugins/cache', 'Claude Code'], ['.codex/plugins/cache', 'Codex'],
58
+ ].map(([rel, host]) => ({ root: path.join(home, rel), label: '~/' + rel, host, scope: 'machine', cache: rel.endsWith('/cache') }));
59
+ if (cwd) for (const [rel, host] of [['.claude/skills', 'Claude Code'], ['.codex/skills', 'Codex'], ['.agents/skills', 'Shared']])
60
+ roots.push({ root: path.join(cwd, rel), label: 'project/' + rel, host, scope: 'project' });
61
+ // A large plugin's node_modules must not starve selected-project roots.
62
+ roots.sort((a, b) => Number(Boolean(a.cache)) - Number(Boolean(b.cache)));
63
+ const registryRoots = ['catalog', 'anthropic-skills', 'personal-skills'].map(rel => ({
64
+ root: path.join(home, '.great_cto', rel), label: '~/.great_cto/' + rel, host: 'Registry', scope: 'machine',
65
+ }));
66
+ const skills = [], sources = [], byFile = new Map();
67
+ let entries = 0, readBytes = 0, truncated = false;
68
+ const exhausted = () => {
69
+ if (entries >= cap.entries || skills.length >= cap.skills || readBytes >= cap.totalBytes || Date.now() - started > cap.milliseconds) {
70
+ truncated = true; return true;
71
+ }
72
+ return false;
73
+ };
74
+ async function add(file, source, declaredSource = null) {
75
+ if (byFile.has(file)) {
76
+ if (declaredSource) byFile.get(file).declared_source = safeDeclaration(declaredSource);
77
+ return;
78
+ }
79
+ const read = await readBounded(file, Math.min(cap.fileBytes, cap.totalBytes - readBytes), source.root);
80
+ readBytes += read.bytes?.length || 0;
81
+ const location = source.label + '/' + path.relative(source.root, file).split(path.sep).join('/');
82
+ const name = path.basename(file) === 'SKILL.md' ? path.basename(path.dirname(file)) : path.basename(file, '.md');
83
+ const warnings = [];
84
+ if (read.state === 'observed' && path.basename(file) === 'SKILL.md') {
85
+ const text = read.bytes.toString('utf8');
86
+ try {
87
+ const fm = parseFrontmatter(text);
88
+ if (!fm) warnings.push('missing-frontmatter');
89
+ else {
90
+ if (fm.data.name !== name) warnings.push('name-mismatch');
91
+ const d = fm.data.description;
92
+ if (typeof d !== 'string' || d.length < DESC_MIN || d.length > DESC_MAX) warnings.push('description-length');
93
+ }
94
+ } catch { warnings.push('invalid-frontmatter'); }
95
+ if (strictYamlIssues(text).length) warnings.push('ambiguous-yaml');
96
+ if (read.bytes.length > SIZE_BUDGET) warnings.push('over-context-budget');
97
+ }
98
+ if (read.state !== 'observed') source.state = 'partial';
99
+ const segments = path.relative(source.root, file).split(path.sep);
100
+ const row = { id: digest(source.scope + ':' + location).slice(0, 24), name, host: source.host, scope: source.scope,
101
+ location, declared_source: safeDeclaration(declaredSource),
102
+ cache_version: source.cache && segments.length > 3 ? safeDeclaration(segments[2]) : null,
103
+ read_state: read.state, bytes: read.bytes?.length ?? null,
104
+ document_sha256: read.bytes ? digest(read.bytes) : null, integrity: 'document-only',
105
+ upstream_revision: null, enabled: 'unknown', loaded: 'unknown', quality: 'not-measured', warnings };
106
+ skills.push(row); byFile.set(file, row);
107
+ }
108
+ async function scanSource(descriptor) {
109
+ const source = { ...descriptor, state: 'observed', warnings: [] };
110
+ sources.push(source);
111
+ if (exhausted()) { source.state = 'not-scanned'; return; }
112
+ const startEntries = entries, startSkills = skills.length, startTime = Date.now();
113
+ const sourceExhausted = () => {
114
+ if (exhausted()) return true;
115
+ if (source.cache && (entries - startEntries >= cap.cacheEntries
116
+ || skills.length - startSkills >= cap.cacheSkills || Date.now() - startTime > cap.cacheMilliseconds)) {
117
+ source.state = 'partial';
118
+ if (!source.warnings.includes('source-limit')) source.warnings.push('source-limit');
119
+ truncated = true; return true;
120
+ }
121
+ return false;
122
+ };
123
+ // Reject symlinked ancestors too; directory traversal is fixed, not an
124
+ // arbitrary-file capability. Not an atomic sandbox against a hostile same-UID process.
125
+ try {
126
+ if (await fs.realpath(source.root) !== path.resolve(source.root)) {
127
+ source.state = 'partial'; source.warnings.push('symlink-skipped'); return;
128
+ }
129
+ } catch (e) { source.state = e.code === 'ENOENT' ? 'absent' : 'unreadable'; return; }
130
+ const queue = [[source.root, 0]];
131
+ while (queue.length && !sourceExhausted()) {
132
+ const [dir, depth] = queue.shift();
133
+ let handle;
134
+ try {
135
+ if (await fs.realpath(dir) !== dir) { source.state = 'partial'; source.warnings.push('symlink-skipped'); continue; }
136
+ handle = await fs.opendir(dir);
137
+ for await (const entry of handle) {
138
+ ++entries;
139
+ if (sourceExhausted()) break;
140
+ const file = path.join(dir, entry.name);
141
+ if (entry.isSymbolicLink()) {
142
+ source.state = 'partial';
143
+ if (!source.warnings.includes('symlink-skipped')) source.warnings.push('symlink-skipped');
144
+ } else if (entry.isDirectory()) {
145
+ // Host caches are vendor/product/version/{skills,.codex-plugin}.
146
+ // Beyond version roots, traverse only skill-bearing directories,
147
+ // not package sources, git trees, assets or dependency installations.
148
+ const segments = path.relative(source.root, dir).split(path.sep);
149
+ const skillArea = segments.some(s => s === 'skills' || s === 'migrated-command-skills');
150
+ if (source.cache && depth >= 3 && !skillArea
151
+ && !['skills', '.codex-plugin', 'migrated-command-skills'].includes(entry.name)) continue;
152
+ if (['.git', 'node_modules'].includes(entry.name)) continue;
153
+ if (depth < cap.depth) queue.push([file, depth + 1]);
154
+ else { source.state = 'partial'; source.warnings.push('depth-limit'); truncated = true; }
155
+ } else if (entry.isFile() && entry.name === 'SKILL.md'
156
+ && (!source.cache || path.relative(source.root, file).split(path.sep).some(s => s === 'skills' || s === 'migrated-command-skills'))) {
157
+ await add(file, source);
158
+ }
159
+ }
160
+ } catch { source.state = 'unreadable'; }
161
+ // for-await owns the directory handle, including early break.
162
+ }
163
+ if (queue.length) source.state = 'partial';
164
+ }
165
+ // Local project/host directories and the existing registry have priority over
166
+ // large historical plugin caches. Each cache also gets its own bounded share.
167
+ for (const root of roots.filter(r => !r.cache)) await scanSource(root);
168
+ const registryFile = path.join(home, '.great_cto', 'skills-registry.json');
169
+ const registryRead = await readBounded(registryFile, cap.registryBytes, path.join(home, '.great_cto'));
170
+ const registry = { state: registryRead.state === 'missing' ? 'absent' : 'unreadable', declared_at: null, plugin_version: null };
171
+ if (registryRead.state === 'observed') {
172
+ try {
173
+ const data = JSON.parse(registryRead.bytes.toString('utf8'));
174
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('invalid registry');
175
+ const tiers = ['tier1_great_cto', 'tier2_external', 'tier3_personal'];
176
+ if (!tiers.some(key => Array.isArray(data[key]))) throw new Error('invalid registry');
177
+ registry.state = 'observed';
178
+ registry.declared_at = /^\d{4}-\d{2}-\d{2}T/.test(data.discovered_at || '') ? String(data.discovered_at).slice(0, 32) : null;
179
+ registry.plugin_version = safeDeclaration(data.plugin_version);
180
+ const source = { label: '~/.great_cto/skills-registry.json', state: 'observed', warnings: [], host: 'Registry', scope: 'machine' };
181
+ sources.push(source);
182
+ for (const key of tiers) {
183
+ if (data[key] !== undefined && !Array.isArray(data[key])) { source.state = 'partial'; continue; }
184
+ for (const entry of data[key] || []) {
185
+ ++entries;
186
+ if (exhausted()) { source.state = 'partial'; break; }
187
+ let file = typeof entry?.path === 'string' && path.isAbsolute(entry.path) ? path.resolve(entry.path) : null;
188
+ if (file && inside(originalHome, file)) file = path.join(home, path.relative(originalHome, file));
189
+ else if (file && originalCwd && inside(originalCwd, file)) file = path.join(cwd, path.relative(originalCwd, file));
190
+ const allowed = file && [...roots, ...registryRoots].find(r => inside(r.root, file));
191
+ if (!allowed || !file.endsWith('.md')) { source.state = 'partial'; source.warnings.push('unmanaged-path'); continue; }
192
+ // Registry entries must not bypass the directory symlink guard.
193
+ try {
194
+ if (await fs.realpath(file) !== file) throw new Error('symlink');
195
+ } catch {
196
+ source.state = 'partial';
197
+ source.warnings.push('unavailable-declaration');
198
+ continue;
199
+ }
200
+ await add(file, { ...allowed, state: 'observed' }, entry.source);
201
+ if (byFile.get(file)?.read_state !== 'observed') source.state = 'partial';
202
+ }
203
+ }
204
+ source.warnings = [...new Set(source.warnings)];
205
+ } catch { registry.state = 'unreadable'; }
206
+ }
207
+ for (const root of roots.filter(r => r.cache)) await scanSource(root);
208
+ const partial = truncated || registry.state === 'unreadable' || sources.some(s => !['observed', 'absent'].includes(s.state));
209
+ return { schema: 1, state: partial ? 'partial' : 'observed', observed_at: new Date().toISOString(),
210
+ duration_ms: Date.now() - started, truncated, registry,
211
+ sources: sources.map(({ label, host, scope, state, warnings }) => ({ label, host, scope, state, warnings })),
212
+ skills: skills.sort((a, b) => a.location < b.location ? -1 : a.location > b.location ? 1 : 0) };
213
+ }
214
+
215
+ // Bound memory and coalesce concurrent Claude/Codex board requests. This is a
216
+ // read cache, not a cross-process lock for installation/update (not implemented).
217
+ const cache = new Map();
218
+ export function getSkillsInventory(cwd) {
219
+ const key = os.homedir() + '\0' + path.resolve(cwd);
220
+ const previous = cache.get(key);
221
+ if (previous && (!previous.settled || Date.now() - previous.at < 30000)) return previous.promise;
222
+ const item = { at: Date.now(), settled: false, promise: skillInventory({ cwd }) };
223
+ cache.set(key, item);
224
+ if (cache.size > 8) cache.delete(cache.keys().next().value);
225
+ item.promise.then(() => { item.settled = true; item.at = Date.now(); },
226
+ () => { if (cache.get(key) === item) cache.delete(key); });
227
+ return item.promise;
228
+ }
@@ -2,8 +2,8 @@ import { parentPort, workerData } from 'node:worker_threads';
2
2
  import { scanUsage, summarizeUsage, readCodexTitles, readClaudeLimits, claudeRecorder } from '../../../scripts/lib/session-usage.mjs';
3
3
  try {
4
4
  const snapshot = await scanUsage();
5
- const report = summarizeUsage(snapshot.index, { days: workerData.days, codexTitles: readCodexTitles(), claudeReadings: readClaudeLimits(), claudeRecorderState: claudeRecorder() });
6
- parentPort.postMessage({ ...report, seen: snapshot.seen, files: snapshot.files });
5
+ const report = summarizeUsage(snapshot.index, { days: workerData.days, projectPath: workerData.projectPath, codexTitles: readCodexTitles(), claudeReadings: readClaudeLimits(), claudeRecorderState: claudeRecorder() });
6
+ parentPort.postMessage({ seen: snapshot.seen, files: snapshot.files, ...report });
7
7
  } catch {
8
8
  parentPort.postMessage({ state: 'unavailable', why: 'Session statistics could not be read; retry or inspect local logs.' });
9
9
  }
@@ -1,8 +1,9 @@
1
1
  import { Worker } from 'node:worker_threads';
2
+ import path from 'node:path';
2
3
 
3
- function computeReport(days) {
4
+ function computeReport(days, projectPath) {
4
5
  return new Promise((resolve, reject) => {
5
- const worker = new Worker(new URL('./usage-report-worker.mjs', import.meta.url), { workerData: { days } });
6
+ const worker = new Worker(new URL('./usage-report-worker.mjs', import.meta.url), { workerData: { days, projectPath } });
6
7
  worker.unref();
7
8
  let settled = false;
8
9
  const finish = (error, result) => {
@@ -20,26 +21,33 @@ function computeReport(days) {
20
21
 
21
22
  /** Bounded stale-while-refresh cache. One scan at a time, off the HTTP thread. */
22
23
  export function usageReports({ compute = computeReport, now = Date.now, ttlMs = 60000, limit = 8 } = {}) {
23
- const slots = new Map(); let running = null;
24
+ const slots = new Map(); let running = null, runningKey = null;
24
25
  return {
25
- get(days) {
26
- let slot = slots.get(days);
26
+ get(days, projectPath = null) {
27
+ projectPath = projectPath ? path.resolve(projectPath) : null;
28
+ const key = JSON.stringify([days, projectPath]);
29
+ let slot = slots.get(key);
27
30
  if (!slot) {
28
- if (slots.size >= limit) slots.delete(slots.keys().next().value);
29
- slot = { value: null, at: 0 }; slots.set(days, slot);
31
+ if (slots.size >= limit) {
32
+ const evict = [...slots.keys()].find(k => k !== runningKey);
33
+ if (evict === undefined) return { state: 'computing', why: 'Waiting for the active statistics scan; navigation remains available.' };
34
+ slots.delete(evict);
35
+ }
36
+ slot = { value: null, at: 0 }; slots.set(key, slot);
30
37
  }
31
38
  if ((!slot.value || now() - slot.at >= ttlMs) && !running) {
32
- running = Promise.resolve().then(() => compute(days)).then(value => {
39
+ runningKey = key;
40
+ running = Promise.resolve().then(() => compute(days, projectPath)).then(value => {
33
41
  if (value?.state === 'counted') { slot.value = value; slot.error = null; }
34
42
  else if (!slot.value) slot.value = { state: 'unavailable', why: value?.why || 'Statistics could not be read.' };
35
43
  else slot.error = value?.why || 'Statistics refresh failed.';
36
44
  }, () => {
37
45
  if (!slot.value) slot.value = { state: 'unavailable', why: 'Statistics worker failed; retry after a minute.' };
38
46
  else slot.error = 'Statistics refresh failed.';
39
- }).finally(() => { slot.at = now(); running = null; });
47
+ }).finally(() => { slot.at = now(); running = null; runningKey = null; });
40
48
  }
41
49
  if (!slot.value) return { state: 'computing', why: 'Reading session statistics in a background worker; navigation remains available.' };
42
- return { ...slot.value, refreshing: !!running, stale: !!slot.error || now() - slot.at >= ttlMs, refreshError: slot.error || null };
50
+ return { ...slot.value, refreshing: runningKey === key, stale: !!slot.error || now() - slot.at >= ttlMs, refreshError: slot.error || null };
43
51
  },
44
52
  };
45
53
  }
@@ -28,7 +28,7 @@ import path from 'path';
28
28
  // it exists to protect. Work and History were routed and missing from this list,
29
29
  // so opening either answered 400 into the console; the test now pins the two
30
30
  // lists to each other.
31
- const VALID_VIEWS = ['work', 'history', 'decisions', 'ledger', 'fleet', 'harness', 'usage', 'settings', 'kanban'];
31
+ const VALID_VIEWS = ['work', 'history', 'decisions', 'ledger', 'fleet', 'harness', 'usage', 'skills', 'settings', 'kanban'];
32
32
 
33
33
  function logFilePath(root) {
34
34
  return path.join(root, '.great_cto', 'view-counter.log');