@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.
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,7 +12,7 @@ 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",
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.
@@ -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,
@@ -2,7 +2,12 @@
2
2
  /**
3
3
  * Usage telemetry — UserPromptSubmit hook.
4
4
  *
5
- * Emits prompt_submitted as a boundary marker.
5
+ * Emits prompt_submitted as a boundary marker, then one skill_invoked per
6
+ * AW command typed in the prompt (every form: /aw:plan, /aw-plan, $aw-plan,
7
+ * /prompts:aw-plan, @aw-plan — see lib/aw-usage-commands.js). Only the
8
+ * command name and command_form are sent, never the prompt text.
9
+ * Records last_prompt so the tool hook can tell a Skill call that follows a
10
+ * typed command (follows_typed_command) and who started a stage.
6
11
  * No matchers on any harness — always fires.
7
12
  *
8
13
  * Outputs {} on stdout.
@@ -15,70 +20,25 @@ const {
15
20
  sendAsync,
16
21
  persistSessionSkill,
17
22
  persistSessionSlashCommand,
23
+ persistSessionPrompt,
18
24
  resolvePromptText,
19
25
  tryAcquireDedupe,
20
26
  isCodexInternalTaskTitlePrompt,
21
27
  } = require('../lib/aw-usage-telemetry');
28
+ const { extractTypedCommands, isSdlcStageCommand } = require('../lib/aw-usage-commands');
22
29
 
23
30
  const MAX_STDIN = 1024 * 1024;
24
31
 
25
- // Slash commands we recognize beyond /aw:*. Each entry maps to a namespace
26
- // used in the emitted payload so the dashboard can group by tool family.
27
- // namespace:command → matched by ^/<namespace>:<command>
28
- // bare-name → matched by ^/<bare-name> (Matt Pocock skills, codex `/tdd`)
29
- const NAMESPACED_PREFIXES = ['aw', 'caveman', 'codex', 'graphify', 'rtk', 'lean-ctx'];
30
- const BARE_SLASH_COMMANDS = new Set([
31
- 'tdd', 'diagnose', 'grill-me', 'grill-with-docs', 'triage', 'to-prd', 'zoom-out',
32
- ]);
33
- // `/aw:` sub-commands that count as SDLC stages so the funnel can group by stage.
34
- const AW_SDLC_STAGES = new Set([
35
- 'plan', 'build', 'execute', 'test', 'verify', 'review',
36
- 'deploy', 'ship', 'tdd', 'investigate', 'feature',
37
- ]);
38
-
39
- function extractAwSlashCommand(input) {
40
- const prompt = resolvePromptText(input).trim();
41
-
42
- // Try `/namespace:command` shape first.
43
- const namespacedRe = new RegExp(
44
- `^\\/((?:${NAMESPACED_PREFIXES.join('|')})(?::[a-z0-9-]+)?)(?:\\s+([\\s\\S]*\\S))?\\s*$`,
45
- 'i',
46
- );
47
- let match = prompt.match(namespacedRe);
48
- let commandFull = null;
49
- let commandArgs = '';
50
- if (match) {
51
- commandFull = match[1].toLowerCase();
52
- commandArgs = match[2] || '';
53
- } else {
54
- // Try bare name shape (e.g. `/tdd ...`).
55
- const bareRe = /^\/([a-z][a-z0-9-]*)(?:\s+([\s\S]*\S))?\s*$/i;
56
- const bareMatch = prompt.match(bareRe);
57
- if (bareMatch && BARE_SLASH_COMMANDS.has(bareMatch[1].toLowerCase())) {
58
- commandFull = bareMatch[1].toLowerCase();
59
- commandArgs = bareMatch[2] || '';
60
- }
61
- }
62
- if (!commandFull) return null;
63
-
64
- // Split into namespace + name. Bare names have no namespace.
65
- let namespace = null;
66
- let name = commandFull;
67
- const colonIdx = commandFull.indexOf(':');
68
- if (colonIdx > 0) {
69
- namespace = commandFull.slice(0, colonIdx);
70
- name = commandFull.slice(colonIdx + 1);
71
- }
72
-
73
- const isSdlcStage = namespace === 'aw' && AW_SDLC_STAGES.has(name);
74
-
32
+ // The v1 session records keep their meaning: the first typed command, as the
33
+ // post-tool-use and commit hooks read it. The typed text is no longer kept.
34
+ function toSessionCommand(command) {
75
35
  return {
76
- skill_name: commandFull, // kept for backwards compat with existing skill_invoked consumers
77
- command_namespace: namespace,
78
- command_name: commandFull,
79
- command_args: commandArgs,
80
- is_sdlc_stage: isSdlcStage,
81
- args: commandArgs, // backwards compat alias
36
+ skill_name: command.raw_name,
37
+ command_namespace: command.namespace,
38
+ command_name: command.raw_name,
39
+ command_args: '',
40
+ is_sdlc_stage: isSdlcStageCommand(command),
41
+ args: '',
82
42
  source: 'user_prompt',
83
43
  };
84
44
  }
@@ -95,29 +55,34 @@ function processPromptSubmitInput(input, deps = {}) {
95
55
  const emit = typeof deps.emit === 'function' ? deps.emit : () => {};
96
56
  const persistSkill = typeof deps.persistSkill === 'function' ? deps.persistSkill : () => {};
97
57
  const persistSlashCmd = typeof deps.persistSlashCmd === 'function' ? deps.persistSlashCmd : () => {};
58
+ const persistPrompt = typeof deps.persistPrompt === 'function' ? deps.persistPrompt : () => {};
98
59
  const events = [];
99
-
100
- const slash = extractAwSlashCommand(input);
101
- const promptPayload = {};
102
- if (slash) {
103
- promptPayload.command_namespace = slash.command_namespace;
104
- promptPayload.command_name = slash.command_name;
105
- promptPayload.command_args = slash.command_args;
106
- promptPayload.is_sdlc_stage = slash.is_sdlc_stage;
107
- }
60
+ const sessionId = getSessionId(input);
61
+ const turnId = input?.turn_id || null;
62
+
63
+ const commands = extractTypedCommands(input);
64
+ const [first] = commands;
65
+ const promptPayload = first ? {
66
+ command_namespace: first.namespace,
67
+ command_name: first.raw_name,
68
+ is_sdlc_stage: isSdlcStageCommand(first),
69
+ command_form: first.command_form,
70
+ commands_count: commands.length,
71
+ } : {};
108
72
  events.push({ eventType: 'prompt_submitted', payload: promptPayload });
109
73
 
110
- if (slash) {
111
- persistSkill(getSessionId(input), input?.turn_id || null, slash);
112
- persistSlashCmd(getSessionId(input), slash, {
74
+ persistPrompt(sessionId, { turn_id: turnId, commands });
75
+ if (first) {
76
+ const sessionCommand = toSessionCommand(first);
77
+ persistSkill(sessionId, turnId, sessionCommand);
78
+ persistSlashCmd(sessionId, sessionCommand, {
113
79
  cwd: input?.cwd || (input?.workspace_roots && input.workspace_roots[0]) || null,
114
80
  });
81
+ }
82
+ for (const command of commands) {
115
83
  events.push({
116
84
  eventType: 'skill_invoked',
117
- payload: {
118
- skill_name: slash.skill_name,
119
- args: slash.args,
120
- },
85
+ payload: { skill_name: command.raw_name, source: 'user_prompt', command_form: command.command_form },
121
86
  });
122
87
  }
123
88
 
@@ -153,6 +118,7 @@ function main() {
153
118
  },
154
119
  persistSkill: persistSessionSkill,
155
120
  persistSlashCmd: persistSessionSlashCommand,
121
+ persistPrompt: persistSessionPrompt,
156
122
  });
157
123
  }
158
124
  } catch {
@@ -168,7 +134,6 @@ if (require.main === module) {
168
134
  }
169
135
 
170
136
  module.exports = {
171
- extractAwSlashCommand,
172
137
  processPromptSubmitInput,
173
138
  resolvePromptText,
174
139
  shouldSkipPromptSubmitTelemetry,