@ghl-ai/aw 0.1.93 → 0.2.1

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.
@@ -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, BARE_SLASH_COMMANDS } = 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');
@@ -169,24 +171,31 @@ function resolveProjectHash(context) {
169
171
  }
170
172
 
171
173
  // ── Session file cleanup ─────────────────────────────────────────────
172
- // Prune session files older than SESSION_MAX_AGE_MS to prevent unbounded growth.
173
- // Called once per session start — best-effort, never blocks.
174
+ // Prune session files idle longer than SESSION_FILE_RETENTION_MS to prevent
175
+ // unbounded growth. Called once per session start — best-effort, never blocks.
174
176
 
175
- const SESSION_MAX_AGE_MS = 72 * 60 * 60 * 1000; // 72 hours
177
+ const SESSION_MAX_AGE_MS = 72 * 60 * 60 * 1000; // 72 hours: v1 commit link only
178
+ // Long enough that a session's aw_skills survive until its last commit.
179
+ const SESSION_FILE_RETENTION_MS = 5 * 24 * 60 * 60 * 1000;
176
180
 
177
181
  function pruneStaleSessionFiles() {
182
+ let entries;
178
183
  try {
179
- const entries = fs.readdirSync(SESSION_DIR);
180
- const now = Date.now();
181
- for (const entry of entries) {
182
- if (!entry.endsWith('.json')) continue;
184
+ entries = fs.readdirSync(SESSION_DIR);
185
+ } catch {
186
+ return; // No sessions folder yet.
187
+ }
188
+ const now = Date.now();
189
+ for (const entry of entries) {
190
+ // .tmp: left by a crash between an atomic write and its rename.
191
+ if (!entry.endsWith('.json') && !entry.endsWith('.seq') && !entry.endsWith('.tmp')) continue;
192
+ try {
183
193
  const filePath = path.join(SESSION_DIR, entry);
184
- const stat = fs.statSync(filePath);
185
- if (now - stat.mtimeMs > SESSION_MAX_AGE_MS) {
186
- fs.unlinkSync(filePath);
187
- }
194
+ if (now - fs.statSync(filePath).mtimeMs > SESSION_FILE_RETENTION_MS) fs.unlinkSync(filePath);
195
+ } catch {
196
+ // Gone meanwhile, or not a plain file: skip it and keep pruning the rest.
188
197
  }
189
- } catch { /* best effort */ }
198
+ }
190
199
  }
191
200
 
192
201
  // ── Session model persistence ────────────────────────────────────────
@@ -211,6 +220,8 @@ function readSessionModel(sessionId) {
211
220
 
212
221
  function readSessionState(sessionId) {
213
222
  if (!sessionId) return {};
223
+ // Keep the session's AW-skills file alive with the session, main file or not.
224
+ try { const now = new Date(); fs.utimesSync(awSkillsPath(sessionId), now, now); } catch { /* none recorded yet */ }
214
225
  try {
215
226
  const filePath = path.join(SESSION_DIR, sessionId + '.json');
216
227
  const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -289,6 +300,156 @@ function persistSessionSlashCommand(sessionId, slashCommand, context = {}) {
289
300
  } catch { /* ignore */ }
290
301
  }
291
302
 
303
+ // last_prompt and its `matched` list are written under the session lock and
304
+ // replaced atomically (tmp file + rename), so a tool hook never reads half a
305
+ // file or overwrites a newer prompt.
306
+ const SESSION_LOCK_WAIT_MS = 200;
307
+
308
+ function sessionLockPath(sessionId) {
309
+ return path.join(SESSION_DIR, sessionId + '.json.lock');
310
+ }
311
+
312
+ function writeSessionStateAtomic(sessionId, state) {
313
+ const filePath = path.join(SESSION_DIR, sessionId + '.json');
314
+ const tmpPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
315
+ fs.writeFileSync(tmpPath, JSON.stringify(state));
316
+ fs.renameSync(tmpPath, filePath);
317
+ }
318
+
319
+ // last_prompt: the commands typed in the most recent prompt (empty for a
320
+ // plain prompt). `matched` lists command keys already paired with a skill
321
+ // load, so only the first matching load gets follows_typed_command.
322
+ function persistSessionPrompt(sessionId, { turn_id: turnId = null, commands = [] } = {}) {
323
+ if (!sessionId || sessionId === 'unknown') return;
324
+ const write = () => {
325
+ const state = readSessionState(sessionId);
326
+ writeSessionStateAtomic(sessionId, {
327
+ ...state,
328
+ last_prompt: {
329
+ turn_id: turnId || null,
330
+ commands: commands.map(({ raw_name, command_form, namespace }) => ({ raw_name, command_form, namespace })),
331
+ matched: [],
332
+ at: new Date().toISOString(),
333
+ },
334
+ });
335
+ };
336
+ try {
337
+ fs.mkdirSync(SESSION_DIR, { recursive: true });
338
+ // A new prompt must land even when a tool hook holds the lock too long.
339
+ if (!withFileLock(sessionLockPath(sessionId), write, { waitMs: SESSION_LOCK_WAIT_MS }).ok) write();
340
+ } catch { /* ignore: best effort, like the other session records */ }
341
+ }
342
+
343
+ const TYPED_COMMAND_WINDOW_MS = 10 * 60 * 1000;
344
+
345
+ // aw_skills: the AW skills and stages this session ran, as the harness reported
346
+ // them. The prepare-commit-msg hook writes them as the AW-Skills trailer; the
347
+ // server decides which count as SDLC. Only where a skill came from is checked
348
+ // here, so non-AW skill names never reach a commit.
349
+ const AW_SKILLS_MAX = 20;
350
+ const SAFE_AW_SKILL = /^[a-z0-9:_/$.-]{1,60}$/;
351
+ // The AW router in any form: using-aw-skills, aw:/platform-core:using-aw-skills,
352
+ // aw-using-aw-skills, platform-core-using-aw-skills (registry link folders).
353
+ const ROUTER_SKILL = /(?:^|[:/-])using-aw-skills$/;
354
+
355
+ // Typed forms other than slash_ns only match `aw-` names (aw-usage-commands.js);
356
+ // slash_ns also carries other plugins' namespaces (`/caveman`, `/codex:rescue`).
357
+ function isAwTypedCommand(name, commandForm) {
358
+ return commandForm !== 'slash_ns' || name.startsWith('aw:') || BARE_SLASH_COMMANDS.has(name);
359
+ }
360
+
361
+ /** The aw_skills entry for an event, or null when it is not AW evidence. */
362
+ function awSkillEntry(eventType, payload) {
363
+ let entry = null;
364
+ if (eventType === 'sdlc_stage_started' || eventType === 'sdlc_stage_completed') {
365
+ entry = typeof payload?.stage === 'string' ? `stage:${payload.stage.toLowerCase()}` : null;
366
+ } else if (eventType === 'skill_invoked' && typeof payload?.skill_name === 'string') {
367
+ const name = payload.skill_name.toLowerCase();
368
+ const isAw = payload.source === 'user_prompt' ? isAwTypedCommand(name, payload.command_form) : payload.skill_origin === 'aw';
369
+ entry = isAw ? name : null;
370
+ }
371
+ if (!entry || !SAFE_AW_SKILL.test(entry)) return null;
372
+ return ROUTER_SKILL.test(entry) ? null : entry;
373
+ }
374
+
375
+ // Own file per session: the older session writers (model, last skill) rewrite
376
+ // <session>.json without the lock, so they could wipe or half-write aw_skills.
377
+ function awSkillsPath(sessionId) {
378
+ return path.join(SESSION_DIR, sessionId + '.aw.json');
379
+ }
380
+
381
+ function readAwSkillsState(sessionId) {
382
+ try {
383
+ const state = JSON.parse(fs.readFileSync(awSkillsPath(sessionId), 'utf8'));
384
+ return state && typeof state === 'object' ? state : {};
385
+ } catch {
386
+ // Missing or unreadable: no AW skills recorded for this session.
387
+ return {};
388
+ }
389
+ }
390
+
391
+ function recordSessionAwSkill(sessionId, entry, { now = Date.now() } = {}) {
392
+ if (!sessionId || sessionId === 'unknown') return;
393
+ const write = () => {
394
+ const state = readAwSkillsState(sessionId);
395
+ const skills = Array.isArray(state.aw_skills) ? state.aw_skills : [];
396
+ const filePath = awSkillsPath(sessionId);
397
+ const tmpPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
398
+ fs.writeFileSync(tmpPath, JSON.stringify({
399
+ aw_skills: skills.includes(entry) || skills.length >= AW_SKILLS_MAX ? skills : [...skills, entry],
400
+ aw_used_at: new Date(now).toISOString(),
401
+ }));
402
+ fs.renameSync(tmpPath, filePath);
403
+ };
404
+ try {
405
+ fs.mkdirSync(SESSION_DIR, { recursive: true });
406
+ if (!withFileLock(sessionLockPath(sessionId), write, { waitMs: SESSION_LOCK_WAIT_MS }).ok) write();
407
+ } catch {
408
+ // Not writable: commits from this session carry no AW trailer.
409
+ }
410
+ }
411
+
412
+ /** The AW skills the session ran, safe for a trailer; [] when unknown. */
413
+ function readSessionAwSkills(sessionId) {
414
+ if (!sessionId) return [];
415
+ const skills = readAwSkillsState(sessionId).aw_skills;
416
+ return Array.isArray(skills) ? skills.filter(s => typeof s === 'string' && SAFE_AW_SKILL.test(s)) : [];
417
+ }
418
+
419
+ function isCurrentPrompt(prompt, turnId, now) {
420
+ if (prompt.turn_id && turnId) return prompt.turn_id === turnId;
421
+ return now - parseUpdatedAt(prompt.at) <= TYPED_COMMAND_WINDOW_MS;
422
+ }
423
+
424
+ // B1 follows_typed_command: true once per typed command, for the first skill
425
+ // load whose key matches it in the same prompt (same turn, or within 10
426
+ // minutes when the IDE gives no turn id). The pairing is recorded in
427
+ // last_prompt.matched under a lock so two tool hooks cannot both claim it.
428
+ function claimTypedCommandMatch(sessionId, turnId, skillName, { now = Date.now() } = {}) {
429
+ const key = commandKey(skillName);
430
+ if (!sessionId || !key) return false;
431
+ try {
432
+ const result = withFileLock(sessionLockPath(sessionId), () => {
433
+ const state = readSessionState(sessionId);
434
+ const prompt = state.last_prompt;
435
+ if (!prompt || !Array.isArray(prompt.commands) || !isCurrentPrompt(prompt, turnId, now)) return false;
436
+ const matched = Array.isArray(prompt.matched) ? prompt.matched : [];
437
+ if (matched.includes(key) || !prompt.commands.some(command => commandKey(command.raw_name) === key)) return false;
438
+ writeSessionStateAtomic(sessionId, { ...state, last_prompt: { ...prompt, matched: [...matched, key] } });
439
+ return true;
440
+ }, { waitMs: SESSION_LOCK_WAIT_MS });
441
+ return result.ok && result.value === true;
442
+ } catch {
443
+ // Session file not writable: the field is omitted for this event.
444
+ return false;
445
+ }
446
+ }
447
+
448
+ function readSessionLastPrompt(sessionId) {
449
+ const prompt = readSessionState(sessionId)?.last_prompt;
450
+ return prompt && Array.isArray(prompt.commands) ? prompt : null;
451
+ }
452
+
292
453
  function readSessionLastSlashCommand(sessionId) {
293
454
  const cmd = readSessionState(sessionId)?.last_slash_command;
294
455
  if (!cmd?.command_name) return null;
@@ -348,6 +509,59 @@ function findRecentSdlcSessionForProject(cwd, maxAgeMs = SESSION_MAX_AGE_MS) {
348
509
  return best;
349
510
  }
350
511
 
512
+ // ── Session activity index (B9) ──────────────────────────────────────
513
+ // sessions/by-project/<project_hash>.activity.json lists the IDE sessions
514
+ // active in a project in the last 2 hours. Every event except commit_created
515
+ // touches it; the commit hook reads it to name the IDE a commit came from.
516
+ // The v1 by-project/<hash>.json SDLC index is separate and unchanged.
517
+
518
+ const ACTIVITY_WINDOW_MS = 2 * 60 * 60 * 1000;
519
+
520
+ function activityIndexPath(projectHash) {
521
+ return path.join(SESSION_PROJECT_DIR, `${projectHash}.activity.json`);
522
+ }
523
+
524
+ function readActivityIndex(projectHash) {
525
+ try {
526
+ const sessions = JSON.parse(fs.readFileSync(activityIndexPath(projectHash), 'utf8'))?.sessions;
527
+ return sessions && typeof sessions === 'object' && !Array.isArray(sessions) ? sessions : {};
528
+ } catch {
529
+ // No index yet, or unreadable: no active sessions known.
530
+ return {};
531
+ }
532
+ }
533
+
534
+ function recentEntries(sessions, now) {
535
+ return Object.entries(sessions).filter(([, entry]) => now - parseUpdatedAt(entry?.last_active_at) <= ACTIVITY_WINDOW_MS);
536
+ }
537
+
538
+ function touchSessionActivity(projectHash, sessionId, harness, { now = Date.now() } = {}) {
539
+ if (!projectHash || !sessionId || sessionId === 'unknown') return;
540
+ const filePath = activityIndexPath(projectHash);
541
+ try {
542
+ fs.mkdirSync(SESSION_PROJECT_DIR, { recursive: true });
543
+ withFileLock(`${filePath}.lock`, () => {
544
+ const sessions = Object.fromEntries(recentEntries(readActivityIndex(projectHash), now));
545
+ sessions[sessionId] = { harness, last_active_at: new Date(now).toISOString() };
546
+ const tmpPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
547
+ fs.writeFileSync(tmpPath, JSON.stringify({ sessions }));
548
+ fs.renameSync(tmpPath, filePath);
549
+ });
550
+ } catch {
551
+ // Index not writable: commits from this project carry no origin fields.
552
+ }
553
+ }
554
+
555
+ /** Most recent session active in the project in the last 2 hours, or null. */
556
+ function findRecentActiveSessionForProject(projectHash, { now = Date.now() } = {}) {
557
+ if (!projectHash) return null;
558
+ const recent = recentEntries(readActivityIndex(projectHash), now)
559
+ .sort(([, a], [, b]) => parseUpdatedAt(b.last_active_at) - parseUpdatedAt(a.last_active_at));
560
+ if (recent.length === 0) return null;
561
+ const [sessionId, entry] = recent[0];
562
+ return { session_id: sessionId, harness: typeof entry.harness === 'string' ? entry.harness : 'unknown', ambiguous: recent.length > 1 };
563
+ }
564
+
351
565
  // ── Short-TTL dedupe guards ──────────────────────────────────────────
352
566
 
353
567
  function normalizeDedupePart(value) {
@@ -512,6 +726,14 @@ function readLastAssistantFromTranscript(transcriptPath) {
512
726
 
513
727
  // ── buildEvent ───────────────────────────────────────────────────────
514
728
 
729
+ const SCHEMA_VERSION = 2;
730
+ let _hookVersion;
731
+
732
+ function getHookVersion() {
733
+ if (_hookVersion === undefined) _hookVersion = readHookVersion();
734
+ return _hookVersion;
735
+ }
736
+
515
737
  function buildEvent(hookInput, eventType, payload) {
516
738
  const input = hookInput || {};
517
739
  const cfg = loadConfig();
@@ -540,6 +762,19 @@ function buildEvent(hookInput, eventType, payload) {
540
762
  || (input.workspace_roots && input.workspace_roots[0])
541
763
  || null;
542
764
 
765
+ // A8: the email the IDE itself is signed in with, when the IDE provides one.
766
+ // Cursor passes it in the hook input and to hook scripts; Claude Code and Codex do not.
767
+ const rawAccountEmail = input.user_email || process.env.CURSOR_USER_EMAIL;
768
+ const accountEmail = typeof rawAccountEmail === 'string' && rawAccountEmail ? rawAccountEmail.slice(0, 200) : null;
769
+
770
+ // v2 envelope (spec 4.2). seq is omitted when it cannot be taken safely.
771
+ const hookVersion = getHookVersion();
772
+ const seq = nextSeq(sessionId);
773
+ const projectHash = computeProjectHash(cwd);
774
+ if (eventType !== 'commit_created') touchSessionActivity(projectHash, sessionId, harness);
775
+ const awSkill = awSkillEntry(eventType, payload);
776
+ if (awSkill) recordSessionAwSkill(sessionId, awSkill);
777
+
543
778
  return {
544
779
  session_id: sessionId,
545
780
  generation_id: generationId,
@@ -548,13 +783,17 @@ function buildEvent(hookInput, eventType, payload) {
548
783
  machine_id: cfg.machine_id || 'unknown',
549
784
  github_user: git.user || input.user_email || null,
550
785
  github_email: git.email || input.user_email || null,
551
- project_hash: computeProjectHash(cwd),
786
+ project_hash: projectHash,
552
787
  aw_version: awCli.version,
553
788
  aw_cli_version: awCli.version,
554
789
  aw_cli_version_source: awCli.source,
555
790
  event: eventType,
556
791
  client_ts: new Date().toISOString(),
557
- payload: payload || {},
792
+ ...(accountEmail ? { account_email: accountEmail } : {}),
793
+ event_id: newEventId(),
794
+ schema_version: SCHEMA_VERSION,
795
+ ...(hookVersion ? { hook_version: hookVersion } : {}),
796
+ payload: { ...(payload || {}), ...(seq ? { seq } : {}) },
558
797
  };
559
798
  }
560
799
 
@@ -587,8 +826,15 @@ module.exports = {
587
826
  persistSessionSkill,
588
827
  readSessionSkill,
589
828
  persistSessionSlashCommand,
829
+ persistSessionPrompt,
830
+ claimTypedCommandMatch,
831
+ readSessionLastPrompt,
590
832
  readSessionLastSlashCommand,
833
+ computeProjectHash,
591
834
  findRecentSdlcSessionForProject,
835
+ findRecentActiveSessionForProject,
836
+ readSessionAwSkills,
837
+ touchSessionActivity,
592
838
  readLastAssistantFromTranscript,
593
839
  resolvePromptText,
594
840
  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,18 @@ 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');
16
+ const COMMIT_TRAILER_HOOK = awUsageShellScriptPath('aw-usage-commit-trailer.js');
17
+
18
+ // Adds Agent-Harness, and AW-Skills + AW co-author + AW-Version for sessions
19
+ // that ran an AW skill or stage (see aw-usage-commit-trailer.js). Never fails the commit.
20
+ const ADD_COMMIT_TRAILERS = `\
21
+ TRAILER_HOOK="${COMMIT_TRAILER_HOOK}"
22
+ if command -v node >/dev/null 2>&1 && [ -f "$TRAILER_HOOK" ]; then
23
+ node "$TRAILER_HOOK" "$1" "${AW_CO_AUTHOR}" >/dev/null 2>&1 || true
24
+ fi`;
13
25
 
14
26
  const HOME = homedir();
15
27
  const HOOKS_DIR = join(HOME, '.aw', 'hooks');
@@ -97,10 +109,7 @@ fi
97
109
 
98
110
  # Fire commit_created telemetry if commit has AW co-author trailer
99
111
  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
112
+ TELEMETRY_HOOK="${COMMIT_TELEMETRY_HOOK}"
104
113
  if command -v node >/dev/null 2>&1 && [ -f "$TELEMETRY_HOOK" ]; then
105
114
  COMMIT_HASH="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
106
115
  BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
@@ -127,24 +136,15 @@ fi
127
136
  exit 0
128
137
  `;
129
138
 
130
- // prepare-commit-msg: synchronous hook — appends Co-Authored-By trailer to commits
139
+ // prepare-commit-msg: synchronous hook — adds attribution trailers to commits
131
140
  // in AW-linked repos. Skips merge commits and respects AW_NO_COAUTHOR=1 opt-out.
141
+ // Trailers the agent wrote (Claude, Cursor, Codex) are kept.
132
142
  const PREPARE_COMMIT_MSG = makeDispatcher('prepare-commit-msg', `\
133
- # Inject co-author trailer unless it's a merge commit or opt-out is set
143
+ # Add attribution trailers unless it's a merge commit or opt-out is set
134
144
  if [ "$2" != "merge" ] && [ "$AW_NO_COAUTHOR" != "1" ]; then
135
- # Only brand AW-linked repos (check for .aw/ symlink or worktree)
145
+ # Only AW-linked repos (check for .aw/ symlink or worktree)
136
146
  if [ -L ".aw" ] || [ -f ".aw/.git" ]; then
137
- TRAILER="${AW_CO_AUTHOR}"
138
- # Replace Claude/Cursor co-author trailers with AW
139
- sed -i.bak '/^Co-Authored-By:.*Claude/d' "$1" 2>/dev/null || true
140
- sed -i.bak '/^Co-Authored-By:.*noreply@anthropic\\.com/d' "$1" 2>/dev/null || true
141
- sed -i.bak '/^Co-Authored-By:.*Cursor/d' "$1" 2>/dev/null || true
142
- rm -f "$1.bak"
143
- # Only append if not already present (idempotent)
144
- if ! grep -qF "$TRAILER" "$1"; then
145
- echo "" >> "$1"
146
- echo "$TRAILER" >> "$1"
147
- fi
147
+ ${ADD_COMMIT_TRAILERS}
148
148
  fi
149
149
  fi`);
150
150
 
@@ -253,19 +253,9 @@ case "$(pwd)" in /tmp/aw-*|/var/folders/*/aw-*|*/.aw|*/.aw/*) exit 0 ;; esac
253
253
  # Opt-out escape hatch
254
254
  [ "$AW_NO_COAUTHOR" = "1" ] && exit 0
255
255
 
256
- # Only brand AW-linked repos (check for .aw/ symlink or worktree)
256
+ # Only AW-linked repos (check for .aw/ symlink or worktree)
257
257
  if [ -L ".aw" ] || [ -f ".aw/.git" ]; then
258
- TRAILER="${AW_CO_AUTHOR}"
259
- # Replace Claude/Cursor co-author trailers with AW
260
- sed -i.bak '/^Co-Authored-By:.*Claude/d' "$1" 2>/dev/null || true
261
- sed -i.bak '/^Co-Authored-By:.*noreply@anthropic\\.com/d' "$1" 2>/dev/null || true
262
- sed -i.bak '/^Co-Authored-By:.*Cursor/d' "$1" 2>/dev/null || true
263
- rm -f "$1.bak"
264
- # Only append if not already present (idempotent)
265
- if ! grep -qF "$TRAILER" "$1"; then
266
- echo "" >> "$1"
267
- echo "$TRAILER" >> "$1"
268
- fi
258
+ ${ADD_COMMIT_TRAILERS}
269
259
  fi
270
260
 
271
261
  exit 0
@@ -281,10 +271,7 @@ case "$(pwd)" in /tmp/aw-*|/var/folders/*/aw-*|*/.aw|*/.aw/*) exit 0 ;; esac
281
271
 
282
272
  # Fire commit_created telemetry if commit has AW co-author trailer
283
273
  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
274
+ TELEMETRY_HOOK="${COMMIT_TELEMETRY_HOOK}"
288
275
  if command -v node >/dev/null 2>&1 && [ -f "$TELEMETRY_HOOK" ]; then
289
276
  COMMIT_HASH="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
290
277
  BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"