@tekyzinc/gsd-t 3.29.10 → 4.0.12
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/CHANGELOG.md +176 -0
- package/bin/gsd-t-parallel.cjs +64 -13
- package/bin/gsd-t-test-data-adapters/file-json-array.cjs +27 -3
- package/bin/gsd-t-test-data-adapters/localstorage-key-prefix.cjs +6 -1
- package/bin/gsd-t-test-data-adapters/sqlite-table-where.cjs +16 -1
- package/bin/gsd-t-test-data-ledger.cjs +1 -0
- package/bin/gsd-t.js +48 -376
- package/bin/journey-coverage.cjs +6 -3
- package/bin/parallel-cli.cjs +13 -1
- package/commands/gsd-t-debug.md +47 -533
- package/commands/gsd-t-design-decompose.md +24 -497
- package/commands/gsd-t-doc-ripple.md +23 -139
- package/commands/gsd-t-execute.md +41 -958
- package/commands/gsd-t-help.md +0 -2
- package/commands/gsd-t-impact.md +26 -295
- package/commands/gsd-t-integrate.md +44 -369
- package/commands/gsd-t-milestone.md +25 -124
- package/commands/gsd-t-partition.md +28 -539
- package/commands/gsd-t-plan.md +26 -472
- package/commands/gsd-t-prd.md +25 -311
- package/commands/gsd-t-quick.md +1 -1
- package/commands/gsd-t-resume.md +0 -33
- package/commands/gsd-t-verify.md +52 -569
- package/commands/gsd-t-wave.md +41 -430
- package/package.json +1 -1
- package/scripts/gsd-t-calibration-hook.js +3 -1
- package/scripts/hooks/pre-commit-journey-coverage +0 -2
- package/scripts/hooks/pre-commit-playwright-gate +0 -2
- package/templates/CLAUDE-global.md +43 -173
- package/templates/CLAUDE-project.md +118 -104
- package/templates/prompts/design-verify-subagent.md +3 -0
- package/templates/prompts/qa-subagent.md +3 -0
- package/templates/prompts/red-team-subagent.md +5 -2
- package/templates/workflows/_lib.js +176 -0
- package/templates/workflows/gsd-t-debug.workflow.js +86 -0
- package/templates/workflows/gsd-t-execute.workflow.js +206 -0
- package/templates/workflows/gsd-t-integrate.workflow.js +71 -0
- package/templates/workflows/gsd-t-phase.workflow.js +94 -0
- package/templates/workflows/gsd-t-quick.workflow.js +72 -0
- package/templates/workflows/gsd-t-verify.workflow.js +348 -0
- package/templates/workflows/gsd-t-wave.workflow.js +43 -0
- package/bin/check-headless-sessions.js +0 -140
- package/bin/context-budget-audit.cjs +0 -447
- package/bin/context-meter-config.cjs +0 -101
- package/bin/context-meter-config.test.cjs +0 -101
- package/bin/event-stream.cjs +0 -205
- package/bin/gsd-t-benchmark-orchestrator.js +0 -437
- package/bin/gsd-t-capture-lint.cjs +0 -440
- package/bin/gsd-t-economics.cjs +0 -315
- package/bin/gsd-t-in-session-usage.cjs +0 -213
- package/bin/gsd-t-orchestrator-config.cjs +0 -161
- package/bin/gsd-t-orchestrator-queue.cjs +0 -180
- package/bin/gsd-t-orchestrator-recover.cjs +0 -231
- package/bin/gsd-t-orchestrator-worker.cjs +0 -219
- package/bin/gsd-t-orchestrator.js +0 -535
- package/bin/gsd-t-parallel-probe.cjs +0 -132
- package/bin/gsd-t-ratelimit-probe-worker.cjs +0 -237
- package/bin/gsd-t-ratelimit-probe.cjs +0 -648
- package/bin/gsd-t-report-tokens.cjs +0 -549
- package/bin/gsd-t-stream-feed-client.cjs +0 -151
- package/bin/gsd-t-token-backfill.cjs +0 -366
- package/bin/gsd-t-token-capture.cjs +0 -321
- package/bin/gsd-t-token-dashboard.cjs +0 -353
- package/bin/gsd-t-token-regenerate-log.cjs +0 -129
- package/bin/gsd-t-tool-attribution.cjs +0 -377
- package/bin/gsd-t-tool-cost.cjs +0 -195
- package/bin/gsd-t-transcript-tee.cjs +0 -246
- package/bin/gsd-t-unattended-heartbeat.cjs +0 -188
- package/bin/gsd-t-unattended-platform.cjs +0 -551
- package/bin/gsd-t-unattended-platform.js +0 -381
- package/bin/gsd-t-unattended-safety.cjs +0 -773
- package/bin/gsd-t-unattended-safety.js +0 -788
- package/bin/gsd-t-unattended.cjs +0 -2109
- package/bin/gsd-t-unattended.js +0 -1367
- package/bin/gsd-t-worker-dispatch.cjs +0 -211
- package/bin/handoff-lock.cjs +0 -249
- package/bin/handoff-lock.js +0 -249
- package/bin/headless-auto-spawn.cjs +0 -711
- package/bin/headless-auto-spawn.js +0 -373
- package/bin/headless-exit-codes.cjs +0 -67
- package/bin/live-activity-report.cjs +0 -615
- package/bin/log-tail.cjs +0 -81
- package/bin/m44-proof-measure.cjs +0 -285
- package/bin/m46-iter-proof.cjs +0 -149
- package/bin/m46-worker-proof.cjs +0 -201
- package/bin/m55-substrate-proof.cjs +0 -134
- package/bin/metrics-rollup.js +0 -200
- package/bin/model-windows.cjs +0 -99
- package/bin/model-windows.test.cjs +0 -75
- package/bin/parallelism-report.cjs +0 -535
- package/bin/runway-estimator.cjs +0 -242
- package/bin/spawn-plan-derive.cjs +0 -163
- package/bin/spawn-plan-status-updater.cjs +0 -292
- package/bin/spawn-plan-writer.cjs +0 -204
- package/bin/supervisor-pid-fingerprint.cjs +0 -126
- package/bin/token-budget.cjs +0 -265
- package/bin/unattended-watch-format.cjs +0 -178
- package/bin/watch-progress.js +0 -155
- package/commands/gsd-t-unattended-stop.md +0 -85
- package/commands/gsd-t-unattended-watch.md +0 -465
- package/commands/gsd-t-unattended.md +0 -476
- package/commands/gsd-t-visualize.md +0 -116
- package/scripts/gsd-t-context-meter.e2e.test.js +0 -364
- package/scripts/gsd-t-context-meter.js +0 -341
- package/scripts/gsd-t-context-meter.test.js +0 -471
- package/scripts/gsd-t-dashboard-server.js +0 -1203
- package/scripts/gsd-t-post-commit-spawn-plan.sh +0 -86
- package/scripts/gsd-t-transcript.html +0 -1821
- package/scripts/hooks/gsd-t-conversation-capture.js +0 -439
- package/scripts/hooks/gsd-t-in-session-usage-hook.js +0 -84
- package/scripts/spawn-plan-fmt-tokens.cjs +0 -80
- package/templates/hooks/post-commit-spawn-plan.sh +0 -85
|
@@ -1,447 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Context Budget Audit — measures the static context cost of a Claude Code session
|
|
3
|
-
// before any user work happens. Reports tokens consumed by CLAUDE.md files, command
|
|
4
|
-
// files, MCP server tool descriptions, and skills. Used to diagnose why long-running
|
|
5
|
-
// sessions hit the manual compaction prompt.
|
|
6
|
-
//
|
|
7
|
-
// Usage:
|
|
8
|
-
// node bin/context-budget-audit.js # current project + global
|
|
9
|
-
// node bin/context-budget-audit.js --json # JSON output for tooling
|
|
10
|
-
// node bin/context-budget-audit.js --top 20 # top N largest files
|
|
11
|
-
// node bin/context-budget-audit.js --threshold 5000 # flag files above N tokens
|
|
12
|
-
// node bin/context-budget-audit.js --model claude-opus-4-7 # size % to a model's window (default: 1M)
|
|
13
|
-
|
|
14
|
-
const fs = require('fs');
|
|
15
|
-
const path = require('path');
|
|
16
|
-
const os = require('os');
|
|
17
|
-
|
|
18
|
-
const { windowForModel, SAFE_DEFAULT_WINDOW } = require('./model-windows.cjs');
|
|
19
|
-
|
|
20
|
-
// Token estimation: GPT/Claude tokenizers average ~4 chars/token for English+code.
|
|
21
|
-
// This is a fast deterministic estimate, not a true tokenizer call. Within ~10%.
|
|
22
|
-
const CHARS_PER_TOKEN = 4;
|
|
23
|
-
|
|
24
|
-
// Context window for the "% of window" math. This is a static-analysis CLI with
|
|
25
|
-
// no live transcript, so the model is resolved from --model / GSD_T_MODEL when
|
|
26
|
-
// provided, else the model-windows safe LARGE default (1M). A bare 200K literal
|
|
27
|
-
// (the old value) overstated every percentage 5× for Opus/Sonnet sessions.
|
|
28
|
-
let CONTEXT_WINDOW = (() => {
|
|
29
|
-
const fromEnv = typeof process.env.GSD_T_MODEL === 'string' ? process.env.GSD_T_MODEL : '';
|
|
30
|
-
const argIdx = process.argv.indexOf('--model');
|
|
31
|
-
const fromArg = argIdx !== -1 ? process.argv[argIdx + 1] : '';
|
|
32
|
-
const modelId = fromArg || fromEnv;
|
|
33
|
-
return modelId ? windowForModel(modelId) : SAFE_DEFAULT_WINDOW;
|
|
34
|
-
})();
|
|
35
|
-
|
|
36
|
-
function estimateTokens(bytes) {
|
|
37
|
-
return Math.round(bytes / CHARS_PER_TOKEN);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function fmtPct(n) {
|
|
41
|
-
return `${n.toFixed(1)}%`;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function fmtNum(n) {
|
|
45
|
-
return n.toLocaleString('en-US');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function safeStat(p) {
|
|
49
|
-
try {
|
|
50
|
-
return fs.statSync(p);
|
|
51
|
-
} catch {
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function safeRead(p) {
|
|
57
|
-
try {
|
|
58
|
-
return fs.readFileSync(p, 'utf8');
|
|
59
|
-
} catch {
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function listFiles(dir, ext = null) {
|
|
65
|
-
if (!fs.existsSync(dir)) return [];
|
|
66
|
-
try {
|
|
67
|
-
return fs
|
|
68
|
-
.readdirSync(dir)
|
|
69
|
-
.filter((f) => !ext || f.endsWith(ext))
|
|
70
|
-
.map((f) => path.join(dir, f))
|
|
71
|
-
.filter((p) => {
|
|
72
|
-
const s = safeStat(p);
|
|
73
|
-
return s && s.isFile();
|
|
74
|
-
});
|
|
75
|
-
} catch {
|
|
76
|
-
return [];
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function measureFile(filePath) {
|
|
81
|
-
const stat = safeStat(filePath);
|
|
82
|
-
if (!stat) return null;
|
|
83
|
-
return {
|
|
84
|
-
path: filePath,
|
|
85
|
-
bytes: stat.size,
|
|
86
|
-
tokens: estimateTokens(stat.size),
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function measureCategory(name, files, source) {
|
|
91
|
-
const measured = files
|
|
92
|
-
.map(measureFile)
|
|
93
|
-
.filter(Boolean)
|
|
94
|
-
.sort((a, b) => b.tokens - a.tokens);
|
|
95
|
-
const totalBytes = measured.reduce((s, f) => s + f.bytes, 0);
|
|
96
|
-
const totalTokens = measured.reduce((s, f) => s + f.tokens, 0);
|
|
97
|
-
return {
|
|
98
|
-
name,
|
|
99
|
-
source,
|
|
100
|
-
fileCount: measured.length,
|
|
101
|
-
totalBytes,
|
|
102
|
-
totalTokens,
|
|
103
|
-
pctOfWindow: (totalTokens / CONTEXT_WINDOW) * 100,
|
|
104
|
-
files: measured,
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Estimate MCP tool descriptions cost — each deferred tool exposes a name + ~50-150
|
|
109
|
-
// chars of description in the system prompt. We can't read them directly, but we
|
|
110
|
-
// know the count from the list provided in conversation context (22 in this session).
|
|
111
|
-
function estimateMcpToolsCost(toolCount = 22, avgCharsPerTool = 120) {
|
|
112
|
-
const bytes = toolCount * avgCharsPerTool;
|
|
113
|
-
return {
|
|
114
|
-
name: 'MCP deferred tool manifest (Figma + Gmail + Calendar)',
|
|
115
|
-
source: '~/.claude/settings.json mcpServers (estimated)',
|
|
116
|
-
fileCount: toolCount,
|
|
117
|
-
totalBytes: bytes,
|
|
118
|
-
totalTokens: estimateTokens(bytes),
|
|
119
|
-
pctOfWindow: (estimateTokens(bytes) / CONTEXT_WINDOW) * 100,
|
|
120
|
-
files: [],
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Estimate built-in tool schemas cost — Read, Edit, Write, Bash, Glob, Grep, etc.
|
|
125
|
-
// These are loaded into every session. Approx based on their JSONSchema sizes.
|
|
126
|
-
function estimateBuiltinToolsCost() {
|
|
127
|
-
// Top-of-prompt tools observed: Agent, Bash, Edit, Glob, Grep, Read, ScheduleWakeup,
|
|
128
|
-
// Skill, ToolSearch, Write. Each is ~200-2000 tokens in JSONSchema form.
|
|
129
|
-
const tools = {
|
|
130
|
-
Agent: 1800,
|
|
131
|
-
Bash: 2200,
|
|
132
|
-
Edit: 600,
|
|
133
|
-
Glob: 300,
|
|
134
|
-
Grep: 800,
|
|
135
|
-
Read: 700,
|
|
136
|
-
ScheduleWakeup: 900,
|
|
137
|
-
Skill: 400,
|
|
138
|
-
ToolSearch: 500,
|
|
139
|
-
Write: 400,
|
|
140
|
-
};
|
|
141
|
-
const totalTokens = Object.values(tools).reduce((s, t) => s + t, 0);
|
|
142
|
-
return {
|
|
143
|
-
name: 'Built-in tool schemas (top-of-prompt)',
|
|
144
|
-
source: 'Claude Code system prompt (estimated)',
|
|
145
|
-
fileCount: Object.keys(tools).length,
|
|
146
|
-
totalBytes: totalTokens * CHARS_PER_TOKEN,
|
|
147
|
-
totalTokens,
|
|
148
|
-
pctOfWindow: (totalTokens / CONTEXT_WINDOW) * 100,
|
|
149
|
-
files: [],
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Estimate Claude Code system prompt overhead — the harness instructions, env block,
|
|
154
|
-
// system reminders, gitStatus, etc. Observed in the prompt header.
|
|
155
|
-
function estimateSystemPromptCost() {
|
|
156
|
-
// The system prompt header (everything before "You are an interactive agent") plus
|
|
157
|
-
// the doing-tasks/tone-style/session-guidance sections is ~6000-8000 tokens.
|
|
158
|
-
const tokens = 7000;
|
|
159
|
-
return {
|
|
160
|
-
name: 'Claude Code system prompt (instructions, env, gitStatus)',
|
|
161
|
-
source: 'Claude Code harness',
|
|
162
|
-
fileCount: 1,
|
|
163
|
-
totalBytes: tokens * CHARS_PER_TOKEN,
|
|
164
|
-
totalTokens: tokens,
|
|
165
|
-
pctOfWindow: (tokens / CONTEXT_WINDOW) * 100,
|
|
166
|
-
files: [],
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function audit({ projectDir, globalDir, top = 10, threshold = 0 }) {
|
|
171
|
-
const categories = [];
|
|
172
|
-
|
|
173
|
-
// 1. System prompt overhead
|
|
174
|
-
categories.push(estimateSystemPromptCost());
|
|
175
|
-
|
|
176
|
-
// 2. Built-in tool schemas
|
|
177
|
-
categories.push(estimateBuiltinToolsCost());
|
|
178
|
-
|
|
179
|
-
// 3. MCP deferred tool manifest
|
|
180
|
-
categories.push(estimateMcpToolsCost());
|
|
181
|
-
|
|
182
|
-
// 4. Global CLAUDE.md
|
|
183
|
-
const globalClaude = path.join(globalDir, 'CLAUDE.md');
|
|
184
|
-
if (fs.existsSync(globalClaude)) {
|
|
185
|
-
categories.push(measureCategory('Global ~/.claude/CLAUDE.md', [globalClaude], globalClaude));
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// 5. Project CLAUDE.md
|
|
189
|
-
const projectClaude = path.join(projectDir, 'CLAUDE.md');
|
|
190
|
-
if (fs.existsSync(projectClaude)) {
|
|
191
|
-
categories.push(measureCategory('Project CLAUDE.md', [projectClaude], projectClaude));
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// 6. Auto-memory (MEMORY.md + entries)
|
|
195
|
-
const memoryDir = path.join(
|
|
196
|
-
globalDir,
|
|
197
|
-
'projects',
|
|
198
|
-
`-${projectDir.replace(/\//g, '-').replace(/^-/, '')}`,
|
|
199
|
-
'memory'
|
|
200
|
-
);
|
|
201
|
-
if (fs.existsSync(memoryDir)) {
|
|
202
|
-
const memFiles = listFiles(memoryDir, '.md');
|
|
203
|
-
categories.push(measureCategory('Auto-memory (MEMORY.md + entries)', memFiles, memoryDir));
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// 7. Installed user commands — IMPORTANT: Claude Code's skill system loads only
|
|
207
|
-
// the manifest (name + first-line description) into the system prompt for each
|
|
208
|
-
// command file. The full body loads only when the skill is invoked. So the
|
|
209
|
-
// static cost is much smaller than the file size suggests — roughly ~150 chars
|
|
210
|
-
// per command (name + description) regardless of body length.
|
|
211
|
-
const userCommandsDir = path.join(globalDir, 'commands');
|
|
212
|
-
if (fs.existsSync(userCommandsDir)) {
|
|
213
|
-
const cmdFiles = listFiles(userCommandsDir, '.md');
|
|
214
|
-
const manifestBytesPerCmd = 200; // typical "- name: <desc>" line
|
|
215
|
-
const manifestBytes = cmdFiles.length * manifestBytesPerCmd;
|
|
216
|
-
const fullBodyBytes = cmdFiles.reduce((s, f) => {
|
|
217
|
-
const st = safeStat(f);
|
|
218
|
-
return s + (st ? st.size : 0);
|
|
219
|
-
}, 0);
|
|
220
|
-
categories.push({
|
|
221
|
-
name: 'User commands MANIFEST (~/.claude/commands/ — names+descriptions only)',
|
|
222
|
-
source: userCommandsDir,
|
|
223
|
-
fileCount: cmdFiles.length,
|
|
224
|
-
totalBytes: manifestBytes,
|
|
225
|
-
totalTokens: estimateTokens(manifestBytes),
|
|
226
|
-
pctOfWindow: (estimateTokens(manifestBytes) / CONTEXT_WINDOW) * 100,
|
|
227
|
-
files: cmdFiles
|
|
228
|
-
.map((p) => {
|
|
229
|
-
const st = safeStat(p);
|
|
230
|
-
return st
|
|
231
|
-
? { path: p, bytes: manifestBytesPerCmd, tokens: estimateTokens(manifestBytesPerCmd) }
|
|
232
|
-
: null;
|
|
233
|
-
})
|
|
234
|
-
.filter(Boolean),
|
|
235
|
-
});
|
|
236
|
-
// Also report what the FULL bodies cost when invoked, so trimming targets are visible
|
|
237
|
-
categories.push({
|
|
238
|
-
name: 'User command FULL BODIES (loaded only when each skill is invoked)',
|
|
239
|
-
source: userCommandsDir + ' (per-invocation cost, not baseline)',
|
|
240
|
-
fileCount: cmdFiles.length,
|
|
241
|
-
totalBytes: fullBodyBytes,
|
|
242
|
-
totalTokens: estimateTokens(fullBodyBytes),
|
|
243
|
-
pctOfWindow: (estimateTokens(fullBodyBytes) / CONTEXT_WINDOW) * 100,
|
|
244
|
-
files: cmdFiles.map(measureFile).filter(Boolean).sort((a, b) => b.tokens - a.tokens),
|
|
245
|
-
lazyLoaded: true,
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// 8. Project commands — same lazy-load semantics as user commands. Only count if
|
|
250
|
-
// it's a different directory AND the user's project actually exposes them as
|
|
251
|
-
// skills (most projects don't).
|
|
252
|
-
const projectCommandsDir = path.join(projectDir, 'commands');
|
|
253
|
-
if (
|
|
254
|
-
fs.existsSync(projectCommandsDir) &&
|
|
255
|
-
path.resolve(projectCommandsDir) !== path.resolve(userCommandsDir)
|
|
256
|
-
) {
|
|
257
|
-
const cmdFiles = listFiles(projectCommandsDir, '.md');
|
|
258
|
-
const manifestBytesPerCmd = 200;
|
|
259
|
-
const manifestBytes = cmdFiles.length * manifestBytesPerCmd;
|
|
260
|
-
const fullBodyBytes = cmdFiles.reduce((s, f) => {
|
|
261
|
-
const st = safeStat(f);
|
|
262
|
-
return s + (st ? st.size : 0);
|
|
263
|
-
}, 0);
|
|
264
|
-
categories.push({
|
|
265
|
-
name: 'Project commands MANIFEST (commands/ — names+descriptions only)',
|
|
266
|
-
source: projectCommandsDir,
|
|
267
|
-
fileCount: cmdFiles.length,
|
|
268
|
-
totalBytes: manifestBytes,
|
|
269
|
-
totalTokens: estimateTokens(manifestBytes),
|
|
270
|
-
pctOfWindow: (estimateTokens(manifestBytes) / CONTEXT_WINDOW) * 100,
|
|
271
|
-
files: [],
|
|
272
|
-
});
|
|
273
|
-
categories.push({
|
|
274
|
-
name: 'Project command FULL BODIES (per-invocation, not baseline)',
|
|
275
|
-
source: projectCommandsDir,
|
|
276
|
-
fileCount: cmdFiles.length,
|
|
277
|
-
totalBytes: fullBodyBytes,
|
|
278
|
-
totalTokens: estimateTokens(fullBodyBytes),
|
|
279
|
-
pctOfWindow: (estimateTokens(fullBodyBytes) / CONTEXT_WINDOW) * 100,
|
|
280
|
-
files: cmdFiles.map(measureFile).filter(Boolean).sort((a, b) => b.tokens - a.tokens),
|
|
281
|
-
lazyLoaded: true,
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// 9. Project skills directory (if any)
|
|
286
|
-
const skillsDir = path.join(globalDir, 'skills');
|
|
287
|
-
if (fs.existsSync(skillsDir)) {
|
|
288
|
-
const skillFiles = listFiles(skillsDir, '.md');
|
|
289
|
-
if (skillFiles.length > 0) {
|
|
290
|
-
categories.push(measureCategory('Skills (~/.claude/skills/)', skillFiles, skillsDir));
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Compute totals — exclude lazy-loaded categories from the baseline cost
|
|
295
|
-
const baselineCats = categories.filter((c) => !c.lazyLoaded);
|
|
296
|
-
const totalTokens = baselineCats.reduce((s, c) => s + c.totalTokens, 0);
|
|
297
|
-
const totalBytes = baselineCats.reduce((s, c) => s + c.totalBytes, 0);
|
|
298
|
-
const totalPct = (totalTokens / CONTEXT_WINDOW) * 100;
|
|
299
|
-
const remaining = CONTEXT_WINDOW - totalTokens;
|
|
300
|
-
|
|
301
|
-
return {
|
|
302
|
-
contextWindow: CONTEXT_WINDOW,
|
|
303
|
-
totalBytes,
|
|
304
|
-
totalTokens,
|
|
305
|
-
totalPct,
|
|
306
|
-
remaining,
|
|
307
|
-
remainingPct: (remaining / CONTEXT_WINDOW) * 100,
|
|
308
|
-
categories,
|
|
309
|
-
top,
|
|
310
|
-
threshold,
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function renderReport(result) {
|
|
315
|
-
const lines = [];
|
|
316
|
-
const bar = (pct, width = 40) => {
|
|
317
|
-
const filled = Math.round((pct / 100) * width);
|
|
318
|
-
return '█'.repeat(Math.min(filled, width)) + '░'.repeat(Math.max(width - filled, 0));
|
|
319
|
-
};
|
|
320
|
-
|
|
321
|
-
lines.push('');
|
|
322
|
-
lines.push('═══════════════════════════════════════════════════════════════════');
|
|
323
|
-
lines.push(' CONTEXT BUDGET AUDIT — what consumes context before you type');
|
|
324
|
-
lines.push('═══════════════════════════════════════════════════════════════════');
|
|
325
|
-
lines.push('');
|
|
326
|
-
lines.push(` Context window: ${fmtNum(result.contextWindow)} tokens`);
|
|
327
|
-
lines.push(` Static preamble cost: ${fmtNum(result.totalTokens)} tokens (${fmtPct(result.totalPct)})`);
|
|
328
|
-
lines.push(` Remaining for work: ${fmtNum(result.remaining)} tokens (${fmtPct(result.remainingPct)})`);
|
|
329
|
-
lines.push('');
|
|
330
|
-
lines.push(` [${bar(result.totalPct)}] ${fmtPct(result.totalPct)}`);
|
|
331
|
-
lines.push('');
|
|
332
|
-
|
|
333
|
-
if (result.totalPct >= 70) {
|
|
334
|
-
lines.push(' 🔴 CRITICAL: preamble already consumes >70% of context window.');
|
|
335
|
-
lines.push(' Long-running tasks will trigger manual /compact prompts.');
|
|
336
|
-
} else if (result.totalPct >= 50) {
|
|
337
|
-
lines.push(' ⚠️ WARNING: preamble consumes >50% of context window.');
|
|
338
|
-
lines.push(' Single-pass edits on large files may trigger compaction.');
|
|
339
|
-
} else if (result.totalPct >= 30) {
|
|
340
|
-
lines.push(' ⚡ ELEVATED: preamble consumes >30% of context window.');
|
|
341
|
-
lines.push(' Multi-step workflows may approach compaction threshold.');
|
|
342
|
-
} else {
|
|
343
|
-
lines.push(' ✅ HEALTHY: preamble consumes <30% of context window.');
|
|
344
|
-
}
|
|
345
|
-
lines.push('');
|
|
346
|
-
lines.push('───────────────────────────────────────────────────────────────────');
|
|
347
|
-
lines.push(' BREAKDOWN BY CATEGORY (largest first)');
|
|
348
|
-
lines.push('───────────────────────────────────────────────────────────────────');
|
|
349
|
-
lines.push('');
|
|
350
|
-
|
|
351
|
-
const sortedCats = [...result.categories].sort((a, b) => b.totalTokens - a.totalTokens);
|
|
352
|
-
for (const cat of sortedCats) {
|
|
353
|
-
const tag = cat.lazyLoaded ? ' [LAZY — not in baseline]' : '';
|
|
354
|
-
lines.push(` ${cat.name}${tag}`);
|
|
355
|
-
lines.push(` source: ${cat.source}`);
|
|
356
|
-
lines.push(
|
|
357
|
-
` files: ${cat.fileCount} bytes: ${fmtNum(cat.totalBytes)} tokens: ${fmtNum(cat.totalTokens)} (${fmtPct(cat.pctOfWindow)})`
|
|
358
|
-
);
|
|
359
|
-
lines.push(` [${bar(cat.pctOfWindow, 30)}]`);
|
|
360
|
-
lines.push('');
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Top N files across all categories
|
|
364
|
-
const allFiles = result.categories
|
|
365
|
-
.flatMap((c) => c.files)
|
|
366
|
-
.sort((a, b) => b.tokens - a.tokens);
|
|
367
|
-
if (allFiles.length > 0) {
|
|
368
|
-
lines.push('───────────────────────────────────────────────────────────────────');
|
|
369
|
-
lines.push(` TOP ${result.top} HEAVIEST FILES`);
|
|
370
|
-
lines.push('───────────────────────────────────────────────────────────────────');
|
|
371
|
-
lines.push('');
|
|
372
|
-
const topFiles = allFiles.slice(0, result.top);
|
|
373
|
-
for (const f of topFiles) {
|
|
374
|
-
const pct = (f.tokens / CONTEXT_WINDOW) * 100;
|
|
375
|
-
const flag = pct >= 5 ? ' 🔥' : pct >= 2 ? ' ⚠️' : '';
|
|
376
|
-
const rel = f.path.replace(os.homedir(), '~');
|
|
377
|
-
lines.push(` ${fmtNum(f.tokens).padStart(7)} tok ${fmtPct(pct).padStart(6)} ${rel}${flag}`);
|
|
378
|
-
}
|
|
379
|
-
lines.push('');
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// Files above threshold
|
|
383
|
-
if (result.threshold > 0) {
|
|
384
|
-
const above = allFiles.filter((f) => f.tokens >= result.threshold);
|
|
385
|
-
if (above.length > 0) {
|
|
386
|
-
lines.push('───────────────────────────────────────────────────────────────────');
|
|
387
|
-
lines.push(` FILES ABOVE THRESHOLD (>= ${fmtNum(result.threshold)} tokens)`);
|
|
388
|
-
lines.push('───────────────────────────────────────────────────────────────────');
|
|
389
|
-
lines.push('');
|
|
390
|
-
for (const f of above) {
|
|
391
|
-
const rel = f.path.replace(os.homedir(), '~');
|
|
392
|
-
lines.push(` ${fmtNum(f.tokens).padStart(7)} tok ${rel}`);
|
|
393
|
-
}
|
|
394
|
-
lines.push('');
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
lines.push('───────────────────────────────────────────────────────────────────');
|
|
399
|
-
lines.push(' NOTES');
|
|
400
|
-
lines.push('───────────────────────────────────────────────────────────────────');
|
|
401
|
-
lines.push('');
|
|
402
|
-
lines.push(' - Token estimate uses 4 chars/token (within ~10% of real tokenizer).');
|
|
403
|
-
lines.push(' - "Static preamble" = everything loaded BEFORE you type your first');
|
|
404
|
-
lines.push(' message. Add ~10-20K tokens of typical conversation overhead.');
|
|
405
|
-
lines.push(' - Skills auto-load into the prompt as a list of names+descriptions.');
|
|
406
|
-
lines.push(' Their full bodies are loaded only when invoked, but the manifest');
|
|
407
|
-
lines.push(' itself contributes to baseline.');
|
|
408
|
-
lines.push(' - Built-in and MCP tool costs are estimates; real values vary by');
|
|
409
|
-
lines.push(' Claude Code version. Check ENABLE_TELEMETRY for exact counts.');
|
|
410
|
-
lines.push('');
|
|
411
|
-
|
|
412
|
-
return lines.join('\n');
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
function main() {
|
|
416
|
-
const args = process.argv.slice(2);
|
|
417
|
-
const opts = {
|
|
418
|
-
projectDir: process.cwd(),
|
|
419
|
-
globalDir: path.join(os.homedir(), '.claude'),
|
|
420
|
-
top: 10,
|
|
421
|
-
threshold: 0,
|
|
422
|
-
json: false,
|
|
423
|
-
};
|
|
424
|
-
for (let i = 0; i < args.length; i++) {
|
|
425
|
-
const a = args[i];
|
|
426
|
-
if (a === '--json') opts.json = true;
|
|
427
|
-
else if (a === '--top') opts.top = parseInt(args[++i], 10);
|
|
428
|
-
else if (a === '--threshold') opts.threshold = parseInt(args[++i], 10);
|
|
429
|
-
else if (a === '--project') opts.projectDir = path.resolve(args[++i]);
|
|
430
|
-
else if (a === '--global') opts.globalDir = path.resolve(args[++i]);
|
|
431
|
-
else if (a === '--model') i++; // consumed at init for window sizing; skip value
|
|
432
|
-
else if (a === '--help' || a === '-h') {
|
|
433
|
-
console.log('Usage: node bin/context-budget-audit.js [--json] [--top N] [--threshold N] [--model <claude-model-id>]');
|
|
434
|
-
process.exit(0);
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
const result = audit(opts);
|
|
439
|
-
|
|
440
|
-
if (opts.json) {
|
|
441
|
-
console.log(JSON.stringify(result, null, 2));
|
|
442
|
-
} else {
|
|
443
|
-
console.log(renderReport(result));
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
main();
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Context Meter config loader (M34).
|
|
3
|
-
*
|
|
4
|
-
* Reads .gsd-t/context-meter-config.json, merges over defaults, validates,
|
|
5
|
-
* and returns the resolved config. Missing file → defaults. Unknown schema
|
|
6
|
-
* version or API-key leak → throws with a clear message.
|
|
7
|
-
*
|
|
8
|
-
* See .gsd-t/contracts/context-meter-contract.md for the schema, validation
|
|
9
|
-
* rules, and the API-key-never-stored invariant.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
const fs = require("fs");
|
|
13
|
-
const path = require("path");
|
|
14
|
-
|
|
15
|
-
const DEFAULTS = Object.freeze({
|
|
16
|
-
version: 1,
|
|
17
|
-
thresholdPct: 75,
|
|
18
|
-
modelWindowSize: 200000,
|
|
19
|
-
checkFrequency: 5,
|
|
20
|
-
apiKeyEnvVar: "ANTHROPIC_API_KEY",
|
|
21
|
-
statePath: ".gsd-t/.context-meter-state.json",
|
|
22
|
-
logPath: ".gsd-t/context-meter.log",
|
|
23
|
-
timeoutMs: 2000,
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
const SUPPORTED_VERSION = 1;
|
|
27
|
-
const API_KEY_FIELD_RE = /api.?key/i;
|
|
28
|
-
const HEX_LOOKALIKE_RE = /^[a-zA-Z0-9_-]{64,}$/;
|
|
29
|
-
|
|
30
|
-
function loadConfig(projectRoot) {
|
|
31
|
-
const root = projectRoot || process.cwd();
|
|
32
|
-
const configPath = path.join(root, ".gsd-t", "context-meter-config.json");
|
|
33
|
-
|
|
34
|
-
let userConfig = {};
|
|
35
|
-
if (fs.existsSync(configPath)) {
|
|
36
|
-
const raw = fs.readFileSync(configPath, "utf8");
|
|
37
|
-
try {
|
|
38
|
-
userConfig = JSON.parse(raw);
|
|
39
|
-
} catch (e) {
|
|
40
|
-
throw new Error(
|
|
41
|
-
`context-meter-config: invalid JSON in ${configPath}: ${e.message}`
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
if (!userConfig || typeof userConfig !== "object" || Array.isArray(userConfig)) {
|
|
45
|
-
throw new Error(`context-meter-config: ${configPath} must contain a JSON object`);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
validateNoKeyLeak(userConfig);
|
|
50
|
-
|
|
51
|
-
if (userConfig.version !== undefined && userConfig.version !== SUPPORTED_VERSION) {
|
|
52
|
-
throw new Error(
|
|
53
|
-
`context-meter-config: unsupported schema version ${userConfig.version} ` +
|
|
54
|
-
`(expected ${SUPPORTED_VERSION}). See .gsd-t/contracts/context-meter-contract.md#breaking-changes for migration.`
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const merged = { ...DEFAULTS, ...userConfig, version: SUPPORTED_VERSION };
|
|
59
|
-
validateRanges(merged);
|
|
60
|
-
return merged;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function validateNoKeyLeak(obj) {
|
|
64
|
-
for (const key of Object.keys(obj)) {
|
|
65
|
-
if (key === "apiKeyEnvVar") continue;
|
|
66
|
-
if (API_KEY_FIELD_RE.test(key)) {
|
|
67
|
-
throw new Error(
|
|
68
|
-
`context-meter-config: field "${key}" looks like an API key storage field. ` +
|
|
69
|
-
`API keys must only be read from the env var named in apiKeyEnvVar.`
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
const val = obj[key];
|
|
73
|
-
if (typeof val === "string" && val.length > 100 && HEX_LOOKALIKE_RE.test(val)) {
|
|
74
|
-
throw new Error(
|
|
75
|
-
`context-meter-config: field "${key}" contains a long token-like string. ` +
|
|
76
|
-
`Do not store API keys in config — use apiKeyEnvVar to name the env var.`
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function validateRanges(c) {
|
|
83
|
-
const assert = (cond, msg) => { if (!cond) throw new Error(`context-meter-config: ${msg}`); };
|
|
84
|
-
|
|
85
|
-
assert(Number.isFinite(c.thresholdPct) && c.thresholdPct > 0 && c.thresholdPct < 100,
|
|
86
|
-
`thresholdPct must be a number in (0, 100), got ${c.thresholdPct}`);
|
|
87
|
-
assert(Number.isInteger(c.modelWindowSize) && c.modelWindowSize > 0,
|
|
88
|
-
`modelWindowSize must be a positive integer, got ${c.modelWindowSize}`);
|
|
89
|
-
assert(Number.isInteger(c.checkFrequency) && c.checkFrequency >= 1,
|
|
90
|
-
`checkFrequency must be an integer >= 1, got ${c.checkFrequency}`);
|
|
91
|
-
assert(typeof c.apiKeyEnvVar === "string" && c.apiKeyEnvVar.length > 0,
|
|
92
|
-
`apiKeyEnvVar must be a non-empty string, got ${JSON.stringify(c.apiKeyEnvVar)}`);
|
|
93
|
-
assert(typeof c.statePath === "string" && c.statePath.length > 0,
|
|
94
|
-
`statePath must be a non-empty string`);
|
|
95
|
-
assert(typeof c.logPath === "string" && c.logPath.length > 0,
|
|
96
|
-
`logPath must be a non-empty string`);
|
|
97
|
-
assert(Number.isInteger(c.timeoutMs) && c.timeoutMs > 0,
|
|
98
|
-
`timeoutMs must be a positive integer, got ${c.timeoutMs}`);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
module.exports = { loadConfig, DEFAULTS };
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
const { test } = require("node:test");
|
|
2
|
-
const assert = require("node:assert/strict");
|
|
3
|
-
const fs = require("fs");
|
|
4
|
-
const path = require("path");
|
|
5
|
-
const os = require("os");
|
|
6
|
-
|
|
7
|
-
const { loadConfig, DEFAULTS } = require("./context-meter-config.cjs");
|
|
8
|
-
|
|
9
|
-
function makeProject(config) {
|
|
10
|
-
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cmcfg-"));
|
|
11
|
-
fs.mkdirSync(path.join(root, ".gsd-t"), { recursive: true });
|
|
12
|
-
if (config !== undefined) {
|
|
13
|
-
fs.writeFileSync(
|
|
14
|
-
path.join(root, ".gsd-t", "context-meter-config.json"),
|
|
15
|
-
typeof config === "string" ? config : JSON.stringify(config)
|
|
16
|
-
);
|
|
17
|
-
}
|
|
18
|
-
return root;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
test("missing config file → returns defaults", () => {
|
|
22
|
-
const root = makeProject();
|
|
23
|
-
assert.deepEqual(loadConfig(root), DEFAULTS);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test("valid full config → returns exact values", () => {
|
|
27
|
-
const custom = {
|
|
28
|
-
version: 1,
|
|
29
|
-
thresholdPct: 80,
|
|
30
|
-
modelWindowSize: 400000,
|
|
31
|
-
checkFrequency: 10,
|
|
32
|
-
apiKeyEnvVar: "CLAUDE_KEY",
|
|
33
|
-
statePath: ".gsd-t/my-state.json",
|
|
34
|
-
logPath: ".gsd-t/my.log",
|
|
35
|
-
timeoutMs: 5000,
|
|
36
|
-
};
|
|
37
|
-
const root = makeProject(custom);
|
|
38
|
-
assert.deepEqual(loadConfig(root), custom);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test("partial config → missing fields filled with defaults", () => {
|
|
42
|
-
const root = makeProject({ thresholdPct: 60, timeoutMs: 1000 });
|
|
43
|
-
const cfg = loadConfig(root);
|
|
44
|
-
assert.equal(cfg.thresholdPct, 60);
|
|
45
|
-
assert.equal(cfg.timeoutMs, 1000);
|
|
46
|
-
assert.equal(cfg.modelWindowSize, DEFAULTS.modelWindowSize);
|
|
47
|
-
assert.equal(cfg.checkFrequency, DEFAULTS.checkFrequency);
|
|
48
|
-
assert.equal(cfg.apiKeyEnvVar, DEFAULTS.apiKeyEnvVar);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("thresholdPct out of range throws", () => {
|
|
52
|
-
for (const bad of [0, 100, -5, 150, "80"]) {
|
|
53
|
-
const root = makeProject({ thresholdPct: bad });
|
|
54
|
-
assert.throws(() => loadConfig(root), /thresholdPct/);
|
|
55
|
-
}
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test("modelWindowSize <= 0 throws", () => {
|
|
59
|
-
for (const bad of [0, -1, 1.5, "100"]) {
|
|
60
|
-
const root = makeProject({ modelWindowSize: bad });
|
|
61
|
-
assert.throws(() => loadConfig(root), /modelWindowSize/);
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("checkFrequency < 1 throws", () => {
|
|
66
|
-
for (const bad of [0, -1, 0.5]) {
|
|
67
|
-
const root = makeProject({ checkFrequency: bad });
|
|
68
|
-
assert.throws(() => loadConfig(root), /checkFrequency/);
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("empty apiKeyEnvVar throws", () => {
|
|
73
|
-
const root = makeProject({ apiKeyEnvVar: "" });
|
|
74
|
-
assert.throws(() => loadConfig(root), /apiKeyEnvVar/);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("unknown version throws with migration pointer", () => {
|
|
78
|
-
const root = makeProject({ version: 2, thresholdPct: 75 });
|
|
79
|
-
assert.throws(() => loadConfig(root), /version 2|migration/i);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test("config containing an apiKey field is rejected as leak", () => {
|
|
83
|
-
const root = makeProject({ apiKey: "sk-ant-abc123" });
|
|
84
|
-
assert.throws(() => loadConfig(root), /api.?key/i);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("config containing a long hex-like string value is rejected as leak", () => {
|
|
88
|
-
const longHex = "a".repeat(120);
|
|
89
|
-
const root = makeProject({ customField: longHex });
|
|
90
|
-
assert.throws(() => loadConfig(root), /api.?key|token-like/i);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("invalid JSON throws with clear message", () => {
|
|
94
|
-
const root = makeProject("{ not valid json");
|
|
95
|
-
assert.throws(() => loadConfig(root), /invalid JSON/);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test("non-object JSON throws", () => {
|
|
99
|
-
const root = makeProject("[1, 2, 3]");
|
|
100
|
-
assert.throws(() => loadConfig(root), /JSON object/);
|
|
101
|
-
});
|