glassbox-trace 0.11.0 → 0.12.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/src/cli.mjs CHANGED
@@ -7,9 +7,10 @@ import crypto from 'node:crypto';
7
7
  import { createRequire } from 'node:module';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { readTextFile } from './textfile.mjs';
10
+ import { readTranscript, fileIncludes } from './jsonl.mjs';
10
11
  import { guardResponse } from './guard.mjs';
11
12
  import { judgeClaims, claimFindings, claimsText, claimsMarkdown } from './claims.mjs';
12
- import { NOTES_FILE, ensureMemoryDir, recordSession, memoryNote, noteTold, maskedTrace, recall, recallText, recallMarkdown, memoryReport, memoryText, memoryMarkdown, forget, clearMemory } from './memory.mjs';
13
+ import { NOTES_FILE, ensureMemoryDir, recordSession, memoryNote, noteTold, maskedTrace, recall, recallText, recallMarkdown, memoryReport, memoryText, memoryMarkdown, forget, clearMemory, syncLessonsBlock, toldFromBlock } from './memory.mjs';
13
14
  import { maskSecrets } from './fence.mjs';
14
15
  const require = createRequire(import.meta.url);
15
16
  const core = require('./trace-core.js');
@@ -38,7 +39,7 @@ export function findSessions(opts = {}) {
38
39
  out.sort((a, b) => b.mtime - a.mtime);
39
40
  let list = out;
40
41
  if (opts.project) list = list.filter((s) => s.project.includes(opts.project));
41
- if (opts.grep) { const q = opts.grep.toLowerCase(); list = list.filter((s) => { try { return fs.readFileSync(s.file, 'utf8').toLowerCase().includes(q); } catch (e) { return false; } }); }
42
+ if (opts.grep) { const q = opts.grep.toLowerCase(); list = list.filter((s) => { try { return fileIncludes(s.file, q); } catch (e) { return false; } }); }
42
43
  if (opts.last) list = list.slice(0, opts.last);
43
44
  return list;
44
45
  }
@@ -81,9 +82,11 @@ export function subagentFiles(s) {
81
82
  return out;
82
83
  }
83
84
 
85
+ // Each file as { name, text }, or, past streamBytes() (64 MiB), as parsed records read line by line — a transcript
86
+ // past V8's ~512 MB string limit can't be one string (src/jsonl.mjs, DESIGN.md §21).
84
87
  export function loadSessionFiles(s) {
85
- const files = [{ name: s.id + '.jsonl', text: fs.readFileSync(s.file, 'utf8') }];
86
- for (const f of subagentFiles(s)) files.push({ name: f.name, text: fs.readFileSync(f.path, 'utf8') });
88
+ const files = [readTranscript(s.file, s.id + '.jsonl')];
89
+ for (const f of subagentFiles(s)) files.push(readTranscript(f.path, f.name));
87
90
  return files;
88
91
  }
89
92
 
@@ -102,19 +105,43 @@ export function analyse(files, rates) {
102
105
 
103
106
  // A team rate card: --rates FILE or GLASSBOX_RATES. JSON keyed by model-id prefix, USD per million tokens:
104
107
  // { "claude-opus-5": { "in": 4, "out": 20, "read": 0.4, "w5m": 5, "w1h": 8 } }
105
- // Entries override or extend the built-in card; cache-write rates default to 1.25× / 2× input.
108
+ // Entries override or extend the built-in card; cache-write rates default to 1.25× / 2× input. An optional
109
+ // "over": { "at": 100000, "in": …, "out": …, "read": … } prices requests whose prompt is over `at` tokens.
106
110
  export function loadRates(file) {
107
111
  if (!file) return null;
108
112
  let json; try { json = JSON.parse(readTextFile(String(file))); } catch (e) { throw new Error(`--rates ${file}: ${e.code === 'ENOENT' ? 'no such file' : 'not valid JSON'}`); }
109
113
  if (!json || typeof json !== 'object' || Array.isArray(json)) throw new Error(`--rates ${file}: expected an object keyed by model id`);
110
114
  const out = Object.assign({}, core.RATES);
115
+ const card = (r) => ({ in: r.in, out: r.out, read: r.read, w5m: typeof r.w5m === 'number' ? r.w5m : r.in * 1.25, w1h: typeof r.w1h === 'number' ? r.w1h : r.in * 2 });
116
+ const valid = (r) => r && ['in', 'out', 'read'].every((k) => typeof r[k] === 'number' && r[k] >= 0);
111
117
  for (const [model, r] of Object.entries(json)) {
112
- if (!r || !['in', 'out', 'read'].every((k) => typeof r[k] === 'number' && r[k] >= 0)) throw new Error(`--rates ${file}: "${model}" needs numeric in, out and read (USD per million tokens)`);
113
- out[model] = { in: r.in, out: r.out, read: r.read, w5m: typeof r.w5m === 'number' ? r.w5m : r.in * 1.25, w1h: typeof r.w1h === 'number' ? r.w1h : r.in * 2 };
118
+ if (!valid(r)) throw new Error(`--rates ${file}: "${model}" needs numeric in, out and read (USD per million tokens)`);
119
+ // A user's row covers its whole family: built-in rows that are longer prefixes of it (claude-sonnet-4-5 under
120
+ // claude-sonnet-4) would otherwise win the longest-prefix match and quietly ignore the user's price.
121
+ for (const k of Object.keys(core.RATES)) if (k !== model && k.startsWith(model) && !(k in json)) delete out[k];
122
+ out[model] = card(r);
123
+ if (r.over !== undefined) {
124
+ if (!valid(r.over) || !(typeof r.over.at === 'number' && r.over.at > 0)) throw new Error(`--rates ${file}: "${model}".over needs a positive "at" (prompt tokens) and numeric in, out and read`);
125
+ out[model].over = Object.assign(card(r.over), { at: r.over.at });
126
+ }
114
127
  }
128
+ // Marked, not enumerable: the age check skips a user's card, and the endpoint in a Bedrock id doesn't add 10%
129
+ // on top of a row the user priced from their own cloud sheet.
130
+ Object.defineProperty(out, '_custom', { value: true });
131
+ Object.defineProperty(out, '_userKeys', { value: new Set(Object.keys(json)) });
115
132
  return out;
116
133
  }
117
134
 
135
+ // --platform anthropic|bedrock|vertex, --endpoint global|regional, --geo us: how a cloud deployment is billed.
136
+ export function pricingFromFlags(flags, rates) {
137
+ const pick = (k, allowed) => { if (flags[k] === undefined) return undefined; const v = String(flags[k]).toLowerCase(); if (!allowed.includes(v)) throw new Error(`--${k} must be ${allowed.join(', ').replace(/, ([^,]*)$/, ' or $1')} (got "${flags[k]}")`); return v; };
138
+ const p = { platform: pick('platform', ['anthropic', 'bedrock', 'vertex']), endpoint: pick('endpoint', ['global', 'regional']), geo: pick('geo', ['global', 'us']) };
139
+ if (p.geo === 'global') delete p.geo;
140
+ if (!p.platform && !p.endpoint && !p.geo) return rates;
141
+ if (p.geo && p.platform && p.platform !== 'anthropic') throw new Error('--geo us is a first-party setting (inference_geo); on Bedrock or Vertex use --endpoint regional');
142
+ return core.withPricing(rates, p);
143
+ }
144
+
118
145
  // "90m", "1h", "2d" → milliseconds.
119
146
  export function parseSince(s) {
120
147
  const m = String(s).trim().match(/^(\d+(?:\.\d+)?)\s*(m|min|h|d)$/i);
@@ -122,6 +149,16 @@ export function parseSince(s) {
122
149
  return +m[1] * ({ m: 60e3, min: 60e3, h: 3600e3, d: 86400e3 })[m[2].toLowerCase()];
123
150
  }
124
151
 
152
+ // The structure-only copy the viewer's "Export redacted" makes: timestamps, usage and tool names kept, every prompt,
153
+ // input, result and assistant text blanked. A line that isn't JSON is dropped rather than kept as it was.
154
+ export function redactFiles(files) {
155
+ return files.map((f) => {
156
+ if (/\.json$/i.test(f.name)) { try { return { name: f.name, text: JSON.stringify(core.redact([JSON.parse(f.text)])[0]) }; } catch (e) { return { name: f.name, text: '{}' }; } }
157
+ const recs = []; for (const line of f.text.split('\n')) { if (!line.trim()) continue; try { recs.push(JSON.parse(line)); } catch (e) { } }
158
+ return { name: f.name, text: core.toJsonl(core.redact(recs)) };
159
+ });
160
+ }
161
+
125
162
  const safe = (s) => s.replace(/<\/script/gi, '<\\/script').replace(/<!--/g, '<\\!--');
126
163
  export function embed(files, opts = {}) {
127
164
  const template = opts.template || path.join(ROOT, 'dist/glassbox.html');
@@ -192,6 +229,10 @@ export function checkReport({ trace, findings, cost, claims }, opts = {}) {
192
229
  // Per-file use, keyed when a legend is given; with --redact and no legend the paths would leak, so it is omitted.
193
230
  if (legend || !redact) summary.files = core.fileStats(trace).map((r) => ({ key: legend ? legend.keyFor(r.path) : r.path, reads: r.reads, writes: r.writes, errors: r.errors, agents: r.agents, chars: r.chars }));
194
231
  if (claims) summary.claims = claims.summary; // said vs did, in one line of numbers (additive; schema stays 2)
232
+ // Is the rate card right for this session's models and date? opts.rates is a --rates card (null = built-in).
233
+ summary.rateCard = core.rateCardCheck(m.models, opts.rates || undefined, { latest: m.end, custom: !!(opts.rates && opts.rates._custom) });
234
+ const rateLine = core.rateCardLine(summary.rateCard);
235
+ summary.pricing = core.pricingNote(m.models, opts.rates || undefined);
195
236
  const findingJson = (f) => {
196
237
  const ev = f.evidence || {};
197
238
  const paths = Array.from(new Set((ev.toolCallIds || []).map((id) => { const c = trace.toolCalls.find((x) => x.id === id); return c ? core.filePathOf(c) : null; }).filter(Boolean)));
@@ -207,13 +248,15 @@ export function checkReport({ trace, findings, cost, claims }, opts = {}) {
207
248
  lines.push(`Glassbox · ${m.sessionId ? m.sessionId.slice(0, 8) : 'session'} · ${m.models.join(', ') || 'unknown model'}`);
208
249
  lines.push(` wall ${fmt(T.wallMs)} (active ${fmt(T.activeMs)}) · ${T.turns} turns · ${T.requests} requests · ${T.toolCalls} tool calls (${T.toolErrors} failed${T.orphans ? ', ' + T.orphans + ' unanswered' : ''})`);
209
250
  lines.push(` context served ${fi(T.usage.input + T.usage.cacheRead + T.usage.cacheWrite)} tokens (${T.cacheHitRatio != null ? Math.round(T.cacheHitRatio * 100) + '% cached' : 'no usage'}) · output ${fi(T.usage.output)} (${fi(T.usage.thinking)} thinking) · est. cost ${summary.costLowerBound ? '≥ ' : ''}${usd(summary.cost)}${summary.costLowerBound ? ' (floor)' : ''}`);
251
+ if (summary.pricing) lines.push(' ' + summary.pricing);
252
+ if (rateLine) lines.push(' ' + rateLine);
210
253
  lines.push('');
211
254
  if (!findings.length) lines.push(' no findings');
212
255
  for (const g of collapseFindings(findings)) lines.push(' ' + findingLine(g));
213
256
  if (redact) lines.push('', ' (redacted: prompt text, tool inputs and result text blanked)');
214
257
  lines.push('');
215
258
  lines.push(failing.length ? ` FAIL: ${failing.length} finding${failing.length > 1 ? 's' : ''} at or above "${failOn}"` : ` OK: nothing at or above "${failOn}"`);
216
- const md = core.reportMarkdown(trace, findings, cost, { redact, maxEvidence: opts.maxEvidence });
259
+ const md = core.reportMarkdown(trace, findings, cost, { redact, maxEvidence: opts.maxEvidence }) + [summary.pricing, rateLine].filter(Boolean).map((l) => `\n> ${l}\n`).join('');
217
260
  return { text: lines.join('\n'), json, markdown: md, failed: failing.length > 0 };
218
261
  }
219
262
 
@@ -266,10 +309,21 @@ function undeliveredFindings(sessionId, top, stateDir) {
266
309
  return fresh;
267
310
  }
268
311
 
312
+ // --memory: where the lessons the agent reads live. agent (default): a block in its own memory (auto memory's
313
+ // MEMORY.md, else CLAUDE.local.md); glassbox: 0.11's note from the SessionStart hook.
314
+ export function memoryMode(v) {
315
+ if (v == null || v === true || v === '') return 'agent';
316
+ const m = String(v).toLowerCase();
317
+ if (m !== 'agent' && m !== 'glassbox') throw new Error(`--memory must be agent or glassbox (got "${v}")`);
318
+ return m;
319
+ }
269
320
  export function hookResponse(input, opts = {}) {
270
321
  const failOn = opts.failOn || 'warn';
271
322
  const feedback = !!opts.feedback;
272
- if (input && input.hook_event_name === 'SessionStart') return opts.context ? sessionStartResponse(input) : { suppressOutput: true };
323
+ // One memory (0.12): with --memory agent (the default) the lessons live in the agent's own memory and the
324
+ // SessionStart hook says nothing; --memory glassbox keeps 0.11's note.
325
+ const memMode = opts.memory || 'agent';
326
+ if (input && input.hook_event_name === 'SessionStart') return opts.context && memMode === 'glassbox' ? sessionStartResponse(input) : { suppressOutput: true };
273
327
  // PreToolUse: null means "say nothing" (normal permission flow), never an approval.
274
328
  if (input && input.hook_event_name === 'PreToolUse') return opts.guard ? guardResponse(input) : null;
275
329
  // Second Stop after a feedback block: record what the agent said it would do differently, then let it stop.
@@ -281,7 +335,7 @@ export function hookResponse(input, opts = {}) {
281
335
  if (!input || !input.transcript_path || !fs.existsSync(input.transcript_path)) return { systemMessage: 'Glassbox: no transcript_path in hook input.', suppressOutput: true };
282
336
  const s = sessionFromTranscriptPath(input.transcript_path);
283
337
  const res = analyse(loadSessionFiles(s), opts.rates);
284
- const rep = checkReport(res, { failOn });
338
+ const rep = checkReport(res, { failOn, rates: opts.rates });
285
339
  const T = res.trace.totals;
286
340
  const usd = (v) => v == null ? '' : ' · ' + (v < 1 ? '$' + v.toFixed(3) : '$' + v.toFixed(2));
287
341
  const top = res.findings.filter((f) => SEV[f.severity] <= SEV[failOn]);
@@ -298,7 +352,12 @@ export function hookResponse(input, opts = {}) {
298
352
  writeNotes(input.cwd, `# Glassbox: last session in this project\n\n${when} UTC · session ${String(input.session_id || s.id).slice(0, 8)} · ${head.replace(/^Glassbox · /, '')}\n\n${body.trim()}\n`);
299
353
  } else if (file && fs.existsSync(file)) fs.unlinkSync(file); // a clean session: don't keep old advice around
300
354
  // The memory ledger: this session's digest and lessons. A failure here must not cost the summary.
301
- try { recordSession(input.cwd, res, { sessionId: input.session_id || s.id, failOn }); } catch (e) { out.systemMessage += `\nGlassbox memory: ${e.message}`; }
355
+ try {
356
+ const sid = input.session_id || s.id;
357
+ if (memMode === 'agent') toldFromBlock(input.cwd, sid, { home: opts.home });
358
+ recordSession(input.cwd, res, { sessionId: sid, failOn });
359
+ if (memMode === 'agent') syncLessonsBlock(input.cwd, { home: opts.home });
360
+ } catch (e) { out.systemMessage += `\nGlassbox memory: ${e.message}`; }
302
361
  }
303
362
  // Feedback loop: only on Stop, never while continuing from our own block (stop_hook_active), and each
304
363
  // finding only once per session. stop_hook_active resets every turn, so without the delivered set a
@@ -311,6 +370,9 @@ export function hookResponse(input, opts = {}) {
311
370
  return out;
312
371
  }
313
372
 
373
+ export function readStdinText() {
374
+ return new Promise((resolve) => { let buf = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (d) => { buf += d; }); process.stdin.on('end', () => resolve(buf)); if (process.stdin.isTTY) resolve(''); });
375
+ }
314
376
  export function readStdinJson() {
315
377
  return new Promise((resolve) => { let buf = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (d) => { buf += d; }); process.stdin.on('end', () => { try { resolve(JSON.parse(buf || '{}')); } catch (e) { resolve({}); } }); if (process.stdin.isTTY) resolve({}); });
316
378
  }
@@ -324,19 +386,28 @@ export function installHook(opts = {}) {
324
386
  let settings = {};
325
387
  if (fs.existsSync(file)) { try { settings = JSON.parse(readTextFile(file)); } catch (e) { throw new Error(`${file} is not valid JSON — fix it first (nothing was changed)`); } fs.copyFileSync(file, file + '.glassbox-backup'); }
326
388
  settings.hooks = settings.hooks || {};
327
- const cmd = `${opts.command || 'npx -y glassbox-trace'} hook${opts.feedback ? ' --feedback' : ''}${opts.context ? ' --context' : ''}${opts.guard ? ' --guard' : ''}${opts.failOn ? ' --fail-on ' + opts.failOn : ''}`;
328
- const events = opts.events || ['Stop', ...(opts.context ? ['SessionStart'] : []), ...(opts.guard ? ['PreToolUse'] : [])];
389
+ const { command: cmd, events, entries } = hookEntries(opts);
329
390
  for (const ev of events) {
330
391
  const list = Array.isArray(settings.hooks[ev]) ? settings.hooks[ev] : [];
331
392
  const kept = list.filter((entry) => !(entry && Array.isArray(entry.hooks) && entry.hooks.some((h) => h && typeof h.command === 'string' && HOOK_RE.test(h.command))));
332
- // PreToolUse runs before every tool call, so it gets a short timeout; no matcher means all tools.
333
- kept.push({ hooks: [{ type: 'command', command: cmd, timeout: ev === 'PreToolUse' ? 10 : 60 }] });
393
+ kept.push(entries[ev]);
334
394
  settings.hooks[ev] = kept;
335
395
  }
336
396
  fs.mkdirSync(path.dirname(file), { recursive: true });
337
397
  fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
338
398
  return { file, command: cmd, events };
339
399
  }
400
+ // The hook command and one settings entry per event, shared by `hook install` and `managed`.
401
+ export function hookEntries(opts = {}) {
402
+ // --memory glassbox (0.11's SessionStart note) is the only mode that needs SessionStart; the default writes the
403
+ // lessons into the agent's own memory from the Stop hook (0.12, DESIGN.md §21).
404
+ const legacy = opts.context && opts.memory === 'glassbox';
405
+ const command = `${opts.command || 'npx -y glassbox-trace'} hook${opts.feedback ? ' --feedback' : ''}${opts.context ? ' --context' : ''}${legacy ? ' --memory glassbox' : ''}${opts.guard ? ' --guard' : ''}${opts.failOn ? ' --fail-on ' + opts.failOn : ''}`;
406
+ const events = opts.events || ['Stop', ...(legacy ? ['SessionStart'] : []), ...(opts.guard ? ['PreToolUse'] : [])];
407
+ // PreToolUse runs before every tool call, so it gets a short timeout; no matcher means all tools.
408
+ const entries = {}; for (const ev of events) entries[ev] = { hooks: [{ type: 'command', command, timeout: ev === 'PreToolUse' ? 10 : 60 }] };
409
+ return { command, events, entries };
410
+ }
340
411
  // The guard runs before every tool call, so it can't go through npx (seconds per call). Point the hook at
341
412
  // this install's own entry point instead — unless this *is* a throwaway npx cache copy.
342
413
  export function guardCommand(root) {
@@ -361,7 +432,7 @@ export function parseArgs(argv) {
361
432
  const args = { _: [], flags: {} };
362
433
  for (let i = 0; i < argv.length; i++) {
363
434
  const a = argv[i];
364
- if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); if (v !== undefined) args.flags[k] = v; else if (i + 1 < argv.length && !argv[i + 1].startsWith('-') && ['last', 'grep', 'project', 'out', 'fail-on', 'home', 'command', 'events', 'format', 'label-a', 'label-b', 'port', 'rates', 'since', 'legend', 'claude-md', 'fail-under', 'key', 'limit'].includes(k)) args.flags[k] = argv[++i]; else args.flags[k] = true; }
435
+ if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); if (v !== undefined) args.flags[k] = v; else if (i + 1 < argv.length && !argv[i + 1].startsWith('-') && ['last', 'grep', 'project', 'out', 'fail-on', 'home', 'command', 'events', 'format', 'label-a', 'label-b', 'port', 'rates', 'since', 'legend', 'claude-md', 'fail-under', 'key', 'limit', 'memory', 'top', 'plan', 'plan-price', 'bill-day', 'platform', 'endpoint', 'geo', 'markings-file', 'temp', 'dir'].includes(k)) args.flags[k] = argv[++i]; else args.flags[k] = true; }
365
436
  else args._.push(a);
366
437
  }
367
438
  return args;
@@ -372,7 +443,7 @@ export const HELP = `glassbox — other tools show you what happened in a Claude
372
443
  glassbox open the newest session in your browser
373
444
  glassbox list [--last N] [--grep TEXT] [--project PATH]
374
445
  list sessions, newest first
375
- glassbox open [ID|FILE] [--out FILE.html] [--no-open]
446
+ glassbox open [ID|FILE] [--out FILE.html] [--no-open] [--redact]
376
447
  build a self-contained HTML for a session (id prefix or .jsonl path)
377
448
  glassbox check [ID|FILE] [--fail-on error|warn|info] [--format text|json|md] [--redact]
378
449
  print findings; exit 1 when any finding is at/above --fail-on (default: error), 2 on a usage error
@@ -389,11 +460,12 @@ export const HELP = `glassbox — other tools show you what happened in a Claude
389
460
  same task, two sessions: what changed in time, tokens, cost, tools and findings
390
461
  glassbox watch [ID|FILE] [--port N] [--no-open]
391
462
  live tail: serves the viewer on 127.0.0.1 and pushes the transcript as it grows
392
- glassbox hook install [--feedback] [--context] [--guard] [--fail-on warn]
463
+ glassbox hook install [--feedback] [--context [--memory agent|glassbox]] [--guard] [--fail-on warn]
393
464
  add a Claude Code Stop hook so every session ends with a Glassbox summary;
394
465
  --feedback also hands the findings back to the agent once, so it can learn from them;
395
466
  --context keeps a memory in <project>/.glassbox/ (lessons that keep coming back, a digest
396
- per session, the last session's notes) and gives the next session there what it needs;
467
+ per session) and writes the lessons into the agent's own memory (auto memory's MEMORY.md,
468
+ else CLAUDE.local.md) as one block; --memory glassbox hands 0.11's note at session start;
397
469
  --guard blocks a tool call that already failed twice in a row, unchanged, and says why
398
470
  glassbox hook uninstall remove it (a .glassbox-backup of settings.json is kept)
399
471
  glassbox collect DIR [--since 30d] [--format text|md|json] [--out FILE] [--top N]
@@ -414,6 +486,9 @@ export const HELP = `glassbox — other tools show you what happened in a Claude
414
486
  credential-file reads — with a masked preview and a fingerprint, never the value.
415
487
  no target = every session under the home; exit 1 when anything at/above --fail-on was found
416
488
  --shred overwrites each value in place with [FENCED:<rule>:<fingerprint>] (no backup)
489
+ --markings also finds export-control markings (classification banners, ITAR / EAR
490
+ legends, CUI, DoD distribution statements B–F, ECCNs); --markings-file FILE adds your
491
+ own ({ id, pattern, flags?, severity? } per entry). Markings are never shredded
417
492
  glassbox recall [WORDS…] [--project DIR] [--since 30d] [--limit N] [--format text|md|json] [--out FILE]
418
493
  search this project's past sessions: what was asked, written, run, committed and said,
419
494
  from the transcripts still on disk and .glassbox/memory.json; no words = the latest ones
@@ -424,11 +499,33 @@ export const HELP = `glassbox — other tools show you what happened in a Claude
424
499
  glassbox memory audit [--project DIR] [--format text|md|json] [--out FILE] [--fail-on error|warn|info|none]
425
500
  everything loaded before the first prompt (CLAUDE.md files, rules, @imports, auto memory,
426
501
  Glassbox's own note) with its token cost, dead imports and paths, duplicates and conflicting rules
502
+ glassbox plan [--since 7d] [--plan pro|max-5x|max-20x|none] [--plan-price N] [--bill-day D] [--top N] [--format text|md|json] [--out FILE] [--redact]
503
+ what a Pro/Max plan carried, at list price: billing months vs the plan price, the last
504
+ 7 days by model, rebuilt 5-hour windows (heaviest first) and limit hits. The plan comes
505
+ from ~/.claude.json; Claude Code on this machine only, and not your share of the limits
506
+ glassbox plan --live add the plan's real readings from the status line: the latest 5-hour and weekly %,
507
+ exact windows with their peak %, and how much the week moved while each session ran
508
+ glassbox statusline install [--no-color]
509
+ put the plan's real 5-hour and weekly % in Claude Code's status bar, and log each
510
+ change to ~/.claude/glassbox/limits.jsonl for plan --live; an existing status line
511
+ keeps running in front of it
512
+ glassbox statusline uninstall remove it and put back the status line you had (a .glassbox-backup is kept)
513
+ glassbox managed [--hook [--feedback] [--context [--memory agent|glassbox]] [--guard]] [--statusline] [--temp allow|redact|deny] [--install | --out FILE] [--uninstall] [--command CMD]
514
+ Glassbox for every user on the machine, through Claude Code's managed settings: prints
515
+ the settings, or with --install writes its own drop-in, managed-settings.d/glassbox.json
516
+ (needs an administrator; never edits managed-settings.json). --temp sets GLASSBOX_TEMP
427
517
  glassbox hook (what Claude Code runs: reads the hook JSON on stdin, replies on stdout)
428
518
 
429
519
  Options --home DIR use DIR instead of ~/.claude (or set GLASSBOX_HOME)
430
- --rates FILE a JSON rate card for check, compare and hook (or set GLASSBOX_RATES)
520
+ --rates FILE a JSON rate card for check, compare, plan and hook (or set GLASSBOX_RATES)
521
+ --platform anthropic|bedrock|vertex --endpoint global|regional --geo us
522
+ how the requests were billed: regional endpoints add 10% on Claude 4.5 and later (Bedrock
523
+ ids carry it: us.anthropic.… is regional, global.anthropic.… is not); --geo us is first-party
524
+ US-only inference, +10% on Claude 4.6 and later
431
525
  --version --help
526
+ Temp open writes the transcript to the temp folder unless --out is given; GLASSBOX_TEMP=redact blanks
527
+ the text first, GLASSBOX_TEMP=deny forbids it (also read from env in the managed settings);
528
+ open --redact does the same for one run
432
529
  Browser set GLASSBOX_BROWSER to a command to open HTML files with
433
530
 
434
531
  Nothing leaves your machine. Subagent transcripts next to the session are included automatically.`;
@@ -465,7 +562,7 @@ export async function main(argv, io = {}) {
465
562
  if (args.flags.help || cmd === 'help') { out(HELP); return 0; }
466
563
  if (args.flags.version) { out(JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version); return 0; }
467
564
  try {
468
- const rates = loadRates(args.flags.rates || (io.env || process.env).GLASSBOX_RATES);
565
+ const rates = pricingFromFlags(args.flags, loadRates(args.flags.rates || (io.env || process.env).GLASSBOX_RATES));
469
566
  if (cmd === 'list') {
470
567
  const list = findSessions({ home, last: args.flags.last ? +args.flags.last : 20, grep: args.flags.grep, project: args.flags.project });
471
568
  if (!list.length) { out(noSessionsMessage(home)); return 0; }
@@ -489,7 +586,7 @@ export async function main(argv, io = {}) {
489
586
  const since = args.flags.since ? parseSince(args.flags.since) : null;
490
587
  const list = findSessions({ home, project: args.flags.project }).filter((s) => since == null || s.mtime >= Date.now() - since);
491
588
  const fmtOut = outputFormat(args.flags);
492
- const reps = list.map((s) => ({ s, rep: checkReport(analyse(loadSessionFiles(s), rates), { failOn: args.flags['fail-on'], redact: !!args.flags.redact, legend }) }));
589
+ const reps = list.map((s) => ({ s, rep: checkReport(analyse(loadSessionFiles(s), rates), { failOn: args.flags['fail-on'], redact: !!args.flags.redact, legend, rates }) }));
493
590
  saveLegend();
494
591
  const failed = reps.filter((r) => r.rep.failed).length;
495
592
  if (fmtOut === 'json') out(JSON.stringify({ glassbox: core.VERSION, schema: 2, redacted: !!args.flags.redact, legend: !!legend, failOn: args.flags['fail-on'] || 'error', failed: failed > 0, sessions: reps.map((r) => r.rep.json) }, null, 2));
@@ -503,7 +600,7 @@ export async function main(argv, io = {}) {
503
600
  }
504
601
  if (cmd === 'check') {
505
602
  const s = resolveTarget(args._[1], { home });
506
- const res = checkReport(analyse(loadSessionFiles(s), rates), { failOn: args.flags['fail-on'], redact: !!args.flags.redact, legend });
603
+ const res = checkReport(analyse(loadSessionFiles(s), rates), { failOn: args.flags['fail-on'], redact: !!args.flags.redact, legend, rates });
507
604
  saveLegend();
508
605
  const fmtOut = outputFormat(args.flags);
509
606
  if (fmtOut === 'json') out(JSON.stringify(res.json, null, 2)); else if (fmtOut === 'md') out(res.markdown); else out(res.text);
@@ -526,12 +623,21 @@ export async function main(argv, io = {}) {
526
623
  return 0;
527
624
  }
528
625
  if (cmd === 'open' && !args.flags.watch) {
626
+ // The temp folder is the one place `open` writes without being told where; GLASSBOX_TEMP (or the managed
627
+ // settings' env) can make that redacted-only or forbid it (src/managed.mjs).
628
+ const { tempPolicy } = await import('./managed.mjs');
629
+ const tp = args.flags.out ? null : tempPolicy(io.env || process.env, io.managedDir);
630
+ if (tp && tp.policy === 'deny') throw new Error(`the temp folder is closed to transcripts (GLASSBOX_TEMP=deny, from ${tp.from}): pass --out FILE to choose where the HTML goes`);
529
631
  const s = resolveTarget(args._[1], { home });
530
- const files = loadSessionFiles(s);
632
+ const raw = loadSessionFiles(s);
633
+ const big = raw.find((f) => Array.isArray(f.records));
634
+ if (big) throw new Error(`${big.name} is ${(big.bytes / 1048576).toFixed(0)} MB — too large for the browser viewer, which reads a transcript whole. glassbox check ${s.id.slice(0, 8)} reads it line by line.`);
635
+ const redacted = !!args.flags.redact || (tp && tp.policy === 'redact');
636
+ const files = redacted ? redactFiles(raw) : raw;
531
637
  const html = embed(files, { template: io.template });
532
638
  const outFile = args.flags.out ? path.resolve(args.flags.out) : path.join(os.tmpdir(), `glassbox-${s.id.slice(0, 8)}.html`);
533
639
  fs.writeFileSync(outFile, html);
534
- out(`${outFile} (${files.length} file${files.length > 1 ? 's' : ''}, ${Math.round(html.length / 1024)} KB)`);
640
+ out(`${outFile} (${files.length} file${files.length > 1 ? 's' : ''}, ${Math.round(html.length / 1024)} KB${redacted ? ', redacted' : ''})`);
535
641
  if (!args.flags['no-open'] && !io.noOpen) openInBrowser(outFile);
536
642
  return 0;
537
643
  }
@@ -572,12 +678,12 @@ export async function main(argv, io = {}) {
572
678
  }
573
679
  if (cmd === 'hook') {
574
680
  const sub = args._[1];
575
- if (sub === 'install') { const r = installHook({ home, feedback: !!args.flags.feedback, context: !!args.flags.context, guard: !!args.flags.guard, failOn: args.flags['fail-on'], command: args.flags.command || (args.flags.guard ? guardCommand(ROOT) : undefined), events: args.flags.events ? String(args.flags.events).split(',') : undefined }); out(`Installed ${r.events.join(', ')} hook${r.events.length > 1 ? 's' : ''} in ${r.file}\n ${r.command}\nEvery session now ends with a Glassbox summary${args.flags.feedback ? ', and findings are handed back to the agent once' : ''}${args.flags.context ? `; findings are kept in <project>/${NOTES_FILE.replace(/\\/g, '/')} and handed to the next session there` : ''}${args.flags.guard ? '; a call that already failed twice in a row, unchanged, is blocked with the reason' : ''}. Restart Claude Code to pick it up.`); return 0; }
681
+ if (sub === 'install') { const mem = memoryMode(args.flags.memory); const r = installHook({ home, feedback: !!args.flags.feedback, context: !!args.flags.context, memory: mem, guard: !!args.flags.guard, failOn: args.flags['fail-on'], command: args.flags.command || (args.flags.guard ? guardCommand(ROOT) : undefined), events: args.flags.events ? String(args.flags.events).split(',') : undefined }); out(`Installed ${r.events.join(', ')} hook${r.events.length > 1 ? 's' : ''} in ${r.file}\n ${r.command}\nEvery session now ends with a Glassbox summary${args.flags.feedback ? ', and findings are handed back to the agent once' : ''}${args.flags.context ? (mem === 'agent' ? '; the lessons that keep coming back are kept in the agent\'s own memory (auto memory\'s MEMORY.md, else CLAUDE.local.md), in one block Glassbox rewrites' : `; findings are kept in <project>/${NOTES_FILE.replace(/\\/g, '/')} and handed to the next session there`) : ''}${args.flags.guard ? '; a call that already failed twice in a row, unchanged, is blocked with the reason' : ''}. Restart Claude Code to pick it up.`); return 0; }
576
682
  if (sub === 'uninstall') { const r = uninstallHook({ home }); out(`Removed ${r.removed} Glassbox hook${r.removed === 1 ? '' : 's'} from ${r.file}`); return 0; }
577
683
  // A hook must never fail loudly: exit 2 from a Stop hook blocks Claude with the error as the reason.
578
684
  // A null reply prints nothing, which for PreToolUse means "no decision".
579
685
  let reply = null, event = null;
580
- try { const input = io.stdin !== undefined ? io.stdin : await readStdinJson(); event = input && input.hook_event_name; reply = hookResponse(input, { failOn: args.flags['fail-on'], feedback: !!args.flags.feedback, context: !!args.flags.context, guard: !!args.flags.guard, rates, stateDir: io.stateDir }); }
686
+ try { const input = io.stdin !== undefined ? io.stdin : await readStdinJson(); event = input && input.hook_event_name; reply = hookResponse(input, { failOn: args.flags['fail-on'], feedback: !!args.flags.feedback, context: !!args.flags.context, memory: memoryMode(args.flags.memory), guard: !!args.flags.guard, rates, stateDir: io.stateDir, home }); }
581
687
  catch (e) { reply = event === 'PreToolUse' ? null : { systemMessage: 'Glassbox: ' + e.message, suppressOutput: true }; }
582
688
  if (reply != null) out(JSON.stringify(reply));
583
689
  return 0;
@@ -604,7 +710,7 @@ export async function main(argv, io = {}) {
604
710
  const since = args.flags.since ? Date.now() - parseSince(args.flags.since) : null;
605
711
  const failOn = args.flags['fail-on'] || 'error';
606
712
  const fmtOut = outputFormat(args.flags);
607
- const rep = fence({ home, target: args._[1], since, project: args.flags.project, shred: !!args.flags.shred, keyFile: args.flags.key, sessionsOnly: !!args.flags['sessions-only'] });
713
+ const rep = fence({ home, target: args._[1], since, project: args.flags.project, shred: !!args.flags.shred, keyFile: args.flags.key, sessionsOnly: !!args.flags['sessions-only'], markings: !!args.flags.markings, markingsFile: args.flags['markings-file'] });
608
714
  const failed = failedAt(rep, failOn);
609
715
  const text = fmtOut === 'json' ? JSON.stringify(rep, null, 2) : fmtOut === 'md' ? fenceMarkdown(rep) : fenceText(rep);
610
716
  if (args.flags.out) { const f = path.resolve(args.flags.out); fs.writeFileSync(f, text); out(`${f} (${rep.scanned.files} files, ${rep.findings.length} findings${rep.shredded ? `, ${rep.shredded.values} values shredded` : ''})`); }
@@ -652,12 +758,57 @@ export async function main(argv, io = {}) {
652
758
  out(keys.length ? `Forgot ${keys.length} lesson${keys.length === 1 ? '' : 's'} in ${project}:\n ${keys.join('\n ')}` : `No lesson matches "${what}" in ${project} — glassbox memory lists them`);
653
759
  return keys.length ? 0 : 1;
654
760
  }
655
- if (sub === 'clear') { const removed = clearMemory(project); out(removed.length ? `Removed:\n ${removed.join('\n ')}` : `No Glassbox memory in ${project}`); return 0; }
761
+ if (sub === 'clear') { const removed = clearMemory(project, { home }); out(removed.length ? `Removed:\n ${removed.join('\n ')}` : `No Glassbox memory in ${project}`); return 0; }
656
762
  if (sub !== 'show') throw new Error(`memory: unknown subcommand "${sub}" (show, forget, clear, audit)`);
657
- const rep = memoryReport({ project });
763
+ const rep = memoryReport({ project, home });
658
764
  writeOut(fmtOut === 'json' ? JSON.stringify(rep, null, 2) : fmtOut === 'md' ? memoryMarkdown(rep) : memoryText(rep), `${rep.sessions} sessions, ${rep.summary.active} active lessons`);
659
765
  return 0;
660
766
  }
767
+ if (cmd === 'plan') {
768
+ // What a Pro/Max plan carried: list price by billing month, last 7 days, rebuilt 5-hour windows, limit hits.
769
+ const { plan, planText, planMarkdown } = await import('./plan.mjs');
770
+ const num = (k) => { if (args.flags[k] === undefined) return null; const v = Number(args.flags[k]); if (!Number.isFinite(v) || v < 0) throw new Error(`--${k} must be a number (got "${args.flags[k]}")`); return v; };
771
+ const fmtOut = outputFormat(args.flags);
772
+ const rep = plan({ home, now: io.now, since: args.flags.since ? parseSince(args.flags.since) : undefined, plan: args.flags.plan !== undefined ? String(args.flags.plan) : undefined, planPrice: num('plan-price'), billDay: num('bill-day'), top: num('top') || 5, redact: !!args.flags.redact, rates, live: !!args.flags.live });
773
+ const text = fmtOut === 'json' ? JSON.stringify(rep, null, 2) : fmtOut === 'md' ? planMarkdown(rep) : planText(rep);
774
+ if (args.flags.out) { const f = path.resolve(args.flags.out); fs.writeFileSync(f, text); out(`${f} (${rep.sessions} sessions, ${rep.requests} requests)`); }
775
+ else out(text.replace(/\n$/, ''));
776
+ return 0;
777
+ }
778
+ if (cmd === 'managed') {
779
+ // Glassbox for every user on the machine: a drop-in in Claude Code's managed-settings.d/, printed or written.
780
+ const mg = await import('./managed.mjs');
781
+ const dir = args.flags.dir ? String(args.flags.dir) : mg.managedDir(process.platform, io.env || process.env);
782
+ const file = args.flags.out ? path.resolve(String(args.flags.out)) : mg.dropInFile(dir);
783
+ if (args.flags.uninstall) { const r = mg.removeDropIn({ file }); out(r.removed ? `Removed ${r.file}` : `Nothing to remove: ${r.file} is not there`); return 0; }
784
+ const command = args.flags.command ? String(args.flags.command) : guardCommand(ROOT);
785
+ const frag = mg.managedFragment({ command, hook: !!args.flags.hook, feedback: !!args.flags.feedback, context: !!args.flags.context, memory: memoryMode(args.flags.memory), guard: !!args.flags.guard, failOn: args.flags['fail-on'], statusline: !!args.flags.statusline, noColor: !!args.flags['no-color'], temp: args.flags.temp !== undefined ? String(args.flags.temp).toLowerCase() : undefined });
786
+ if ((frag.hooks || frag.statusLine) && mg.userScoped(command)) err(`managed: the command points into ${os.homedir()}, which the machine's other users can't run. Install Glassbox machine-wide (npm i -g with a shared prefix) or pass --command.`);
787
+ if (!args.flags.install && !args.flags.out) { out(JSON.stringify(frag, null, 2)); err(`managed: printed only. To apply it for every user, save it as ${mg.dropInFile(dir)} (needs an administrator), or run this again with --install.`); return 0; }
788
+ let r; try { r = mg.writeDropIn(frag, { file }); } catch (e) { if (e.code === 'EACCES' || e.code === 'EPERM') throw new Error(`can't write ${file}: run this from an administrator prompt (or with sudo), or print the fragment and hand it to whoever manages the machine`); throw e; }
789
+ out(`Wrote ${r.file}${frag.hooks ? `\n hooks: ${Object.keys(frag.hooks).join(', ')}` : ''}${frag.statusLine ? `\n statusLine: ${frag.statusLine.command}` : ''}${frag.env ? `\n env: GLASSBOX_TEMP=${frag.env.GLASSBOX_TEMP}` : ''}\nClaude Code reads it at start for every user on this machine. Remove with: glassbox managed --uninstall`);
790
+ return 0;
791
+ }
792
+ if (cmd === 'statusline') {
793
+ // What Claude Code runs on every status refresh (JSON on stdin), or install / uninstall in settings.json.
794
+ const sl = await import('./statusline.mjs');
795
+ const sub = args._[1];
796
+ if (sub === 'install') {
797
+ const r = sl.installStatusLine({ home, command: args.flags.command || guardCommand(ROOT), noColor: !!args.flags['no-color'] });
798
+ out(`Installed the Glassbox status line in ${r.file}\n ${r.command}\n${r.kept ? `Your previous status line keeps running in front of it: ${r.kept}\n` : ''}Readings go to ${sl.limitsFile(home)}; \`glassbox plan --live\` reads them. Restart Claude Code to pick it up.`);
799
+ return 0;
800
+ }
801
+ if (sub === 'uninstall') { const r = sl.uninstallStatusLine({ home }); out(r.removed ? `Removed the Glassbox status line from ${r.file}${r.restored ? `; restored ${r.restored}` : ''}` : `No Glassbox status line in ${r.file}`); return 0; }
802
+ // Never fail loudly: a status bar that shows an error on every refresh is worse than a short one.
803
+ let line;
804
+ try {
805
+ const raw = io.stdin !== undefined ? (typeof io.stdin === 'string' ? io.stdin : JSON.stringify(io.stdin)) : await readStdinText();
806
+ let input = {}; try { input = JSON.parse(raw || '{}'); } catch (e) { }
807
+ line = sl.statusline(input, { home, now: io.now, raw, color: !(args.flags['no-color'] || (io.env || process.env).NO_COLOR) });
808
+ } catch (e) { line = 'glassbox: ' + e.message; }
809
+ out(line);
810
+ return 0;
811
+ }
661
812
  err('Unknown command: ' + cmd + '\n'); out(HELP); return 2;
662
813
  } catch (e) { err('glassbox: ' + e.message); return 2; }
663
814
  }