@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.
@@ -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 = ensureCodexHookEnabled(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
- function ensureCodexHookEnabled(content) {
707
- if (/^\s*codex_hooks\s*=\s*true\b/m.test(content)) {
708
- return content;
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
- if (/^\s*codex_hooks\s*=\s*false\b/m.test(content)) {
712
- return content.replace(
713
- /^\s*codex_hooks\s*=\s*false\b.*$/m,
714
- `codex_hooks = true ${HOOK_LINE_REPLACED_MARKER}`
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
- const featuresHeaderMatch = content.match(/^\[features\]\s*$/m);
719
- if (featuresHeaderMatch && featuresHeaderMatch.index !== undefined) {
720
- const insertAt = featuresHeaderMatch.index + featuresHeaderMatch[0].length;
721
- return `${content.slice(0, insertAt)}\ncodex_hooks = true ${HOOK_LINE_MARKER}${content.slice(insertAt)}`;
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
- return tidyToml(`${content.trimEnd()}\n\n${buildManagedBlock(FEATURES_BLOCK_NAME, ['[features]', 'codex_hooks = true'])}\n`);
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, ['[features]', 'codex_hooks = true']));
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 readdirSync(dir).filter((entry) => {
108
+ return readdir(dir).filter((entry) => {
107
109
  if (entry.startsWith('.')) return false;
108
- try { return statSync(join(dir, entry)).isDirectory(); } catch { return false; }
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
  }