@ghl-ai/aw 0.1.92 → 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,386 @@
|
|
|
1
|
+
// aw-usage-wiring.mjs — single owner of where the aw-usage telemetry hook
|
|
2
|
+
// scripts live on a laptop and how Claude Code is wired to them.
|
|
3
|
+
//
|
|
4
|
+
// This repo (libs/aw/hooks/aw-usage/) is the only source of the aw-usage
|
|
5
|
+
// scripts. They are installed to ~/.aw/aw-usage/{hooks,lib,package.json}
|
|
6
|
+
// and every harness (Claude Code, Codex, git post-commit, and the Cursor
|
|
7
|
+
// adapter in aw-ecc) runs them from there.
|
|
8
|
+
//
|
|
9
|
+
// Both writers of ~/.claude/settings.json — startup.mjs (runs on init, link,
|
|
10
|
+
// pull, startup and post-upgrade) and install-aw-usage-hooks.mjs (runs on
|
|
11
|
+
// init) — use the entry builders below, so they always agree.
|
|
12
|
+
|
|
13
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import * as fmt from './fmt.mjs';
|
|
17
|
+
|
|
18
|
+
const PACKAGE_ROOT = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const BUNDLED_ROOT = join(PACKAGE_ROOT, 'hooks', 'aw-usage');
|
|
20
|
+
|
|
21
|
+
// hook_version (B8, P5): the aw CLI version that installed ~/.aw/aw-usage.
|
|
22
|
+
// The hooks read it from the installed package.json and send it on every event.
|
|
23
|
+
const CLI_VERSION = readJsonVersion(join(PACKAGE_ROOT, 'package.json'));
|
|
24
|
+
|
|
25
|
+
export const AW_USAGE_DIR_SEGMENTS = ['.aw', 'aw-usage'];
|
|
26
|
+
export const AW_USAGE_SHELL_ROOT = '$HOME/.aw/aw-usage';
|
|
27
|
+
|
|
28
|
+
export const AW_USAGE_HOOK_SCRIPTS = [
|
|
29
|
+
'aw-usage-prompt-submit.js',
|
|
30
|
+
'aw-usage-post-tool-use.js',
|
|
31
|
+
'aw-usage-post-tool-use-failure.js',
|
|
32
|
+
'aw-usage-session-start.js',
|
|
33
|
+
'aw-usage-stop.js',
|
|
34
|
+
'aw-usage-telemetry-send.js',
|
|
35
|
+
'aw-usage-commit-created.js',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// The aw-ecc Cursor adapter checks for these lib files, package.json and
|
|
39
|
+
// hooks/aw-usage-telemetry-send.js before using ~/.aw/aw-usage
|
|
40
|
+
// (scripts/cursor-aw-hooks/adapter.js, AW_USAGE_REQUIRED_FILES). Keep in sync.
|
|
41
|
+
export const AW_USAGE_LIB_FILES = ['aw-usage-telemetry.js', 'aw-pricing.js', 'aw-usage-envelope.js', 'aw-usage-queue.js', 'aw-usage-commands.js', 'aw-usage-skill-source.js', 'aw-usage-stage-events.js'];
|
|
42
|
+
|
|
43
|
+
export const CLAUDE_TELEMETRY_DESCRIPTION = 'AW usage telemetry';
|
|
44
|
+
export const CLAUDE_MEMORY_STOP_DESCRIPTION = 'AW-managed: aw-memory stop hooks';
|
|
45
|
+
|
|
46
|
+
// The AW memory scripts still ship with aw-ecc; only telemetry moved here.
|
|
47
|
+
const CLAUDE_MEMORY_STOP_COMMAND =
|
|
48
|
+
'STDIN="$(cat)"; ' +
|
|
49
|
+
'test -f "$HOME/.aw-ecc/scripts/hooks/aw-memory-intent-capture.js" && printf \'%s\' "$STDIN" | node "$HOME/.aw-ecc/scripts/hooks/aw-memory-intent-capture.js" || true; ' +
|
|
50
|
+
'test -f "$HOME/.aw-ecc/scripts/hooks/aw-memory-sync.js" && printf \'%s\' "$STDIN" | node "$HOME/.aw-ecc/scripts/hooks/aw-memory-sync.js" || true';
|
|
51
|
+
|
|
52
|
+
const CLAUDE_TELEMETRY_PHASES = [
|
|
53
|
+
{ phase: 'SessionStart', script: 'aw-usage-session-start.js' },
|
|
54
|
+
{ phase: 'UserPromptSubmit', script: 'aw-usage-prompt-submit.js' },
|
|
55
|
+
{ phase: 'PostToolUse', script: 'aw-usage-post-tool-use.js', matcher: '*' },
|
|
56
|
+
{ phase: 'PostToolUseFailure', script: 'aw-usage-post-tool-use-failure.js', matcher: '*' },
|
|
57
|
+
{ phase: 'Stop', script: 'aw-usage-stop.js', matcher: '*' },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
// ── Paths ────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export function awUsageRoot(homeDir) {
|
|
63
|
+
return join(homeDir, ...AW_USAGE_DIR_SEGMENTS);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function awUsageShellScriptPath(scriptName) {
|
|
67
|
+
return `${AW_USAGE_SHELL_ROOT}/hooks/${scriptName}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Copy step ────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
// Tri-state: 'copied' wrote bytes, 'unchanged' already identical, 'failed'
|
|
73
|
+
// source missing or the read/write threw (permissions, ENOTDIR, EBUSY…).
|
|
74
|
+
function copyIfChanged(src, dest) {
|
|
75
|
+
if (!existsSync(src)) return 'failed';
|
|
76
|
+
try {
|
|
77
|
+
if (existsSync(dest) && readFileSync(src).equals(readFileSync(dest))) return 'unchanged';
|
|
78
|
+
copyFileSync(src, dest);
|
|
79
|
+
return 'copied';
|
|
80
|
+
} catch {
|
|
81
|
+
return 'failed';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readJsonVersion(path) {
|
|
86
|
+
try {
|
|
87
|
+
const version = JSON.parse(readFileSync(path, 'utf8')).version;
|
|
88
|
+
return typeof version === 'string' && version ? version : null;
|
|
89
|
+
} catch {
|
|
90
|
+
// Missing or unreadable: no version known; callers treat null as "not stamped".
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// package.json is the bundled file plus "version": cliVersion.
|
|
96
|
+
function writeStampedPackageJson(src, dest, cliVersion) {
|
|
97
|
+
try {
|
|
98
|
+
const content = `${JSON.stringify({ ...JSON.parse(readFileSync(src, 'utf8')), version: cliVersion }, null, 2)}\n`;
|
|
99
|
+
if (existsSync(dest) && readFileSync(dest, 'utf8') === content) return 'unchanged';
|
|
100
|
+
// Not atomic: a hook reading mid-write sends no hook_version for that one event.
|
|
101
|
+
writeFileSync(dest, content);
|
|
102
|
+
return 'copied';
|
|
103
|
+
} catch {
|
|
104
|
+
// Reported as 'failed' to the caller, like every other copy failure.
|
|
105
|
+
return 'failed';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function ensureDir(dir) {
|
|
110
|
+
try {
|
|
111
|
+
mkdirSync(dir, { recursive: true });
|
|
112
|
+
return true;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Copy the bundled aw-usage scripts to ~/.aw/aw-usage/. Idempotent.
|
|
120
|
+
* package.json is written with "version": cliVersion (the running aw CLI).
|
|
121
|
+
* @param {string} homeDir
|
|
122
|
+
* @param {{ cliVersion?: string | null }} [opts]
|
|
123
|
+
* @returns {{ copied: number, unchanged: number, failed: number, missing: string[],
|
|
124
|
+
* byDir: Record<'hooks'|'lib'|'root', { copied: number, unchanged: number, failed: number }> }}
|
|
125
|
+
* `missing` lists relative paths that could not be written.
|
|
126
|
+
*/
|
|
127
|
+
export function copyAwUsageScripts(homeDir, { cliVersion = CLI_VERSION } = {}) {
|
|
128
|
+
const root = awUsageRoot(homeDir);
|
|
129
|
+
const result = { copied: 0, unchanged: 0, failed: 0, missing: [], byDir: {} };
|
|
130
|
+
const groups = [
|
|
131
|
+
{ key: 'hooks', dir: 'hooks', files: AW_USAGE_HOOK_SCRIPTS },
|
|
132
|
+
{ key: 'lib', dir: 'lib', files: AW_USAGE_LIB_FILES },
|
|
133
|
+
{ key: 'root', dir: '', files: ['package.json'] },
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
for (const { key, dir, files } of groups) {
|
|
137
|
+
const counts = { copied: 0, unchanged: 0, failed: 0 };
|
|
138
|
+
result.byDir[key] = counts;
|
|
139
|
+
const destDir = dir ? join(root, dir) : root;
|
|
140
|
+
const dirReady = ensureDir(destDir);
|
|
141
|
+
for (const name of files) {
|
|
142
|
+
const rel = dir ? `${dir}/${name}` : name;
|
|
143
|
+
const src = join(BUNDLED_ROOT, dir, name);
|
|
144
|
+
const dest = join(destDir, name);
|
|
145
|
+
let outcome = 'failed';
|
|
146
|
+
if (dirReady) {
|
|
147
|
+
outcome = key === 'root' && cliVersion
|
|
148
|
+
? writeStampedPackageJson(src, dest, cliVersion)
|
|
149
|
+
: copyIfChanged(src, dest);
|
|
150
|
+
}
|
|
151
|
+
result[outcome]++;
|
|
152
|
+
counts[outcome]++;
|
|
153
|
+
if (outcome === 'failed') result.missing.push(rel);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Relative paths under ~/.aw/aw-usage/ that are absent. The scripts only work
|
|
161
|
+
* as a set (hooks require lib/, lib/ needs package.json), so wiring treats
|
|
162
|
+
* copy A as installed only when this is empty.
|
|
163
|
+
*/
|
|
164
|
+
export function missingAwUsageFiles(homeDir) {
|
|
165
|
+
const root = awUsageRoot(homeDir);
|
|
166
|
+
return [
|
|
167
|
+
...AW_USAGE_HOOK_SCRIPTS.map(name => `hooks/${name}`),
|
|
168
|
+
...AW_USAGE_LIB_FILES.map(name => `lib/${name}`),
|
|
169
|
+
'package.json',
|
|
170
|
+
].filter(rel => !isFile(join(root, ...rel.split('/'))));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The CLI version stamped in ~/.aw/aw-usage/package.json, or null. */
|
|
174
|
+
export function installedAwUsageVersion(homeDir) {
|
|
175
|
+
return readJsonVersion(join(awUsageRoot(homeDir), 'package.json'));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* B8: an installed hook version different from the running CLI means the
|
|
180
|
+
* refresh did not land. Copy once more; if the stamp is still wrong, warn
|
|
181
|
+
* once. Never throws.
|
|
182
|
+
* @returns {boolean} true when the installed version matches
|
|
183
|
+
*/
|
|
184
|
+
export function ensureAwUsageVersion(homeDir, { cliVersion = CLI_VERSION } = {}) {
|
|
185
|
+
if (!cliVersion || installedAwUsageVersion(homeDir) === cliVersion) return true;
|
|
186
|
+
copyAwUsageScripts(homeDir, { cliVersion });
|
|
187
|
+
const installed = installedAwUsageVersion(homeDir);
|
|
188
|
+
if (installed === cliVersion) return true;
|
|
189
|
+
warnOnce('aw-usage-version', `aw-usage hooks in ${awUsageRoot(homeDir)} are version ${installed ?? 'unknown'}, `
|
|
190
|
+
+ `but this aw CLI is ${cliVersion}, and AW could not update them. `
|
|
191
|
+
+ 'Telemetry keeps using the old hooks. Check that folder\'s permissions, then run aw init.');
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isFile(path) {
|
|
196
|
+
try {
|
|
197
|
+
return statSync(path).isFile();
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Warnings (printed once per process; init runs both Claude writers) ─
|
|
204
|
+
|
|
205
|
+
const warned = new Set();
|
|
206
|
+
|
|
207
|
+
function warnOnce(key, message) {
|
|
208
|
+
if (warned.has(key)) return;
|
|
209
|
+
warned.add(key);
|
|
210
|
+
fmt.logWarn(message);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function warnIncompleteAwUsageInstall(missing) {
|
|
214
|
+
warnOnce('aw-usage-incomplete', `aw-usage telemetry files are missing from ~/.aw/aw-usage (${missing.join(', ')}). `
|
|
215
|
+
+ 'Existing Claude telemetry hooks were kept. Check that folder\'s permissions, then run aw init.');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function warnStuckAwUsageFiles(stuck) {
|
|
219
|
+
warnOnce('aw-usage-stuck', `AW could not update these files in ~/.aw/aw-usage (${stuck.join(', ')}). `
|
|
220
|
+
+ 'They are still the old version. Existing Claude telemetry hooks were kept. Check that folder\'s permissions, then run aw init.');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function warnUnreadableClaudeSettings(settingsPath) {
|
|
224
|
+
warnOnce(`unreadable:${settingsPath}`, `${settingsPath} is not valid JSON. `
|
|
225
|
+
+ 'AW left it unchanged and did not update its hooks. Fix the file, then run aw init.');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Claude settings.json entries ─────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
export function claudeTelemetryPhases() {
|
|
231
|
+
return CLAUDE_TELEMETRY_PHASES.map(def => ({ ...def }));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function buildClaudeTelemetryEntry(phaseDef) {
|
|
235
|
+
const entry = {
|
|
236
|
+
hooks: [{
|
|
237
|
+
type: 'command',
|
|
238
|
+
command: `node "${awUsageShellScriptPath(phaseDef.script)}"`,
|
|
239
|
+
async: true,
|
|
240
|
+
timeout: 10,
|
|
241
|
+
}],
|
|
242
|
+
description: CLAUDE_TELEMETRY_DESCRIPTION,
|
|
243
|
+
};
|
|
244
|
+
if (phaseDef.matcher) entry.matcher = phaseDef.matcher;
|
|
245
|
+
return entry;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function buildClaudeMemoryStopEntry() {
|
|
249
|
+
return {
|
|
250
|
+
hooks: [{ type: 'command', command: CLAUDE_MEMORY_STOP_COMMAND }],
|
|
251
|
+
description: CLAUDE_MEMORY_STOP_DESCRIPTION,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function entryCommands(entry) {
|
|
256
|
+
return Array.isArray(entry?.hooks)
|
|
257
|
+
? entry.hooks.map(hook => (typeof hook?.command === 'string' ? hook.command : ''))
|
|
258
|
+
: [];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Every place an aw-usage script has been installed from:
|
|
262
|
+
// <root>/scripts/hooks/<name> (aw-ecc, ${CLAUDE_PLUGIN_ROOT}, ~/.claude/scripts)
|
|
263
|
+
// and ~/.aw/aw-usage/hooks/<name>. AW memory scripts have only ever been
|
|
264
|
+
// wired from ~/.aw-ecc/scripts/hooks/. Anchoring on these paths keeps user and
|
|
265
|
+
// project hooks with similar names out of reach.
|
|
266
|
+
const AW_USAGE_SCRIPT_PATTERN = /\/(?:scripts\/hooks|aw-usage\/hooks)\/aw-usage-[a-z-]+\.js/;
|
|
267
|
+
const AW_MEMORY_SCRIPT_PATTERN = /\/\.aw-ecc\/scripts\/hooks\/aw-memory-[a-z-]+\.js/;
|
|
268
|
+
const COPY_A_SCRIPT_PATTERN = /\/aw-usage\/hooks\/aw-usage-[a-z-]+\.js/;
|
|
269
|
+
|
|
270
|
+
function commandsMatch(entry, pattern) {
|
|
271
|
+
return entryCommands(entry).some(command => pattern.test(command.split('\\').join('/')));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** True for the canonical telemetry entry and every legacy aw-usage entry
|
|
275
|
+
* (aw-ecc, ${CLAUDE_PLUGIN_ROOT}, ~/.claude/scripts, combined Stop). */
|
|
276
|
+
export function isClaudeTelemetryEntry(entry) {
|
|
277
|
+
if (entry?.description === CLAUDE_TELEMETRY_DESCRIPTION) return true;
|
|
278
|
+
return commandsMatch(entry, AW_USAGE_SCRIPT_PATTERN);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** True for the memory-only Stop entry, described or not. A combined legacy
|
|
282
|
+
* entry that also runs aw-usage telemetry is a telemetry entry instead. */
|
|
283
|
+
export function isClaudeMemoryStopEntry(entry) {
|
|
284
|
+
if (entry?.description === CLAUDE_MEMORY_STOP_DESCRIPTION) return true;
|
|
285
|
+
if (isClaudeTelemetryEntry(entry)) return false;
|
|
286
|
+
return commandsMatch(entry, AW_MEMORY_SCRIPT_PATTERN);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Merge / remove (pure; return new objects) ────────────────────────
|
|
290
|
+
|
|
291
|
+
// A managed entry can also hold the user's own commands next to the AW ones.
|
|
292
|
+
// Returns the entry with only the user's commands, or null when none are
|
|
293
|
+
// left. An entry carrying an AW description belongs to AW and is dropped whole.
|
|
294
|
+
function userPart(entry, pattern) {
|
|
295
|
+
if (entry?.description === CLAUDE_TELEMETRY_DESCRIPTION || entry?.description === CLAUDE_MEMORY_STOP_DESCRIPTION) return null;
|
|
296
|
+
const hooks = Array.isArray(entry?.hooks)
|
|
297
|
+
? entry.hooks.filter(hook => !(typeof hook?.command === 'string' && pattern.test(hook.command.split('\\').join('/'))))
|
|
298
|
+
: [];
|
|
299
|
+
return hooks.length > 0 ? { ...entry, hooks } : null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function removeManaged(entries, isManaged, pattern) {
|
|
303
|
+
return entries.flatMap(entry => {
|
|
304
|
+
if (!isManaged(entry)) return [entry];
|
|
305
|
+
const rest = userPart(entry, pattern);
|
|
306
|
+
return rest ? [rest] : [];
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Keeps unmanaged entries in order. The first managed entry is replaced in
|
|
311
|
+
// place by `desired`; any further managed entries are dropped. The user's own
|
|
312
|
+
// commands in a managed entry are kept, after it. When nothing managed exists,
|
|
313
|
+
// `desired` is appended.
|
|
314
|
+
function upsertManaged(entries, isManaged, pattern, desired) {
|
|
315
|
+
const out = [];
|
|
316
|
+
let placed = false;
|
|
317
|
+
for (const entry of entries) {
|
|
318
|
+
if (!isManaged(entry)) {
|
|
319
|
+
out.push(entry);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (!placed) {
|
|
323
|
+
out.push(desired);
|
|
324
|
+
placed = true;
|
|
325
|
+
}
|
|
326
|
+
const rest = userPart(entry, pattern);
|
|
327
|
+
if (rest) out.push(rest);
|
|
328
|
+
}
|
|
329
|
+
if (!placed) out.push(desired);
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function phaseEntries(hooks, phase) {
|
|
334
|
+
return Array.isArray(hooks?.[phase]) ? hooks[phase] : [];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function withPhase(hooks, phase, entries) {
|
|
338
|
+
const next = { ...hooks };
|
|
339
|
+
if (entries.length > 0) next[phase] = entries;
|
|
340
|
+
else delete next[phase];
|
|
341
|
+
return next;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Return a new Claude `hooks` object. Unrelated hooks keep their order.
|
|
346
|
+
*
|
|
347
|
+
* installed (every ~/.aw/aw-usage file present): exactly one aw-usage
|
|
348
|
+
* telemetry entry per phase and exactly one AW memory Stop entry.
|
|
349
|
+
*
|
|
350
|
+
* not installed (a copy failed): only entries pointing at ~/.aw/aw-usage are
|
|
351
|
+
* removed, since they would crash. Older entries that still work (aw-ecc, a
|
|
352
|
+
* combined telemetry + memory Stop) stay exactly as they are, and the memory
|
|
353
|
+
* entry is added only when nothing already runs the memory scripts.
|
|
354
|
+
* @param {Record<string, unknown[]>} hooks
|
|
355
|
+
* @param {{ installed: boolean }} opts
|
|
356
|
+
*/
|
|
357
|
+
export function mergeClaudeAwUsageHooks(hooks, { installed }) {
|
|
358
|
+
let next = hooks && typeof hooks === 'object' && !Array.isArray(hooks) ? { ...hooks } : {};
|
|
359
|
+
if (!installed) {
|
|
360
|
+
for (const def of CLAUDE_TELEMETRY_PHASES) {
|
|
361
|
+
next = withPhase(next, def.phase, removeManaged(phaseEntries(next, def.phase), entry => commandsMatch(entry, COPY_A_SCRIPT_PATTERN), COPY_A_SCRIPT_PATTERN));
|
|
362
|
+
}
|
|
363
|
+
const stop = phaseEntries(next, 'Stop');
|
|
364
|
+
const memoryRuns = stop.some(entry => isClaudeMemoryStopEntry(entry) || commandsMatch(entry, AW_MEMORY_SCRIPT_PATTERN));
|
|
365
|
+
return memoryRuns ? next : withPhase(next, 'Stop', [...stop, buildClaudeMemoryStopEntry()]);
|
|
366
|
+
}
|
|
367
|
+
for (const def of CLAUDE_TELEMETRY_PHASES) {
|
|
368
|
+
next = withPhase(next, def.phase, upsertManaged(phaseEntries(next, def.phase), isClaudeTelemetryEntry, AW_USAGE_SCRIPT_PATTERN, buildClaudeTelemetryEntry(def)));
|
|
369
|
+
}
|
|
370
|
+
return withPhase(next, 'Stop', upsertManaged(phaseEntries(next, 'Stop'), isClaudeMemoryStopEntry, AW_MEMORY_SCRIPT_PATTERN, buildClaudeMemoryStopEntry()));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Return a new Claude `hooks` object without aw-usage telemetry or AW memory
|
|
374
|
+
* Stop entries. Only the five telemetry phases are cleaned; aw-usage entries
|
|
375
|
+
* under any other event are left alone, as they were never AW-managed. */
|
|
376
|
+
export function removeClaudeAwUsageHooks(hooks) {
|
|
377
|
+
let next = hooks && typeof hooks === 'object' && !Array.isArray(hooks) ? { ...hooks } : {};
|
|
378
|
+
for (const def of CLAUDE_TELEMETRY_PHASES) {
|
|
379
|
+
if (!Array.isArray(next[def.phase])) continue;
|
|
380
|
+
next = withPhase(next, def.phase, removeManaged(phaseEntries(next, def.phase), isClaudeTelemetryEntry, AW_USAGE_SCRIPT_PATTERN));
|
|
381
|
+
}
|
|
382
|
+
if (Array.isArray(next.Stop)) {
|
|
383
|
+
next = withPhase(next, 'Stop', removeManaged(phaseEntries(next, 'Stop'), isClaudeMemoryStopEntry, AW_MEMORY_SCRIPT_PATTERN));
|
|
384
|
+
}
|
|
385
|
+
return next;
|
|
386
|
+
}
|
package/codex.mjs
CHANGED
|
@@ -17,6 +17,8 @@ const CLAUDE_PROMPT_COMMAND = 'bash "${CLAUDE_PROJECT_DIR}/scripts/hooks/session
|
|
|
17
17
|
const CONFIG_FILE_MARKER = '# aw-managed-file: codex-ecc-baseline';
|
|
18
18
|
const HOOK_LINE_MARKER = '# aw-managed: codex_hooks';
|
|
19
19
|
const HOOK_LINE_REPLACED_MARKER = '# aw-managed: codex_hooks restored-from-false';
|
|
20
|
+
const HOOKS_FEATURE_MARKER = '# aw-managed: hooks';
|
|
21
|
+
const FEATURES_LINES = ['[features]', 'hooks = true', 'codex_hooks = true'];
|
|
20
22
|
const FEATURES_BLOCK_NAME = 'features';
|
|
21
23
|
const AW_SESSION_START_MARKER = '# aw-managed: codex-aw-session-start';
|
|
22
24
|
const SUPPORTED_CODEX_WORKSPACE_PHASES = new Set(
|
|
@@ -207,7 +209,7 @@ function ensureCodexConfig(filePath) {
|
|
|
207
209
|
}
|
|
208
210
|
|
|
209
211
|
let next = existing;
|
|
210
|
-
next =
|
|
212
|
+
next = ensureCodexHooksFeature(next, { marked: true });
|
|
211
213
|
next = ensureManagedServerBlocks(next);
|
|
212
214
|
|
|
213
215
|
if (next === existing) {
|
|
@@ -673,6 +675,10 @@ function removeManagedCodexConfig(filePath) {
|
|
|
673
675
|
new RegExp(`^\\s*codex_hooks\\s*=\\s*true\\s*${escapeRegExp(HOOK_LINE_REPLACED_MARKER)}\\s*$\\n?`, 'm'),
|
|
674
676
|
'codex_hooks = false\n'
|
|
675
677
|
);
|
|
678
|
+
next = next.replace(
|
|
679
|
+
new RegExp(`^\\s*hooks\\s*=\\s*true\\s*${escapeRegExp(HOOKS_FEATURE_MARKER)}\\s*$\\n?`, 'm'),
|
|
680
|
+
''
|
|
681
|
+
);
|
|
676
682
|
|
|
677
683
|
next = tidyToml(next);
|
|
678
684
|
|
|
@@ -703,25 +709,74 @@ function ensureManagedServerBlocks(content) {
|
|
|
703
709
|
return tidyToml(next);
|
|
704
710
|
}
|
|
705
711
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
712
|
+
// ── Codex hooks feature flag ─────────────────────────────────────────
|
|
713
|
+
// Codex runs hooks only when [features] enables them. Codex 0.155.1 reads
|
|
714
|
+
// `hooks` and warns that the older `codex_hooks` is deprecated; older Codex
|
|
715
|
+
// versions read only `codex_hooks`. AW writes both and treats either as
|
|
716
|
+
// enabled. A `hooks = false` the user wrote wins and is never overridden.
|
|
717
|
+
// Used by this file (workspace config, aw-managed markers) and startup.mjs
|
|
718
|
+
// (~/.codex/config.toml, no markers) so both writers agree.
|
|
719
|
+
|
|
720
|
+
const FEATURES_HEADER = /^\[features\][ \t]*(?:#[^\r\n]*)?\r?$/m;
|
|
721
|
+
// `features.hooks = …` or `features = { … }`: a form this writer does not edit.
|
|
722
|
+
const FEATURES_OTHER_FORM = /^[ \t]*features[ \t]*[.=]/m;
|
|
723
|
+
|
|
724
|
+
// [start, end) of the [features] table body, or null when there is none.
|
|
725
|
+
function featuresBody(content) {
|
|
726
|
+
const header = content.match(FEATURES_HEADER);
|
|
727
|
+
if (!header || header.index === undefined) return null;
|
|
728
|
+
const start = header.index + header[0].length;
|
|
729
|
+
const nextTable = content.slice(start).search(/^[ \t]*\[/m);
|
|
730
|
+
return { start, end: nextTable === -1 ? content.length : start + nextTable };
|
|
731
|
+
}
|
|
710
732
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
733
|
+
// true / false for `key = true|false` in [features]; 'other' for any other
|
|
734
|
+
// value (left to the user); null when the key is absent.
|
|
735
|
+
function featureFlag(content, key) {
|
|
736
|
+
const dotted = content.match(new RegExp(`^[ \\t]*features\\.${key}[ \\t]*=[ \\t]*(true|false)\\b`, 'm'));
|
|
737
|
+
if (dotted) return dotted[1] === 'true';
|
|
738
|
+
const body = featuresBody(content);
|
|
739
|
+
if (!body) return null;
|
|
740
|
+
const match = content.slice(body.start, body.end).match(new RegExp(`^[ \\t]*${key}[ \\t]*=[ \\t]*(\\S*)`, 'm'));
|
|
741
|
+
if (!match) return null;
|
|
742
|
+
if (/^true\b/.test(match[1])) return true;
|
|
743
|
+
if (/^false\b/.test(match[1])) return false;
|
|
744
|
+
return 'other';
|
|
745
|
+
}
|
|
717
746
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
747
|
+
export function codexHooksFeatureEnabled(content) {
|
|
748
|
+
const hooks = featureFlag(content, 'hooks');
|
|
749
|
+
if (hooks === true || hooks === false) return hooks;
|
|
750
|
+
return featureFlag(content, 'codex_hooks') === true;
|
|
751
|
+
}
|
|
723
752
|
|
|
724
|
-
|
|
753
|
+
/**
|
|
754
|
+
* Return content with Codex hooks enabled under both feature names.
|
|
755
|
+
* @param {string} content config.toml text
|
|
756
|
+
* @param {{ marked?: boolean }} [opts] marked: tag added lines as aw-managed so remove can undo them
|
|
757
|
+
*/
|
|
758
|
+
export function ensureCodexHooksFeature(content, { marked = false } = {}) {
|
|
759
|
+
if (featureFlag(content, 'hooks') === false || FEATURES_OTHER_FORM.test(content)) return content;
|
|
760
|
+
const tag = marker => (marked ? ` ${marker}` : '');
|
|
761
|
+
|
|
762
|
+
let next = content;
|
|
763
|
+
if (featureFlag(next, 'codex_hooks') === false) {
|
|
764
|
+
const body = featuresBody(next);
|
|
765
|
+
const section = next.slice(body.start, body.end)
|
|
766
|
+
.replace(/^[ \t]*codex_hooks[ \t]*=[ \t]*false\b[^\r\n]*/m, `codex_hooks = true${tag(HOOK_LINE_REPLACED_MARKER)}`);
|
|
767
|
+
next = next.slice(0, body.start) + section + next.slice(body.end);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const missing = [];
|
|
771
|
+
if (featureFlag(next, 'hooks') === null) missing.push(`hooks = true${tag(HOOKS_FEATURE_MARKER)}`);
|
|
772
|
+
if (featureFlag(next, 'codex_hooks') === null) missing.push(`codex_hooks = true${tag(HOOK_LINE_MARKER)}`);
|
|
773
|
+
if (missing.length === 0) return next;
|
|
774
|
+
|
|
775
|
+
const body = featuresBody(next);
|
|
776
|
+
if (body) return `${next.slice(0, body.start)}\n${missing.join('\n')}${next.slice(body.start)}`;
|
|
777
|
+
if (marked) return tidyToml(`${next.trimEnd()}\n\n${buildManagedBlock(FEATURES_BLOCK_NAME, FEATURES_LINES)}\n`);
|
|
778
|
+
const trimmed = next.trimEnd();
|
|
779
|
+
return `${trimmed ? `${trimmed}\n\n` : ''}${FEATURES_LINES.join('\n')}\n`;
|
|
725
780
|
}
|
|
726
781
|
|
|
727
782
|
function buildManagedConfigFile() {
|
|
@@ -739,7 +794,7 @@ function buildManagedConfigFile() {
|
|
|
739
794
|
lines.push('', ...buildManagedBlockLines(serverBlockName(server.name), server.lines));
|
|
740
795
|
}
|
|
741
796
|
|
|
742
|
-
lines.push('', ...buildManagedBlockLines(FEATURES_BLOCK_NAME,
|
|
797
|
+
lines.push('', ...buildManagedBlockLines(FEATURES_BLOCK_NAME, FEATURES_LINES));
|
|
743
798
|
return `${lines.join('\n')}\n`;
|
|
744
799
|
}
|
|
745
800
|
|
|
@@ -827,7 +882,7 @@ function pruneEmptyDir(dirPath) {
|
|
|
827
882
|
|
|
828
883
|
function isLegacyHookOnlyConfig(content) {
|
|
829
884
|
const trimmed = content.trim();
|
|
830
|
-
return trimmed === '[features]\ncodex_hooks = true';
|
|
885
|
+
return trimmed === '[features]\ncodex_hooks = true' || trimmed === FEATURES_LINES.join('\n');
|
|
831
886
|
}
|
|
832
887
|
|
|
833
888
|
function escapeRegExp(value) {
|
package/commands/c4.mjs
CHANGED
|
@@ -101,11 +101,13 @@ function safeReaddirSync(dir) {
|
|
|
101
101
|
try { return readdirSync(dir); } catch { return []; }
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
function safeListNamespaceDirs(dir) {
|
|
104
|
+
function safeListNamespaceDirs(dir, fs = {}) {
|
|
105
|
+
const readdir = fs.readdirSync ?? readdirSync;
|
|
106
|
+
const stat = fs.statSync ?? statSync;
|
|
105
107
|
try {
|
|
106
|
-
return
|
|
108
|
+
return readdir(dir).filter((entry) => {
|
|
107
109
|
if (entry.startsWith('.')) return false;
|
|
108
|
-
try { return
|
|
110
|
+
try { return stat(join(dir, entry)).isDirectory(); } catch { return false; }
|
|
109
111
|
});
|
|
110
112
|
} catch { return []; }
|
|
111
113
|
}
|
|
@@ -411,11 +413,11 @@ export async function c4Command(rawArgs, overrides = {}) {
|
|
|
411
413
|
// check, so we specifically look for directories (namespaces like
|
|
412
414
|
// "platform") to confirm the registry content was actually pulled.
|
|
413
415
|
{
|
|
414
|
-
const registryNamespaces = safeListNamespaceDirs(awRegistry);
|
|
416
|
+
const registryNamespaces = safeListNamespaceDirs(awRegistry, fs);
|
|
415
417
|
if (registryNamespaces.length === 0) {
|
|
416
418
|
writer.stderr('[aw-c4] registry has no namespace directories after init — retrying with aw pull\n');
|
|
417
419
|
const pullRes = spawnSync('aw', ['pull'], { stdio: 'pipe' });
|
|
418
|
-
if (pullRes?.status !== 0 || safeListNamespaceDirs(awRegistry).length === 0) {
|
|
420
|
+
if (pullRes?.status !== 0 || safeListNamespaceDirs(awRegistry, fs).length === 0) {
|
|
419
421
|
writer.stderr('[aw-c4] FATAL: registry commands were not fetched\n');
|
|
420
422
|
return exit(1);
|
|
421
423
|
}
|