@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/aw-usage-wiring.mjs +386 -0
- package/codex.mjs +74 -19
- package/commands/c4.mjs +7 -5
- package/commands/init.mjs +89 -28
- package/ecc.mjs +1 -1
- package/hook-cleanup.mjs +4 -3
- package/hooks/aw-usage/hooks/aw-usage-commit-created.js +21 -0
- package/hooks/aw-usage/hooks/aw-usage-post-tool-use.js +159 -33
- package/hooks/aw-usage/hooks/aw-usage-prompt-submit.js +39 -74
- package/hooks/aw-usage/hooks/aw-usage-telemetry-send.js +67 -7
- package/hooks/aw-usage/lib/aw-usage-commands.js +122 -0
- package/hooks/aw-usage/lib/aw-usage-envelope.js +121 -0
- package/hooks/aw-usage/lib/aw-usage-queue.js +168 -0
- package/hooks/aw-usage/lib/aw-usage-skill-source.js +138 -0
- package/hooks/aw-usage/lib/aw-usage-stage-events.js +179 -0
- package/hooks/aw-usage/lib/aw-usage-telemetry.js +163 -3
- package/hooks/aw-usage/package.json +1 -1
- package/hooks/codex-home.mjs +8 -6
- package/hooks.mjs +5 -8
- package/install-aw-usage-hooks.mjs +65 -215
- package/package.json +2 -1
- package/startup.mjs +54 -155
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
233
|
-
skillName = pathMatch ? pathMatch[1] :
|
|
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
|
-
|
|
238
|
-
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (
|
|
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
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
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:
|
|
77
|
-
command_namespace: namespace,
|
|
78
|
-
command_name:
|
|
79
|
-
command_args:
|
|
80
|
-
is_sdlc_stage:
|
|
81
|
-
args:
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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,
|