@ghl-ai/aw 0.1.93 → 0.2.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.
@@ -0,0 +1,179 @@
1
+ /**
2
+ * AW Usage Telemetry — loop-aware stage events (B4).
3
+ *
4
+ * isCompletionStatus(status) — P13 text rule for a finished stage
5
+ * decideStageEvents(history, { stage, status, now }) — pure: events + new history
6
+ * processStateJsonWrite({ filePath, … }) — read the written state.json, update the
7
+ * laptop history, return the events to send
8
+ *
9
+ * A write to .aw_docs/features/<slug>/state.json is a start when its `stage`
10
+ * is new or changed (or the same stage again after it was completed), and a
11
+ * completion when its `status` reads as finished. History lives on the laptop
12
+ * in ~/.aw/telemetry/stages/<project_hash>/<slug>.json (P12): the hook never
13
+ * writes into the repo. When the stage skills write a fixed status and a
14
+ * stage history into state.json (https://app.clickup.com/t/14zhcp5e0bh), only
15
+ * the input of decideStageEvents changes.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const path = require('path');
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const { withFileLock, sessionFileName } = require('./aw-usage-envelope');
24
+ const { commandKey } = require('./aw-usage-commands');
25
+
26
+ const STAGES_DIR = path.join(os.homedir(), '.aw', 'telemetry', 'stages');
27
+ // `investigate` is a valid stage outside the plan → ship order.
28
+ const KNOWN_STAGES = new Set(['plan', 'build', 'test', 'verify', 'review', 'deploy', 'ship', 'investigate']);
29
+ const STAGE_FOR_COMMAND = {
30
+ plan: 'plan', build: 'build', execute: 'build', test: 'test', verify: 'verify',
31
+ review: 'review', deploy: 'deploy', ship: 'ship', investigate: 'investigate',
32
+ };
33
+ const STATE_JSON = /(?:^|\/)\.aw_docs\/features\/([^/]+)\/state\.json$/;
34
+ const MAX_STATE_BYTES = 1024 * 1024;
35
+ const MAX_STATUS_RAW = 60;
36
+ const MAX_PATTERN_ID = 100;
37
+ const COMPLETED = /(?:^|_)(?:complete|completed|done|passed|pass)(?:_|$)/;
38
+ const NOT_COMPLETED = /(?:^|_)(?:blocked|failed|fail)(?:_|$)/;
39
+
40
+ // `stage`: the stage the write is in. `ready_for_<stage>` for that same stage
41
+ // means it has not started yet, so it is not a completion.
42
+ function isCompletionStatus(status, stage = null) {
43
+ if (typeof status !== 'string' || !status) return false;
44
+ const value = status.toLowerCase().replace(/[^a-z0-9]+/g, '_');
45
+ if (NOT_COMPLETED.test(value)) return false;
46
+ if (value.startsWith('ready_for_')) return !stage || value !== `ready_for_${stage}`;
47
+ return COMPLETED.test(value);
48
+ }
49
+
50
+ function decideStageEvents(history, { stage, status, now }) {
51
+ if (!KNOWN_STAGES.has(stage)) return { events: [], history };
52
+ const entries = Array.isArray(history?.entries) ? [...history.entries] : [];
53
+ const events = [];
54
+ const completion = isCompletionStatus(status, stage);
55
+ const current = entries[entries.length - 1];
56
+
57
+ const isNewStage = !current || current.stage !== stage;
58
+ if (isNewStage || (current.completed && !completion)) {
59
+ const loopIndex = entries.filter(entry => entry.stage === stage).length;
60
+ events.push({ type: 'started', stage, from_stage: current ? current.stage : null, loop_index: loopIndex });
61
+ entries.push({ stage, loop_index: loopIndex, started_at: now, completed: false });
62
+ }
63
+
64
+ const open = entries[entries.length - 1];
65
+ if (completion && open.stage === stage && !open.completed) {
66
+ events.push({ type: 'completed', stage, loop_index: open.loop_index });
67
+ entries[entries.length - 1] = { ...open, completed: true };
68
+ }
69
+ return { events, history: { entries } };
70
+ }
71
+
72
+ /** 'user' when the last prompt typed the command for this stage, else 'agent'. */
73
+ function stageStartedBy(stage, lastPromptCommands) {
74
+ const typed = Array.isArray(lastPromptCommands) ? lastPromptCommands : [];
75
+ return typed.some(command => STAGE_FOR_COMMAND[commandKey(command?.raw_name)] === stage) ? 'user' : 'agent';
76
+ }
77
+
78
+ function readStateJson(absolutePath) {
79
+ try {
80
+ if (fs.statSync(absolutePath).size > MAX_STATE_BYTES) return null;
81
+ const state = JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
82
+ return state && typeof state === 'object' && !Array.isArray(state) ? state : null;
83
+ } catch {
84
+ // Unreadable or not JSON: no stage event for this write.
85
+ return null;
86
+ }
87
+ }
88
+
89
+ function readHistory(historyPath) {
90
+ try {
91
+ return JSON.parse(fs.readFileSync(historyPath, 'utf8'));
92
+ } catch {
93
+ // First write for this feature on this laptop.
94
+ return { entries: [] };
95
+ }
96
+ }
97
+
98
+ // tmp file + rename: a crash mid-write never leaves half a history behind.
99
+ function writeHistory(historyPath, history) {
100
+ const tmpPath = `${historyPath}.${process.pid}.tmp`;
101
+ fs.writeFileSync(tmpPath, JSON.stringify(history), { mode: 0o600 });
102
+ fs.renameSync(tmpPath, historyPath);
103
+ }
104
+
105
+ function toEvent(decision, { slug, relativePath, state, startedBy }) {
106
+ if (decision.type === 'started') {
107
+ const patternId = typeof state.pattern_id === 'string' && state.pattern_id.length <= MAX_PATTERN_ID ? state.pattern_id : null;
108
+ return {
109
+ eventType: 'sdlc_stage_started',
110
+ payload: {
111
+ feature_slug: slug,
112
+ stage: decision.stage,
113
+ from_stage: decision.from_stage,
114
+ loop_index: decision.loop_index,
115
+ started_by: startedBy,
116
+ ...(patternId ? { pattern_id: patternId } : {}),
117
+ file_path: relativePath,
118
+ },
119
+ };
120
+ }
121
+ return {
122
+ eventType: 'sdlc_stage_completed',
123
+ payload: {
124
+ feature_slug: slug,
125
+ stage: decision.stage,
126
+ loop_index: decision.loop_index,
127
+ status_raw: String(state.status).slice(0, MAX_STATUS_RAW),
128
+ file_path: relativePath,
129
+ },
130
+ };
131
+ }
132
+
133
+ /**
134
+ * @param {{ filePath: string, projectRoot: string, projectHash: string|null,
135
+ * lastPromptCommands?: Array<{ raw_name: string }>, stagesDir?: string, now?: () => string }} opts
136
+ * @returns {Array<{ eventType: string, payload: object }>}
137
+ */
138
+ function processStateJsonWrite({ filePath, projectRoot, projectHash, lastPromptCommands = [], stagesDir = STAGES_DIR, now = () => new Date().toISOString() }) {
139
+ const norm = String(filePath || '').replace(/\\/g, '/');
140
+ if (!projectHash || !projectRoot) return [];
141
+ const root = path.posix.normalize(String(projectRoot).replace(/\\/g, '/')).replace(/\/+$/, '');
142
+ const isAbsolute = norm.startsWith('/') || /^[A-Za-z]:\//.test(norm);
143
+ // Resolve `..` before the check, so a path cannot step out of the project.
144
+ const absolutePath = path.posix.normalize(isAbsolute ? norm : `${root}/${norm}`);
145
+ // Only the project's own feature folders are read.
146
+ if (!root || !absolutePath.startsWith(`${root}/`)) return [];
147
+ const relativePath = absolutePath.slice(root.length + 1);
148
+ const match = relativePath.match(STATE_JSON);
149
+ if (!match) return [];
150
+
151
+ const state = readStateJson(absolutePath);
152
+ const stage = typeof state?.stage === 'string' ? state.stage.trim().toLowerCase() : null;
153
+ if (!state || !KNOWN_STAGES.has(stage)) return [];
154
+
155
+ const slug = match[1];
156
+ const featureDir = path.join(stagesDir, sessionFileName(projectHash));
157
+ const historyPath = path.join(featureDir, `${sessionFileName(slug)}.json`);
158
+ try {
159
+ fs.mkdirSync(featureDir, { recursive: true, mode: 0o700 });
160
+ const result = withFileLock(`${historyPath}.lock`, () => {
161
+ const decision = decideStageEvents(readHistory(historyPath), { stage, status: state.status, now: now() });
162
+ if (decision.events.length > 0) writeHistory(historyPath, decision.history);
163
+ return decision.events;
164
+ }, { waitMs: 200 });
165
+ if (!result.ok) return [];
166
+ const startedBy = stageStartedBy(stage, lastPromptCommands);
167
+ return result.value.map(decision => toEvent(decision, { slug, relativePath, state, startedBy }));
168
+ } catch {
169
+ // Telemetry folder not writable: no stage event, the rest of the hook is unaffected.
170
+ return [];
171
+ }
172
+ }
173
+
174
+ module.exports = {
175
+ isCompletionStatus,
176
+ decideStageEvents,
177
+ stageStartedBy,
178
+ processStateJsonWrite,
179
+ };
@@ -16,6 +16,8 @@ const fs = require('fs');
16
16
  const os = require('os');
17
17
  const { spawn, execSync } = require('child_process');
18
18
  const crypto = require('crypto');
19
+ const { newEventId, readHookVersion, nextSeq, withFileLock } = require('./aw-usage-envelope');
20
+ const { commandKey } = require('./aw-usage-commands');
19
21
 
20
22
  const SENDER_SCRIPT = path.join(__dirname, '..', 'hooks', 'aw-usage-telemetry-send.js');
21
23
  const AW_HOME = path.join(os.homedir(), '.aw');
@@ -179,7 +181,7 @@ function pruneStaleSessionFiles() {
179
181
  const entries = fs.readdirSync(SESSION_DIR);
180
182
  const now = Date.now();
181
183
  for (const entry of entries) {
182
- if (!entry.endsWith('.json')) continue;
184
+ if (!entry.endsWith('.json') && !entry.endsWith('.seq')) continue;
183
185
  const filePath = path.join(SESSION_DIR, entry);
184
186
  const stat = fs.statSync(filePath);
185
187
  if (now - stat.mtimeMs > SESSION_MAX_AGE_MS) {
@@ -289,6 +291,82 @@ function persistSessionSlashCommand(sessionId, slashCommand, context = {}) {
289
291
  } catch { /* ignore */ }
290
292
  }
291
293
 
294
+ // last_prompt and its `matched` list are written under the session lock and
295
+ // replaced atomically (tmp file + rename), so a tool hook never reads half a
296
+ // file or overwrites a newer prompt.
297
+ const SESSION_LOCK_WAIT_MS = 200;
298
+
299
+ function sessionLockPath(sessionId) {
300
+ return path.join(SESSION_DIR, sessionId + '.json.lock');
301
+ }
302
+
303
+ function writeSessionStateAtomic(sessionId, state) {
304
+ const filePath = path.join(SESSION_DIR, sessionId + '.json');
305
+ const tmpPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
306
+ fs.writeFileSync(tmpPath, JSON.stringify(state));
307
+ fs.renameSync(tmpPath, filePath);
308
+ }
309
+
310
+ // last_prompt: the commands typed in the most recent prompt (empty for a
311
+ // plain prompt). `matched` lists command keys already paired with a skill
312
+ // load, so only the first matching load gets follows_typed_command.
313
+ function persistSessionPrompt(sessionId, { turn_id: turnId = null, commands = [] } = {}) {
314
+ if (!sessionId || sessionId === 'unknown') return;
315
+ const write = () => {
316
+ const state = readSessionState(sessionId);
317
+ writeSessionStateAtomic(sessionId, {
318
+ ...state,
319
+ last_prompt: {
320
+ turn_id: turnId || null,
321
+ commands: commands.map(({ raw_name, command_form, namespace }) => ({ raw_name, command_form, namespace })),
322
+ matched: [],
323
+ at: new Date().toISOString(),
324
+ },
325
+ });
326
+ };
327
+ try {
328
+ fs.mkdirSync(SESSION_DIR, { recursive: true });
329
+ // A new prompt must land even when a tool hook holds the lock too long.
330
+ if (!withFileLock(sessionLockPath(sessionId), write, { waitMs: SESSION_LOCK_WAIT_MS }).ok) write();
331
+ } catch { /* ignore: best effort, like the other session records */ }
332
+ }
333
+
334
+ const TYPED_COMMAND_WINDOW_MS = 10 * 60 * 1000;
335
+
336
+ function isCurrentPrompt(prompt, turnId, now) {
337
+ if (prompt.turn_id && turnId) return prompt.turn_id === turnId;
338
+ return now - parseUpdatedAt(prompt.at) <= TYPED_COMMAND_WINDOW_MS;
339
+ }
340
+
341
+ // B1 follows_typed_command: true once per typed command, for the first skill
342
+ // load whose key matches it in the same prompt (same turn, or within 10
343
+ // minutes when the IDE gives no turn id). The pairing is recorded in
344
+ // last_prompt.matched under a lock so two tool hooks cannot both claim it.
345
+ function claimTypedCommandMatch(sessionId, turnId, skillName, { now = Date.now() } = {}) {
346
+ const key = commandKey(skillName);
347
+ if (!sessionId || !key) return false;
348
+ try {
349
+ const result = withFileLock(sessionLockPath(sessionId), () => {
350
+ const state = readSessionState(sessionId);
351
+ const prompt = state.last_prompt;
352
+ if (!prompt || !Array.isArray(prompt.commands) || !isCurrentPrompt(prompt, turnId, now)) return false;
353
+ const matched = Array.isArray(prompt.matched) ? prompt.matched : [];
354
+ if (matched.includes(key) || !prompt.commands.some(command => commandKey(command.raw_name) === key)) return false;
355
+ writeSessionStateAtomic(sessionId, { ...state, last_prompt: { ...prompt, matched: [...matched, key] } });
356
+ return true;
357
+ }, { waitMs: SESSION_LOCK_WAIT_MS });
358
+ return result.ok && result.value === true;
359
+ } catch {
360
+ // Session file not writable: the field is omitted for this event.
361
+ return false;
362
+ }
363
+ }
364
+
365
+ function readSessionLastPrompt(sessionId) {
366
+ const prompt = readSessionState(sessionId)?.last_prompt;
367
+ return prompt && Array.isArray(prompt.commands) ? prompt : null;
368
+ }
369
+
292
370
  function readSessionLastSlashCommand(sessionId) {
293
371
  const cmd = readSessionState(sessionId)?.last_slash_command;
294
372
  if (!cmd?.command_name) return null;
@@ -348,6 +426,59 @@ function findRecentSdlcSessionForProject(cwd, maxAgeMs = SESSION_MAX_AGE_MS) {
348
426
  return best;
349
427
  }
350
428
 
429
+ // ── Session activity index (B9) ──────────────────────────────────────
430
+ // sessions/by-project/<project_hash>.activity.json lists the IDE sessions
431
+ // active in a project in the last 2 hours. Every event except commit_created
432
+ // touches it; the commit hook reads it to name the IDE a commit came from.
433
+ // The v1 by-project/<hash>.json SDLC index is separate and unchanged.
434
+
435
+ const ACTIVITY_WINDOW_MS = 2 * 60 * 60 * 1000;
436
+
437
+ function activityIndexPath(projectHash) {
438
+ return path.join(SESSION_PROJECT_DIR, `${projectHash}.activity.json`);
439
+ }
440
+
441
+ function readActivityIndex(projectHash) {
442
+ try {
443
+ const sessions = JSON.parse(fs.readFileSync(activityIndexPath(projectHash), 'utf8'))?.sessions;
444
+ return sessions && typeof sessions === 'object' && !Array.isArray(sessions) ? sessions : {};
445
+ } catch {
446
+ // No index yet, or unreadable: no active sessions known.
447
+ return {};
448
+ }
449
+ }
450
+
451
+ function recentEntries(sessions, now) {
452
+ return Object.entries(sessions).filter(([, entry]) => now - parseUpdatedAt(entry?.last_active_at) <= ACTIVITY_WINDOW_MS);
453
+ }
454
+
455
+ function touchSessionActivity(projectHash, sessionId, harness, { now = Date.now() } = {}) {
456
+ if (!projectHash || !sessionId || sessionId === 'unknown') return;
457
+ const filePath = activityIndexPath(projectHash);
458
+ try {
459
+ fs.mkdirSync(SESSION_PROJECT_DIR, { recursive: true });
460
+ withFileLock(`${filePath}.lock`, () => {
461
+ const sessions = Object.fromEntries(recentEntries(readActivityIndex(projectHash), now));
462
+ sessions[sessionId] = { harness, last_active_at: new Date(now).toISOString() };
463
+ const tmpPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
464
+ fs.writeFileSync(tmpPath, JSON.stringify({ sessions }));
465
+ fs.renameSync(tmpPath, filePath);
466
+ });
467
+ } catch {
468
+ // Index not writable: commits from this project carry no origin fields.
469
+ }
470
+ }
471
+
472
+ /** Most recent session active in the project in the last 2 hours, or null. */
473
+ function findRecentActiveSessionForProject(projectHash, { now = Date.now() } = {}) {
474
+ if (!projectHash) return null;
475
+ const recent = recentEntries(readActivityIndex(projectHash), now)
476
+ .sort(([, a], [, b]) => parseUpdatedAt(b.last_active_at) - parseUpdatedAt(a.last_active_at));
477
+ if (recent.length === 0) return null;
478
+ const [sessionId, entry] = recent[0];
479
+ return { session_id: sessionId, harness: typeof entry.harness === 'string' ? entry.harness : 'unknown', ambiguous: recent.length > 1 };
480
+ }
481
+
351
482
  // ── Short-TTL dedupe guards ──────────────────────────────────────────
352
483
 
353
484
  function normalizeDedupePart(value) {
@@ -512,6 +643,14 @@ function readLastAssistantFromTranscript(transcriptPath) {
512
643
 
513
644
  // ── buildEvent ───────────────────────────────────────────────────────
514
645
 
646
+ const SCHEMA_VERSION = 2;
647
+ let _hookVersion;
648
+
649
+ function getHookVersion() {
650
+ if (_hookVersion === undefined) _hookVersion = readHookVersion();
651
+ return _hookVersion;
652
+ }
653
+
515
654
  function buildEvent(hookInput, eventType, payload) {
516
655
  const input = hookInput || {};
517
656
  const cfg = loadConfig();
@@ -540,6 +679,17 @@ function buildEvent(hookInput, eventType, payload) {
540
679
  || (input.workspace_roots && input.workspace_roots[0])
541
680
  || null;
542
681
 
682
+ // A8: the email the IDE itself is signed in with, when the IDE provides one.
683
+ // Cursor passes it in the hook input and to hook scripts; Claude Code and Codex do not.
684
+ const rawAccountEmail = input.user_email || process.env.CURSOR_USER_EMAIL;
685
+ const accountEmail = typeof rawAccountEmail === 'string' && rawAccountEmail ? rawAccountEmail.slice(0, 200) : null;
686
+
687
+ // v2 envelope (spec 4.2). seq is omitted when it cannot be taken safely.
688
+ const hookVersion = getHookVersion();
689
+ const seq = nextSeq(sessionId);
690
+ const projectHash = computeProjectHash(cwd);
691
+ if (eventType !== 'commit_created') touchSessionActivity(projectHash, sessionId, harness);
692
+
543
693
  return {
544
694
  session_id: sessionId,
545
695
  generation_id: generationId,
@@ -548,13 +698,17 @@ function buildEvent(hookInput, eventType, payload) {
548
698
  machine_id: cfg.machine_id || 'unknown',
549
699
  github_user: git.user || input.user_email || null,
550
700
  github_email: git.email || input.user_email || null,
551
- project_hash: computeProjectHash(cwd),
701
+ project_hash: projectHash,
552
702
  aw_version: awCli.version,
553
703
  aw_cli_version: awCli.version,
554
704
  aw_cli_version_source: awCli.source,
555
705
  event: eventType,
556
706
  client_ts: new Date().toISOString(),
557
- payload: payload || {},
707
+ ...(accountEmail ? { account_email: accountEmail } : {}),
708
+ event_id: newEventId(),
709
+ schema_version: SCHEMA_VERSION,
710
+ ...(hookVersion ? { hook_version: hookVersion } : {}),
711
+ payload: { ...(payload || {}), ...(seq ? { seq } : {}) },
558
712
  };
559
713
  }
560
714
 
@@ -587,8 +741,14 @@ module.exports = {
587
741
  persistSessionSkill,
588
742
  readSessionSkill,
589
743
  persistSessionSlashCommand,
744
+ persistSessionPrompt,
745
+ claimTypedCommandMatch,
746
+ readSessionLastPrompt,
590
747
  readSessionLastSlashCommand,
748
+ computeProjectHash,
591
749
  findRecentSdlcSessionForProject,
750
+ findRecentActiveSessionForProject,
751
+ touchSessionActivity,
592
752
  readLastAssistantFromTranscript,
593
753
  resolvePromptText,
594
754
  getAwCliVersionDetails,
@@ -1,4 +1,4 @@
1
1
  {
2
2
  "type": "commonjs",
3
- "_comment": "Override parent libs/aw/package.json's \"type\": \"module\". The aw-usage hook scripts use require() because they're invoked by Claude Code / Cursor / Codex hooks via `node <path>` — they need to behave the same when bundled in @ghl-ai/aw as when installed by aw-ecc directly into ~/.claude/scripts/hooks/."
3
+ "_comment": "Override parent libs/aw/package.json's \"type\": \"module\". The aw-usage hook scripts use require() because Claude Code, Cursor, Codex and git run them with `node <path>`. aw copies this file with the scripts to ~/.aw/aw-usage/ so they stay CommonJS there too."
4
4
  }
@@ -1,5 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
 
3
+ import { awUsageShellScriptPath } from '../aw-usage-wiring.mjs';
3
4
  import { getSupportedHarnessPhaseEntries } from '../hook-manifest.mjs';
4
5
  import {
5
6
  buildRegistryDelegatingPhaseScriptWithTelemetry,
@@ -32,7 +33,7 @@ const CODEX_HOME_PHASE_BLUEPRINTS = {
32
33
  '$HOME/.aw/.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh',
33
34
  ],
34
35
  warningMessage: 'WARNING: AW using-aw-skills hook not found in ~/.aw_registry. Run aw init or aw pull platform.',
35
- telemetryHookPath: '$HOME/.aw-ecc/scripts/hooks/aw-usage-session-start.js',
36
+ telemetryHookPath: awUsageShellScriptPath('aw-usage-session-start.js'),
36
37
  harnessEnv: 'codex',
37
38
  });
38
39
  },
@@ -60,7 +61,7 @@ set -euo pipefail
60
61
  # Capture stdin so we can feed it to both telemetry and the reminder delegate.
61
62
  STDIN=$(cat)
62
63
 
63
- TELEMETRY_HOOK="$HOME/.aw-ecc/scripts/hooks/aw-usage-prompt-submit.js"
64
+ TELEMETRY_HOOK="${awUsageShellScriptPath('aw-usage-prompt-submit.js')}"
64
65
  if [[ -f "$TELEMETRY_HOOK" ]] && command -v node >/dev/null 2>&1; then
65
66
  printf '%s' "$STDIN" | AW_HARNESS=codex node "$TELEMETRY_HOOK" >/dev/null 2>&1 || true
66
67
  fi
@@ -116,9 +117,10 @@ ${this.scriptMarker}
116
117
  set -euo pipefail
117
118
 
118
119
  # AW PostToolUse phase for Codex — usage telemetry.
119
- # Codex exposes PostToolUse for Bash commands only; telemetry derives
120
- # tool_error and Bash-based fallback skill signals from the payload.
121
- TELEMETRY_HOOK="$HOME/.aw-ecc/scripts/hooks/aw-usage-post-tool-use.js"
120
+ # The interactive Codex app sends PostToolUse for Bash commands and for
121
+ # apply_patch (codex exec may send none); telemetry derives tool_error, shell
122
+ # skill reads and stage documents from the payload.
123
+ TELEMETRY_HOOK="${awUsageShellScriptPath('aw-usage-post-tool-use.js')}"
122
124
  if [[ -f "$TELEMETRY_HOOK" ]]; then
123
125
  exec node "$TELEMETRY_HOOK"
124
126
  fi
@@ -148,7 +150,7 @@ set -euo pipefail
148
150
  # Capture stdin before invoking non-blocking sidecars.
149
151
  STDIN=$(cat)
150
152
 
151
- TELEMETRY_HOOK="$HOME/.aw-ecc/scripts/hooks/aw-usage-stop.js"
153
+ TELEMETRY_HOOK="${awUsageShellScriptPath('aw-usage-stop.js')}"
152
154
  if [[ -f "$TELEMETRY_HOOK" ]] && command -v node >/dev/null 2>&1; then
153
155
  printf '%s' "$STDIN" | AW_HARNESS=codex node "$TELEMETRY_HOOK" >/dev/null 2>&1 || true
154
156
  fi
package/hooks.mjs CHANGED
@@ -10,6 +10,9 @@ import { join } from 'node:path';
10
10
  import { homedir } from 'node:os';
11
11
  import * as fmt from './fmt.mjs';
12
12
  import { AW_CO_AUTHOR } from './constants.mjs';
13
+ import { awUsageShellScriptPath } from './aw-usage-wiring.mjs';
14
+
15
+ const COMMIT_TELEMETRY_HOOK = awUsageShellScriptPath('aw-usage-commit-created.js');
13
16
 
14
17
  const HOME = homedir();
15
18
  const HOOKS_DIR = join(HOME, '.aw', 'hooks');
@@ -97,10 +100,7 @@ fi
97
100
 
98
101
  # Fire commit_created telemetry if commit has AW co-author trailer
99
102
  if git log -1 --format='%b' HEAD 2>/dev/null | grep -qF "Co-Authored-By: AW"; then
100
- TELEMETRY_HOOK="$HOME/.claude/scripts/hooks/aw-usage-commit-created.js"
101
- if [ ! -f "$TELEMETRY_HOOK" ]; then
102
- TELEMETRY_HOOK="$HOME/.aw-ecc/scripts/hooks/aw-usage-commit-created.js"
103
- fi
103
+ TELEMETRY_HOOK="${COMMIT_TELEMETRY_HOOK}"
104
104
  if command -v node >/dev/null 2>&1 && [ -f "$TELEMETRY_HOOK" ]; then
105
105
  COMMIT_HASH="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
106
106
  BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
@@ -281,10 +281,7 @@ case "$(pwd)" in /tmp/aw-*|/var/folders/*/aw-*|*/.aw|*/.aw/*) exit 0 ;; esac
281
281
 
282
282
  # Fire commit_created telemetry if commit has AW co-author trailer
283
283
  if git log -1 --format='%b' HEAD 2>/dev/null | grep -qF "Co-Authored-By: AW"; then
284
- TELEMETRY_HOOK="$HOME/.claude/scripts/hooks/aw-usage-commit-created.js"
285
- if [ ! -f "$TELEMETRY_HOOK" ]; then
286
- TELEMETRY_HOOK="$HOME/.aw-ecc/scripts/hooks/aw-usage-commit-created.js"
287
- fi
284
+ TELEMETRY_HOOK="${COMMIT_TELEMETRY_HOOK}"
288
285
  if command -v node >/dev/null 2>&1 && [ -f "$TELEMETRY_HOOK" ]; then
289
286
  COMMIT_HASH="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
290
287
  BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"