packlight-cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +72 -2
  4. package/dist/adapters/claude-code/inventory.d.ts +48 -0
  5. package/dist/adapters/claude-code/inventory.js +449 -0
  6. package/dist/adapters/claude-code/logs.d.ts +54 -0
  7. package/dist/adapters/claude-code/logs.js +359 -0
  8. package/dist/adapters/codex/inventory.d.ts +17 -0
  9. package/dist/adapters/codex/inventory.js +130 -0
  10. package/dist/adapters/codex/logs.d.ts +22 -0
  11. package/dist/adapters/codex/logs.js +151 -0
  12. package/dist/adapters/codex/toml.d.ts +10 -0
  13. package/dist/adapters/codex/toml.js +76 -0
  14. package/dist/archive/apply.d.ts +62 -0
  15. package/dist/archive/apply.js +284 -0
  16. package/dist/archive/fsops.d.ts +24 -0
  17. package/dist/archive/fsops.js +95 -0
  18. package/dist/archive/journal.d.ts +41 -0
  19. package/dist/archive/journal.js +95 -0
  20. package/dist/archive/lock.d.ts +2 -0
  21. package/dist/archive/lock.js +32 -0
  22. package/dist/archive/manifest.d.ts +37 -0
  23. package/dist/archive/manifest.js +9 -0
  24. package/dist/archive/methods.d.ts +46 -0
  25. package/dist/archive/methods.js +239 -0
  26. package/dist/archive/paths.d.ts +18 -0
  27. package/dist/archive/paths.js +61 -0
  28. package/dist/archive/restore.d.ts +30 -0
  29. package/dist/archive/restore.js +84 -0
  30. package/dist/cli.d.ts +2 -0
  31. package/dist/cli.js +329 -0
  32. package/dist/core/buckets.d.ts +3 -0
  33. package/dist/core/buckets.js +6 -0
  34. package/dist/core/frontmatter.d.ts +6 -0
  35. package/dist/core/frontmatter.js +31 -0
  36. package/dist/core/hash.d.ts +19 -0
  37. package/dist/core/hash.js +42 -0
  38. package/dist/core/messages.d.ts +16 -0
  39. package/dist/core/messages.js +17 -0
  40. package/dist/core/scan.d.ts +16 -0
  41. package/dist/core/scan.js +345 -0
  42. package/dist/core/summary.d.ts +4 -0
  43. package/dist/core/summary.js +63 -0
  44. package/dist/core/types.d.ts +185 -0
  45. package/dist/core/types.js +3 -0
  46. package/dist/report/client.d.ts +1 -0
  47. package/dist/report/client.js +676 -0
  48. package/dist/report/fix.d.ts +46 -0
  49. package/dist/report/fix.js +116 -0
  50. package/dist/report/logo.d.ts +7 -0
  51. package/dist/report/logo.js +23 -0
  52. package/dist/report/model.d.ts +103 -0
  53. package/dist/report/model.js +194 -0
  54. package/dist/report/render.d.ts +9 -0
  55. package/dist/report/render.js +45 -0
  56. package/dist/report/styles.d.ts +1 -0
  57. package/dist/report/styles.js +202 -0
  58. package/dist/report/suggest.d.ts +39 -0
  59. package/dist/report/suggest.js +88 -0
  60. package/package.json +56 -4
@@ -0,0 +1,16 @@
1
+ import { type Inventory } from './types.js';
2
+ export interface ScanOptions {
3
+ home: string;
4
+ /** Working directory the scan runs from; sets projectScope when it is inside a known project. */
5
+ cwd: string;
6
+ now?: Date;
7
+ since?: string;
8
+ }
9
+ /**
10
+ * Maps working directories to project roots: Claude Code's own project registry first, then the nearest git root.
11
+ * A folder neither claims belongs to no project, and the home folder is never a project (its .claude is the user's).
12
+ */
13
+ export declare function projectResolver(registered: string[], home: string): (cwd: string) => string | null;
14
+ export declare function scan(opts: ScanOptions): Promise<Inventory>;
15
+ /** Codex: the same inventory contract, read from ~/.codex. Usage comes from SKILL.md reads and MCP tool calls. */
16
+ export declare function scanCodex(opts: ScanOptions): Promise<Inventory>;
@@ -0,0 +1,345 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join, sep } from 'node:path';
3
+ import { claudePaths, collectInventory, registeredProjects, skillUsageNames } from '../adapters/claude-code/inventory.js';
4
+ import { codexPaths, codexProjects, collectCodexInventory } from '../adapters/codex/inventory.js';
5
+ import { scanCodexLogs } from '../adapters/codex/logs.js';
6
+ import { compareVersions, scanLogs } from '../adapters/claude-code/logs.js';
7
+ import { itemId, valueFingerprint } from './hash.js';
8
+ import { SCHEMA_VERSION } from './types.js';
9
+ const DAY = 86_400_000;
10
+ /** A session with more than this share of unrecognised usage lines makes all usage "uncertain" (eng A2). */
11
+ const UNKNOWN_SHARE_LIMIT = 0.01;
12
+ const isUnder = (path, root) => path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
13
+ /**
14
+ * Maps working directories to project roots: Claude Code's own project registry first, then the nearest git root.
15
+ * A folder neither claims belongs to no project, and the home folder is never a project (its .claude is the user's).
16
+ */
17
+ export function projectResolver(registered, home) {
18
+ const roots = registered.filter(r => r !== home).sort((a, b) => b.length - a.length);
19
+ const cache = new Map();
20
+ return (cwd) => {
21
+ const hit = cache.get(cwd);
22
+ if (hit !== undefined)
23
+ return hit;
24
+ let root = roots.find(r => isUnder(cwd, r)) ?? null;
25
+ if (!root) {
26
+ for (let d = cwd; d !== home; d = dirname(d)) {
27
+ if (existsSync(join(d, '.git'))) {
28
+ root = d;
29
+ break;
30
+ }
31
+ if (dirname(d) === d)
32
+ break;
33
+ }
34
+ }
35
+ cache.set(cwd, root);
36
+ return root;
37
+ };
38
+ }
39
+ function addUse(u, use, now) {
40
+ u.total++;
41
+ if (use.ts && now - Date.parse(use.ts) < 30 * DAY)
42
+ u.last30++;
43
+ if (use.ts && (!u.lastUsed || use.ts > u.lastUsed))
44
+ u.lastUsed = use.ts;
45
+ const key = use.project ?? 'global';
46
+ const p = (u.byProject[key] ??= { total: 0, lastUsed: null });
47
+ p.total++;
48
+ if (use.ts && (!p.lastUsed || use.ts > p.lastUsed))
49
+ p.lastUsed = use.ts;
50
+ }
51
+ /** Items a use could belong to: global ones plus the ones scoped to the project the use happened in. */
52
+ const visibleIn = (items, project) => items.filter(i => i.projectRoot === null || i.projectRoot === project);
53
+ function creditUses(index, uses, now, onUnmatched) {
54
+ for (const [key, list] of uses) {
55
+ const owners = index.get(key);
56
+ if (!owners) {
57
+ onUnmatched?.(key, list);
58
+ continue;
59
+ }
60
+ for (const use of list) {
61
+ const candidates = visibleIn(owners, use.project);
62
+ // Shared names cannot be split: every candidate gets the use and is marked ambiguous (CEO O2).
63
+ if (candidates.length > 1)
64
+ for (const c of candidates)
65
+ c.usage.ambiguous = true;
66
+ for (const c of candidates)
67
+ addUse(c.usage, use, now);
68
+ }
69
+ }
70
+ }
71
+ function indexBy(items, kinds) {
72
+ const idx = new Map();
73
+ for (const it of items) {
74
+ if (!kinds.includes(it.kind))
75
+ continue;
76
+ for (const k of it.logKeys) {
77
+ const list = idx.get(k);
78
+ if (list)
79
+ list.push(it);
80
+ else
81
+ idx.set(k, [it]);
82
+ }
83
+ }
84
+ return idx;
85
+ }
86
+ const INJECTING_EVENTS = new Set(['SessionStart', 'UserPromptSubmit']);
87
+ function creditHooks(items, firings, now) {
88
+ const hooks = items.filter(i => i.kind === 'hook' && i.hook);
89
+ const byKey = new Map();
90
+ for (const h of hooks) {
91
+ const k = `${h.hook.event}\u0000${h.hook.command}`;
92
+ const list = byKey.get(k);
93
+ if (list)
94
+ list.push(h);
95
+ else
96
+ byKey.set(k, [h]);
97
+ }
98
+ const durations = new Map();
99
+ for (const f of firings) {
100
+ const owners = byKey.get(`${f.event}\u0000${f.command}`);
101
+ if (!owners)
102
+ continue;
103
+ const candidates = visibleIn(owners, f.project);
104
+ if (!candidates.length)
105
+ continue;
106
+ if (candidates.length > 1)
107
+ for (const c of candidates)
108
+ c.usage.ambiguous = true;
109
+ // Text from a linked context line was injected; otherwise only session-start and prompt hooks inject their output.
110
+ const injected = f.linkedChars > 0 ? f.linkedChars : INJECTING_EVENTS.has(f.event) ? f.ownChars : 0;
111
+ for (const c of candidates) {
112
+ addUse(c.usage, { ts: f.ts, project: f.project }, now);
113
+ const h = (c.usage.hook ??= { firings: 0, injectedChars: 0, avgDurationMs: 0, p95DurationMs: 0, injectionShared: false });
114
+ h.firings++;
115
+ h.injectedChars += Math.round(injected / candidates.length);
116
+ if (f.shared)
117
+ h.injectionShared = true;
118
+ if (f.durationMs !== null) {
119
+ const d = durations.get(c);
120
+ if (d)
121
+ d.push(f.durationMs);
122
+ else
123
+ durations.set(c, [f.durationMs]);
124
+ }
125
+ }
126
+ }
127
+ for (const [item, ds] of durations) {
128
+ ds.sort((a, b) => a - b);
129
+ item.usage.hook.avgDurationMs = Math.round(ds.reduce((a, b) => a + b, 0) / ds.length);
130
+ item.usage.hook.p95DurationMs = ds[Math.min(ds.length - 1, Math.ceil(ds.length * 0.95) - 1)] ?? 0;
131
+ }
132
+ }
133
+ /** A plugin's usage is its parts' usage. */
134
+ function rollUpPlugins(items) {
135
+ for (const p of items.filter(i => i.kind === 'plugin')) {
136
+ for (const c of items.filter(i => i.plugin === p.plugin && i.kind !== 'plugin')) {
137
+ p.usage.total += c.usage.total;
138
+ p.usage.last30 += c.usage.last30;
139
+ if (c.usage.lastUsed && (!p.usage.lastUsed || c.usage.lastUsed > p.usage.lastUsed))
140
+ p.usage.lastUsed = c.usage.lastUsed;
141
+ for (const [proj, v] of Object.entries(c.usage.byProject)) {
142
+ const t = (p.usage.byProject[proj] ??= { total: 0, lastUsed: null });
143
+ t.total += v.total;
144
+ if (v.lastUsed && (!t.lastUsed || v.lastUsed > t.lastUsed))
145
+ t.lastUsed = v.lastUsed;
146
+ }
147
+ }
148
+ }
149
+ }
150
+ function coverage(logs) {
151
+ const reasons = [];
152
+ const over = logs.sessions.filter(s => s.lines > 0 && s.unknownShapeLines / s.lines > UNKNOWN_SHARE_LIMIT);
153
+ if (over.length)
154
+ reasons.push(`${over.length} session(s) have more than 1% unrecognised usage lines`);
155
+ const newest = logs.versions[logs.versions.length - 1];
156
+ if (newest && (logs.unknownByVersion[newest] ?? 0) > 0)
157
+ reasons.push(`Claude Code ${newest} wrote ${logs.unknownByVersion[newest]} unrecognised usage line(s)`);
158
+ if (!logs.sessions.length)
159
+ reasons.push('no session logs found');
160
+ return { uncertain: reasons.length > 0, reasons };
161
+ }
162
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
163
+ export async function scan(opts) {
164
+ const now = opts.now ?? new Date();
165
+ const paths = claudePaths(opts.home);
166
+ const registered = registeredProjects(paths);
167
+ const projectOf = projectResolver(registered, opts.home);
168
+ const logs = await scanLogs(paths.projectsLogDir, { projectOf, since: opts.since });
169
+ const sessionsByRoot = new Map();
170
+ for (const s of logs.sessions)
171
+ if (s.projectRoot)
172
+ sessionsByRoot.set(s.projectRoot, (sessionsByRoot.get(s.projectRoot) ?? 0) + 1);
173
+ const roots = [...new Set([...registered, ...sessionsByRoot.keys()])].filter(r => r !== opts.home).sort();
174
+ const existing = roots.filter(r => existsSync(r));
175
+ const items = collectInventory(paths, existing);
176
+ const t = now.getTime();
177
+ creditUses(indexBy(items, ['skill', 'command']), logs.skillUses, t);
178
+ creditUses(indexBy(items, ['agent']), logs.agentUses, t);
179
+ creditUses(indexBy(items, ['mcp']), logs.mcpUses, t, (key, list) => {
180
+ // claude.ai connectors appear only as opaque ids in the logs; they are listed, and removable only on claude.ai.
181
+ if (!UUID.test(key))
182
+ return;
183
+ const it = {
184
+ id: itemId({ agent: 'claude-code', kind: 'mcp', source: 'claude.ai connector', name: key, path: null }),
185
+ agent: 'claude-code', kind: 'mcp', name: key, source: 'claude.ai connector', path: null, projectRoot: null, enabled: true,
186
+ description: '', descHash: '', standingChars: 0, targetFingerprint: valueFingerprint(key), firstSeen: null, firstSeenSource: null,
187
+ logKeys: [key], declarations: [], removal: { method: 'manual', where: 'claude.ai › Settings › Connectors' },
188
+ usage: { total: 0, last30: 0, lastUsed: null, byProject: {}, ambiguous: false, uncertain: false },
189
+ };
190
+ for (const use of list)
191
+ addUse(it.usage, use, t);
192
+ items.push(it);
193
+ });
194
+ creditHooks(items, logs.hookFirings, t);
195
+ rollUpPlugins(items);
196
+ const cov = coverage(logs);
197
+ if (cov.uncertain)
198
+ for (const it of items)
199
+ it.usage.uncertain = true;
200
+ // Ids must be unique; a collision means two declarations hash alike and packlight cannot act safely (eng A1).
201
+ const seen = new Map();
202
+ for (const it of items) {
203
+ const prev = seen.get(it.id);
204
+ if (prev)
205
+ throw new Error(`Two items share the id ${it.id}: ${prev.path ?? prev.name} and ${it.path ?? it.name}`);
206
+ seen.set(it.id, it);
207
+ }
208
+ const budget = {};
209
+ for (const l of logs.listings)
210
+ budget[l.projectRoot ?? 'global'] = l;
211
+ const starts = logs.sessions.map(s => s.start).filter((x) => !!x).sort();
212
+ const scope = projectOf(opts.cwd);
213
+ const projectScope = scope && roots.includes(scope) ? scope : 'global';
214
+ // Each MCP server's tool names and instructions, from the scope's newest tool list, load every session.
215
+ const toolListing = {};
216
+ for (const l of logs.toolListings)
217
+ toolListing[l.projectRoot ?? 'global'] = l;
218
+ const tl = toolListing[projectScope] ?? logs.toolListings.at(-1);
219
+ if (tl) {
220
+ const norm = (k) => k.toLowerCase().replace(/[^a-z0-9]+/g, '_');
221
+ const byKey = new Map();
222
+ for (const it of items.filter(i => i.kind === 'mcp'))
223
+ for (const k of it.logKeys)
224
+ byKey.set(norm(k), [...(byKey.get(norm(k)) ?? []), it]);
225
+ for (const [key, load] of Object.entries(tl.servers)) {
226
+ let owners = byKey.get(norm(key));
227
+ if (!owners && UUID.test(key)) {
228
+ // A claude.ai connector no session called: listed all the same, and removable only on claude.ai.
229
+ const it = {
230
+ id: itemId({ agent: 'claude-code', kind: 'mcp', source: 'claude.ai connector', name: key, path: null }),
231
+ agent: 'claude-code', kind: 'mcp', name: key, source: 'claude.ai connector', path: null, projectRoot: null, enabled: true,
232
+ description: '', descHash: '', standingChars: 0, targetFingerprint: valueFingerprint(key), firstSeen: null, firstSeenSource: null,
233
+ logKeys: [key], declarations: [], removal: { method: 'manual', where: 'claude.ai › Settings › Connectors' },
234
+ usage: { total: 0, last30: 0, lastUsed: null, byProject: {}, ambiguous: false, uncertain: false },
235
+ };
236
+ items.push(it);
237
+ owners = [it];
238
+ }
239
+ for (const it of owners ?? []) {
240
+ if (!it.enabled)
241
+ continue;
242
+ it.standingChars = load.chars + load.instructionChars;
243
+ if (it.source === 'claude.ai connector' && !it.description)
244
+ it.description = load.label ?? (load.sample?.length ? `Tools: ${load.sample.join(', ')}${load.tools > load.sample.length ? `, and ${load.tools - load.sample.length} more` : ''}` : '');
245
+ }
246
+ }
247
+ }
248
+ return {
249
+ schemaVersion: SCHEMA_VERSION,
250
+ scanId: now.toISOString().replace(/[:.]/g, '-'),
251
+ agent: 'claude-code',
252
+ createdAt: now.toISOString(),
253
+ home: opts.home,
254
+ projectScope,
255
+ window: { from: opts.since ?? starts[0] ?? null, to: now.toISOString() },
256
+ sessionsInWindow: logs.sessions.filter(s => s.start).length,
257
+ linesTotal: logs.linesTotal,
258
+ linesUnknownShape: logs.linesUnknownShape,
259
+ linesUnreadable: logs.linesUnreadable,
260
+ claudeCodeVersions: logs.versions.sort(compareVersions),
261
+ coverage: cov,
262
+ projects: roots.map(root => ({ root, sessions: sessionsByRoot.get(root) ?? 0, exists: existing.includes(root) })),
263
+ sessions: logs.sessions,
264
+ budget,
265
+ toolListing,
266
+ listingPriority: skillUsageNames(paths),
267
+ unattributedHookChars: Math.round(logs.unattributedHookChars),
268
+ items,
269
+ };
270
+ }
271
+ /** Codex: the same inventory contract, read from ~/.codex. Usage comes from SKILL.md reads and MCP tool calls. */
272
+ export async function scanCodex(opts) {
273
+ const now = opts.now ?? new Date();
274
+ const paths = codexPaths(opts.home);
275
+ const registered = codexProjects(paths);
276
+ const projectOf = projectResolver(registered, opts.home);
277
+ const logs = await scanCodexLogs(paths.sessions, { projectOf, since: opts.since });
278
+ const sessionsByRoot = new Map();
279
+ for (const s of logs.sessions)
280
+ if (s.projectRoot)
281
+ sessionsByRoot.set(s.projectRoot, (sessionsByRoot.get(s.projectRoot) ?? 0) + 1);
282
+ const roots = [...new Set([...registered, ...sessionsByRoot.keys()])].filter(r => r !== opts.home).sort();
283
+ const existing = roots.filter(r => existsSync(r));
284
+ const items = collectCodexInventory(paths, existing);
285
+ const t = now.getTime();
286
+ creditUses(indexBy(items, ['skill', 'command']), logs.skillUses, t);
287
+ creditUses(indexBy(items, ['mcp']), logs.mcpUses, t);
288
+ rollUpPlugins(items);
289
+ const reasons = [];
290
+ if (!logs.sessions.length)
291
+ reasons.push('no session logs found');
292
+ const newest = logs.versions.at(-1);
293
+ if (newest && (logs.unknownByVersion[newest] ?? 0) > 0)
294
+ reasons.push(`Codex ${newest} wrote ${logs.unknownByVersion[newest]} unrecognised log line(s)`);
295
+ const cov = { uncertain: reasons.length > 0, reasons };
296
+ if (cov.uncertain)
297
+ for (const it of items)
298
+ it.usage.uncertain = true;
299
+ const seen = new Map();
300
+ for (const it of items) {
301
+ const prev = seen.get(it.id);
302
+ if (prev)
303
+ throw new Error(`Two items share the id ${it.id}: ${prev.path ?? prev.name} and ${it.path ?? it.name}`);
304
+ seen.set(it.id, it);
305
+ }
306
+ const scope = projectOf(opts.cwd);
307
+ const projectScope = scope && roots.includes(scope) ? scope : 'global';
308
+ // What a session starts with: the scope's newest world_state, and first-request tokens from every scoped session.
309
+ const scoped = logs.starts.filter(s => projectScope === 'global' || s.projectRoot === projectScope);
310
+ const newestStart = scoped.at(-1) ?? logs.starts.at(-1);
311
+ const codexStart = newestStart ? { ...newestStart, firstRequestTokens: (scoped.length ? scoped : logs.starts).flatMap(s => s.firstRequestTokens) } : undefined;
312
+ // Only skills in the logged listing load; the rest sit on disk (a plugin the app has not switched on, say).
313
+ // An empty listing counts too: Codex runs started with skills off (codex exec from another agent, say) load none.
314
+ const listed = codexStart?.listedSkills;
315
+ if (listed) {
316
+ const names = new Set(listed);
317
+ for (const it of items)
318
+ if ((it.kind === 'skill') && !names.has(it.name))
319
+ it.standingChars = 0;
320
+ for (const p of items.filter(i => i.kind === 'plugin'))
321
+ p.standingChars = 0;
322
+ }
323
+ const starts = logs.sessions.map(s => s.start).filter((x) => !!x).sort();
324
+ return {
325
+ schemaVersion: SCHEMA_VERSION,
326
+ scanId: now.toISOString().replace(/[:.]/g, '-'),
327
+ agent: 'codex',
328
+ createdAt: now.toISOString(),
329
+ home: opts.home,
330
+ projectScope,
331
+ window: { from: opts.since ?? starts[0] ?? null, to: now.toISOString() },
332
+ sessionsInWindow: logs.sessions.length,
333
+ linesTotal: logs.linesTotal,
334
+ linesUnknownShape: logs.linesUnknownShape,
335
+ linesUnreadable: logs.linesUnreadable,
336
+ claudeCodeVersions: logs.versions,
337
+ coverage: cov,
338
+ projects: roots.map(root => ({ root, sessions: sessionsByRoot.get(root) ?? 0, exists: existing.includes(root) })),
339
+ sessions: logs.sessions,
340
+ budget: {},
341
+ ...(codexStart ? { codexStart } : {}),
342
+ unattributedHookChars: 0,
343
+ items,
344
+ };
345
+ }
@@ -0,0 +1,4 @@
1
+ import type { Inventory } from './types.js';
2
+ export declare const chars: (n: number) => string;
3
+ /** The text summary `packlight scan` prints: counts, the listing budget and the heaviest hooks. */
4
+ export declare function summarize(inv: Inventory, ms: number): string;
@@ -0,0 +1,63 @@
1
+ const KIND_ORDER = ['skill', 'command', 'agent', 'hook', 'plugin', 'mcp', 'instructions'];
2
+ const KIND_LABEL = { skill: 'skills', command: 'commands', agent: 'agents', hook: 'hooks', plugin: 'plugins', mcp: 'MCP servers', instructions: 'instruction files' };
3
+ export const chars = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(1).replace(/\.0$/, '')}M` : n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k` : String(Math.round(n));
4
+ /** The text summary `packlight scan` prints: counts, the listing budget and the heaviest hooks. */
5
+ export function summarize(inv, ms) {
6
+ const out = [];
7
+ const from = inv.window.from ? inv.window.from.slice(0, 10) : 'the start';
8
+ out.push(`Scanned ${inv.sessionsInWindow} sessions from ${from} to ${inv.window.to.slice(0, 10)} in ${(ms / 1000).toFixed(1)} s.`);
9
+ out.push(`Scope: ${inv.projectScope}`);
10
+ out.push('');
11
+ const counts = KIND_ORDER.map(k => [k, inv.items.filter(i => i.kind === k)]).filter(([, l]) => l.length);
12
+ for (const [k, list] of counts) {
13
+ const detail = k === 'instructions'
14
+ ? `${chars(list.reduce((n, i) => n + i.standingChars, 0))} chars loaded in their projects`
15
+ : `${list.filter(i => i.usage.total === 0).length} not observed`;
16
+ out.push(` ${String(list.length).padStart(4)} ${KIND_LABEL[k].padEnd(18)} ${detail}`);
17
+ }
18
+ if (inv.agent === 'codex') {
19
+ out.push('');
20
+ const s = inv.codexStart;
21
+ if (s) {
22
+ const t = [...s.firstRequestTokens].sort((a, b) => a - b);
23
+ const median = t[Math.floor(t.length / 2)];
24
+ out.push(`Session start (${s.timestamp?.slice(0, 10) ?? 'latest'}, Codex ${s.version ?? '?'}): ${median !== undefined ? `about ${chars(median)} tokens in the first request (median of ${t.length} sessions, measured by Codex); ` : ''}${s.listedSkills && !s.listedSkills.length ? 'no skills listed (these sessions started with skills off)' : `skill listing ${chars(s.skillListingChars)} chars`}, AGENTS.md ${chars(s.agentsMdChars)}${s.pluginInstructionChars ? `, plugin instructions ${chars(s.pluginInstructionChars)}` : ''}${s.appInstructionChars ? `, app instructions ${chars(s.appInstructionChars)}` : ''}.`);
25
+ }
26
+ else
27
+ out.push('Session start: no Codex session recorded what it started with.');
28
+ if (inv.coverage.uncertain)
29
+ out.push(`\nUsage may be incomplete: ${inv.coverage.reasons.join('; ')}.`);
30
+ return out.join('\n');
31
+ }
32
+ // A project scan reads that project's listing; a global scan reads the newest listing from any session.
33
+ const newest = Object.values(inv.budget).sort((a, b) => (a.timestamp ?? '').localeCompare(b.timestamp ?? '')).at(-1);
34
+ const budget = inv.projectScope !== 'global' ? inv.budget[inv.projectScope] ?? newest : newest;
35
+ out.push('');
36
+ if (budget) {
37
+ out.push(`Skill listing (${budget.timestamp?.slice(0, 10) ?? 'latest'}, Claude Code ${budget.version ?? '?'}): ${budget.dropped.length} of ${budget.skillCount} skills listed without their description (${chars(budget.listingChars)} chars).`);
38
+ }
39
+ else {
40
+ out.push('Skill listing: no listing found in these logs.');
41
+ }
42
+ const tl = inv.toolListing?.[inv.projectScope] ?? Object.values(inv.toolListing ?? {}).sort((a, b) => (a.timestamp ?? '').localeCompare(b.timestamp ?? '')).at(-1);
43
+ if (tl) {
44
+ const servers = Object.values(tl.servers);
45
+ const top = Object.entries(tl.servers).sort((a, b) => b[1].chars + b[1].instructionChars - a[1].chars - a[1].instructionChars).slice(0, 3)
46
+ .map(([k, s]) => `${s.label ?? (inv.items.find(i => i.kind === 'mcp' && i.logKeys.includes(k))?.description?.startsWith('Tools:') ? `${k.slice(0, 8)}… (${s.sample?.slice(0, 2).join(', ')})` : k)} ${chars(s.chars + s.instructionChars)}`);
47
+ out.push(`MCP tool list (${tl.timestamp?.slice(0, 10) ?? 'latest'}): ${servers.reduce((n, s) => n + s.tools, 0)} tools from ${servers.length} servers, ${chars(servers.reduce((n, s) => n + s.chars, 0))} chars of tool names and ${chars(servers.reduce((n, s) => n + s.instructionChars, 0))} of instructions every session. Largest: ${top.join(', ')}.`);
48
+ }
49
+ const hooks = inv.items.filter(i => i.usage.hook && i.usage.hook.firings > 0)
50
+ .sort((a, b) => b.usage.hook.injectedChars - a.usage.hook.injectedChars).slice(0, 5);
51
+ if (hooks.length) {
52
+ out.push('Hooks by injected text:');
53
+ for (const h of hooks) {
54
+ const u = h.usage.hook;
55
+ out.push(` ${h.name} ${u.firings} firings, ${chars(u.injectedChars / u.firings)} chars per firing, p95 ${u.p95DurationMs} ms${u.injectionShared ? ' (shared)' : ''}${h.usage.ambiguous ? ' (ambiguous)' : ''}`);
56
+ }
57
+ }
58
+ if (inv.coverage.uncertain)
59
+ out.push(`\nUsage may be incomplete: ${inv.coverage.reasons.join('; ')}.`);
60
+ if (inv.linesUnreadable)
61
+ out.push(`${inv.linesUnreadable} log lines could not be read.`);
62
+ return out.join('\n');
63
+ }
@@ -0,0 +1,185 @@
1
+ export declare const SCHEMA_VERSION = 1;
2
+ export type Agent = 'claude-code' | 'codex';
3
+ export type Kind = 'skill' | 'command' | 'agent' | 'hook' | 'plugin' | 'mcp' | 'instructions';
4
+ export type RemovalMethod = 'move' | 'hook-extract' | 'mcp-extract' | 'plugin-disable' | 'manual';
5
+ export type DeclScope = 'user' | 'local' | 'project' | 'project-local' | 'plugin' | 'app';
6
+ /** One place an item is declared (eng X2): an item can be enabled in several settings files at once. */
7
+ export interface Declaration {
8
+ file: string;
9
+ scope: DeclScope;
10
+ /** JSON pointer inside `file` for settings-file declarations (hooks, MCP servers, plugin flags). */
11
+ pointer?: string;
12
+ enabled?: boolean;
13
+ }
14
+ export interface HookUsage {
15
+ firings: number;
16
+ /** Characters the hook added to the context: linked additional-context text, else hook output. */
17
+ injectedChars: number;
18
+ avgDurationMs: number;
19
+ p95DurationMs: number;
20
+ /** Injected text could not be split between several hooks that fired on the same tool call. */
21
+ injectionShared: boolean;
22
+ }
23
+ export interface Usage {
24
+ total: number;
25
+ last30: number;
26
+ lastUsed: string | null;
27
+ /** Uses per project root (or "global" when the line had no project). */
28
+ byProject: Record<string, {
29
+ total: number;
30
+ lastUsed: string | null;
31
+ }>;
32
+ /** Another item shares the name or command the logs record, so the count cannot be split (CEO O2). */
33
+ ambiguous: boolean;
34
+ /** The logs held lines packlight could not recognise, so a zero may be wrong (eng A2). */
35
+ uncertain: boolean;
36
+ hook?: HookUsage;
37
+ }
38
+ export interface Item {
39
+ id: string;
40
+ agent: Agent;
41
+ kind: Kind;
42
+ name: string;
43
+ /** Human source label: "personal", "project", "plugin:<name>", "claude.ai synced", "user config", … */
44
+ source: string;
45
+ /** Absolute path of the item on disk, or null for items that only live in an app or web UI. */
46
+ path: string | null;
47
+ /** Project root for project-scoped items; null for items that load in every project. */
48
+ projectRoot: string | null;
49
+ enabled: boolean;
50
+ description: string;
51
+ /** sha256 of name + description (drives incremental rating). */
52
+ descHash: string;
53
+ /** Characters this item puts into every session's context (0 for hooks and on-demand items). */
54
+ standingChars: number;
55
+ /** Content fingerprint checked again before apply (eng X1). */
56
+ targetFingerprint: string;
57
+ /** Earliest known install time (design-delta DE5); null when unknown. */
58
+ firstSeen: string | null;
59
+ firstSeenSource: 'birthtime' | 'mtime' | 'installedAt' | null;
60
+ /** Keys the session logs use for this item (skill name, "plugin:skill", subagent type, MCP server …). */
61
+ logKeys: string[];
62
+ /** Parent plugin key ("name@marketplace") for plugin-bundled items; plugins are the removal unit (CEO O3). */
63
+ plugin?: string;
64
+ hook?: {
65
+ event: string;
66
+ matcher: string | null;
67
+ command: string;
68
+ };
69
+ members?: Partial<Record<Kind, number>>;
70
+ declarations: Declaration[];
71
+ /** suite: a folder other skills depend on (gstack); never moved, never offered by the fix. */
72
+ removal: {
73
+ method: RemovalMethod;
74
+ where?: string;
75
+ suite?: true;
76
+ };
77
+ usage: Usage;
78
+ }
79
+ export interface SessionRecord {
80
+ id: string;
81
+ file: string;
82
+ projectRoot: string | null;
83
+ start: string | null;
84
+ end: string | null;
85
+ lines: number;
86
+ unknownShapeLines: number;
87
+ unreadableLines: number;
88
+ versions: string[];
89
+ }
90
+ /** One skill listing as Claude Code logged it: which skills kept their description (eng X8). */
91
+ export interface BudgetObservation {
92
+ sessionId: string;
93
+ timestamp: string | null;
94
+ version: string | null;
95
+ projectRoot: string | null;
96
+ skillCount: number;
97
+ listingChars: number;
98
+ withDescription: string[];
99
+ dropped: string[];
100
+ /** Every line in listing order, with its size as logged (multi-line descriptions included). */
101
+ entries?: ListingLine[];
102
+ }
103
+ export interface CodexStart {
104
+ sessionId: string;
105
+ timestamp: string | null;
106
+ version: string | null;
107
+ projectRoot: string | null;
108
+ skillListingChars: number;
109
+ /** Skill names in that listing: skills on disk but not listed do not load. */
110
+ listedSkills?: string[];
111
+ agentsMdChars: number;
112
+ pluginInstructionChars: number;
113
+ appInstructionChars: number;
114
+ /** Input tokens of each scoped session's first request, as Codex counted them (includes the first message). */
115
+ firstRequestTokens: number[];
116
+ }
117
+ export interface ListingLine {
118
+ name: string;
119
+ chars: number;
120
+ described: boolean;
121
+ }
122
+ /** One server's share of a session's tool list: its tool-name lines and its instructions block. */
123
+ export interface ToolServerLoad {
124
+ tools: number;
125
+ chars: number;
126
+ instructionChars: number;
127
+ label?: string; /** A few tool names, to tell id-named connectors apart. */
128
+ sample?: string[];
129
+ }
130
+ /**
131
+ * The tool list one session was given: Claude Code names every deferred tool at session start, and MCP servers
132
+ * may add instructions. Logged as deltas; this is their sum for the session.
133
+ */
134
+ export interface ToolListing {
135
+ sessionId: string;
136
+ timestamp: string | null;
137
+ version: string | null;
138
+ projectRoot: string | null;
139
+ /** By server key: the part of "mcp__<key>__<tool>", or the instructions' server name in the same form. */
140
+ servers: Record<string, ToolServerLoad>;
141
+ /** Claude Code's own deferred tools (no "mcp__" prefix). */
142
+ builtIn: {
143
+ tools: number;
144
+ chars: number;
145
+ };
146
+ }
147
+ export interface Inventory {
148
+ schemaVersion: number;
149
+ scanId: string;
150
+ agent: Agent;
151
+ createdAt: string;
152
+ home: string;
153
+ /** "global", or the project root the scan was run for (eng X6, CEO O1). */
154
+ projectScope: string;
155
+ window: {
156
+ from: string | null;
157
+ to: string;
158
+ };
159
+ sessionsInWindow: number;
160
+ linesTotal: number;
161
+ linesUnknownShape: number;
162
+ linesUnreadable: number;
163
+ claudeCodeVersions: string[];
164
+ coverage: {
165
+ uncertain: boolean;
166
+ reasons: string[];
167
+ };
168
+ projects: {
169
+ root: string;
170
+ sessions: number;
171
+ exists: boolean;
172
+ }[];
173
+ sessions: SessionRecord[];
174
+ /** Newest initial skill listing per project root ("global" key for no project). */
175
+ budget: Record<string, BudgetObservation>;
176
+ /** Codex only: what its newest session started with, and measured first-request tokens (median over the window). */
177
+ codexStart?: CodexStart;
178
+ /** Newest tool list per project root ("global" key for no project). */
179
+ toolListing?: Record<string, ToolListing>;
180
+ /** Skills Claude Code itself counts as used (~/.claude.json skillUsage): it keeps their descriptions first. */
181
+ listingPriority?: string[];
182
+ /** Hook context the logs recorded but no hook line could be matched to. */
183
+ unattributedHookChars: number;
184
+ items: Item[];
185
+ }
@@ -0,0 +1,3 @@
1
+ // The inventory contract (eng X6). Every later part of packlight reads this shape;
2
+ // change it only with a schemaVersion bump.
3
+ export const SCHEMA_VERSION = 1;
@@ -0,0 +1 @@
1
+ export declare function clientMain(): void;