great-cto 3.59.1 → 3.60.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.
- package/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/lib/projects.mjs +2 -1
- package/board/packages/board/lib/routes.mjs +81 -13
- package/board/packages/board/lib/skills-inventory.mjs +228 -0
- package/board/packages/board/lib/usage-report-worker.mjs +2 -2
- package/board/packages/board/lib/usage-report.mjs +18 -10
- package/board/packages/board/lib/view-counter.mjs +1 -1
- package/board/packages/board/public/index.html +139 -34
- package/board/packages/board/public/share.html +4 -4
- package/board/scripts/lib/outcomes.mjs +8 -3
- package/board/scripts/lib/session-usage.mjs +72 -4
- package/board/scripts/skill-lint.mjs +182 -0
- package/package.json +1 -1
|
@@ -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.
|
|
4
|
+
"version": "3.60.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
|
|
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
|
-
//
|
|
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
|
|
1607
|
-
//
|
|
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
|
|
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
|
|
2308
|
-
const
|
|
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; }, (
|
|
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.
|
|
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
|
|
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({
|
|
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
|
-
|
|
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)
|
|
29
|
-
|
|
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
|
-
|
|
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:
|
|
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');
|