@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
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AW Usage Telemetry — loop-aware stage events (B4).
|
|
3
|
+
*
|
|
4
|
+
* isCompletionStatus(status) — P13 text rule for a finished stage
|
|
5
|
+
* decideStageEvents(history, { stage, status, now }) — pure: events + new history
|
|
6
|
+
* processStateJsonWrite({ filePath, … }) — read the written state.json, update the
|
|
7
|
+
* laptop history, return the events to send
|
|
8
|
+
*
|
|
9
|
+
* A write to .aw_docs/features/<slug>/state.json is a start when its `stage`
|
|
10
|
+
* is new or changed (or the same stage again after it was completed), and a
|
|
11
|
+
* completion when its `status` reads as finished. History lives on the laptop
|
|
12
|
+
* in ~/.aw/telemetry/stages/<project_hash>/<slug>.json (P12): the hook never
|
|
13
|
+
* writes into the repo. When the stage skills write a fixed status and a
|
|
14
|
+
* stage history into state.json (https://app.clickup.com/t/14zhcp5e0bh), only
|
|
15
|
+
* the input of decideStageEvents changes.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const { withFileLock, sessionFileName } = require('./aw-usage-envelope');
|
|
24
|
+
const { commandKey } = require('./aw-usage-commands');
|
|
25
|
+
|
|
26
|
+
const STAGES_DIR = path.join(os.homedir(), '.aw', 'telemetry', 'stages');
|
|
27
|
+
// `investigate` is a valid stage outside the plan → ship order.
|
|
28
|
+
const KNOWN_STAGES = new Set(['plan', 'build', 'test', 'verify', 'review', 'deploy', 'ship', 'investigate']);
|
|
29
|
+
const STAGE_FOR_COMMAND = {
|
|
30
|
+
plan: 'plan', build: 'build', execute: 'build', test: 'test', verify: 'verify',
|
|
31
|
+
review: 'review', deploy: 'deploy', ship: 'ship', investigate: 'investigate',
|
|
32
|
+
};
|
|
33
|
+
const STATE_JSON = /(?:^|\/)\.aw_docs\/features\/([^/]+)\/state\.json$/;
|
|
34
|
+
const MAX_STATE_BYTES = 1024 * 1024;
|
|
35
|
+
const MAX_STATUS_RAW = 60;
|
|
36
|
+
const MAX_PATTERN_ID = 100;
|
|
37
|
+
const COMPLETED = /(?:^|_)(?:complete|completed|done|passed|pass)(?:_|$)/;
|
|
38
|
+
const NOT_COMPLETED = /(?:^|_)(?:blocked|failed|fail)(?:_|$)/;
|
|
39
|
+
|
|
40
|
+
// `stage`: the stage the write is in. `ready_for_<stage>` for that same stage
|
|
41
|
+
// means it has not started yet, so it is not a completion.
|
|
42
|
+
function isCompletionStatus(status, stage = null) {
|
|
43
|
+
if (typeof status !== 'string' || !status) return false;
|
|
44
|
+
const value = status.toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
|
45
|
+
if (NOT_COMPLETED.test(value)) return false;
|
|
46
|
+
if (value.startsWith('ready_for_')) return !stage || value !== `ready_for_${stage}`;
|
|
47
|
+
return COMPLETED.test(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function decideStageEvents(history, { stage, status, now }) {
|
|
51
|
+
if (!KNOWN_STAGES.has(stage)) return { events: [], history };
|
|
52
|
+
const entries = Array.isArray(history?.entries) ? [...history.entries] : [];
|
|
53
|
+
const events = [];
|
|
54
|
+
const completion = isCompletionStatus(status, stage);
|
|
55
|
+
const current = entries[entries.length - 1];
|
|
56
|
+
|
|
57
|
+
const isNewStage = !current || current.stage !== stage;
|
|
58
|
+
if (isNewStage || (current.completed && !completion)) {
|
|
59
|
+
const loopIndex = entries.filter(entry => entry.stage === stage).length;
|
|
60
|
+
events.push({ type: 'started', stage, from_stage: current ? current.stage : null, loop_index: loopIndex });
|
|
61
|
+
entries.push({ stage, loop_index: loopIndex, started_at: now, completed: false });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const open = entries[entries.length - 1];
|
|
65
|
+
if (completion && open.stage === stage && !open.completed) {
|
|
66
|
+
events.push({ type: 'completed', stage, loop_index: open.loop_index });
|
|
67
|
+
entries[entries.length - 1] = { ...open, completed: true };
|
|
68
|
+
}
|
|
69
|
+
return { events, history: { entries } };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 'user' when the last prompt typed the command for this stage, else 'agent'. */
|
|
73
|
+
function stageStartedBy(stage, lastPromptCommands) {
|
|
74
|
+
const typed = Array.isArray(lastPromptCommands) ? lastPromptCommands : [];
|
|
75
|
+
return typed.some(command => STAGE_FOR_COMMAND[commandKey(command?.raw_name)] === stage) ? 'user' : 'agent';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readStateJson(absolutePath) {
|
|
79
|
+
try {
|
|
80
|
+
if (fs.statSync(absolutePath).size > MAX_STATE_BYTES) return null;
|
|
81
|
+
const state = JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
|
|
82
|
+
return state && typeof state === 'object' && !Array.isArray(state) ? state : null;
|
|
83
|
+
} catch {
|
|
84
|
+
// Unreadable or not JSON: no stage event for this write.
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readHistory(historyPath) {
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(fs.readFileSync(historyPath, 'utf8'));
|
|
92
|
+
} catch {
|
|
93
|
+
// First write for this feature on this laptop.
|
|
94
|
+
return { entries: [] };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// tmp file + rename: a crash mid-write never leaves half a history behind.
|
|
99
|
+
function writeHistory(historyPath, history) {
|
|
100
|
+
const tmpPath = `${historyPath}.${process.pid}.tmp`;
|
|
101
|
+
fs.writeFileSync(tmpPath, JSON.stringify(history), { mode: 0o600 });
|
|
102
|
+
fs.renameSync(tmpPath, historyPath);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toEvent(decision, { slug, relativePath, state, startedBy }) {
|
|
106
|
+
if (decision.type === 'started') {
|
|
107
|
+
const patternId = typeof state.pattern_id === 'string' && state.pattern_id.length <= MAX_PATTERN_ID ? state.pattern_id : null;
|
|
108
|
+
return {
|
|
109
|
+
eventType: 'sdlc_stage_started',
|
|
110
|
+
payload: {
|
|
111
|
+
feature_slug: slug,
|
|
112
|
+
stage: decision.stage,
|
|
113
|
+
from_stage: decision.from_stage,
|
|
114
|
+
loop_index: decision.loop_index,
|
|
115
|
+
started_by: startedBy,
|
|
116
|
+
...(patternId ? { pattern_id: patternId } : {}),
|
|
117
|
+
file_path: relativePath,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
eventType: 'sdlc_stage_completed',
|
|
123
|
+
payload: {
|
|
124
|
+
feature_slug: slug,
|
|
125
|
+
stage: decision.stage,
|
|
126
|
+
loop_index: decision.loop_index,
|
|
127
|
+
status_raw: String(state.status).slice(0, MAX_STATUS_RAW),
|
|
128
|
+
file_path: relativePath,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {{ filePath: string, projectRoot: string, projectHash: string|null,
|
|
135
|
+
* lastPromptCommands?: Array<{ raw_name: string }>, stagesDir?: string, now?: () => string }} opts
|
|
136
|
+
* @returns {Array<{ eventType: string, payload: object }>}
|
|
137
|
+
*/
|
|
138
|
+
function processStateJsonWrite({ filePath, projectRoot, projectHash, lastPromptCommands = [], stagesDir = STAGES_DIR, now = () => new Date().toISOString() }) {
|
|
139
|
+
const norm = String(filePath || '').replace(/\\/g, '/');
|
|
140
|
+
if (!projectHash || !projectRoot) return [];
|
|
141
|
+
const root = path.posix.normalize(String(projectRoot).replace(/\\/g, '/')).replace(/\/+$/, '');
|
|
142
|
+
const isAbsolute = norm.startsWith('/') || /^[A-Za-z]:\//.test(norm);
|
|
143
|
+
// Resolve `..` before the check, so a path cannot step out of the project.
|
|
144
|
+
const absolutePath = path.posix.normalize(isAbsolute ? norm : `${root}/${norm}`);
|
|
145
|
+
// Only the project's own feature folders are read.
|
|
146
|
+
if (!root || !absolutePath.startsWith(`${root}/`)) return [];
|
|
147
|
+
const relativePath = absolutePath.slice(root.length + 1);
|
|
148
|
+
const match = relativePath.match(STATE_JSON);
|
|
149
|
+
if (!match) return [];
|
|
150
|
+
|
|
151
|
+
const state = readStateJson(absolutePath);
|
|
152
|
+
const stage = typeof state?.stage === 'string' ? state.stage.trim().toLowerCase() : null;
|
|
153
|
+
if (!state || !KNOWN_STAGES.has(stage)) return [];
|
|
154
|
+
|
|
155
|
+
const slug = match[1];
|
|
156
|
+
const featureDir = path.join(stagesDir, sessionFileName(projectHash));
|
|
157
|
+
const historyPath = path.join(featureDir, `${sessionFileName(slug)}.json`);
|
|
158
|
+
try {
|
|
159
|
+
fs.mkdirSync(featureDir, { recursive: true, mode: 0o700 });
|
|
160
|
+
const result = withFileLock(`${historyPath}.lock`, () => {
|
|
161
|
+
const decision = decideStageEvents(readHistory(historyPath), { stage, status: state.status, now: now() });
|
|
162
|
+
if (decision.events.length > 0) writeHistory(historyPath, decision.history);
|
|
163
|
+
return decision.events;
|
|
164
|
+
}, { waitMs: 200 });
|
|
165
|
+
if (!result.ok) return [];
|
|
166
|
+
const startedBy = stageStartedBy(stage, lastPromptCommands);
|
|
167
|
+
return result.value.map(decision => toEvent(decision, { slug, relativePath, state, startedBy }));
|
|
168
|
+
} catch {
|
|
169
|
+
// Telemetry folder not writable: no stage event, the rest of the hook is unaffected.
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
isCompletionStatus,
|
|
176
|
+
decideStageEvents,
|
|
177
|
+
stageStartedBy,
|
|
178
|
+
processStateJsonWrite,
|
|
179
|
+
};
|
|
@@ -16,6 +16,8 @@ const fs = require('fs');
|
|
|
16
16
|
const os = require('os');
|
|
17
17
|
const { spawn, execSync } = require('child_process');
|
|
18
18
|
const crypto = require('crypto');
|
|
19
|
+
const { newEventId, readHookVersion, nextSeq, withFileLock } = require('./aw-usage-envelope');
|
|
20
|
+
const { commandKey } = require('./aw-usage-commands');
|
|
19
21
|
|
|
20
22
|
const SENDER_SCRIPT = path.join(__dirname, '..', 'hooks', 'aw-usage-telemetry-send.js');
|
|
21
23
|
const AW_HOME = path.join(os.homedir(), '.aw');
|
|
@@ -179,7 +181,7 @@ function pruneStaleSessionFiles() {
|
|
|
179
181
|
const entries = fs.readdirSync(SESSION_DIR);
|
|
180
182
|
const now = Date.now();
|
|
181
183
|
for (const entry of entries) {
|
|
182
|
-
if (!entry.endsWith('.json')) continue;
|
|
184
|
+
if (!entry.endsWith('.json') && !entry.endsWith('.seq')) continue;
|
|
183
185
|
const filePath = path.join(SESSION_DIR, entry);
|
|
184
186
|
const stat = fs.statSync(filePath);
|
|
185
187
|
if (now - stat.mtimeMs > SESSION_MAX_AGE_MS) {
|
|
@@ -289,6 +291,82 @@ function persistSessionSlashCommand(sessionId, slashCommand, context = {}) {
|
|
|
289
291
|
} catch { /* ignore */ }
|
|
290
292
|
}
|
|
291
293
|
|
|
294
|
+
// last_prompt and its `matched` list are written under the session lock and
|
|
295
|
+
// replaced atomically (tmp file + rename), so a tool hook never reads half a
|
|
296
|
+
// file or overwrites a newer prompt.
|
|
297
|
+
const SESSION_LOCK_WAIT_MS = 200;
|
|
298
|
+
|
|
299
|
+
function sessionLockPath(sessionId) {
|
|
300
|
+
return path.join(SESSION_DIR, sessionId + '.json.lock');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function writeSessionStateAtomic(sessionId, state) {
|
|
304
|
+
const filePath = path.join(SESSION_DIR, sessionId + '.json');
|
|
305
|
+
const tmpPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
306
|
+
fs.writeFileSync(tmpPath, JSON.stringify(state));
|
|
307
|
+
fs.renameSync(tmpPath, filePath);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// last_prompt: the commands typed in the most recent prompt (empty for a
|
|
311
|
+
// plain prompt). `matched` lists command keys already paired with a skill
|
|
312
|
+
// load, so only the first matching load gets follows_typed_command.
|
|
313
|
+
function persistSessionPrompt(sessionId, { turn_id: turnId = null, commands = [] } = {}) {
|
|
314
|
+
if (!sessionId || sessionId === 'unknown') return;
|
|
315
|
+
const write = () => {
|
|
316
|
+
const state = readSessionState(sessionId);
|
|
317
|
+
writeSessionStateAtomic(sessionId, {
|
|
318
|
+
...state,
|
|
319
|
+
last_prompt: {
|
|
320
|
+
turn_id: turnId || null,
|
|
321
|
+
commands: commands.map(({ raw_name, command_form, namespace }) => ({ raw_name, command_form, namespace })),
|
|
322
|
+
matched: [],
|
|
323
|
+
at: new Date().toISOString(),
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
};
|
|
327
|
+
try {
|
|
328
|
+
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
|
329
|
+
// A new prompt must land even when a tool hook holds the lock too long.
|
|
330
|
+
if (!withFileLock(sessionLockPath(sessionId), write, { waitMs: SESSION_LOCK_WAIT_MS }).ok) write();
|
|
331
|
+
} catch { /* ignore: best effort, like the other session records */ }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const TYPED_COMMAND_WINDOW_MS = 10 * 60 * 1000;
|
|
335
|
+
|
|
336
|
+
function isCurrentPrompt(prompt, turnId, now) {
|
|
337
|
+
if (prompt.turn_id && turnId) return prompt.turn_id === turnId;
|
|
338
|
+
return now - parseUpdatedAt(prompt.at) <= TYPED_COMMAND_WINDOW_MS;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// B1 follows_typed_command: true once per typed command, for the first skill
|
|
342
|
+
// load whose key matches it in the same prompt (same turn, or within 10
|
|
343
|
+
// minutes when the IDE gives no turn id). The pairing is recorded in
|
|
344
|
+
// last_prompt.matched under a lock so two tool hooks cannot both claim it.
|
|
345
|
+
function claimTypedCommandMatch(sessionId, turnId, skillName, { now = Date.now() } = {}) {
|
|
346
|
+
const key = commandKey(skillName);
|
|
347
|
+
if (!sessionId || !key) return false;
|
|
348
|
+
try {
|
|
349
|
+
const result = withFileLock(sessionLockPath(sessionId), () => {
|
|
350
|
+
const state = readSessionState(sessionId);
|
|
351
|
+
const prompt = state.last_prompt;
|
|
352
|
+
if (!prompt || !Array.isArray(prompt.commands) || !isCurrentPrompt(prompt, turnId, now)) return false;
|
|
353
|
+
const matched = Array.isArray(prompt.matched) ? prompt.matched : [];
|
|
354
|
+
if (matched.includes(key) || !prompt.commands.some(command => commandKey(command.raw_name) === key)) return false;
|
|
355
|
+
writeSessionStateAtomic(sessionId, { ...state, last_prompt: { ...prompt, matched: [...matched, key] } });
|
|
356
|
+
return true;
|
|
357
|
+
}, { waitMs: SESSION_LOCK_WAIT_MS });
|
|
358
|
+
return result.ok && result.value === true;
|
|
359
|
+
} catch {
|
|
360
|
+
// Session file not writable: the field is omitted for this event.
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function readSessionLastPrompt(sessionId) {
|
|
366
|
+
const prompt = readSessionState(sessionId)?.last_prompt;
|
|
367
|
+
return prompt && Array.isArray(prompt.commands) ? prompt : null;
|
|
368
|
+
}
|
|
369
|
+
|
|
292
370
|
function readSessionLastSlashCommand(sessionId) {
|
|
293
371
|
const cmd = readSessionState(sessionId)?.last_slash_command;
|
|
294
372
|
if (!cmd?.command_name) return null;
|
|
@@ -348,6 +426,59 @@ function findRecentSdlcSessionForProject(cwd, maxAgeMs = SESSION_MAX_AGE_MS) {
|
|
|
348
426
|
return best;
|
|
349
427
|
}
|
|
350
428
|
|
|
429
|
+
// ── Session activity index (B9) ──────────────────────────────────────
|
|
430
|
+
// sessions/by-project/<project_hash>.activity.json lists the IDE sessions
|
|
431
|
+
// active in a project in the last 2 hours. Every event except commit_created
|
|
432
|
+
// touches it; the commit hook reads it to name the IDE a commit came from.
|
|
433
|
+
// The v1 by-project/<hash>.json SDLC index is separate and unchanged.
|
|
434
|
+
|
|
435
|
+
const ACTIVITY_WINDOW_MS = 2 * 60 * 60 * 1000;
|
|
436
|
+
|
|
437
|
+
function activityIndexPath(projectHash) {
|
|
438
|
+
return path.join(SESSION_PROJECT_DIR, `${projectHash}.activity.json`);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function readActivityIndex(projectHash) {
|
|
442
|
+
try {
|
|
443
|
+
const sessions = JSON.parse(fs.readFileSync(activityIndexPath(projectHash), 'utf8'))?.sessions;
|
|
444
|
+
return sessions && typeof sessions === 'object' && !Array.isArray(sessions) ? sessions : {};
|
|
445
|
+
} catch {
|
|
446
|
+
// No index yet, or unreadable: no active sessions known.
|
|
447
|
+
return {};
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function recentEntries(sessions, now) {
|
|
452
|
+
return Object.entries(sessions).filter(([, entry]) => now - parseUpdatedAt(entry?.last_active_at) <= ACTIVITY_WINDOW_MS);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function touchSessionActivity(projectHash, sessionId, harness, { now = Date.now() } = {}) {
|
|
456
|
+
if (!projectHash || !sessionId || sessionId === 'unknown') return;
|
|
457
|
+
const filePath = activityIndexPath(projectHash);
|
|
458
|
+
try {
|
|
459
|
+
fs.mkdirSync(SESSION_PROJECT_DIR, { recursive: true });
|
|
460
|
+
withFileLock(`${filePath}.lock`, () => {
|
|
461
|
+
const sessions = Object.fromEntries(recentEntries(readActivityIndex(projectHash), now));
|
|
462
|
+
sessions[sessionId] = { harness, last_active_at: new Date(now).toISOString() };
|
|
463
|
+
const tmpPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
464
|
+
fs.writeFileSync(tmpPath, JSON.stringify({ sessions }));
|
|
465
|
+
fs.renameSync(tmpPath, filePath);
|
|
466
|
+
});
|
|
467
|
+
} catch {
|
|
468
|
+
// Index not writable: commits from this project carry no origin fields.
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Most recent session active in the project in the last 2 hours, or null. */
|
|
473
|
+
function findRecentActiveSessionForProject(projectHash, { now = Date.now() } = {}) {
|
|
474
|
+
if (!projectHash) return null;
|
|
475
|
+
const recent = recentEntries(readActivityIndex(projectHash), now)
|
|
476
|
+
.sort(([, a], [, b]) => parseUpdatedAt(b.last_active_at) - parseUpdatedAt(a.last_active_at));
|
|
477
|
+
if (recent.length === 0) return null;
|
|
478
|
+
const [sessionId, entry] = recent[0];
|
|
479
|
+
return { session_id: sessionId, harness: typeof entry.harness === 'string' ? entry.harness : 'unknown', ambiguous: recent.length > 1 };
|
|
480
|
+
}
|
|
481
|
+
|
|
351
482
|
// ── Short-TTL dedupe guards ──────────────────────────────────────────
|
|
352
483
|
|
|
353
484
|
function normalizeDedupePart(value) {
|
|
@@ -512,6 +643,14 @@ function readLastAssistantFromTranscript(transcriptPath) {
|
|
|
512
643
|
|
|
513
644
|
// ── buildEvent ───────────────────────────────────────────────────────
|
|
514
645
|
|
|
646
|
+
const SCHEMA_VERSION = 2;
|
|
647
|
+
let _hookVersion;
|
|
648
|
+
|
|
649
|
+
function getHookVersion() {
|
|
650
|
+
if (_hookVersion === undefined) _hookVersion = readHookVersion();
|
|
651
|
+
return _hookVersion;
|
|
652
|
+
}
|
|
653
|
+
|
|
515
654
|
function buildEvent(hookInput, eventType, payload) {
|
|
516
655
|
const input = hookInput || {};
|
|
517
656
|
const cfg = loadConfig();
|
|
@@ -540,6 +679,17 @@ function buildEvent(hookInput, eventType, payload) {
|
|
|
540
679
|
|| (input.workspace_roots && input.workspace_roots[0])
|
|
541
680
|
|| null;
|
|
542
681
|
|
|
682
|
+
// A8: the email the IDE itself is signed in with, when the IDE provides one.
|
|
683
|
+
// Cursor passes it in the hook input and to hook scripts; Claude Code and Codex do not.
|
|
684
|
+
const rawAccountEmail = input.user_email || process.env.CURSOR_USER_EMAIL;
|
|
685
|
+
const accountEmail = typeof rawAccountEmail === 'string' && rawAccountEmail ? rawAccountEmail.slice(0, 200) : null;
|
|
686
|
+
|
|
687
|
+
// v2 envelope (spec 4.2). seq is omitted when it cannot be taken safely.
|
|
688
|
+
const hookVersion = getHookVersion();
|
|
689
|
+
const seq = nextSeq(sessionId);
|
|
690
|
+
const projectHash = computeProjectHash(cwd);
|
|
691
|
+
if (eventType !== 'commit_created') touchSessionActivity(projectHash, sessionId, harness);
|
|
692
|
+
|
|
543
693
|
return {
|
|
544
694
|
session_id: sessionId,
|
|
545
695
|
generation_id: generationId,
|
|
@@ -548,13 +698,17 @@ function buildEvent(hookInput, eventType, payload) {
|
|
|
548
698
|
machine_id: cfg.machine_id || 'unknown',
|
|
549
699
|
github_user: git.user || input.user_email || null,
|
|
550
700
|
github_email: git.email || input.user_email || null,
|
|
551
|
-
project_hash:
|
|
701
|
+
project_hash: projectHash,
|
|
552
702
|
aw_version: awCli.version,
|
|
553
703
|
aw_cli_version: awCli.version,
|
|
554
704
|
aw_cli_version_source: awCli.source,
|
|
555
705
|
event: eventType,
|
|
556
706
|
client_ts: new Date().toISOString(),
|
|
557
|
-
|
|
707
|
+
...(accountEmail ? { account_email: accountEmail } : {}),
|
|
708
|
+
event_id: newEventId(),
|
|
709
|
+
schema_version: SCHEMA_VERSION,
|
|
710
|
+
...(hookVersion ? { hook_version: hookVersion } : {}),
|
|
711
|
+
payload: { ...(payload || {}), ...(seq ? { seq } : {}) },
|
|
558
712
|
};
|
|
559
713
|
}
|
|
560
714
|
|
|
@@ -587,8 +741,14 @@ module.exports = {
|
|
|
587
741
|
persistSessionSkill,
|
|
588
742
|
readSessionSkill,
|
|
589
743
|
persistSessionSlashCommand,
|
|
744
|
+
persistSessionPrompt,
|
|
745
|
+
claimTypedCommandMatch,
|
|
746
|
+
readSessionLastPrompt,
|
|
590
747
|
readSessionLastSlashCommand,
|
|
748
|
+
computeProjectHash,
|
|
591
749
|
findRecentSdlcSessionForProject,
|
|
750
|
+
findRecentActiveSessionForProject,
|
|
751
|
+
touchSessionActivity,
|
|
592
752
|
readLastAssistantFromTranscript,
|
|
593
753
|
resolvePromptText,
|
|
594
754
|
getAwCliVersionDetails,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "commonjs",
|
|
3
|
-
"_comment": "Override parent libs/aw/package.json's \"type\": \"module\". The aw-usage hook scripts use require() because
|
|
3
|
+
"_comment": "Override parent libs/aw/package.json's \"type\": \"module\". The aw-usage hook scripts use require() because Claude Code, Cursor, Codex and git run them with `node <path>`. aw copies this file with the scripts to ~/.aw/aw-usage/ so they stay CommonJS there too."
|
|
4
4
|
}
|
package/hooks/codex-home.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
|
|
3
|
+
import { awUsageShellScriptPath } from '../aw-usage-wiring.mjs';
|
|
3
4
|
import { getSupportedHarnessPhaseEntries } from '../hook-manifest.mjs';
|
|
4
5
|
import {
|
|
5
6
|
buildRegistryDelegatingPhaseScriptWithTelemetry,
|
|
@@ -32,7 +33,7 @@ const CODEX_HOME_PHASE_BLUEPRINTS = {
|
|
|
32
33
|
'$HOME/.aw/.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh',
|
|
33
34
|
],
|
|
34
35
|
warningMessage: 'WARNING: AW using-aw-skills hook not found in ~/.aw_registry. Run aw init or aw pull platform.',
|
|
35
|
-
telemetryHookPath: '
|
|
36
|
+
telemetryHookPath: awUsageShellScriptPath('aw-usage-session-start.js'),
|
|
36
37
|
harnessEnv: 'codex',
|
|
37
38
|
});
|
|
38
39
|
},
|
|
@@ -60,7 +61,7 @@ set -euo pipefail
|
|
|
60
61
|
# Capture stdin so we can feed it to both telemetry and the reminder delegate.
|
|
61
62
|
STDIN=$(cat)
|
|
62
63
|
|
|
63
|
-
TELEMETRY_HOOK="$
|
|
64
|
+
TELEMETRY_HOOK="${awUsageShellScriptPath('aw-usage-prompt-submit.js')}"
|
|
64
65
|
if [[ -f "$TELEMETRY_HOOK" ]] && command -v node >/dev/null 2>&1; then
|
|
65
66
|
printf '%s' "$STDIN" | AW_HARNESS=codex node "$TELEMETRY_HOOK" >/dev/null 2>&1 || true
|
|
66
67
|
fi
|
|
@@ -116,9 +117,10 @@ ${this.scriptMarker}
|
|
|
116
117
|
set -euo pipefail
|
|
117
118
|
|
|
118
119
|
# AW PostToolUse phase for Codex — usage telemetry.
|
|
119
|
-
# Codex
|
|
120
|
-
#
|
|
121
|
-
|
|
120
|
+
# The interactive Codex app sends PostToolUse for Bash commands and for
|
|
121
|
+
# apply_patch (codex exec may send none); telemetry derives tool_error, shell
|
|
122
|
+
# skill reads and stage documents from the payload.
|
|
123
|
+
TELEMETRY_HOOK="${awUsageShellScriptPath('aw-usage-post-tool-use.js')}"
|
|
122
124
|
if [[ -f "$TELEMETRY_HOOK" ]]; then
|
|
123
125
|
exec node "$TELEMETRY_HOOK"
|
|
124
126
|
fi
|
|
@@ -148,7 +150,7 @@ set -euo pipefail
|
|
|
148
150
|
# Capture stdin before invoking non-blocking sidecars.
|
|
149
151
|
STDIN=$(cat)
|
|
150
152
|
|
|
151
|
-
TELEMETRY_HOOK="$
|
|
153
|
+
TELEMETRY_HOOK="${awUsageShellScriptPath('aw-usage-stop.js')}"
|
|
152
154
|
if [[ -f "$TELEMETRY_HOOK" ]] && command -v node >/dev/null 2>&1; then
|
|
153
155
|
printf '%s' "$STDIN" | AW_HARNESS=codex node "$TELEMETRY_HOOK" >/dev/null 2>&1 || true
|
|
154
156
|
fi
|
package/hooks.mjs
CHANGED
|
@@ -10,6 +10,9 @@ import { join } from 'node:path';
|
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
11
|
import * as fmt from './fmt.mjs';
|
|
12
12
|
import { AW_CO_AUTHOR } from './constants.mjs';
|
|
13
|
+
import { awUsageShellScriptPath } from './aw-usage-wiring.mjs';
|
|
14
|
+
|
|
15
|
+
const COMMIT_TELEMETRY_HOOK = awUsageShellScriptPath('aw-usage-commit-created.js');
|
|
13
16
|
|
|
14
17
|
const HOME = homedir();
|
|
15
18
|
const HOOKS_DIR = join(HOME, '.aw', 'hooks');
|
|
@@ -97,10 +100,7 @@ fi
|
|
|
97
100
|
|
|
98
101
|
# Fire commit_created telemetry if commit has AW co-author trailer
|
|
99
102
|
if git log -1 --format='%b' HEAD 2>/dev/null | grep -qF "Co-Authored-By: AW"; then
|
|
100
|
-
TELEMETRY_HOOK="$
|
|
101
|
-
if [ ! -f "$TELEMETRY_HOOK" ]; then
|
|
102
|
-
TELEMETRY_HOOK="$HOME/.aw-ecc/scripts/hooks/aw-usage-commit-created.js"
|
|
103
|
-
fi
|
|
103
|
+
TELEMETRY_HOOK="${COMMIT_TELEMETRY_HOOK}"
|
|
104
104
|
if command -v node >/dev/null 2>&1 && [ -f "$TELEMETRY_HOOK" ]; then
|
|
105
105
|
COMMIT_HASH="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
|
|
106
106
|
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
|
|
@@ -281,10 +281,7 @@ case "$(pwd)" in /tmp/aw-*|/var/folders/*/aw-*|*/.aw|*/.aw/*) exit 0 ;; esac
|
|
|
281
281
|
|
|
282
282
|
# Fire commit_created telemetry if commit has AW co-author trailer
|
|
283
283
|
if git log -1 --format='%b' HEAD 2>/dev/null | grep -qF "Co-Authored-By: AW"; then
|
|
284
|
-
TELEMETRY_HOOK="$
|
|
285
|
-
if [ ! -f "$TELEMETRY_HOOK" ]; then
|
|
286
|
-
TELEMETRY_HOOK="$HOME/.aw-ecc/scripts/hooks/aw-usage-commit-created.js"
|
|
287
|
-
fi
|
|
284
|
+
TELEMETRY_HOOK="${COMMIT_TELEMETRY_HOOK}"
|
|
288
285
|
if command -v node >/dev/null 2>&1 && [ -f "$TELEMETRY_HOOK" ]; then
|
|
289
286
|
COMMIT_HASH="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
|
|
290
287
|
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
|