@tekyzinc/gsd-t 2.73.24 → 2.74.10

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.
@@ -0,0 +1,432 @@
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
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const os = require('os');
16
+
17
+ // Token estimation: GPT/Claude tokenizers average ~4 chars/token for English+code.
18
+ // This is a fast deterministic estimate, not a true tokenizer call. Within ~10%.
19
+ const CHARS_PER_TOKEN = 4;
20
+ const CONTEXT_WINDOW = 200_000; // claude-opus-4-6 default
21
+
22
+ function estimateTokens(bytes) {
23
+ return Math.round(bytes / CHARS_PER_TOKEN);
24
+ }
25
+
26
+ function fmtPct(n) {
27
+ return `${n.toFixed(1)}%`;
28
+ }
29
+
30
+ function fmtNum(n) {
31
+ return n.toLocaleString('en-US');
32
+ }
33
+
34
+ function safeStat(p) {
35
+ try {
36
+ return fs.statSync(p);
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function safeRead(p) {
43
+ try {
44
+ return fs.readFileSync(p, 'utf8');
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function listFiles(dir, ext = null) {
51
+ if (!fs.existsSync(dir)) return [];
52
+ try {
53
+ return fs
54
+ .readdirSync(dir)
55
+ .filter((f) => !ext || f.endsWith(ext))
56
+ .map((f) => path.join(dir, f))
57
+ .filter((p) => {
58
+ const s = safeStat(p);
59
+ return s && s.isFile();
60
+ });
61
+ } catch {
62
+ return [];
63
+ }
64
+ }
65
+
66
+ function measureFile(filePath) {
67
+ const stat = safeStat(filePath);
68
+ if (!stat) return null;
69
+ return {
70
+ path: filePath,
71
+ bytes: stat.size,
72
+ tokens: estimateTokens(stat.size),
73
+ };
74
+ }
75
+
76
+ function measureCategory(name, files, source) {
77
+ const measured = files
78
+ .map(measureFile)
79
+ .filter(Boolean)
80
+ .sort((a, b) => b.tokens - a.tokens);
81
+ const totalBytes = measured.reduce((s, f) => s + f.bytes, 0);
82
+ const totalTokens = measured.reduce((s, f) => s + f.tokens, 0);
83
+ return {
84
+ name,
85
+ source,
86
+ fileCount: measured.length,
87
+ totalBytes,
88
+ totalTokens,
89
+ pctOfWindow: (totalTokens / CONTEXT_WINDOW) * 100,
90
+ files: measured,
91
+ };
92
+ }
93
+
94
+ // Estimate MCP tool descriptions cost — each deferred tool exposes a name + ~50-150
95
+ // chars of description in the system prompt. We can't read them directly, but we
96
+ // know the count from the list provided in conversation context (22 in this session).
97
+ function estimateMcpToolsCost(toolCount = 22, avgCharsPerTool = 120) {
98
+ const bytes = toolCount * avgCharsPerTool;
99
+ return {
100
+ name: 'MCP deferred tool manifest (Figma + Gmail + Calendar)',
101
+ source: '~/.claude/settings.json mcpServers (estimated)',
102
+ fileCount: toolCount,
103
+ totalBytes: bytes,
104
+ totalTokens: estimateTokens(bytes),
105
+ pctOfWindow: (estimateTokens(bytes) / CONTEXT_WINDOW) * 100,
106
+ files: [],
107
+ };
108
+ }
109
+
110
+ // Estimate built-in tool schemas cost — Read, Edit, Write, Bash, Glob, Grep, etc.
111
+ // These are loaded into every session. Approx based on their JSONSchema sizes.
112
+ function estimateBuiltinToolsCost() {
113
+ // Top-of-prompt tools observed: Agent, Bash, Edit, Glob, Grep, Read, ScheduleWakeup,
114
+ // Skill, ToolSearch, Write. Each is ~200-2000 tokens in JSONSchema form.
115
+ const tools = {
116
+ Agent: 1800,
117
+ Bash: 2200,
118
+ Edit: 600,
119
+ Glob: 300,
120
+ Grep: 800,
121
+ Read: 700,
122
+ ScheduleWakeup: 900,
123
+ Skill: 400,
124
+ ToolSearch: 500,
125
+ Write: 400,
126
+ };
127
+ const totalTokens = Object.values(tools).reduce((s, t) => s + t, 0);
128
+ return {
129
+ name: 'Built-in tool schemas (top-of-prompt)',
130
+ source: 'Claude Code system prompt (estimated)',
131
+ fileCount: Object.keys(tools).length,
132
+ totalBytes: totalTokens * CHARS_PER_TOKEN,
133
+ totalTokens,
134
+ pctOfWindow: (totalTokens / CONTEXT_WINDOW) * 100,
135
+ files: [],
136
+ };
137
+ }
138
+
139
+ // Estimate Claude Code system prompt overhead — the harness instructions, env block,
140
+ // system reminders, gitStatus, etc. Observed in the prompt header.
141
+ function estimateSystemPromptCost() {
142
+ // The system prompt header (everything before "You are an interactive agent") plus
143
+ // the doing-tasks/tone-style/session-guidance sections is ~6000-8000 tokens.
144
+ const tokens = 7000;
145
+ return {
146
+ name: 'Claude Code system prompt (instructions, env, gitStatus)',
147
+ source: 'Claude Code harness',
148
+ fileCount: 1,
149
+ totalBytes: tokens * CHARS_PER_TOKEN,
150
+ totalTokens: tokens,
151
+ pctOfWindow: (tokens / CONTEXT_WINDOW) * 100,
152
+ files: [],
153
+ };
154
+ }
155
+
156
+ function audit({ projectDir, globalDir, top = 10, threshold = 0 }) {
157
+ const categories = [];
158
+
159
+ // 1. System prompt overhead
160
+ categories.push(estimateSystemPromptCost());
161
+
162
+ // 2. Built-in tool schemas
163
+ categories.push(estimateBuiltinToolsCost());
164
+
165
+ // 3. MCP deferred tool manifest
166
+ categories.push(estimateMcpToolsCost());
167
+
168
+ // 4. Global CLAUDE.md
169
+ const globalClaude = path.join(globalDir, 'CLAUDE.md');
170
+ if (fs.existsSync(globalClaude)) {
171
+ categories.push(measureCategory('Global ~/.claude/CLAUDE.md', [globalClaude], globalClaude));
172
+ }
173
+
174
+ // 5. Project CLAUDE.md
175
+ const projectClaude = path.join(projectDir, 'CLAUDE.md');
176
+ if (fs.existsSync(projectClaude)) {
177
+ categories.push(measureCategory('Project CLAUDE.md', [projectClaude], projectClaude));
178
+ }
179
+
180
+ // 6. Auto-memory (MEMORY.md + entries)
181
+ const memoryDir = path.join(
182
+ globalDir,
183
+ 'projects',
184
+ `-${projectDir.replace(/\//g, '-').replace(/^-/, '')}`,
185
+ 'memory'
186
+ );
187
+ if (fs.existsSync(memoryDir)) {
188
+ const memFiles = listFiles(memoryDir, '.md');
189
+ categories.push(measureCategory('Auto-memory (MEMORY.md + entries)', memFiles, memoryDir));
190
+ }
191
+
192
+ // 7. Installed user commands — IMPORTANT: Claude Code's skill system loads only
193
+ // the manifest (name + first-line description) into the system prompt for each
194
+ // command file. The full body loads only when the skill is invoked. So the
195
+ // static cost is much smaller than the file size suggests — roughly ~150 chars
196
+ // per command (name + description) regardless of body length.
197
+ const userCommandsDir = path.join(globalDir, 'commands');
198
+ if (fs.existsSync(userCommandsDir)) {
199
+ const cmdFiles = listFiles(userCommandsDir, '.md');
200
+ const manifestBytesPerCmd = 200; // typical "- name: <desc>" line
201
+ const manifestBytes = cmdFiles.length * manifestBytesPerCmd;
202
+ const fullBodyBytes = cmdFiles.reduce((s, f) => {
203
+ const st = safeStat(f);
204
+ return s + (st ? st.size : 0);
205
+ }, 0);
206
+ categories.push({
207
+ name: 'User commands MANIFEST (~/.claude/commands/ — names+descriptions only)',
208
+ source: userCommandsDir,
209
+ fileCount: cmdFiles.length,
210
+ totalBytes: manifestBytes,
211
+ totalTokens: estimateTokens(manifestBytes),
212
+ pctOfWindow: (estimateTokens(manifestBytes) / CONTEXT_WINDOW) * 100,
213
+ files: cmdFiles
214
+ .map((p) => {
215
+ const st = safeStat(p);
216
+ return st
217
+ ? { path: p, bytes: manifestBytesPerCmd, tokens: estimateTokens(manifestBytesPerCmd) }
218
+ : null;
219
+ })
220
+ .filter(Boolean),
221
+ });
222
+ // Also report what the FULL bodies cost when invoked, so trimming targets are visible
223
+ categories.push({
224
+ name: 'User command FULL BODIES (loaded only when each skill is invoked)',
225
+ source: userCommandsDir + ' (per-invocation cost, not baseline)',
226
+ fileCount: cmdFiles.length,
227
+ totalBytes: fullBodyBytes,
228
+ totalTokens: estimateTokens(fullBodyBytes),
229
+ pctOfWindow: (estimateTokens(fullBodyBytes) / CONTEXT_WINDOW) * 100,
230
+ files: cmdFiles.map(measureFile).filter(Boolean).sort((a, b) => b.tokens - a.tokens),
231
+ lazyLoaded: true,
232
+ });
233
+ }
234
+
235
+ // 8. Project commands — same lazy-load semantics as user commands. Only count if
236
+ // it's a different directory AND the user's project actually exposes them as
237
+ // skills (most projects don't).
238
+ const projectCommandsDir = path.join(projectDir, 'commands');
239
+ if (
240
+ fs.existsSync(projectCommandsDir) &&
241
+ path.resolve(projectCommandsDir) !== path.resolve(userCommandsDir)
242
+ ) {
243
+ const cmdFiles = listFiles(projectCommandsDir, '.md');
244
+ const manifestBytesPerCmd = 200;
245
+ const manifestBytes = cmdFiles.length * manifestBytesPerCmd;
246
+ const fullBodyBytes = cmdFiles.reduce((s, f) => {
247
+ const st = safeStat(f);
248
+ return s + (st ? st.size : 0);
249
+ }, 0);
250
+ categories.push({
251
+ name: 'Project commands MANIFEST (commands/ — names+descriptions only)',
252
+ source: projectCommandsDir,
253
+ fileCount: cmdFiles.length,
254
+ totalBytes: manifestBytes,
255
+ totalTokens: estimateTokens(manifestBytes),
256
+ pctOfWindow: (estimateTokens(manifestBytes) / CONTEXT_WINDOW) * 100,
257
+ files: [],
258
+ });
259
+ categories.push({
260
+ name: 'Project command FULL BODIES (per-invocation, not baseline)',
261
+ source: projectCommandsDir,
262
+ fileCount: cmdFiles.length,
263
+ totalBytes: fullBodyBytes,
264
+ totalTokens: estimateTokens(fullBodyBytes),
265
+ pctOfWindow: (estimateTokens(fullBodyBytes) / CONTEXT_WINDOW) * 100,
266
+ files: cmdFiles.map(measureFile).filter(Boolean).sort((a, b) => b.tokens - a.tokens),
267
+ lazyLoaded: true,
268
+ });
269
+ }
270
+
271
+ // 9. Project skills directory (if any)
272
+ const skillsDir = path.join(globalDir, 'skills');
273
+ if (fs.existsSync(skillsDir)) {
274
+ const skillFiles = listFiles(skillsDir, '.md');
275
+ if (skillFiles.length > 0) {
276
+ categories.push(measureCategory('Skills (~/.claude/skills/)', skillFiles, skillsDir));
277
+ }
278
+ }
279
+
280
+ // Compute totals — exclude lazy-loaded categories from the baseline cost
281
+ const baselineCats = categories.filter((c) => !c.lazyLoaded);
282
+ const totalTokens = baselineCats.reduce((s, c) => s + c.totalTokens, 0);
283
+ const totalBytes = baselineCats.reduce((s, c) => s + c.totalBytes, 0);
284
+ const totalPct = (totalTokens / CONTEXT_WINDOW) * 100;
285
+ const remaining = CONTEXT_WINDOW - totalTokens;
286
+
287
+ return {
288
+ contextWindow: CONTEXT_WINDOW,
289
+ totalBytes,
290
+ totalTokens,
291
+ totalPct,
292
+ remaining,
293
+ remainingPct: (remaining / CONTEXT_WINDOW) * 100,
294
+ categories,
295
+ top,
296
+ threshold,
297
+ };
298
+ }
299
+
300
+ function renderReport(result) {
301
+ const lines = [];
302
+ const bar = (pct, width = 40) => {
303
+ const filled = Math.round((pct / 100) * width);
304
+ return '█'.repeat(Math.min(filled, width)) + '░'.repeat(Math.max(width - filled, 0));
305
+ };
306
+
307
+ lines.push('');
308
+ lines.push('═══════════════════════════════════════════════════════════════════');
309
+ lines.push(' CONTEXT BUDGET AUDIT — what consumes context before you type');
310
+ lines.push('═══════════════════════════════════════════════════════════════════');
311
+ lines.push('');
312
+ lines.push(` Context window: ${fmtNum(result.contextWindow)} tokens`);
313
+ lines.push(` Static preamble cost: ${fmtNum(result.totalTokens)} tokens (${fmtPct(result.totalPct)})`);
314
+ lines.push(` Remaining for work: ${fmtNum(result.remaining)} tokens (${fmtPct(result.remainingPct)})`);
315
+ lines.push('');
316
+ lines.push(` [${bar(result.totalPct)}] ${fmtPct(result.totalPct)}`);
317
+ lines.push('');
318
+
319
+ if (result.totalPct >= 70) {
320
+ lines.push(' 🔴 CRITICAL: preamble already consumes >70% of context window.');
321
+ lines.push(' Long-running tasks will trigger manual /compact prompts.');
322
+ } else if (result.totalPct >= 50) {
323
+ lines.push(' ⚠️ WARNING: preamble consumes >50% of context window.');
324
+ lines.push(' Single-pass edits on large files may trigger compaction.');
325
+ } else if (result.totalPct >= 30) {
326
+ lines.push(' ⚡ ELEVATED: preamble consumes >30% of context window.');
327
+ lines.push(' Multi-step workflows may approach compaction threshold.');
328
+ } else {
329
+ lines.push(' ✅ HEALTHY: preamble consumes <30% of context window.');
330
+ }
331
+ lines.push('');
332
+ lines.push('───────────────────────────────────────────────────────────────────');
333
+ lines.push(' BREAKDOWN BY CATEGORY (largest first)');
334
+ lines.push('───────────────────────────────────────────────────────────────────');
335
+ lines.push('');
336
+
337
+ const sortedCats = [...result.categories].sort((a, b) => b.totalTokens - a.totalTokens);
338
+ for (const cat of sortedCats) {
339
+ const tag = cat.lazyLoaded ? ' [LAZY — not in baseline]' : '';
340
+ lines.push(` ${cat.name}${tag}`);
341
+ lines.push(` source: ${cat.source}`);
342
+ lines.push(
343
+ ` files: ${cat.fileCount} bytes: ${fmtNum(cat.totalBytes)} tokens: ${fmtNum(cat.totalTokens)} (${fmtPct(cat.pctOfWindow)})`
344
+ );
345
+ lines.push(` [${bar(cat.pctOfWindow, 30)}]`);
346
+ lines.push('');
347
+ }
348
+
349
+ // Top N files across all categories
350
+ const allFiles = result.categories
351
+ .flatMap((c) => c.files)
352
+ .sort((a, b) => b.tokens - a.tokens);
353
+ if (allFiles.length > 0) {
354
+ lines.push('───────────────────────────────────────────────────────────────────');
355
+ lines.push(` TOP ${result.top} HEAVIEST FILES`);
356
+ lines.push('───────────────────────────────────────────────────────────────────');
357
+ lines.push('');
358
+ const topFiles = allFiles.slice(0, result.top);
359
+ for (const f of topFiles) {
360
+ const pct = (f.tokens / CONTEXT_WINDOW) * 100;
361
+ const flag = pct >= 5 ? ' 🔥' : pct >= 2 ? ' ⚠️' : '';
362
+ const rel = f.path.replace(os.homedir(), '~');
363
+ lines.push(` ${fmtNum(f.tokens).padStart(7)} tok ${fmtPct(pct).padStart(6)} ${rel}${flag}`);
364
+ }
365
+ lines.push('');
366
+ }
367
+
368
+ // Files above threshold
369
+ if (result.threshold > 0) {
370
+ const above = allFiles.filter((f) => f.tokens >= result.threshold);
371
+ if (above.length > 0) {
372
+ lines.push('───────────────────────────────────────────────────────────────────');
373
+ lines.push(` FILES ABOVE THRESHOLD (>= ${fmtNum(result.threshold)} tokens)`);
374
+ lines.push('───────────────────────────────────────────────────────────────────');
375
+ lines.push('');
376
+ for (const f of above) {
377
+ const rel = f.path.replace(os.homedir(), '~');
378
+ lines.push(` ${fmtNum(f.tokens).padStart(7)} tok ${rel}`);
379
+ }
380
+ lines.push('');
381
+ }
382
+ }
383
+
384
+ lines.push('───────────────────────────────────────────────────────────────────');
385
+ lines.push(' NOTES');
386
+ lines.push('───────────────────────────────────────────────────────────────────');
387
+ lines.push('');
388
+ lines.push(' - Token estimate uses 4 chars/token (within ~10% of real tokenizer).');
389
+ lines.push(' - "Static preamble" = everything loaded BEFORE you type your first');
390
+ lines.push(' message. Add ~10-20K tokens of typical conversation overhead.');
391
+ lines.push(' - Skills auto-load into the prompt as a list of names+descriptions.');
392
+ lines.push(' Their full bodies are loaded only when invoked, but the manifest');
393
+ lines.push(' itself contributes to baseline.');
394
+ lines.push(' - Built-in and MCP tool costs are estimates; real values vary by');
395
+ lines.push(' Claude Code version. Check ENABLE_TELEMETRY for exact counts.');
396
+ lines.push('');
397
+
398
+ return lines.join('\n');
399
+ }
400
+
401
+ function main() {
402
+ const args = process.argv.slice(2);
403
+ const opts = {
404
+ projectDir: process.cwd(),
405
+ globalDir: path.join(os.homedir(), '.claude'),
406
+ top: 10,
407
+ threshold: 0,
408
+ json: false,
409
+ };
410
+ for (let i = 0; i < args.length; i++) {
411
+ const a = args[i];
412
+ if (a === '--json') opts.json = true;
413
+ else if (a === '--top') opts.top = parseInt(args[++i], 10);
414
+ else if (a === '--threshold') opts.threshold = parseInt(args[++i], 10);
415
+ else if (a === '--project') opts.projectDir = path.resolve(args[++i]);
416
+ else if (a === '--global') opts.globalDir = path.resolve(args[++i]);
417
+ else if (a === '--help' || a === '-h') {
418
+ console.log('Usage: node bin/context-budget-audit.js [--json] [--top N] [--threshold N]');
419
+ process.exit(0);
420
+ }
421
+ }
422
+
423
+ const result = audit(opts);
424
+
425
+ if (opts.json) {
426
+ console.log(JSON.stringify(result, null, 2));
427
+ } else {
428
+ console.log(renderReport(result));
429
+ }
430
+ }
431
+
432
+ main();
package/bin/gsd-t.js CHANGED
@@ -1546,7 +1546,9 @@ function updateSingleProject(projectDir, counts) {
1546
1546
  }
1547
1547
  const guardAdded = updateProjectClaudeMd(claudeMd, projectName);
1548
1548
  const changelogCreated = createProjectChangelog(projectDir, projectName);
1549
- if (guardAdded || changelogCreated) {
1549
+ const binToolsCopied = copyBinToolsToProject(projectDir, projectName);
1550
+ const archiveRan = runProgressArchiveMigration(projectDir, projectName);
1551
+ if (guardAdded || changelogCreated || binToolsCopied || archiveRan) {
1550
1552
  counts.updated++;
1551
1553
  } else {
1552
1554
  info(`${projectName} — already up to date`);
@@ -1554,6 +1556,82 @@ function updateSingleProject(projectDir, counts) {
1554
1556
  }
1555
1557
  }
1556
1558
 
1559
+ // Bin tools that should ship with every registered project. Listed here so adding
1560
+ // a new tool only requires appending to this array.
1561
+ const PROJECT_BIN_TOOLS = ["archive-progress.js", "log-tail.js", "context-budget-audit.js"];
1562
+
1563
+ function copyBinToolsToProject(projectDir, projectName) {
1564
+ const projectBinDir = path.join(projectDir, "bin");
1565
+ if (!fs.existsSync(projectBinDir)) {
1566
+ try {
1567
+ fs.mkdirSync(projectBinDir, { recursive: true });
1568
+ } catch {
1569
+ return false;
1570
+ }
1571
+ }
1572
+ let copied = 0;
1573
+ for (const tool of PROJECT_BIN_TOOLS) {
1574
+ const src = path.join(PKG_ROOT, "bin", tool);
1575
+ const dest = path.join(projectBinDir, tool);
1576
+ if (!fs.existsSync(src)) continue;
1577
+ let needsCopy = true;
1578
+ if (fs.existsSync(dest)) {
1579
+ try {
1580
+ const srcContent = fs.readFileSync(src, "utf8");
1581
+ const destContent = fs.readFileSync(dest, "utf8");
1582
+ if (srcContent === destContent) needsCopy = false;
1583
+ } catch {
1584
+ // fall through, will copy
1585
+ }
1586
+ }
1587
+ if (needsCopy) {
1588
+ try {
1589
+ fs.copyFileSync(src, dest);
1590
+ try { fs.chmodSync(dest, 0o755); } catch {}
1591
+ copied++;
1592
+ } catch (e) {
1593
+ warn(`${projectName} — failed to copy ${tool}: ${e.message}`);
1594
+ }
1595
+ }
1596
+ }
1597
+ if (copied > 0) {
1598
+ info(`${projectName} — copied ${copied} bin tool(s)`);
1599
+ return true;
1600
+ }
1601
+ return false;
1602
+ }
1603
+
1604
+ // One-shot migration: roll the project's progress.md Decision Log into archive
1605
+ // files using bin/archive-progress.js. A marker file ensures we only do this once
1606
+ // per project — subsequent runs are no-ops.
1607
+ function runProgressArchiveMigration(projectDir, projectName) {
1608
+ const progressMd = path.join(projectDir, ".gsd-t", "progress.md");
1609
+ if (!fs.existsSync(progressMd)) return false;
1610
+
1611
+ const markerPath = path.join(projectDir, ".gsd-t", ".archive-migration-v1");
1612
+ if (fs.existsSync(markerPath)) return false;
1613
+
1614
+ const archiveScript = path.join(projectDir, "bin", "archive-progress.js");
1615
+ if (!fs.existsSync(archiveScript)) return false;
1616
+
1617
+ try {
1618
+ const output = execFileSync("node", [archiveScript, "--quiet"], {
1619
+ cwd: projectDir,
1620
+ encoding: "utf8",
1621
+ stdio: ["pipe", "pipe", "pipe"],
1622
+ });
1623
+ fs.writeFileSync(
1624
+ markerPath,
1625
+ `# archive-migration-v1\nApplied: ${new Date().toISOString()}\nTool: bin/archive-progress.js\n`
1626
+ );
1627
+ info(`${projectName} — progress.md Decision Log archived (one-time migration)`);
1628
+ return true;
1629
+ } catch (e) {
1630
+ warn(`${projectName} — archive migration failed: ${e.message}`);
1631
+ return false;
1632
+ }
1633
+ }
1634
+
1557
1635
  function showUpdateAllSummary(total, counts, playwrightMissing, swaggerMissing, syncCount) {
1558
1636
  log("");
1559
1637
  heading("Update All Complete");
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ // Log Tail — print the last N lines of a log file. Used by GSD-T command files to
3
+ // truncate test/build output before forwarding it into the conversation context.
4
+ //
5
+ // Usage:
6
+ // node bin/log-tail.js <logfile> # print last 100 lines
7
+ // node bin/log-tail.js <logfile> 500 # print last 500 lines
8
+ // node bin/log-tail.js <logfile> --on-fail # print 500 lines if the log contains
9
+ // "FAIL", "ERROR", or non-zero exit;
10
+ // 100 lines otherwise
11
+ //
12
+ // Why: piping `npm test` or `playwright test` directly into a Bash tool result
13
+ // dumps the entire stdout (often 5K-50K tokens) into context. This helper writes
14
+ // the full log to disk and prints only the tail, with a header showing the path
15
+ // to the full log so the agent can read more if needed.
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ function parseArgs(argv) {
21
+ const opts = { logFile: null, lines: 100, onFail: false };
22
+ for (let i = 0; i < argv.length; i++) {
23
+ const a = argv[i];
24
+ if (a === '--on-fail') opts.onFail = true;
25
+ else if (a === '--help' || a === '-h') {
26
+ console.log('Usage: node bin/log-tail.js <logfile> [N=100] [--on-fail]');
27
+ process.exit(0);
28
+ } else if (!opts.logFile) {
29
+ opts.logFile = a;
30
+ } else if (/^\d+$/.test(a)) {
31
+ opts.lines = parseInt(a, 10);
32
+ }
33
+ }
34
+ return opts;
35
+ }
36
+
37
+ function detectFailure(content) {
38
+ return /\b(FAIL|FAILED|ERROR|Exception|Traceback|Test Failed|✗|❌)\b/i.test(content);
39
+ }
40
+
41
+ function tail(content, n) {
42
+ const lines = content.split('\n');
43
+ if (lines.length <= n) return lines;
44
+ return lines.slice(-n);
45
+ }
46
+
47
+ function main() {
48
+ const opts = parseArgs(process.argv.slice(2));
49
+ if (!opts.logFile) {
50
+ console.error('Usage: node bin/log-tail.js <logfile> [N=100] [--on-fail]');
51
+ process.exit(2);
52
+ }
53
+
54
+ const abs = path.resolve(opts.logFile);
55
+ if (!fs.existsSync(abs)) {
56
+ console.error(`log-tail: file not found — ${abs}`);
57
+ process.exit(2);
58
+ }
59
+
60
+ const content = fs.readFileSync(abs, 'utf8');
61
+ let n = opts.lines;
62
+ if (opts.onFail) {
63
+ n = detectFailure(content) ? 500 : 100;
64
+ }
65
+
66
+ const total = content.split('\n').length;
67
+ const tailLines = tail(content, n);
68
+ const truncated = total > n;
69
+
70
+ console.log(`─── log tail: ${abs} ───`);
71
+ console.log(` total lines: ${total} showing: ${tailLines.length} truncated: ${truncated}`);
72
+ if (truncated) {
73
+ console.log(` full log: cat ${abs}`);
74
+ }
75
+ console.log('─────────────────────────────────────────────────────────────');
76
+ console.log(tailLines.join('\n'));
77
+ }
78
+
79
+ if (require.main === module) main();
80
+
81
+ module.exports = { tail, detectFailure };