@ghl-ai/aw 0.2.0 → 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.
@@ -33,6 +33,7 @@ export const AW_USAGE_HOOK_SCRIPTS = [
33
33
  'aw-usage-stop.js',
34
34
  'aw-usage-telemetry-send.js',
35
35
  'aw-usage-commit-created.js',
36
+ 'aw-usage-commit-trailer.js',
36
37
  ];
37
38
 
38
39
  // The aw-ecc Cursor adapter checks for these lib files, package.json and
package/c4/detect.mjs CHANGED
@@ -5,7 +5,8 @@
5
5
  * 'cursor-cloud' | 'codex-web' | 'claude-web' | 'unknown'
6
6
  *
7
7
  * Detection signals (precedence top → bottom):
8
- * 1. cursor-cloud ← env.CURSOR_AGENT === 'true' || env.CURSOR_BACKGROUND_AGENT_ID
8
+ * 1. cursor-cloud ← env.CURSOR_CONVERSATION_ID starts with 'bc-'
9
+ * || env.CURSOR_AGENT === 'true' || env.CURSOR_BACKGROUND_AGENT_ID
9
10
  * 2. codex-web ← env.CODEX_ENVIRONMENT === '1' || /\.codex(\/|$)/.test(cwd)
10
11
  * 3. claude-web ← env.CLAUDE_CODE_REMOTE === 'true' || fsProbe('/home/user/.claude')
11
12
  * 4. unknown ← fallback
@@ -46,7 +47,8 @@ export function detectHarness({
46
47
  return /** @type {HarnessId} */ (override);
47
48
  }
48
49
 
49
- if (env.CURSOR_AGENT === 'true' || env.CURSOR_BACKGROUND_AGENT_ID) {
50
+ // Cloud VMs set CURSOR_AGENT=1 (as local agents do) and a bc-<uuid> conversation id.
51
+ if (env.CURSOR_CONVERSATION_ID?.startsWith('bc-') || env.CURSOR_AGENT === 'true' || env.CURSOR_BACKGROUND_AGENT_ID) {
50
52
  return 'cursor-cloud';
51
53
  }
52
54
 
package/commands/c4.mjs CHANGED
@@ -259,7 +259,7 @@ export async function c4Command(rawArgs, overrides = {}) {
259
259
 
260
260
  // Step 1 — harness detection (override wins).
261
261
  const cliHarness = args['--harness'];
262
- const harness = SUPPORTED_HARNESSES.includes(cliHarness) ? cliHarness : c4.detectHarness({ env }).harness;
262
+ const harness = SUPPORTED_HARNESSES.includes(cliHarness) ? cliHarness : c4.detectHarness({ env });
263
263
  const home = env.HOME ?? '~';
264
264
  const awHome = join(home, '.aw');
265
265
  const awRegistry = join(awHome, '.aw_registry');
package/ecc.mjs CHANGED
@@ -19,10 +19,10 @@ const REQUIRED_ECC_FILES = [
19
19
  "scripts/sync-ecc-to-codex.sh",
20
20
  ];
21
21
 
22
- const MARKETPLACE_NAME = "aw-marketplace";
22
+ export const MARKETPLACE_NAME = "aw-marketplace";
23
23
  const PLUGIN_KEY = `aw@${MARKETPLACE_NAME}`;
24
24
 
25
- function eccDir() { return join(homedir(), ".aw-ecc"); }
25
+ export function eccDir() { return join(homedir(), ".aw-ecc"); }
26
26
 
27
27
  // "claude" uses a no-commands module set so hooks, rules, shared references,
28
28
  // and install-state land in ~/.claude/ — while slash commands stay owned by
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Commit attribution trailers — called from the prepare-commit-msg git hook.
4
+ *
5
+ * Adds to the commit message file:
6
+ * Agent-Harness: <harness>; session=<id> when git runs inside an agent session
7
+ * (harness only when the id is unsafe)
8
+ * AW-Skills: <a>, <b>, stage:<s>, when that session ran an AW skill or
9
+ * <co-author line> and AW-Version: <v> stage at any point (aw_skills)
10
+ *
11
+ * The laptop records which AW skills ran; the server decides which are SDLC.
12
+ *
13
+ * The session comes from the variable each agent sets in its shell
14
+ * (CLAUDE_CODE_SESSION_ID, CURSOR_CONVERSATION_ID, CODEX_THREAD_ID). It is the
15
+ * same id the AW hooks send as session_id, so the server can join on it. A
16
+ * hand-typed commit has none of them and gets no trailer. Trailers the agent
17
+ * wrote itself are left alone. On --amend in the same session our own lines
18
+ * are replaced, not repeated; a message that already carries another session's
19
+ * Agent-Harness (rebase, cherry-pick, amend) is left unchanged. Any error
20
+ * leaves the message unchanged.
21
+ *
22
+ * Usage: node aw-usage-commit-trailer.js <commit-msg-file> <co-author-line>
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('fs');
28
+ const { readSessionAwSkills, getAwCliVersionDetails } = require('../lib/aw-usage-telemetry');
29
+
30
+ const SESSION_ENV = [
31
+ ['CLAUDE_CODE_SESSION_ID', 'claude'],
32
+ ['CURSOR_CONVERSATION_ID', 'cursor'],
33
+ ['CODEX_THREAD_ID', 'codex'],
34
+ ];
35
+ // Also keeps the id from adding lines to the message or leaving the sessions folder.
36
+ const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
37
+ // Lines this script owns: replaced on --amend so the latest values win.
38
+ const OWN_TRAILER = /^(?:Agent-Harness|AW-Skills|AW-Version): /;
39
+
40
+ function commitSession(env) {
41
+ for (const [name, harness] of SESSION_ENV) {
42
+ const id = env[name];
43
+ if (!id) continue;
44
+ // An unsafe id still tells us the harness; it is never written or used as a path.
45
+ if (!SAFE_SESSION_ID.test(id)) return { harness, sessionId: null };
46
+ // Cursor cloud agents get a bc-<uuid> conversation id; local agents a plain UUID.
47
+ return { harness: harness === 'cursor' && id.startsWith('bc-') ? 'cursor-cloud' : harness, sessionId: id };
48
+ }
49
+ return null;
50
+ }
51
+
52
+ function buildTrailers({ env, coAuthor }) {
53
+ const session = commitSession(env);
54
+ if (!session) return [];
55
+ if (!session.sessionId) return [`Agent-Harness: ${session.harness}`];
56
+ const trailers = [`Agent-Harness: ${session.harness}; session=${session.sessionId}`];
57
+ const skills = readSessionAwSkills(session.sessionId);
58
+ if (coAuthor && skills.length > 0) {
59
+ trailers.push(`AW-Skills: ${skills.join(', ')}`, coAuthor);
60
+ const version = getAwCliVersionDetails().version;
61
+ if (version) trailers.push(`AW-Version: ${version}`);
62
+ }
63
+ return trailers;
64
+ }
65
+
66
+ // git commit -v: git drops this line and everything below it (the diff).
67
+ const SCISSORS = /^# -+ >8 -+$/;
68
+ const TRAILER_LINE = /^[A-Za-z][A-Za-z0-9-]*: /;
69
+ // Editor commits end with git's `#` help text, which git strips after the editor.
70
+ // shortcut: assumes the default core.commentChar '#'; read it from git config if teams change it.
71
+ const isComment = (line) => line.startsWith('#');
72
+
73
+ function appendTrailers(message, trailers) {
74
+ const eol = message.includes('\r\n') ? '\r\n' : '\n';
75
+ const lines = message.split(/\r?\n/);
76
+ // A commit another session wrote (rebase, cherry-pick, amend) keeps its own
77
+ // attribution: replacing it would erase that session's evidence.
78
+ const harnessLines = lines.filter((line) => line.startsWith('Agent-Harness: '));
79
+ if (harnessLines.length > 0 && !harnessLines.includes(trailers[0])) return message;
80
+
81
+ // The message git keeps ends at the last line that is not blank, a comment or below the cut.
82
+ const cut = lines.findIndex((line) => SCISSORS.test(line));
83
+ const head = cut === -1 ? lines : lines.slice(0, cut);
84
+ let end = head.length;
85
+ while (end > 0 && (!head[end - 1].trim() || isComment(head[end - 1]))) end -= 1;
86
+ // Drop our own stale lines (from an earlier commit of this message) first.
87
+ const kept = head.slice(0, end).filter((line) => !OWN_TRAILER.test(line) || trailers.includes(line));
88
+ // No real content: leave it, so git still aborts an empty commit message.
89
+ if (!kept.some((line) => line.trim() && !isComment(line))) return message;
90
+ const missing = trailers.filter((trailer) => !kept.includes(trailer));
91
+ if (missing.length === 0) return message;
92
+
93
+ // Join the existing trailer block when the message already ends with one.
94
+ const body = kept.join('\n').replace(/\s+$/, '');
95
+ const lastParagraph = body.split(/\n\s*\n/).pop();
96
+ const endsWithTrailers = body.includes('\n') && lastParagraph.split('\n').every((line) => TRAILER_LINE.test(line));
97
+ const rest = [...head.slice(end), ...(cut === -1 ? [] : lines.slice(cut))].join('\n');
98
+ const updated = `${body}${endsWithTrailers ? '\n' : '\n\n'}${missing.join('\n')}\n${rest}`;
99
+ return eol === '\n' ? updated : updated.replace(/\n/g, eol);
100
+ }
101
+
102
+ function main() {
103
+ const [file, coAuthor] = process.argv.slice(2);
104
+ if (!file) return;
105
+ try {
106
+ const trailers = buildTrailers({ env: process.env, coAuthor });
107
+ if (trailers.length === 0) return;
108
+ const message = fs.readFileSync(file, 'utf8');
109
+ const updated = appendTrailers(message, trailers);
110
+ if (updated !== message) fs.writeFileSync(file, updated);
111
+ } catch {
112
+ // Never block or change a commit because attribution failed.
113
+ }
114
+ }
115
+
116
+ if (require.main === module) {
117
+ main();
118
+ }
119
+
120
+ module.exports = {
121
+ appendTrailers,
122
+ buildTrailers,
123
+ commitSession,
124
+ };
@@ -116,6 +116,7 @@ function isSdlcStageCommand(command) {
116
116
 
117
117
  module.exports = {
118
118
  AW_SDLC_STAGES,
119
+ BARE_SLASH_COMMANDS,
119
120
  extractTypedCommands,
120
121
  commandKey,
121
122
  isSdlcStageCommand,
@@ -8,8 +8,13 @@
8
8
  * this hook does not know
9
9
  *
10
10
  * skill_origin rule (P11):
11
- * - Skill tool: `aw` for `aw:<x>`, `other` for any other namespace, omitted for a bare name.
12
- * - File or shell read: `aw` when the SKILL.md is under ~/.aw-ecc/, ~/.aw/,
11
+ * - Skill tool: `aw` for `aw:<x>`. Otherwise the skill's folder in ~/.claude/skills
12
+ * (`ns:name` -> `ns-name`, else the folder whose SKILL.md declares that name),
13
+ * when present, decides by the file rule below; else
14
+ * `other` for any other namespace, omitted for a bare name.
15
+ * - File or shell read: `aw` when the SKILL.md, or the file a link to it points
16
+ * to (aw links registry skills into ~/.codex/skills and ~/.cursor/skills), is
17
+ * under ~/.aw-ecc/, ~/.aw/,
13
18
  * ~/.claude/plugins/cache/aw-marketplace/, or is a destinationPath in
14
19
  * ~/.cursor/ecc-install-state.json or ~/.codex/ecc-install-state.json
15
20
  * (those folders mix AW and other skills; the install state lists what AW
@@ -66,10 +71,36 @@ function listedSkillPaths(home) {
66
71
  return listedCache.get(home);
67
72
  }
68
73
 
74
+ // Read-only: resolves links, never creates, moves or deletes anything. The OS
75
+ // resolver (.native) applies `..` after following links; the JS one collapses
76
+ // `..` first, so `~/.aw/link/../x` would wrongly stay under ~/.aw.
77
+ function realPath(filePath) {
78
+ try {
79
+ return fs.realpathSync.native(filePath);
80
+ } catch {
81
+ // Broken link, loop, unreadable folder or missing file.
82
+ return null;
83
+ }
84
+ }
85
+
86
+ function isUnderAwRoot(filePath, homes) {
87
+ const file = toForwardSlashes(filePath);
88
+ return homes.some(home => AW_ROOTS.some(segments => file.startsWith(`${toForwardSlashes(path.join(home, ...segments))}/`)));
89
+ }
90
+
91
+ // The real file decides: aw links registry skills into ~/.codex/skills,
92
+ // ~/.cursor/skills and ~/.claude/skills, pointing into ~/.aw. A path that cannot
93
+ // be resolved (missing, broken link) is judged as given.
94
+ // Known limit: an AW folder that is itself a link (e.g. ~/.aw-ecc -> a dev
95
+ // checkout) is not followed, so its skills count as other. `aw init` clones into
96
+ // the real folders.
69
97
  function originFromPath(filePath, home) {
70
- const absolute = toForwardSlashes(path.normalize(expandHome(filePath, home)));
71
- const underAwRoot = AW_ROOTS.some(segments => absolute.startsWith(`${toForwardSlashes(path.join(home, ...segments))}/`));
72
- return underAwRoot || listedSkillPaths(home).has(absolute) ? 'aw' : 'other';
98
+ const given = expandHome(filePath, home);
99
+ const absolute = path.normalize(given);
100
+ if (listedSkillPaths(home).has(toForwardSlashes(absolute))) return 'aw';
101
+ const real = realPath(given);
102
+ if (!real) return isUnderAwRoot(absolute, [home]) ? 'aw' : 'other';
103
+ return isUnderAwRoot(real, [home, realPath(home) || home]) ? 'aw' : 'other';
73
104
  }
74
105
 
75
106
  function originFromName(name) {
@@ -78,10 +109,69 @@ function originFromName(name) {
78
109
  return name.slice(0, colon).toLowerCase() === 'aw' ? 'aw' : 'other';
79
110
  }
80
111
 
112
+ // A Skill-tool name as one folder name: `platform-core:echo-direct` -> `platform-core-echo-direct`.
113
+ const SKILL_NAME = /^[A-Za-z0-9._-]+(?::[A-Za-z0-9._-]+)?$/;
114
+
115
+ // Claude's Skill tool gives only a name. Its folder in ~/.claude/skills (often a
116
+ // link aw made into ~/.aw/.aw_registry) is where the skill came from.
117
+ const SKILL_HEAD_BYTES = 2048;
118
+ const namedCache = new Map();
119
+
120
+ function frontmatterName(file) {
121
+ let fd;
122
+ try {
123
+ fd = fs.openSync(file, 'r');
124
+ const buf = Buffer.alloc(SKILL_HEAD_BYTES);
125
+ const head = buf.toString('utf8', 0, fs.readSync(fd, buf, 0, SKILL_HEAD_BYTES, 0)).replace(/^\uFEFF/, '');
126
+ // Only the front matter (between the opening and closing ---) names the skill.
127
+ const frontMatter = head.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
128
+ const match = frontMatter && frontMatter[1].match(/^name:[ \t]*["']?([^"'\r\n]+?)["']?[ \t]*$/m);
129
+ return match ? match[1] : null;
130
+ } catch {
131
+ // Unreadable or broken link: it cannot be the skill Claude loaded.
132
+ return null;
133
+ } finally {
134
+ if (fd !== undefined) fs.closeSync(fd);
135
+ }
136
+ }
137
+
138
+ // name -> SKILL.md for skills whose folder name differs from their `name:`
139
+ // (e.g. platform-core-architecture-design declares platform-shared:architecture-design).
140
+ // Read once per process.
141
+ function skillsByName(dir) {
142
+ if (!namedCache.has(dir)) {
143
+ const byName = new Map();
144
+ let entries = [];
145
+ try { entries = fs.readdirSync(dir); } catch { /* no skills folder: nothing to match */ }
146
+ for (const entry of entries) {
147
+ const file = path.join(dir, entry, 'SKILL.md');
148
+ const name = frontmatterName(file);
149
+ if (name && !byName.has(name)) byName.set(name, file);
150
+ }
151
+ namedCache.set(dir, byName);
152
+ }
153
+ return namedCache.get(dir);
154
+ }
155
+
156
+ function claudeSkillFile(name, home) {
157
+ if (!SKILL_NAME.test(name) || name.split(':').some(part => /^\.+$/.test(part))) return null;
158
+ const dir = path.join(home, '.claude', 'skills');
159
+ const file = path.join(dir, name.replace(':', '-'), 'SKILL.md');
160
+ if (fs.existsSync(file)) return file;
161
+ return skillsByName(dir).get(name) || null;
162
+ }
163
+
81
164
  /** @param {{ name?: string, path?: string }} skill */
82
- function skillOrigin(skill, { home = os.homedir() } = {}) {
165
+ function skillOrigin(skill, { home: givenHome = os.homedir() } = {}) {
166
+ // One spelling of HOME (no trailing slash) for prefix checks and caches.
167
+ const home = path.resolve(givenHome);
83
168
  if (typeof skill?.path === 'string' && skill.path) return originFromPath(skill.path, home);
84
- if (typeof skill?.name === 'string' && skill.name) return originFromName(skill.name);
169
+ if (typeof skill?.name === 'string' && skill.name) {
170
+ const byName = originFromName(skill.name);
171
+ if (byName === 'aw') return byName;
172
+ const file = claudeSkillFile(skill.name, home);
173
+ return file ? originFromPath(file, home) : byName;
174
+ }
85
175
  return null;
86
176
  }
87
177
 
@@ -130,6 +220,7 @@ function cursorProbeSkillReads(toolName, toolInput) {
130
220
  }
131
221
 
132
222
  module.exports = {
223
+ AW_ROOTS,
133
224
  skillOrigin,
134
225
  skillSourceFields,
135
226
  shellSkillReads,
@@ -17,7 +17,7 @@ const os = require('os');
17
17
  const { spawn, execSync } = require('child_process');
18
18
  const crypto = require('crypto');
19
19
  const { newEventId, readHookVersion, nextSeq, withFileLock } = require('./aw-usage-envelope');
20
- const { commandKey } = require('./aw-usage-commands');
20
+ const { commandKey, BARE_SLASH_COMMANDS } = require('./aw-usage-commands');
21
21
 
22
22
  const SENDER_SCRIPT = path.join(__dirname, '..', 'hooks', 'aw-usage-telemetry-send.js');
23
23
  const AW_HOME = path.join(os.homedir(), '.aw');
@@ -171,24 +171,31 @@ function resolveProjectHash(context) {
171
171
  }
172
172
 
173
173
  // ── Session file cleanup ─────────────────────────────────────────────
174
- // Prune session files older than SESSION_MAX_AGE_MS to prevent unbounded growth.
175
- // 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.
176
176
 
177
- 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;
178
180
 
179
181
  function pruneStaleSessionFiles() {
182
+ let entries;
180
183
  try {
181
- const entries = fs.readdirSync(SESSION_DIR);
182
- const now = Date.now();
183
- for (const entry of entries) {
184
- if (!entry.endsWith('.json') && !entry.endsWith('.seq')) 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 {
185
193
  const filePath = path.join(SESSION_DIR, entry);
186
- const stat = fs.statSync(filePath);
187
- if (now - stat.mtimeMs > SESSION_MAX_AGE_MS) {
188
- fs.unlinkSync(filePath);
189
- }
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.
190
197
  }
191
- } catch { /* best effort */ }
198
+ }
192
199
  }
193
200
 
194
201
  // ── Session model persistence ────────────────────────────────────────
@@ -213,6 +220,8 @@ function readSessionModel(sessionId) {
213
220
 
214
221
  function readSessionState(sessionId) {
215
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 */ }
216
225
  try {
217
226
  const filePath = path.join(SESSION_DIR, sessionId + '.json');
218
227
  const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -333,6 +342,80 @@ function persistSessionPrompt(sessionId, { turn_id: turnId = null, commands = []
333
342
 
334
343
  const TYPED_COMMAND_WINDOW_MS = 10 * 60 * 1000;
335
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
+
336
419
  function isCurrentPrompt(prompt, turnId, now) {
337
420
  if (prompt.turn_id && turnId) return prompt.turn_id === turnId;
338
421
  return now - parseUpdatedAt(prompt.at) <= TYPED_COMMAND_WINDOW_MS;
@@ -689,6 +772,8 @@ function buildEvent(hookInput, eventType, payload) {
689
772
  const seq = nextSeq(sessionId);
690
773
  const projectHash = computeProjectHash(cwd);
691
774
  if (eventType !== 'commit_created') touchSessionActivity(projectHash, sessionId, harness);
775
+ const awSkill = awSkillEntry(eventType, payload);
776
+ if (awSkill) recordSessionAwSkill(sessionId, awSkill);
692
777
 
693
778
  return {
694
779
  session_id: sessionId,
@@ -748,6 +833,7 @@ module.exports = {
748
833
  computeProjectHash,
749
834
  findRecentSdlcSessionForProject,
750
835
  findRecentActiveSessionForProject,
836
+ readSessionAwSkills,
751
837
  touchSessionActivity,
752
838
  readLastAssistantFromTranscript,
753
839
  resolvePromptText,
package/hooks.mjs CHANGED
@@ -13,6 +13,15 @@ import { AW_CO_AUTHOR } from './constants.mjs';
13
13
  import { awUsageShellScriptPath } from './aw-usage-wiring.mjs';
14
14
 
15
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`;
16
25
 
17
26
  const HOME = homedir();
18
27
  const HOOKS_DIR = join(HOME, '.aw', 'hooks');
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {