@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.
package/commands/init.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  readdirSync,
14
14
  readFileSync,
15
15
  rmSync,
16
+ readlinkSync,
16
17
  realpathSync,
17
18
  } from 'node:fs';
18
19
  import { execSync } from 'node:child_process';
@@ -116,11 +117,31 @@ function formatIntegrationStatusSummary(statuses, installedNow = []) {
116
117
  ].join('\n');
117
118
  }
118
119
 
120
+ // Copy the upgraded package's aw-usage scripts to ~/.aw/aw-usage directly,
121
+ // so a failed aw-ecc clone below cannot leave the old scripts installed.
122
+ // Packages older than the shared wiring module have no file to import; their
123
+ // ECC sync refreshes the scripts through startup preferences instead.
124
+ async function refreshAwUsageScriptsFromPackage(packageRoot, { silent }) {
125
+ const wiringPath = join(packageRoot, 'aw-usage-wiring.mjs');
126
+ if (!existsSync(wiringPath)) return;
127
+ try {
128
+ const wiring = await import(`${pathToFileURL(wiringPath).href}?aw-post-update=${Date.now()}`);
129
+ const result = wiring.copyAwUsageScripts(HOME);
130
+ if (result.failed > 0 && !silent) {
131
+ fmt.logWarn(`AW updated, but ${result.failed} aw-usage script(s) could not be copied to ~/.aw/aw-usage/`);
132
+ }
133
+ } catch (err) {
134
+ if (!silent) fmt.logWarn(`AW updated, but refreshing aw-usage scripts failed: ${err.message}`);
135
+ }
136
+ }
137
+
119
138
  export async function syncEccAfterAwUpdate(updateResult, cwd, { silent = false } = {}) {
120
139
  if (updateResult?.status !== 'upgraded' || !updateResult.packageRoot) {
121
140
  return { synced: false, reason: updateResult?.reason || updateResult?.status || 'not-upgraded' };
122
141
  }
123
142
 
143
+ await refreshAwUsageScriptsFromPackage(updateResult.packageRoot, { silent });
144
+
124
145
  const eccModulePath = join(updateResult.packageRoot, 'ecc.mjs');
125
146
  if (!existsSync(eccModulePath)) {
126
147
  return { synced: false, reason: 'missing-ecc-module' };
@@ -278,6 +299,54 @@ export function ensureAwGitignore(awHome) {
278
299
  try { writeFileSync(excludePath, next); } catch (err) { void err; /* best effort */ }
279
300
  }
280
301
 
302
+ // ── Recovery for a half-finished init ───────────────────────────────────
303
+
304
+ // Backward-compat symlink: ~/.aw_registry/ → ~/.aw/.aw_registry/.
305
+ // Uses lstatSync (not existsSync) so a dangling symlink is re-pointed instead
306
+ // of tripping EEXIST on symlinkSync.
307
+ function ensureRegistryLink({ silent = false } = {}) {
308
+ const target = join(AW_HOME, REGISTRY_DIR);
309
+
310
+ let link = null;
311
+ try { link = lstatSync(GLOBAL_AW_DIR); } catch { /* not there yet */ }
312
+
313
+ // Legacy plain directory — the migration path handles it, don't clobber it.
314
+ if (link && !link.isSymbolicLink()) return;
315
+
316
+ if (link) {
317
+ try {
318
+ if (readlinkSync(GLOBAL_AW_DIR) === target && existsSync(GLOBAL_AW_DIR)) return;
319
+ } catch { /* unreadable — re-point below */ }
320
+ try { rmSync(GLOBAL_AW_DIR); } catch (e) {
321
+ if (!silent) fmt.logWarn(`Could not replace ~/.aw_registry/: ${errorMessage(e)}`);
322
+ return;
323
+ }
324
+ }
325
+
326
+ try {
327
+ symlinkSync(target, GLOBAL_AW_DIR);
328
+ if (!silent) fmt.logStep('Registry linked');
329
+ } catch (e) {
330
+ if (!silent) fmt.logWarn(`Could not create symlink ~/.aw_registry/: ${errorMessage(e)}`);
331
+ }
332
+ }
333
+
334
+ // Namespaces the clone actually has checked out, so a rebuilt config matches
335
+ // what is on disk instead of resetting the user to platform-only.
336
+ function namespacesInSparseCheckout(awHome) {
337
+ let listed = '';
338
+ try {
339
+ listed = execSync('git sparse-checkout list', { cwd: awHome, stdio: 'pipe', encoding: 'utf8' });
340
+ } catch { return []; }
341
+
342
+ const prefix = `${REGISTRY_DIR}/`;
343
+ return listed.split('\n')
344
+ .map(line => line.trim().replace(/^\//, ''))
345
+ .filter(line => line.startsWith(prefix))
346
+ .map(line => line.slice(prefix.length).replace(/\/$/, ''))
347
+ .filter(ns => ns && ns !== 'platform' && !ns.includes('.'));
348
+ }
349
+
281
350
  // ── IDE tasks for auto-pull ─────────────────────────────────────────────
282
351
 
283
352
  function installIdeTasks() {
@@ -428,10 +497,28 @@ export async function initCommand(args) {
428
497
  const _rawCwd = process.cwd();
429
498
  const cwd = (() => { try { return realpathSync(_rawCwd); } catch { return _rawCwd; } })();
430
499
 
500
+ if (!user) {
501
+ try {
502
+ user = execSync('git config user.name', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
503
+ } catch { /* git not configured */ }
504
+ }
505
+
431
506
  // ── Re-init path: already set up with native git clone ────────────────
432
507
 
433
508
  if (isGitNative) {
434
- const cfg = config.load(GLOBAL_AW_DIR);
509
+ // An init interrupted after the clone but before the symlink and config
510
+ // were written leaves a clone that passes isValidClone() while every later
511
+ // command dies on config.load(). Repair that here rather than reporting
512
+ // "already initialized" and no-oping forever. (#852)
513
+ ensureRegistryLink({ silent });
514
+ let cfg = config.load(GLOBAL_AW_DIR);
515
+ if (!cfg) {
516
+ cfg = config.create(GLOBAL_AW_DIR, { namespace: activeNamespace(), user });
517
+ for (const ns of namespacesInSparseCheckout(AW_HOME)) {
518
+ cfg = config.addPattern(GLOBAL_AW_DIR, ns);
519
+ }
520
+ if (!silent) fmt.logStep('Recovered missing .sync-config.json from the existing clone');
521
+ }
435
522
 
436
523
  const isNewSubTeam = folderName && cfg && !cfg.include.includes(folderName);
437
524
  if (isNewSubTeam) {
@@ -600,12 +687,6 @@ export async function initCommand(args) {
600
687
 
601
688
  // ── Full init: first time setup ───────────────────────────────────────
602
689
 
603
- if (!user) {
604
- try {
605
- user = execSync('git config user.name', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
606
- } catch { /* git not configured */ }
607
- }
608
-
609
690
  // Determine sparse paths
610
691
  const sparsePaths = [`.aw_registry/platform`, DOCS_SOURCE_DIR, AW_DOCS_DIR, RULES_SOURCE_DIR, `.aw_registry/AW-PROTOCOL.md`, `CODEOWNERS`];
611
692
  if (folderName) {
@@ -630,27 +711,7 @@ export async function initCommand(args) {
630
711
  fmt.cancel(e.message);
631
712
  }
632
713
 
633
- // Create backward-compat symlink: ~/.aw_registry/ → ~/.aw/.aw_registry/
634
- // Use lstatSync (not existsSync) so we handle dangling symlinks correctly.
635
- let awRegistryLstat = null;
636
- try { awRegistryLstat = lstatSync(GLOBAL_AW_DIR); } catch { /* doesn't exist */ }
637
- if (!awRegistryLstat) {
638
- try {
639
- symlinkSync(join(AW_HOME, REGISTRY_DIR), GLOBAL_AW_DIR);
640
- fmt.logStep('Registry linked');
641
- } catch (e) {
642
- fmt.logWarn(`Could not create symlink ~/.aw_registry/: ${e.message}`);
643
- }
644
- } else if (awRegistryLstat.isSymbolicLink()) {
645
- // Stale or dangling — re-point to the new clone
646
- try {
647
- rmSync(GLOBAL_AW_DIR);
648
- symlinkSync(join(AW_HOME, REGISTRY_DIR), GLOBAL_AW_DIR);
649
- fmt.logStep('Registry linked');
650
- } catch (e) {
651
- fmt.logWarn(`Could not update symlink ~/.aw_registry/: ${e.message}`);
652
- }
653
- }
714
+ ensureRegistryLink({ silent });
654
715
 
655
716
  // Create sync config — default to 'platform' when no namespace specified
656
717
  const cfg = config.create(GLOBAL_AW_DIR, { namespace: activeNamespace(), user });
package/ecc.mjs CHANGED
@@ -12,17 +12,17 @@ import { applyStoredStartupPreferences } from "./startup.mjs";
12
12
 
13
13
  const AW_ECC_REPO_SSH = "git@github.com:shreyansh-ghl/aw-ecc.git";
14
14
  const AW_ECC_REPO_HTTPS = "https://github.com/shreyansh-ghl/aw-ecc.git";
15
- export const AW_ECC_TAG = "v1.4.77";
15
+ export const AW_ECC_TAG = "v1.5.0";
16
16
  const REQUIRED_ECC_FILES = [
17
17
  "package.json",
18
18
  "scripts/install-apply.js",
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
package/hook-cleanup.mjs CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  import { dirname, join } from 'node:path';
13
13
  import { homedir } from 'node:os';
14
14
  import { randomBytes } from 'node:crypto';
15
+ import { isClaudeMemoryStopEntry, isClaudeTelemetryEntry } from './aw-usage-wiring.mjs';
15
16
 
16
17
  const SCHEMA_VERSION = 'aw-hooks.v1';
17
18
  const HOME = homedir();
@@ -19,10 +20,10 @@ const MANIFEST_PATH = join(HOME, '.aw', 'hooks', 'manifest.json');
19
20
 
20
21
  // ── Patterns matching AW-managed hook entries ────────────────────────────
21
22
 
23
+ // AW-managed Claude entries: aw-usage telemetry (any install location) and the
24
+ // AW memory Stop entry. Shared matchers keep prune, nuke and the writers aligned.
22
25
  function isManagedClaudeEntry(entry) {
23
- if (entry?.description === 'AW usage telemetry') return true;
24
- const cmds = Array.isArray(entry?.hooks) ? entry.hooks.map(h => h?.command || '') : [];
25
- return cmds.some(c => c.includes('aw-usage-'));
26
+ return isClaudeTelemetryEntry(entry) || isClaudeMemoryStopEntry(entry);
26
27
  }
27
28
 
28
29
  function isManagedCursorEntry(entry) {
@@ -6,6 +6,12 @@
6
6
  * Co-Authored-By trailer. Works for all harnesses (Claude, Cursor, Codex)
7
7
  * since it fires from a git-level hook, not a harness-specific one.
8
8
  *
9
+ * B9 origin: origin_harness / origin_session_id name the IDE session the
10
+ * commit came from — CLAUDE_CODE_SESSION_ID when git runs inside Claude Code,
11
+ * else the most recent session in the project activity index (last 2 hours),
12
+ * with ambiguous_session when another session was active too. The v1
13
+ * session_id / linked_session_source rule is unchanged.
14
+ *
9
15
  * Usage: node aw-usage-commit-created.js <commit_hash> <branch> [cwd]
10
16
  */
11
17
 
@@ -16,8 +22,22 @@ const {
16
22
  sendAsync,
17
23
  isDisabled,
18
24
  findRecentSdlcSessionForProject,
25
+ findRecentActiveSessionForProject,
26
+ computeProjectHash,
19
27
  } = require('../lib/aw-usage-telemetry');
20
28
 
29
+ function commitOrigin(cwd) {
30
+ const claudeSession = process.env.CLAUDE_CODE_SESSION_ID;
31
+ if (claudeSession) return { origin_harness: 'claude', origin_session_id: claudeSession };
32
+ const recent = findRecentActiveSessionForProject(computeProjectHash(cwd));
33
+ if (!recent) return {};
34
+ return {
35
+ origin_harness: recent.harness,
36
+ origin_session_id: recent.session_id,
37
+ ...(recent.ambiguous ? { ambiguous_session: true } : {}),
38
+ };
39
+ }
40
+
21
41
  function buildCommitCreatedEvent({ commitHash = 'unknown', branch = 'unknown', cwd = process.cwd() } = {}) {
22
42
  const linkedSession = findRecentSdlcSessionForProject(cwd);
23
43
  const event = buildEvent({
@@ -28,6 +48,7 @@ function buildCommitCreatedEvent({ commitHash = 'unknown', branch = 'unknown', c
28
48
  commit_sha: commitHash,
29
49
  branch,
30
50
  ...(linkedSession?.session_id ? { linked_session_source: 'project_recent_sdlc_session' } : {}),
51
+ ...commitOrigin(cwd),
31
52
  });
32
53
 
33
54
  // Override harness to 'git' since this fires from a git hook, not a harness.
@@ -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
+ };
@@ -2,7 +2,14 @@
2
2
  /**
3
3
  * Usage telemetry — PostToolUse hook.
4
4
  *
5
- * Detects: skill invocations, agent spawns, skill pushes.
5
+ * Detects: skill invocations, agent spawns, skill pushes, and stage documents
6
+ * written by any write tool (sdlc_artifact_written: Write, Edit, MultiEdit,
7
+ * Codex apply_patch and shell `apply_patch` heredocs), and loop-aware stage
8
+ * start/finish events from state.json writes (lib/aw-usage-stage-events.js).
9
+ * Skill events say how the skill was reached (source: skill_tool, file_read,
10
+ * shell_read, or other_tool from the B6 Cursor probe), where its file came
11
+ * from (skill_origin) and whether it follows a typed command
12
+ * (follows_typed_command); see lib/aw-usage-skill-source.js.
6
13
  * PR contribution is tracked via Co-Authored-By trailer in prepare-commit-msg hook.
7
14
  * Outputs {} on stdout (Claude/Codex parse stdout as JSON).
8
15
  */
@@ -13,10 +20,14 @@ const {
13
20
  buildEvent,
14
21
  detectHarness,
15
22
  sendAsync,
16
- readSessionSkill,
23
+ claimTypedCommandMatch,
24
+ computeProjectHash,
25
+ readSessionLastPrompt,
17
26
  readSessionLastSlashCommand,
18
27
  tryAcquireDedupe,
19
28
  } = require('../lib/aw-usage-telemetry');
29
+ const { processStateJsonWrite } = require('../lib/aw-usage-stage-events');
30
+ const { skillSourceFields, shellSkillReads, cursorProbeSkillReads } = require('../lib/aw-usage-skill-source');
20
31
 
21
32
  const MAX_STDIN = 1024 * 1024;
22
33
 
@@ -85,6 +96,85 @@ function detectSdlcArtifact(filePath) {
85
96
  }
86
97
  return null;
87
98
  }
99
+
100
+ // ── Stage documents from every write tool (B5) ───────────────────────
101
+
102
+ const PATCH_FILE_LINE = /^\*\*\* (Add|Update) File: (.+?)\s*$/gm;
103
+
104
+ /** Files an apply_patch text adds or updates. Deleted files are left out. */
105
+ function parsePatchFiles(text) {
106
+ const files = [];
107
+ for (const match of String(text || '').matchAll(PATCH_FILE_LINE)) {
108
+ files.push({ file_path: match[2], action: match[1] === 'Add' ? 'create' : 'edit' });
109
+ }
110
+ return files;
111
+ }
112
+
113
+ function projectRoot(input) {
114
+ const root = input?.cwd || (Array.isArray(input?.workspace_roots) && input.workspace_roots[0]) || '';
115
+ return normalizePath(root).replace(/\/+$/, '');
116
+ }
117
+
118
+ /**
119
+ * Path relative to the project root when inside it. Outside it, a feature
120
+ * document sends only its `.aw_docs/…` part (no home folder or user name);
121
+ * any other path is sent as given.
122
+ */
123
+ function relativeToProject(filePath, input) {
124
+ const norm = normalizePath(filePath);
125
+ const root = projectRoot(input);
126
+ if (root && norm.startsWith(`${root}/`)) return norm.slice(root.length + 1);
127
+ const awDocs = norm.indexOf('/.aw_docs/');
128
+ return awDocs >= 0 ? norm.slice(awDocs + 1) : norm;
129
+ }
130
+
131
+ function absoluteInProject(filePath, input) {
132
+ const norm = normalizePath(filePath);
133
+ const root = projectRoot(input);
134
+ return root && !norm.startsWith('/') && !/^[A-Za-z]:\//.test(norm) ? `${root}/${norm}` : norm;
135
+ }
136
+
137
+ function writeAction(toolName, input) {
138
+ if (toolName === 'Edit' || toolName === 'MultiEdit') return 'edit';
139
+ // Cursor uses Write to create and to edit and does not say which.
140
+ if (detectHarness(input) === 'cursor') return 'write';
141
+ return toObject(parseMaybeJsonObject(input?.tool_response)).type === 'update' ? 'edit' : 'create';
142
+ }
143
+
144
+ function writtenFiles(toolName, input) {
145
+ if (toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit') {
146
+ const filePath = input?.tool_input?.file_path;
147
+ return filePath ? [{ file_path: filePath, action: writeAction(toolName, input) }] : [];
148
+ }
149
+ if (toolName === 'apply_patch') {
150
+ return parsePatchFiles(input?.tool_input?.command || input?.tool_input?.input || '');
151
+ }
152
+ if (toolName === 'Bash' || toolName === 'Shell') {
153
+ const command = getCommand(input);
154
+ return /\bapply_patch\b/.test(command) && command.includes('*** Begin Patch') ? parsePatchFiles(command) : [];
155
+ }
156
+ return [];
157
+ }
158
+
159
+ function stageDocumentEvents(toolName, input) {
160
+ const events = [];
161
+ for (const file of writtenFiles(toolName, input)) {
162
+ const artifact = detectSdlcArtifact(absoluteInProject(file.file_path, input));
163
+ if (!artifact) continue;
164
+ events.push({
165
+ eventType: 'sdlc_artifact_written',
166
+ payload: {
167
+ file_path: relativeToProject(file.file_path, input),
168
+ action: file.action,
169
+ tool_name: toolName,
170
+ artifact_type: artifact.artifact_type,
171
+ sdlc_stage: artifact.sdlc_stage,
172
+ },
173
+ });
174
+ }
175
+ return events;
176
+ }
177
+
88
178
  function parseMaybeJsonObject(value) {
89
179
  if (typeof value !== 'string') return value;
90
180
  const trimmed = value.trim();
@@ -213,33 +303,48 @@ function shouldEmitSkillName(skillName) {
213
303
  return Boolean(skillName) && skillName !== 'using-aw-skills';
214
304
  }
215
305
 
306
+ // One skill_invoked event. `skill` is { name, path? }: the path decides
307
+ // skill_origin for file and shell reads, the name for the Skill tool.
308
+ // follows_typed_command is added by processPostToolUseInput, which owns the
309
+ // session state; this collector stays free of side effects.
310
+ function skillEvent(source, input, skill, options) {
311
+ return {
312
+ eventType: 'skill_invoked',
313
+ payload: {
314
+ skill_name: skill.name,
315
+ ...skillSourceFields(source, input, skill, { home: options.home }),
316
+ },
317
+ };
318
+ }
319
+
320
+ function withTypedCommandMatch(event, claimTypedCommand) {
321
+ if (event.eventType !== 'skill_invoked' || !claimTypedCommand(event.payload.skill_name)) return event;
322
+ return { ...event, payload: { ...event.payload, follows_typed_command: true } };
323
+ }
324
+
216
325
  function collectPostToolUseEvents(input, options = {}) {
217
326
  const events = [];
218
327
  const toolName = input?.tool_name || '';
219
- const promptSkillOverride = options.promptSkillOverride || null;
220
328
  // Session-scoped slash command from the most recent /aw:*, /tdd, etc.
221
329
  // Used to correlate test/artifact writes back to the originating SDLC stage.
222
330
  const slashCmd = options.sessionSlashCommand || null;
223
331
 
224
332
  // Skill detection: Claude uses tool_name='Skill', Cursor reads SKILL.md via 'Read' tool.
225
333
  const filePath = input?.tool_input?.file_path || '';
226
- const isSkillRead = toolName === 'Read' && /\/SKILL\.md$/i.test(filePath);
334
+ const skillPath = normalizePath(filePath);
335
+ const isSkillRead = toolName === 'Read' && /\/SKILL\.md$/i.test(skillPath);
227
336
 
228
337
  if (toolName === 'Skill' || isSkillRead) {
229
338
  let skillName = input?.tool_input?.skill || input?.tool_input?.args?.skill || '';
230
339
  // For Cursor: extract skill name from path (e.g. .../skills/<skill-name>/SKILL.md)
231
340
  if (!skillName && isSkillRead) {
232
- const pathMatch = filePath.match(/\/skills\/([^/]+)\/SKILL\.md$/i);
233
- skillName = pathMatch ? pathMatch[1] : filePath.split('/').slice(-2, -1)[0] || '';
341
+ const pathMatch = skillPath.match(/\/skills\/([^/]+)\/SKILL\.md$/i);
342
+ skillName = pathMatch ? pathMatch[1] : skillPath.split('/').slice(-2, -1)[0] || '';
234
343
  }
235
344
  if (shouldEmitSkillName(skillName)) {
236
- events.push({
237
- eventType: 'skill_invoked',
238
- payload: {
239
- skill_name: skillName,
240
- args: input?.tool_input?.args || '',
241
- },
242
- });
345
+ events.push(toolName === 'Skill'
346
+ ? skillEvent('skill_tool', input, { name: skillName }, options)
347
+ : skillEvent('file_read', input, { name: skillName, path: skillPath }, options));
243
348
  }
244
349
  } else if (toolName === 'Agent') {
245
350
  events.push({
@@ -251,6 +356,15 @@ function collectPostToolUseEvents(input, options = {}) {
251
356
  });
252
357
  }
253
358
 
359
+ // B6 probe: remove in the release after the one that ships it
360
+ // (https://app.clickup.com/t/14zhcp5e0b5). Finds the tool Cursor uses to load
361
+ // a skill when it is not Read, a shell or a search tool.
362
+ if (detectHarness(input) === 'cursor') {
363
+ for (const read of cursorProbeSkillReads(toolName, input?.tool_input)) {
364
+ if (shouldEmitSkillName(read.name)) events.push(skillEvent('other_tool', input, read, options));
365
+ }
366
+ }
367
+
254
368
  const toolResult = normalizeToolResult(input);
255
369
  if (isExplicitFailureExitCode(toolResult.exitCode)
256
370
  || inferShellFailureFromMessage(toolName, toolResult.rawErrorMessage)) {
@@ -296,18 +410,10 @@ function collectPostToolUseEvents(input, options = {}) {
296
410
  });
297
411
  }
298
412
 
299
- // Codex skill detection: Bash commands that read SKILL.md files.
300
- if (!promptSkillOverride) {
301
- const skillCmdMatch = cmd.match(/\/skills\/([^/]+)\/SKILL\.md/i);
302
- if (skillCmdMatch && shouldEmitSkillName(skillCmdMatch[1])) {
303
- events.push({
304
- eventType: 'skill_invoked',
305
- payload: {
306
- skill_name: skillCmdMatch[1],
307
- args: '',
308
- },
309
- });
310
- }
413
+ // Codex loads skills with shell reads (`sed -n … SKILL.md` inside compound
414
+ // commands): one event per distinct SKILL.md, also while a typed command is active.
415
+ for (const read of shellSkillReads(cmd)) {
416
+ if (shouldEmitSkillName(read.name)) events.push(skillEvent('shell_read', input, read, options));
311
417
  }
312
418
  if (/\baw\s+push\b/.test(cmd)) {
313
419
  const skillMatch = cmd.match(/aw\s+push\s+(\S+)/);
@@ -361,22 +467,40 @@ function collectPostToolUseEvents(input, options = {}) {
361
467
  }
362
468
  }
363
469
 
470
+ events.push(...stageDocumentEvents(toolName, input));
471
+
364
472
  return events;
365
473
  }
366
474
 
475
+ // B4: a write to .aw_docs/features/<slug>/state.json by any write tool.
476
+ function stageEvents(input, sessionId) {
477
+ try {
478
+ const root = projectRoot(input);
479
+ const lastPromptCommands = readSessionLastPrompt(sessionId)?.commands || [];
480
+ return writtenFiles(input?.tool_name || '', input).flatMap(file => processStateJsonWrite({
481
+ filePath: file.file_path,
482
+ projectRoot: root,
483
+ projectHash: computeProjectHash(input?.cwd || (Array.isArray(input?.workspace_roots) && input.workspace_roots[0]) || null),
484
+ lastPromptCommands,
485
+ }));
486
+ } catch {
487
+ // Odd hook input: no stage events, but the other events of this tool call are still sent.
488
+ return [];
489
+ }
490
+ }
491
+
367
492
  function processPostToolUseInput(input, deps = {}) {
368
493
  const emit = typeof deps.emit === 'function' ? deps.emit : () => {};
369
494
  const sessionId = getSessionId(input);
370
- const promptSkillOverride = deps.promptSkillOverride || readSessionSkill(
371
- sessionId,
372
- input?.turn_id || null,
373
- );
374
495
  const sessionSlashCommand = deps.sessionSlashCommand
375
496
  || readSessionLastSlashCommand(sessionId);
376
- const events = collectPostToolUseEvents(input, {
377
- promptSkillOverride,
378
- sessionSlashCommand,
379
- });
497
+ const claimTypedCommand = typeof deps.claimTypedCommand === 'function'
498
+ ? deps.claimTypedCommand
499
+ : skillName => claimTypedCommandMatch(sessionId, input?.turn_id || null, skillName);
500
+ const events = [
501
+ ...collectPostToolUseEvents(input, { sessionSlashCommand }).map(event => withTypedCommandMatch(event, claimTypedCommand)),
502
+ ...stageEvents(input, sessionId),
503
+ ];
380
504
  for (const event of events) {
381
505
  emit(event.eventType, event.payload);
382
506
  }
@@ -426,6 +550,8 @@ if (require.main === module) {
426
550
 
427
551
  module.exports = {
428
552
  collectPostToolUseEvents,
553
+ parsePatchFiles,
554
+ relativeToProject,
429
555
  detectDeployCommand,
430
556
  detectSdlcArtifact,
431
557
  detectTestFramework,