@ghl-ai/aw 0.1.55 → 0.1.56-beta.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 +24 -4
- package/ecc.mjs +1 -1
- package/hooks/aw-usage/hooks/aw-usage-commit-created.js +45 -0
- package/hooks/aw-usage/hooks/aw-usage-post-tool-use-failure.js +67 -0
- package/hooks/aw-usage/hooks/aw-usage-post-tool-use.js +373 -0
- package/hooks/aw-usage/hooks/aw-usage-prompt-submit.js +173 -0
- package/hooks/aw-usage/hooks/aw-usage-session-start.js +48 -0
- package/hooks/aw-usage/hooks/aw-usage-stop.js +182 -0
- package/hooks/aw-usage/hooks/aw-usage-telemetry-send.js +84 -0
- package/hooks/aw-usage/lib/aw-pricing.js +306 -0
- package/hooks/aw-usage/lib/aw-usage-telemetry.js +503 -0
- package/hooks/aw-usage/package.json +4 -0
- package/hooks.mjs +10 -5
- package/install-aw-usage-hooks.mjs +303 -0
- package/integrations.mjs +88 -38
- package/package.json +3 -2
package/commands/init.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { linkWorkspace } from '../link.mjs';
|
|
|
27
27
|
import { generateCommands, copyInstructions, initAwDocs, syncHomeHarnessInstructions } from '../integrate.mjs';
|
|
28
28
|
import { setupMcp } from '../mcp.mjs';
|
|
29
29
|
import { ensureContextModeIntegration, isContextModeRequested } from '../integrations/context-mode.mjs';
|
|
30
|
-
import { applyStoredStartupPreferences, ensureAwRuntimeHook } from '../startup.mjs';
|
|
30
|
+
import { applyStoredStartupPreferences, ensureAwRuntimeHook, isDefaultRoutingEnabled } from '../startup.mjs';
|
|
31
31
|
import { installLocalCommitHook } from '../hooks.mjs';
|
|
32
32
|
import { autoUpdate, promptUpdate } from '../update.mjs';
|
|
33
33
|
import { installGlobalHooks } from '../hooks.mjs';
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
} from '../git.mjs';
|
|
52
52
|
import { REGISTRY_DIR, REGISTRY_REPO, REGISTRY_URL, DOCS_SOURCE_DIR, AW_DOCS_DIR, RULES_SOURCE_DIR, RULES_RUNTIME_DIR } from '../constants.mjs';
|
|
53
53
|
import { syncFileTree } from '../file-tree.mjs';
|
|
54
|
+
import { installAwUsageHooks, formatAwUsageHooksInstallReport } from '../install-aw-usage-hooks.mjs';
|
|
54
55
|
|
|
55
56
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
56
57
|
const VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version;
|
|
@@ -607,12 +608,30 @@ export async function initCommand(args) {
|
|
|
607
608
|
];
|
|
608
609
|
if (!silent) fmt.logStep(`IDE wired — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`);
|
|
609
610
|
|
|
610
|
-
// Write hook manifest after all hook installation is complete
|
|
611
|
-
try { writeHookManifest({ eccVersion: AW_ECC_TAG, awVersion: VERSION }); } catch { /* best effort */ }
|
|
612
|
-
|
|
613
611
|
// Ensure telemetry config exists (generates machine_id on first run)
|
|
614
612
|
ensureTelemetryConfig();
|
|
615
613
|
|
|
614
|
+
// Install bundled aw-usage producer hooks into ~/.claude.
|
|
615
|
+
// Copies the scripts + lib files and non-destructively merges 5 hook phases
|
|
616
|
+
// (SessionStart, UserPromptSubmit, PostToolUse, PostToolUseFailure, Stop)
|
|
617
|
+
// into ~/.claude/settings.json. Idempotent. After this, every Claude Code
|
|
618
|
+
// session emits usage_events to the live telemetry API automatically.
|
|
619
|
+
let awUsageHooksReport = null;
|
|
620
|
+
try {
|
|
621
|
+
if (isDefaultRoutingEnabled(HOME, process.env)) {
|
|
622
|
+
const result = installAwUsageHooks();
|
|
623
|
+
awUsageHooksReport = formatAwUsageHooksInstallReport(result);
|
|
624
|
+
}
|
|
625
|
+
} catch (e) {
|
|
626
|
+
// Non-fatal — telemetry is observational. Surface the error in silent mode
|
|
627
|
+
// logs but don't block the install.
|
|
628
|
+
if (!silent) fmt.note(`aw-usage hooks install: ${e.message}`, 'Telemetry');
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Write hook manifest after all hook installation is complete, including
|
|
632
|
+
// bundled usage hooks, so `aw nuke` can prune AW-managed settings entries.
|
|
633
|
+
try { writeHookManifest({ eccVersion: AW_ECC_TAG, awVersion: VERSION }); } catch { /* best effort */ }
|
|
634
|
+
|
|
616
635
|
// Auto-install suggested integrations (Codex, Caveman, Graphify, etc) - unless --no-integrations
|
|
617
636
|
let installedIntegrations = [];
|
|
618
637
|
if (!silent && !skipIntegrations) {
|
|
@@ -636,6 +655,7 @@ export async function initCommand(args) {
|
|
|
636
655
|
? ` ${chalk.green('✓')} Removed ${removedLegacyStartupFiles.length} legacy repo startup file${removedLegacyStartupFiles.length > 1 ? 's' : ''}`
|
|
637
656
|
: null,
|
|
638
657
|
hooksInstalled ? ` ${chalk.green('✓')} Git hooks: auto-sync on pull/clone (core.hooksPath)` : null,
|
|
658
|
+
awUsageHooksReport ? ` ${chalk.green('✓')} ${awUsageHooksReport}` : null,
|
|
639
659
|
` ${chalk.green('✓')} IDE task: auto-sync on workspace open`,
|
|
640
660
|
cwd !== HOME && isWorktree(join(cwd, '.aw')) ? ` ${chalk.green('✓')} Linked in current project` : null,
|
|
641
661
|
installedIntegrations.length > 0 ? ` ${chalk.green('✓')} Integrations: ${installedIntegrations.join(', ')}` : null,
|
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.
|
|
15
|
+
export const AW_ECC_TAG = "v1.4.62";
|
|
16
16
|
|
|
17
17
|
const MARKETPLACE_NAME = "aw-marketplace";
|
|
18
18
|
const PLUGIN_KEY = `aw@${MARKETPLACE_NAME}`;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Usage telemetry — commit_created event.
|
|
4
|
+
*
|
|
5
|
+
* Called from the post-commit git hook when the commit has an AW
|
|
6
|
+
* Co-Authored-By trailer. Works for all harnesses (Claude, Cursor, Codex)
|
|
7
|
+
* since it fires from a git-level hook, not a harness-specific one.
|
|
8
|
+
*
|
|
9
|
+
* Usage: node aw-usage-commit-created.js <commit_hash> <branch> [cwd]
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const { buildEvent, sendAsync, isDisabled } = require('../lib/aw-usage-telemetry');
|
|
15
|
+
|
|
16
|
+
function buildCommitCreatedEvent({ commitHash = 'unknown', branch = 'unknown', cwd = process.cwd() } = {}) {
|
|
17
|
+
// Git hooks have no harness session context, but cwd lets buildEvent derive
|
|
18
|
+
// project_hash so the dashboard can correlate commits back to /aw:* sessions.
|
|
19
|
+
const event = buildEvent({ cwd }, 'commit_created', {
|
|
20
|
+
commit_hash: commitHash,
|
|
21
|
+
branch,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Override harness to 'git' since this fires from a git hook, not a harness.
|
|
25
|
+
event.harness = 'git';
|
|
26
|
+
return event;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function main() {
|
|
30
|
+
if (isDisabled()) process.exit(0);
|
|
31
|
+
|
|
32
|
+
const commitHash = process.argv[2] || 'unknown';
|
|
33
|
+
const branch = process.argv[3] || 'unknown';
|
|
34
|
+
const cwd = process.argv[4] || process.cwd();
|
|
35
|
+
|
|
36
|
+
sendAsync(buildCommitCreatedEvent({ commitHash, branch, cwd }));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (require.main === module) {
|
|
40
|
+
main();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
buildCommitCreatedEvent,
|
|
45
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Usage telemetry — PostToolUseFailure hook.
|
|
4
|
+
*
|
|
5
|
+
* Claude: error (string/object) + is_interrupt (boolean).
|
|
6
|
+
* Cursor: error_message (string) + failure_type ("timeout"/"error"/"permission_denied").
|
|
7
|
+
* Codex: does NOT support PostToolUseFailure — this hook won't fire.
|
|
8
|
+
*
|
|
9
|
+
* Outputs {} on stdout.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const { buildEvent, sendAsync } = require('../lib/aw-usage-telemetry');
|
|
15
|
+
|
|
16
|
+
const MAX_STDIN = 1024 * 1024;
|
|
17
|
+
let raw = '';
|
|
18
|
+
|
|
19
|
+
function classifyFailureMessage(errorMessage) {
|
|
20
|
+
if (typeof errorMessage !== 'string' || !errorMessage.trim()) return 'tool_failed';
|
|
21
|
+
if (/\bNo such file or directory\b/i.test(errorMessage)) return 'no_such_file_or_directory';
|
|
22
|
+
if (/\bPermission denied\b/i.test(errorMessage)) return 'permission_denied';
|
|
23
|
+
if (/\bOperation not permitted\b/i.test(errorMessage)) return 'operation_not_permitted';
|
|
24
|
+
if (/\bcommand not found\b/i.test(errorMessage)) return 'command_not_found';
|
|
25
|
+
if (/\bcannot access\b/i.test(errorMessage)) return 'cannot_access_path';
|
|
26
|
+
if (/\bis a directory\b/i.test(errorMessage)) return 'path_is_directory';
|
|
27
|
+
return 'tool_failed';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
process.stdin.setEncoding('utf8');
|
|
31
|
+
process.stdin.on('data', chunk => {
|
|
32
|
+
if (raw.length < MAX_STDIN) {
|
|
33
|
+
raw += chunk.substring(0, MAX_STDIN - raw.length);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
process.stdin.on('end', () => {
|
|
38
|
+
try {
|
|
39
|
+
const input = JSON.parse(raw);
|
|
40
|
+
const toolName = input.tool_name || 'unknown';
|
|
41
|
+
|
|
42
|
+
// Normalize error fields across Claude and Cursor
|
|
43
|
+
let errorMessage;
|
|
44
|
+
let failureType;
|
|
45
|
+
|
|
46
|
+
if (input.error_message !== undefined) {
|
|
47
|
+
// Cursor format
|
|
48
|
+
errorMessage = String(input.error_message || '');
|
|
49
|
+
failureType = input.failure_type || 'error';
|
|
50
|
+
} else {
|
|
51
|
+
// Claude format
|
|
52
|
+
const err = input.error;
|
|
53
|
+
errorMessage = typeof err === 'string' ? err : (err?.message || JSON.stringify(err) || 'unknown');
|
|
54
|
+
failureType = input.is_interrupt ? 'interrupt' : 'error';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
sendAsync(buildEvent(input, 'tool_error', {
|
|
58
|
+
tool_name: toolName,
|
|
59
|
+
error_message: classifyFailureMessage(errorMessage),
|
|
60
|
+
failure_type: failureType,
|
|
61
|
+
}));
|
|
62
|
+
} catch {
|
|
63
|
+
// Non-blocking.
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
process.stdout.write('{}');
|
|
67
|
+
});
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Usage telemetry — PostToolUse hook.
|
|
4
|
+
*
|
|
5
|
+
* Detects: skill invocations, agent spawns, skill pushes.
|
|
6
|
+
* PR contribution is tracked via Co-Authored-By trailer in prepare-commit-msg hook.
|
|
7
|
+
* Outputs {} on stdout (Claude/Codex parse stdout as JSON).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
buildEvent,
|
|
14
|
+
detectHarness,
|
|
15
|
+
sendAsync,
|
|
16
|
+
readSessionSkill,
|
|
17
|
+
readSessionLastSlashCommand,
|
|
18
|
+
tryAcquireDedupe,
|
|
19
|
+
} = require('../lib/aw-usage-telemetry');
|
|
20
|
+
|
|
21
|
+
const MAX_STDIN = 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
// ── Test-file detection ──────────────────────────────────────────────
|
|
24
|
+
// Match common test conventions across JS/TS, Python, Go, Rust, Java, Kotlin.
|
|
25
|
+
// Designed to be permissive: a hit means "this was probably a test file";
|
|
26
|
+
// false positives are cheap (dashboard groups by guess) and false negatives
|
|
27
|
+
// are worse (lost SDLC signal). Use forward slashes — normalize backslashes
|
|
28
|
+
// before matching since Windows paths come through as \.
|
|
29
|
+
function normalizePath(p) {
|
|
30
|
+
return typeof p === 'string' ? p.replace(/\\/g, '/') : '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Patterns are evaluated top-to-bottom; earlier matches win. Order from
|
|
34
|
+
// most-specific to least so e.g. `tests/foo.rs` is `rust-test`, not pytest.
|
|
35
|
+
const TEST_PATH_PATTERNS = [
|
|
36
|
+
// JS / TS (jest, vitest, mocha, jasmine)
|
|
37
|
+
{ regex: /(?:^|\/)__tests__\/.+\.(?:ts|tsx|js|jsx|mjs|cjs)$/i, framework: 'jest-like' },
|
|
38
|
+
{ regex: /\.(?:spec|test)\.(?:ts|tsx|js|jsx|mjs|cjs)$/i, framework: 'jest-vitest' },
|
|
39
|
+
// Go (`xxx_test.go`)
|
|
40
|
+
{ regex: /_test\.go$/i, framework: 'go-test' },
|
|
41
|
+
// Rust (`#[cfg(test)]` lives inline, but `tests/` dir is canonical)
|
|
42
|
+
{ regex: /(?:^|\/)tests\/.+\.rs$/i, framework: 'rust-test' },
|
|
43
|
+
// Java / Kotlin
|
|
44
|
+
{ regex: /(?:^|\/)src\/test\/.+\.(?:java|kt|kts)$/i, framework: 'junit' },
|
|
45
|
+
{ regex: /Test\.(?:java|kt)$/, framework: 'junit' },
|
|
46
|
+
// Python (pytest / unittest) — keyed on .py to avoid claiming Rust/Go test dirs.
|
|
47
|
+
{ regex: /(?:^|\/)tests?\/.+\.py$/i, framework: 'pytest' },
|
|
48
|
+
{ regex: /\/test_[a-z0-9_-]+\.py$/i, framework: 'pytest' },
|
|
49
|
+
{ regex: /_test\.py$/i, framework: 'pytest' },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
function detectTestFramework(filePath) {
|
|
53
|
+
const norm = normalizePath(filePath);
|
|
54
|
+
if (!norm) return null;
|
|
55
|
+
for (const { regex, framework } of TEST_PATH_PATTERNS) {
|
|
56
|
+
if (regex.test(norm)) return framework;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── SDLC artifact detection ──────────────────────────────────────────
|
|
62
|
+
// Plans, PRDs, specs, tasks, design docs, ADRs, learnings — written by
|
|
63
|
+
// the /aw:plan, /aw:ship, /aw:execute, etc. flows. Maps to an artifact_type
|
|
64
|
+
// + a coarse sdlc_stage so the funnel query can group.
|
|
65
|
+
const SDLC_ARTIFACT_PATTERNS = [
|
|
66
|
+
{ regex: /(?:^|\/)plan\.md$/i, artifact_type: 'plan', sdlc_stage: 'plan' },
|
|
67
|
+
{ regex: /(?:^|\/)prd\.md$/i, artifact_type: 'prd', sdlc_stage: 'plan' },
|
|
68
|
+
{ regex: /(?:^|\/)spec\.md$/i, artifact_type: 'spec', sdlc_stage: 'plan' },
|
|
69
|
+
{ regex: /(?:^|\/)tasks\.md$/i, artifact_type: 'tasks', sdlc_stage: 'plan' },
|
|
70
|
+
{ regex: /(?:^|\/)design\.md$/i, artifact_type: 'design', sdlc_stage: 'plan' },
|
|
71
|
+
{ regex: /(?:^|\/)tech_doc\.md$/i, artifact_type: 'tech_doc', sdlc_stage: 'plan' },
|
|
72
|
+
{ regex: /(?:^|\/)architecture\.md$/i, artifact_type: 'architecture', sdlc_stage: 'plan' },
|
|
73
|
+
{ regex: /\/\.aw_docs\/runs\/[^/]+\/plan\.md$/i, artifact_type: 'run_plan', sdlc_stage: 'plan' },
|
|
74
|
+
{ regex: /\/\.aw_docs\/features\/[^/]+\/[^/]+\.md$/i, artifact_type: 'feature_doc', sdlc_stage: 'plan' },
|
|
75
|
+
{ regex: /\/\.aw_docs\/learnings\/[^/]+\.md$/i, artifact_type: 'learning', sdlc_stage: 'learn' },
|
|
76
|
+
// ADR convention
|
|
77
|
+
{ regex: /(?:^|\/)adr-\d+[a-z0-9-]*\.md$/i, artifact_type: 'adr', sdlc_stage: 'plan' },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
function detectSdlcArtifact(filePath) {
|
|
81
|
+
const norm = normalizePath(filePath);
|
|
82
|
+
if (!norm) return null;
|
|
83
|
+
for (const { regex, artifact_type, sdlc_stage } of SDLC_ARTIFACT_PATTERNS) {
|
|
84
|
+
if (regex.test(norm)) return { artifact_type, sdlc_stage };
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
function parseMaybeJsonObject(value) {
|
|
89
|
+
if (typeof value !== 'string') return value;
|
|
90
|
+
const trimmed = value.trim();
|
|
91
|
+
if (!trimmed || !/^[{[]/.test(trimmed)) return value;
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(trimmed);
|
|
94
|
+
} catch {
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toObject(value) {
|
|
100
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getSessionId(input) {
|
|
104
|
+
return input?.session_id || input?.conversation_id || null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getCommand(input) {
|
|
108
|
+
return String(
|
|
109
|
+
input?.tool_input?.command
|
|
110
|
+
|| input?.tool_input?.args?.command
|
|
111
|
+
|| ''
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeToolResult(input) {
|
|
116
|
+
const rawToolResponse = parseMaybeJsonObject(input?.tool_response);
|
|
117
|
+
const rawToolOutput = parseMaybeJsonObject(input?.tool_output);
|
|
118
|
+
const toolResponse = toObject(rawToolResponse);
|
|
119
|
+
const toolOutput = toObject(rawToolOutput);
|
|
120
|
+
const exitCode = [
|
|
121
|
+
input?.exit_code,
|
|
122
|
+
toolResponse.exit_code,
|
|
123
|
+
toolResponse.exitCode,
|
|
124
|
+
toolOutput.exit_code,
|
|
125
|
+
toolOutput.exitCode,
|
|
126
|
+
].find(value => value !== undefined && value !== null && value !== '');
|
|
127
|
+
const rawMessage = [
|
|
128
|
+
toolResponse.stderr,
|
|
129
|
+
toolOutput.stderr,
|
|
130
|
+
toolResponse.output,
|
|
131
|
+
toolOutput.output,
|
|
132
|
+
typeof rawToolResponse === 'string' ? rawToolResponse : '',
|
|
133
|
+
typeof rawToolOutput === 'string' ? rawToolOutput : '',
|
|
134
|
+
input?.stderr,
|
|
135
|
+
input?.output,
|
|
136
|
+
].filter(value => typeof value === 'string' && value.trim()).join('\n');
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
exitCode: exitCode === undefined ? null : Number(exitCode),
|
|
140
|
+
rawErrorMessage: rawMessage,
|
|
141
|
+
errorMessage: classifyFailureMessage(rawMessage),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function classifyFailureMessage(errorMessage) {
|
|
146
|
+
if (typeof errorMessage !== 'string' || !errorMessage.trim()) return 'tool_failed';
|
|
147
|
+
if (/\bNo such file or directory\b/i.test(errorMessage)) return 'no_such_file_or_directory';
|
|
148
|
+
if (/\bPermission denied\b/i.test(errorMessage)) return 'permission_denied';
|
|
149
|
+
if (/\bOperation not permitted\b/i.test(errorMessage)) return 'operation_not_permitted';
|
|
150
|
+
if (/\bcommand not found\b/i.test(errorMessage)) return 'command_not_found';
|
|
151
|
+
if (/\bcannot access\b/i.test(errorMessage)) return 'cannot_access_path';
|
|
152
|
+
if (/\bis a directory\b/i.test(errorMessage)) return 'path_is_directory';
|
|
153
|
+
return 'tool_failed';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isExplicitFailureExitCode(exitCode) {
|
|
157
|
+
return exitCode !== null && Number.isFinite(exitCode) && exitCode !== 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function inferShellFailureFromMessage(toolName, errorMessage) {
|
|
161
|
+
if (toolName !== 'Shell' && toolName !== 'Bash') return false;
|
|
162
|
+
if (typeof errorMessage !== 'string' || !errorMessage.trim()) return false;
|
|
163
|
+
const patterns = [
|
|
164
|
+
/(?:^|\n)[^:\n]+:\s.*\bNo such file or directory\b/i,
|
|
165
|
+
/(?:^|\n)[^:\n]+:\s.*\bPermission denied\b/i,
|
|
166
|
+
/(?:^|\n)[^:\n]+:\s.*\bOperation not permitted\b/i,
|
|
167
|
+
/(?:^|\n)(?:bash|zsh|sh): .*?\bcommand not found\b/i,
|
|
168
|
+
/(?:^|\n)[^:\n]+:\s.*\bcannot access\b/i,
|
|
169
|
+
/(?:^|\n)[^:\n]+:\s.*\bis a directory\b/i,
|
|
170
|
+
];
|
|
171
|
+
return patterns.some(pattern => pattern.test(errorMessage));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function shouldEmitSkillName(skillName) {
|
|
175
|
+
return Boolean(skillName) && skillName !== 'using-aw-skills';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function collectPostToolUseEvents(input, options = {}) {
|
|
179
|
+
const events = [];
|
|
180
|
+
const toolName = input?.tool_name || '';
|
|
181
|
+
const promptSkillOverride = options.promptSkillOverride || null;
|
|
182
|
+
// Session-scoped slash command from the most recent /aw:*, /tdd, etc.
|
|
183
|
+
// Used to correlate test/artifact writes back to the originating SDLC stage.
|
|
184
|
+
const slashCmd = options.sessionSlashCommand || null;
|
|
185
|
+
|
|
186
|
+
// Skill detection: Claude uses tool_name='Skill', Cursor reads SKILL.md via 'Read' tool.
|
|
187
|
+
const filePath = input?.tool_input?.file_path || '';
|
|
188
|
+
const isSkillRead = toolName === 'Read' && /\/SKILL\.md$/i.test(filePath);
|
|
189
|
+
|
|
190
|
+
if (toolName === 'Skill' || isSkillRead) {
|
|
191
|
+
let skillName = input?.tool_input?.skill || input?.tool_input?.args?.skill || '';
|
|
192
|
+
// For Cursor: extract skill name from path (e.g. .../skills/<skill-name>/SKILL.md)
|
|
193
|
+
if (!skillName && isSkillRead) {
|
|
194
|
+
const pathMatch = filePath.match(/\/skills\/([^/]+)\/SKILL\.md$/i);
|
|
195
|
+
skillName = pathMatch ? pathMatch[1] : filePath.split('/').slice(-2, -1)[0] || '';
|
|
196
|
+
}
|
|
197
|
+
if (shouldEmitSkillName(skillName)) {
|
|
198
|
+
events.push({
|
|
199
|
+
eventType: 'skill_invoked',
|
|
200
|
+
payload: {
|
|
201
|
+
skill_name: skillName,
|
|
202
|
+
args: input?.tool_input?.args || '',
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
} else if (toolName === 'Agent') {
|
|
207
|
+
events.push({
|
|
208
|
+
eventType: 'agent_spawned',
|
|
209
|
+
payload: {
|
|
210
|
+
agent_type: input?.tool_input?.subagent_type || 'general-purpose',
|
|
211
|
+
description: input?.tool_input?.description || '',
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const toolResult = normalizeToolResult(input);
|
|
217
|
+
if (isExplicitFailureExitCode(toolResult.exitCode)
|
|
218
|
+
|| inferShellFailureFromMessage(toolName, toolResult.rawErrorMessage)) {
|
|
219
|
+
const payload = {
|
|
220
|
+
tool_name: toolName || 'unknown',
|
|
221
|
+
error_message: toolResult.errorMessage,
|
|
222
|
+
failure_type: 'error',
|
|
223
|
+
};
|
|
224
|
+
if (isExplicitFailureExitCode(toolResult.exitCode)) {
|
|
225
|
+
payload.exit_code = toolResult.exitCode;
|
|
226
|
+
}
|
|
227
|
+
events.push({
|
|
228
|
+
eventType: 'tool_error',
|
|
229
|
+
payload,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (toolName === 'Shell' || toolName === 'Bash') {
|
|
234
|
+
const cmd = getCommand(input);
|
|
235
|
+
// Codex skill detection: Bash commands that read SKILL.md files.
|
|
236
|
+
if (!promptSkillOverride) {
|
|
237
|
+
const skillCmdMatch = cmd.match(/\/skills\/([^/]+)\/SKILL\.md/i);
|
|
238
|
+
if (skillCmdMatch && shouldEmitSkillName(skillCmdMatch[1])) {
|
|
239
|
+
events.push({
|
|
240
|
+
eventType: 'skill_invoked',
|
|
241
|
+
payload: {
|
|
242
|
+
skill_name: skillCmdMatch[1],
|
|
243
|
+
args: '',
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (/\baw\s+push\b/.test(cmd)) {
|
|
249
|
+
const skillMatch = cmd.match(/aw\s+push\s+(\S+)/);
|
|
250
|
+
events.push({
|
|
251
|
+
eventType: 'skill_pushed',
|
|
252
|
+
payload: {
|
|
253
|
+
skill_name: skillMatch ? skillMatch[1] : '',
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Test-file write detection — match Write/Edit/MultiEdit on common test paths.
|
|
260
|
+
// Emits once per matching write, with a framework guess and the originating
|
|
261
|
+
// slash command (e.g. /aw:test) if the session is in an SDLC stage. The
|
|
262
|
+
// dashboard funnel uses sdlc_correlated_command to compute "tests written
|
|
263
|
+
// via /aw:test" vs "tests written ad-hoc".
|
|
264
|
+
if (toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit') {
|
|
265
|
+
const framework = detectTestFramework(filePath);
|
|
266
|
+
if (framework) {
|
|
267
|
+
events.push({
|
|
268
|
+
eventType: 'test_file_written',
|
|
269
|
+
payload: {
|
|
270
|
+
file_path: filePath,
|
|
271
|
+
test_framework_guess: framework,
|
|
272
|
+
tool_name: toolName,
|
|
273
|
+
sdlc_correlated_command: slashCmd ? slashCmd.command_name : null,
|
|
274
|
+
sdlc_correlated_namespace: slashCmd ? slashCmd.command_namespace : null,
|
|
275
|
+
sdlc_correlated_is_sdlc_stage: slashCmd ? Boolean(slashCmd.is_sdlc_stage) : false,
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// SDLC artifact creation — emit on Write only (creation, not Edit) so
|
|
281
|
+
// we don't double-count every save of an existing plan.md.
|
|
282
|
+
if (toolName === 'Write') {
|
|
283
|
+
const artifact = detectSdlcArtifact(filePath);
|
|
284
|
+
if (artifact) {
|
|
285
|
+
events.push({
|
|
286
|
+
eventType: 'sdlc_artifact_created',
|
|
287
|
+
payload: {
|
|
288
|
+
file_path: filePath,
|
|
289
|
+
artifact_type: artifact.artifact_type,
|
|
290
|
+
sdlc_stage: artifact.sdlc_stage,
|
|
291
|
+
sdlc_correlated_command: slashCmd ? slashCmd.command_name : null,
|
|
292
|
+
sdlc_correlated_namespace: slashCmd ? slashCmd.command_namespace : null,
|
|
293
|
+
sdlc_correlated_is_sdlc_stage: slashCmd ? Boolean(slashCmd.is_sdlc_stage) : false,
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return events;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function processPostToolUseInput(input, deps = {}) {
|
|
304
|
+
const emit = typeof deps.emit === 'function' ? deps.emit : () => {};
|
|
305
|
+
const sessionId = getSessionId(input);
|
|
306
|
+
const promptSkillOverride = deps.promptSkillOverride || readSessionSkill(
|
|
307
|
+
sessionId,
|
|
308
|
+
input?.turn_id || null,
|
|
309
|
+
);
|
|
310
|
+
const sessionSlashCommand = deps.sessionSlashCommand
|
|
311
|
+
|| readSessionLastSlashCommand(sessionId);
|
|
312
|
+
const events = collectPostToolUseEvents(input, {
|
|
313
|
+
promptSkillOverride,
|
|
314
|
+
sessionSlashCommand,
|
|
315
|
+
});
|
|
316
|
+
for (const event of events) {
|
|
317
|
+
emit(event.eventType, event.payload);
|
|
318
|
+
}
|
|
319
|
+
return events;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function main() {
|
|
323
|
+
let raw = '';
|
|
324
|
+
|
|
325
|
+
process.stdin.setEncoding('utf8');
|
|
326
|
+
process.stdin.on('data', chunk => {
|
|
327
|
+
if (raw.length < MAX_STDIN) {
|
|
328
|
+
raw += chunk.substring(0, MAX_STDIN - raw.length);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
process.stdin.on('end', () => {
|
|
333
|
+
try {
|
|
334
|
+
const input = JSON.parse(raw);
|
|
335
|
+
const shouldDedup = detectHarness(input) === 'codex' || Boolean(input?.tool_use_id);
|
|
336
|
+
if (!shouldDedup || tryAcquireDedupe('post-tool-use', [
|
|
337
|
+
getSessionId(input),
|
|
338
|
+
input?.turn_id || '',
|
|
339
|
+
input?.tool_use_id || '',
|
|
340
|
+
input?.tool_name || '',
|
|
341
|
+
getCommand(input),
|
|
342
|
+
input?.tool_input?.file_path || '',
|
|
343
|
+
input?.tool_input?.description || '',
|
|
344
|
+
])) {
|
|
345
|
+
processPostToolUseInput(input, {
|
|
346
|
+
emit(eventType, payload) {
|
|
347
|
+
sendAsync(buildEvent(input, eventType, payload));
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
// Non-blocking — never fail the hook.
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
process.stdout.write('{}');
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (require.main === module) {
|
|
360
|
+
main();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
module.exports = {
|
|
364
|
+
collectPostToolUseEvents,
|
|
365
|
+
detectSdlcArtifact,
|
|
366
|
+
detectTestFramework,
|
|
367
|
+
inferShellFailureFromMessage,
|
|
368
|
+
isExplicitFailureExitCode,
|
|
369
|
+
normalizeToolResult,
|
|
370
|
+
parseMaybeJsonObject,
|
|
371
|
+
processPostToolUseInput,
|
|
372
|
+
shouldEmitSkillName,
|
|
373
|
+
};
|