@ghl-ai/aw 0.1.37-beta.73 → 0.1.37-beta.74

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.
@@ -1,13 +1,15 @@
1
- // commands/telemetry.mjs — `aw telemetry [push|flush|enable|disable|status]`
1
+ // commands/telemetry.mjs — `aw telemetry [push|flush|flush-queue|scan-prs|enable|disable|status]`
2
2
  //
3
3
  // Subcommands:
4
- // push — Automatic telemetry push after every task run (called by collect-telemetry.sh)
5
- // flush — Flush token data from Claude JSONL sessions
6
- // enableRe-enable anonymous analytics
7
- // disable Opt out of anonymous analytics
8
- // status Show telemetry status (default)
9
-
10
- import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
4
+ // push — Automatic telemetry push after every task run (called by collect-telemetry.sh)
5
+ // flush — Flush token data from Claude JSONL sessions
6
+ // flush-queueRetry queued payloads that failed to push (offline queue)
7
+ // scan-prs Scan GitHub repos for AI-authored PRs (Co-Authored-By attribution)
8
+ // enable Re-enable anonymous analytics
9
+ // disable — Opt out of anonymous analytics
10
+ // status — Show telemetry status (default)
11
+
12
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';
11
13
  import { execSync } from 'node:child_process';
12
14
  import { join } from 'node:path';
13
15
  import { homedir } from 'node:os';
@@ -59,6 +61,10 @@ export async function telemetryCommand(args) {
59
61
  await pushCommand(args);
60
62
  } else if (subcommand === 'flush') {
61
63
  await flushCommand(args);
64
+ } else if (subcommand === 'flush-queue') {
65
+ await flushQueueCommand();
66
+ } else if (subcommand === 'scan-prs') {
67
+ await scanPrsCommand(args);
62
68
  } else if (subcommand === 'disable') {
63
69
  disableTelemetry();
64
70
  fmt.logSuccess('Telemetry disabled. No anonymous usage data will be sent.');
@@ -73,12 +79,14 @@ export async function telemetryCommand(args) {
73
79
  fmt.logStep(`Machine ID: ${chalk.dim(status.machine_id)}`);
74
80
  fmt.logStep(`Config: ${chalk.dim(status.config_path)}`);
75
81
  fmt.logMessage('');
76
- fmt.logMessage(` ${chalk.dim('aw telemetry disable')} — opt out of anonymous analytics`);
77
- fmt.logMessage(` ${chalk.dim('aw telemetry enable')} — re-enable analytics`);
78
- fmt.logMessage(` ${chalk.dim('aw telemetry push')} — push telemetry data`);
79
- fmt.logMessage(` ${chalk.dim('aw telemetry flush')} — flush token data from sessions`);
82
+ fmt.logMessage(` ${chalk.dim('aw telemetry disable')} — opt out of anonymous analytics`);
83
+ fmt.logMessage(` ${chalk.dim('aw telemetry enable')} — re-enable analytics`);
84
+ fmt.logMessage(` ${chalk.dim('aw telemetry push')} — push telemetry data`);
85
+ fmt.logMessage(` ${chalk.dim('aw telemetry flush')} — flush token data from sessions`);
86
+ fmt.logMessage(` ${chalk.dim('aw telemetry flush-queue')} — retry queued payloads (offline queue)`);
87
+ fmt.logMessage(` ${chalk.dim('aw telemetry scan-prs')} — scan GitHub for AI-authored PRs`);
80
88
  } else {
81
- fmt.cancel(`Unknown telemetry subcommand: ${subcommand}. Use: aw telemetry [push|flush|enable|disable|status]`);
89
+ fmt.cancel(`Unknown telemetry subcommand: ${subcommand}. Use: aw telemetry [push|flush|flush-queue|scan-prs|enable|disable|status]`);
82
90
  }
83
91
  }
84
92
 
@@ -146,6 +154,24 @@ async function pushCommand(args) {
146
154
  agents_used: record.agents_used ?? [],
147
155
  skills_applied: record.skills_applied ?? [],
148
156
  github_login: record.github_login ?? resolveGithubLogin() ?? undefined,
157
+ // Phase 2 fields
158
+ cache_creation_tokens: record.cache_creation_tokens ?? null,
159
+ cache_read_tokens: record.cache_read_tokens ?? null,
160
+ project_hash: record.project_hash ?? null,
161
+ platform: record.platform ?? null,
162
+ platform_version: record.platform_version ?? null,
163
+ session_id: record.session_id ?? null,
164
+ session_duration_ms: record.session_duration_ms ?? null,
165
+ compaction_count: record.compaction_count ?? null,
166
+ mcp_tools_used: record.mcp_tools_used ?? [],
167
+ checkpoint_from: record.checkpoint_from ?? null,
168
+ checkpoint_to: record.checkpoint_to ?? null,
169
+ pr_url: record.pr_url ?? null,
170
+ pr_number: record.pr_number ?? null,
171
+ pr_repo: record.pr_repo ?? null,
172
+ pr_files_changed: record.pr_files_changed ?? null,
173
+ error_type: record.error_type ?? null,
174
+ error_message: record.error_message ?? null,
149
175
  };
150
176
 
151
177
  if (!dto.shell_run_id || !dto.command) {
@@ -393,3 +419,309 @@ async function pushDto(dto) {
393
419
  clearTimeout(timer);
394
420
  }
395
421
  }
422
+
423
+ // ── telemetry flush-queue ────────────────────────────────────────────────────
424
+
425
+ async function flushQueueCommand() {
426
+ const queuePath = join(homedir(), '.aw', 'telemetry-queue.jsonl');
427
+
428
+ if (!existsSync(queuePath)) {
429
+ fmt.logSuccess('Offline queue is empty — nothing to flush.');
430
+ return;
431
+ }
432
+
433
+ let lines;
434
+ try {
435
+ lines = readFileSync(queuePath, 'utf8').trim().split('\n').filter(Boolean);
436
+ } catch {
437
+ fmt.logWarn('flush-queue: could not read queue file');
438
+ return;
439
+ }
440
+
441
+ if (lines.length === 0) {
442
+ fmt.logSuccess('Offline queue is empty — nothing to flush.');
443
+ return;
444
+ }
445
+
446
+ fmt.logStep(`Found ${lines.length} queued payload(s). Flushing...`);
447
+
448
+ const QUEUE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
449
+ const remaining = [];
450
+ let flushed = 0;
451
+ let expired = 0;
452
+
453
+ for (const line of lines) {
454
+ let entry;
455
+ try { entry = JSON.parse(line); } catch { continue; }
456
+
457
+ // Skip expired (>7 days)
458
+ if (Date.now() - new Date(entry.queued_at).getTime() > QUEUE_TTL_MS) {
459
+ expired++;
460
+ continue;
461
+ }
462
+
463
+ const apiKey = process.env['AW_API_KEY'] ?? '';
464
+ const controller = new AbortController();
465
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
466
+ try {
467
+ const res = await fetch(AW_TELEMETRY_URL, {
468
+ method: 'POST',
469
+ headers: {
470
+ 'Content-Type': 'application/json',
471
+ ...(apiKey ? { 'X-API-Key': apiKey } : {}),
472
+ },
473
+ body: JSON.stringify(entry.payload),
474
+ signal: controller.signal,
475
+ });
476
+ clearTimeout(timer);
477
+
478
+ if (res.ok) {
479
+ flushed++;
480
+ } else {
481
+ entry.attempts = (entry.attempts || 0) + 1;
482
+ remaining.push(entry);
483
+ }
484
+ } catch {
485
+ clearTimeout(timer);
486
+ entry.attempts = (entry.attempts || 0) + 1;
487
+ remaining.push(entry);
488
+ }
489
+ }
490
+
491
+ // Rewrite queue with remaining entries
492
+ if (remaining.length > 0) {
493
+ writeFileSync(queuePath, remaining.map(e => JSON.stringify(e)).join('\n') + '\n');
494
+ } else {
495
+ try { unlinkSync(queuePath); } catch { /* already gone */ }
496
+ }
497
+
498
+ fmt.logSuccess(`Queue flush complete: ${flushed} pushed, ${expired} expired, ${remaining.length} still queued.`);
499
+ }
500
+
501
+ // ── telemetry scan-prs ───────────────────────────────────────────────────────
502
+ // Scans GitHub repos for commits with Co-Authored-By trailers from AI coding
503
+ // assistants. For each match, finds the containing PR and POSTs attribution
504
+ // back to the telemetry API.
505
+ //
506
+ // Usage:
507
+ // aw telemetry scan-prs # scan repos from config
508
+ // aw telemetry scan-prs --org=myorg # scan all repos in an org
509
+ // aw telemetry scan-prs --repo=owner/repo # scan a specific repo
510
+ // aw telemetry scan-prs --days=7 # how far back to scan (default: 7)
511
+
512
+ const CO_AUTHOR_PATTERNS = [
513
+ 'Co-Authored-By: Claude',
514
+ 'Co-Authored-By: claude',
515
+ 'Co-authored-by: Claude',
516
+ 'Co-authored-by: claude',
517
+ // Cursor / Copilot / generic AI
518
+ 'Co-Authored-By: GitHub Copilot',
519
+ 'Co-authored-by: GitHub Copilot',
520
+ ];
521
+
522
+ // GitHub search query qualifier for co-authored commits
523
+ const SEARCH_TRAILER = 'Co-Authored-By Claude';
524
+
525
+ async function scanPrsCommand(args) {
526
+ const ghToken = process.env.GITHUB_TOKEN || resolveGhToken();
527
+ if (!ghToken) {
528
+ fmt.cancel('scan-prs: GITHUB_TOKEN not set. Export it or run `gh auth login`.');
529
+ return;
530
+ }
531
+
532
+ const days = parseInt(args['--days'] || '7', 10);
533
+ const sinceDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
534
+ const specificRepo = args['--repo'] || null;
535
+ const org = args['--org'] || null;
536
+
537
+ fmt.intro('aw telemetry scan-prs');
538
+ fmt.logStep(`Scanning for AI-authored commits since ${sinceDate}...`);
539
+
540
+ // Determine repos to scan
541
+ let repos = [];
542
+ if (specificRepo) {
543
+ repos = [specificRepo];
544
+ } else if (org) {
545
+ repos = await listOrgRepos(org, ghToken);
546
+ if (repos.length === 0) {
547
+ fmt.logWarn(`No repos found for org "${org}"`);
548
+ return;
549
+ }
550
+ fmt.logStep(`Found ${repos.length} repos in ${org}`);
551
+ } else {
552
+ // Try to get repos from config
553
+ const configPath = join(homedir(), '.aw', 'config.json');
554
+ if (existsSync(configPath)) {
555
+ try {
556
+ const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
557
+ if (cfg.scan_repos && Array.isArray(cfg.scan_repos)) {
558
+ repos = cfg.scan_repos;
559
+ }
560
+ if (cfg.scan_org) {
561
+ repos = await listOrgRepos(cfg.scan_org, ghToken);
562
+ }
563
+ } catch { /* use empty */ }
564
+ }
565
+
566
+ if (repos.length === 0) {
567
+ // Fallback: try to detect from current git remote
568
+ try {
569
+ const remote = execSync('git remote get-url origin', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
570
+ const match = remote.match(/github\.com[:/]([^/]+\/[^/.]+)/);
571
+ if (match) repos = [match[1]];
572
+ } catch { /* no git */ }
573
+ }
574
+
575
+ if (repos.length === 0) {
576
+ fmt.cancel('scan-prs: no repos to scan. Use --repo=owner/repo, --org=myorg, or add scan_repos to ~/.aw/config.json');
577
+ return;
578
+ }
579
+ }
580
+
581
+ let totalFound = 0;
582
+ let totalAttributed = 0;
583
+
584
+ for (const repo of repos) {
585
+ const commits = await findAiAuthoredCommits(repo, sinceDate, ghToken);
586
+ if (commits.length === 0) continue;
587
+
588
+ fmt.logStep(`${repo}: found ${commits.length} AI-authored commit(s)`);
589
+ totalFound += commits.length;
590
+
591
+ for (const commit of commits) {
592
+ const pr = await findPrForCommit(repo, commit.sha, ghToken);
593
+ if (!pr) continue;
594
+
595
+ // POST attribution to API
596
+ const dto = {
597
+ shell_run_id: `pr-scan-${commit.sha.slice(0, 12)}`,
598
+ command: 'pr-scan',
599
+ status: 'complete',
600
+ github_login: commit.author,
601
+ pr_url: pr.html_url,
602
+ pr_number: pr.number,
603
+ pr_repo: repo,
604
+ pr_files_changed: pr.changed_files || null,
605
+ branch: pr.head?.ref || null,
606
+ };
607
+
608
+ const controller = new AbortController();
609
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
610
+ try {
611
+ const apiKey = process.env['AW_API_KEY'] ?? '';
612
+ const res = await fetch(AW_TELEMETRY_URL, {
613
+ method: 'POST',
614
+ headers: {
615
+ 'Content-Type': 'application/json',
616
+ ...(apiKey ? { 'X-API-Key': apiKey } : {}),
617
+ },
618
+ body: JSON.stringify(dto),
619
+ signal: controller.signal,
620
+ });
621
+ clearTimeout(timer);
622
+ if (res.ok) totalAttributed++;
623
+ } catch {
624
+ clearTimeout(timer);
625
+ }
626
+ }
627
+ }
628
+
629
+ fmt.logSuccess(`Scan complete: ${totalFound} AI commits found, ${totalAttributed} PRs attributed.`);
630
+
631
+ // Save last scan timestamp
632
+ const configPath = join(homedir(), '.aw', 'config.json');
633
+ try {
634
+ let cfg = {};
635
+ if (existsSync(configPath)) {
636
+ try { cfg = JSON.parse(readFileSync(configPath, 'utf8')); } catch { cfg = {}; }
637
+ }
638
+ cfg.last_pr_scan = new Date().toISOString();
639
+ cfg.last_pr_scan_found = totalFound;
640
+ cfg.last_pr_scan_attributed = totalAttributed;
641
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n');
642
+ } catch { /* best effort */ }
643
+ }
644
+
645
+ /** Resolve GitHub token from gh CLI auth. */
646
+ function resolveGhToken() {
647
+ try {
648
+ return execSync('gh auth token', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
649
+ } catch { return null; }
650
+ }
651
+
652
+ /** List repos in a GitHub org (up to 100, public + private). */
653
+ async function listOrgRepos(org, token) {
654
+ try {
655
+ const res = await fetch(`https://api.github.com/orgs/${encodeURIComponent(org)}/repos?per_page=100&sort=pushed&direction=desc`, {
656
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json' },
657
+ });
658
+ if (!res.ok) return [];
659
+ const data = await res.json();
660
+ return data.map(r => r.full_name);
661
+ } catch { return []; }
662
+ }
663
+
664
+ /**
665
+ * Find commits with AI Co-Authored-By trailers in a repo.
666
+ * Uses GitHub Search API for commit messages.
667
+ */
668
+ async function findAiAuthoredCommits(repo, since, token) {
669
+ const commits = [];
670
+
671
+ // GitHub commit search: search for "Co-Authored-By Claude" in the repo
672
+ // The search API is rate-limited (30 req/min for auth users), so we batch.
673
+ const query = encodeURIComponent(`repo:${repo} "${SEARCH_TRAILER}" committer-date:>=${since}`);
674
+ const url = `https://api.github.com/search/commits?q=${query}&per_page=100&sort=committer-date&order=desc`;
675
+
676
+ try {
677
+ const res = await fetch(url, {
678
+ headers: {
679
+ Authorization: `Bearer ${token}`,
680
+ Accept: 'application/vnd.github.cloak-preview+json',
681
+ },
682
+ });
683
+ if (!res.ok) {
684
+ if (res.status === 422) {
685
+ // Validation failed — repo might not exist or search syntax issue
686
+ return commits;
687
+ }
688
+ fmt.logWarn(`scan-prs: GitHub search returned ${res.status} for ${repo}`);
689
+ return commits;
690
+ }
691
+
692
+ const data = await res.json();
693
+ for (const item of (data.items || [])) {
694
+ const message = item.commit?.message || '';
695
+ if (CO_AUTHOR_PATTERNS.some(p => message.includes(p))) {
696
+ commits.push({
697
+ sha: item.sha,
698
+ message: message.slice(0, 256),
699
+ author: item.author?.login || item.commit?.author?.email || 'unknown',
700
+ date: item.commit?.committer?.date || null,
701
+ });
702
+ }
703
+ }
704
+ } catch (err) {
705
+ fmt.logWarn(`scan-prs: search failed for ${repo} — ${err.message}`);
706
+ }
707
+
708
+ return commits;
709
+ }
710
+
711
+ /**
712
+ * Find the PR that contains a specific commit SHA.
713
+ * Returns the first PR found, or null.
714
+ */
715
+ async function findPrForCommit(repo, sha, token) {
716
+ try {
717
+ const res = await fetch(`https://api.github.com/repos/${repo}/commits/${sha}/pulls`, {
718
+ headers: {
719
+ Authorization: `Bearer ${token}`,
720
+ Accept: 'application/vnd.github+json',
721
+ },
722
+ });
723
+ if (!res.ok) return null;
724
+ const prs = await res.json();
725
+ return prs.length > 0 ? prs[0] : null;
726
+ } catch { return null; }
727
+ }
@@ -254,6 +254,8 @@ function extractFromTranscript(transcriptPath) {
254
254
  let toolTotal = 0;
255
255
  let toolFailed = 0;
256
256
  const prUrls = new Set();
257
+ let errorType = null;
258
+ let errorMessage = null;
257
259
 
258
260
  for (const line of lines) {
259
261
  try {
@@ -323,6 +325,21 @@ function extractFromTranscript(transcriptPath) {
323
325
  if (ec && ec !== 0) toolFailed++;
324
326
  }
325
327
  }
328
+
329
+ // ── Error detection ──────────────────────────────────────────────
330
+ // Capture last error from transcript for structured error reporting.
331
+ const entryError = entry.error || entry.message?.error;
332
+ if (entryError) {
333
+ const errStr = typeof entryError === 'string' ? entryError : (entryError.message || JSON.stringify(entryError));
334
+ errorMessage = errStr.slice(0, 1024);
335
+ // Classify error type
336
+ if (/rate.?limit|429|too many requests/i.test(errStr)) errorType = 'rate_limit';
337
+ else if (/context.?(overflow|length|window|limit|too long)/i.test(errStr)) errorType = 'context_overflow';
338
+ else if (/timeout|timed?\s*out|ETIMEDOUT/i.test(errStr)) errorType = 'timeout';
339
+ else if (/cancel|abort|interrupt/i.test(errStr)) errorType = 'user_cancel';
340
+ else if (/tool.?(fail|error)|execution.?error/i.test(errStr)) errorType = 'tool_failure';
341
+ else errorType = 'unknown';
342
+ }
326
343
  } catch { /* skip malformed lines */ }
327
344
  }
328
345
 
@@ -362,6 +379,8 @@ function extractFromTranscript(transcriptPath) {
362
379
  toolPassed: toolTotal - toolFailed,
363
380
  toolFailed,
364
381
  prUrls: [...prUrls],
382
+ errorType,
383
+ errorMessage,
365
384
  };
366
385
  } catch {
367
386
  return null;
@@ -687,6 +706,12 @@ export async function handleStop(input) {
687
706
  session.skills_applied = [...existing];
688
707
  }
689
708
 
709
+ // Track last error
710
+ if (transcript?.errorType) {
711
+ session.error_type = transcript.errorType;
712
+ session.error_message = transcript.errorMessage;
713
+ }
714
+
690
715
  writeSession(sessionId, session);
691
716
  }
692
717
 
@@ -773,6 +798,9 @@ export async function handlePreCompact(input) {
773
798
  pr_url: prUrl,
774
799
  pr_number: prNumber,
775
800
  pr_repo: prRepo,
801
+ // Error detail
802
+ error_type: session.error_type || null,
803
+ error_message: session.error_message || null,
776
804
  };
777
805
 
778
806
  const ok = await pushToApi(dto, session.namespace);
@@ -865,6 +893,9 @@ export async function handleSessionEnd(input) {
865
893
  pr_url: prUrl,
866
894
  pr_number: prNumber,
867
895
  pr_repo: prRepo,
896
+ // Error detail
897
+ error_type: session.error_type || null,
898
+ error_message: session.error_message || null,
868
899
  };
869
900
 
870
901
  const ok = await pushToApi(dto, session.namespace);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.37-beta.73",
3
+ "version": "0.1.37-beta.74",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": "bin.js",