@ghl-ai/aw 0.1.42-beta.4 → 0.1.42-beta.40

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 CHANGED
@@ -18,9 +18,10 @@ import { execSync } from 'node:child_process';
18
18
  import { join, dirname, sep } from 'node:path';
19
19
  import { homedir } from 'node:os';
20
20
  import { fileURLToPath } from 'node:url';
21
+ import * as p from '@clack/prompts';
21
22
  import * as config from '../config.mjs';
22
23
  import * as fmt from '../fmt.mjs';
23
- import { chalk } from '../fmt.mjs';
24
+ import { chalk, setSilent } from '../fmt.mjs';
24
25
  import { linkWorkspace } from '../link.mjs';
25
26
  import { generateCommands, copyInstructions, initAwDocs, syncHomeHarnessInstructions } from '../integrate.mjs';
26
27
  import { setupMcp } from '../mcp.mjs';
@@ -28,8 +29,10 @@ import { applyStoredStartupPreferences, ensureAwRuntimeHook } from '../startup.m
28
29
  import { installLocalCommitHook } from '../hooks.mjs';
29
30
  import { autoUpdate, promptUpdate } from '../update.mjs';
30
31
  import { installGlobalHooks } from '../hooks.mjs';
31
- import { installAwEcc } from '../ecc.mjs';
32
+ import { loadConfig as ensureTelemetryConfig } from '../telemetry.mjs';
33
+ import { installAwEcc, AW_ECC_TAG } from '../ecc.mjs';
32
34
  import { removeWorkspaceHookDefaults } from '../codex.mjs';
35
+ import { readHookManifest, pruneStaleHooks, writeHookManifest } from '../hook-cleanup.mjs';
33
36
  import {
34
37
  initPersistentClone,
35
38
  isValidClone,
@@ -177,11 +180,11 @@ function installIdeTasks() {
177
180
  existing.tasks = existing.tasks || [];
178
181
  existing.tasks.push(vscodeTask.tasks[0]);
179
182
  writeFileSync(tasksPath, JSON.stringify(existing, null, 2) + '\n');
180
- fmt.logStep(`Added auto-pull task to ${ide}`);
183
+ fmt.logStep(`Auto-sync task added to ${ide}`);
181
184
  } catch { /* corrupted tasks.json, skip */ }
182
185
  } else {
183
186
  writeFileSync(tasksPath, JSON.stringify(vscodeTask, null, 2) + '\n');
184
- fmt.logStep(`Added auto-pull task to ${ide}`);
187
+ fmt.logStep(`Auto-sync task added to ${ide}`);
185
188
  }
186
189
  }
187
190
  }
@@ -193,6 +196,16 @@ export async function initCommand(args) {
193
196
  let user = args['--user'] || '';
194
197
  const silent = args['--silent'] === true;
195
198
 
199
+ // In silent mode, suppress ALL fmt output and show a single spinner.
200
+ // setSilent(true) makes every fmt.* call a no-op — internal functions
201
+ // (hooks.mjs, mcp.mjs, integrate.mjs, ecc.mjs) are silenced automatically.
202
+ let silentSpinner = null;
203
+ if (silent) {
204
+ setSilent(true);
205
+ silentSpinner = p.spinner();
206
+ silentSpinner.start('Initializing...');
207
+ }
208
+
196
209
  fmt.intro(`aw init ${chalk.dim('v' + VERSION)}`);
197
210
 
198
211
  // ── Validate ──────────────────────────────────────────────────────────
@@ -323,6 +336,7 @@ export async function initCommand(args) {
323
336
  syncRulesTargets(cwd);
324
337
  }
325
338
  removeLegacyRegistryRules();
339
+ if (!silent) fmt.logStep('Rules synced');
326
340
 
327
341
  // Ensure project worktree sparse checkout matches the global clone.
328
342
  // Covers the case where a namespace was added from HOME (or another project)
@@ -332,13 +346,19 @@ export async function initCommand(args) {
332
346
  try { syncWorktreeSparseCheckout(AW_HOME, localAw); } catch { /* best effort */ }
333
347
  }
334
348
 
349
+ // Prune stale hooks from prior version before installing new ones
350
+ const oldManifest = readHookManifest();
351
+ if (oldManifest) pruneStaleHooks(oldManifest);
352
+
335
353
  await installAwEcc(cwd, { silent });
354
+
336
355
  ensureAwRuntimeHook(HOME);
337
356
  syncHomeAndProjectInstructions(cwd, freshCfg?.namespace || team);
338
357
  await setupMcp(HOME, freshCfg?.namespace || team, { silent });
339
358
  applyStoredStartupPreferences(HOME);
340
359
  const removedLegacyStartupFiles = cwd !== HOME ? removeWorkspaceHookDefaults(cwd) : [];
341
360
  installGlobalHooks();
361
+ if (!silent) fmt.logStep('Hooks and IDE integration configured');
342
362
 
343
363
  // Remove old local .git/hooks/post-checkout that pre-dates core.hooksPath (creates stale .aw_registry symlink)
344
364
  if (cwd !== HOME) {
@@ -365,13 +385,19 @@ export async function initCommand(args) {
365
385
 
366
386
  // Wire ~/.claude/.cursor/.codex to the project's registry when in a project,
367
387
  // so edits to project/.aw/.aw_registry/ are instantly visible in global IDE dirs.
388
+ if (!silent) fmt.logStep('Wiring IDE symlinks...');
368
389
  const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
369
390
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
370
391
  const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
371
392
  const commands = generateCommands(HOME, { silent: true });
372
393
  if (cwd !== HOME) installLocalCommitHook(cwd);
394
+ if (!silent) fmt.logStep(`IDE wired — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`);
395
+
396
+ // Write hook manifest after all hook installation is complete
397
+ try { writeHookManifest({ eccVersion: AW_ECC_TAG, awVersion: VERSION }); } catch { /* best effort */ }
373
398
 
374
399
  if (silent) {
400
+ if (silentSpinner) { silentSpinner.stop('Done'); setSilent(false); }
375
401
  autoUpdate(await args._updateCheck);
376
402
  } else {
377
403
  fmt.outro([
@@ -417,7 +443,6 @@ export async function initCommand(args) {
417
443
  }
418
444
 
419
445
  fmt.note([
420
- `${chalk.dim('source:')} ~/.aw/ → ~/.aw_registry/ (symlink)`,
421
446
  folderName ? `${chalk.dim('namespace:')} ${folderName}` : `${chalk.dim('namespace:')} ${chalk.dim('none')}`,
422
447
  user ? `${chalk.dim('user:')} ${user}` : null,
423
448
  `${chalk.dim('version:')} v${VERSION}`,
@@ -442,7 +467,7 @@ export async function initCommand(args) {
442
467
  if (!awRegistryLstat) {
443
468
  try {
444
469
  symlinkSync(join(AW_HOME, REGISTRY_DIR), GLOBAL_AW_DIR);
445
- fmt.logStep('Created ~/.aw_registry/ symlink');
470
+ fmt.logStep('Registry linked');
446
471
  } catch (e) {
447
472
  fmt.logWarn(`Could not create symlink ~/.aw_registry/: ${e.message}`);
448
473
  }
@@ -451,7 +476,7 @@ export async function initCommand(args) {
451
476
  try {
452
477
  rmSync(GLOBAL_AW_DIR);
453
478
  symlinkSync(join(AW_HOME, REGISTRY_DIR), GLOBAL_AW_DIR);
454
- fmt.logStep('Updated ~/.aw_registry/ symlink');
479
+ fmt.logStep('Registry linked');
455
480
  } catch (e) {
456
481
  fmt.logWarn(`Could not update symlink ~/.aw_registry/: ${e.message}`);
457
482
  }
@@ -462,21 +487,32 @@ export async function initCommand(args) {
462
487
  if (folderName) {
463
488
  config.addPattern(GLOBAL_AW_DIR, folderName);
464
489
  }
490
+ // Parallel batch A: rules sync (HOME + cwd are independent targets)
465
491
  syncRulesTargets(HOME);
466
- if (cwd !== HOME) {
467
- syncRulesTargets(cwd);
468
- }
492
+ if (cwd !== HOME) syncRulesTargets(cwd);
469
493
  removeLegacyRegistryRules();
494
+ if (!silent) fmt.logStep('Rules synced');
470
495
 
471
496
  // Step 3: Setup tasks, MCP, hooks
497
+ // Prune stale hooks from prior version before installing new ones
498
+ const oldManifestFresh = readHookManifest();
499
+ if (oldManifestFresh) pruneStaleHooks(oldManifestFresh);
500
+
472
501
  await installAwEcc(cwd, { silent });
502
+
473
503
  ensureAwRuntimeHook(HOME);
474
- syncHomeAndProjectInstructions(cwd, team);
475
- const mcpFiles = await setupMcp(HOME, team, { silent }) || [];
504
+
505
+ // Parallel batch B: post-ECC setup (instructions and MCP are independent)
506
+ const [, mcpFiles] = await Promise.all([
507
+ Promise.resolve(syncHomeAndProjectInstructions(cwd, team)),
508
+ setupMcp(HOME, team, { silent }),
509
+ ]);
510
+ // applyStoredStartupPreferences reads settings written by ECC — keep after batch B
476
511
  applyStoredStartupPreferences(HOME);
477
512
  const removedLegacyStartupFiles = cwd !== HOME ? removeWorkspaceHookDefaults(cwd) : [];
478
513
  const hooksInstalled = installGlobalHooks();
479
514
  installIdeTasks();
515
+ if (!silent) fmt.logStep('Hooks and IDE integration configured');
480
516
 
481
517
  // Remove old local .git/hooks/post-checkout that pre-dates core.hooksPath
482
518
  if (cwd !== HOME) {
@@ -502,18 +538,28 @@ export async function initCommand(args) {
502
538
 
503
539
  // Step 5: Wire ~/.claude/.cursor/.codex to the project's registry when in a project,
504
540
  // so edits to project/.aw/.aw_registry/ are instantly visible in global IDE dirs.
505
- const ideSpinner = fmt.spinner();
506
- ideSpinner.start('Wiring IDE symlinks...');
541
+ if (!silent) fmt.logStep('Wiring IDE symlinks...');
507
542
  const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
508
543
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
509
- const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
544
+ // Parallel batch C: symlinks + commands are independent
510
545
  if (cwd !== HOME) installLocalCommitHook(cwd);
511
- ideSpinner.message('Generating commands...');
512
- const commands = generateCommands(HOME, { silent: true });
513
- ideSpinner.stop(`IDE wired — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`);
546
+ const [symlinks, commands] = [
547
+ linkWorkspace(HOME, awDirForLinks, { silent: true }),
548
+ generateCommands(HOME, { silent: true }),
549
+ ];
550
+ if (!silent) fmt.logStep(`IDE wired — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`);
551
+
552
+ // Write hook manifest after all hook installation is complete
553
+ try { writeHookManifest({ eccVersion: AW_ECC_TAG, awVersion: VERSION }); } catch { /* best effort */ }
554
+
555
+ // Ensure telemetry config exists (generates machine_id on first run)
556
+ ensureTelemetryConfig();
514
557
 
515
558
  // Offer to update if a newer version is available
516
- await promptUpdate(await args._updateCheck);
559
+ if (!silent) await promptUpdate(await args._updateCheck);
560
+
561
+ // Stop silent spinner before outro (outro is already suppressed by setSilent)
562
+ if (silentSpinner) { silentSpinner.stop('Done'); setSilent(false); }
517
563
 
518
564
  fmt.outro([
519
565
  '⟁ Install complete',
package/commands/nuke.mjs CHANGED
@@ -16,6 +16,7 @@ import { uninstallAwEcc } from '../ecc.mjs';
16
16
  import { removeMcpConfig } from '../mcp.mjs';
17
17
  import { listProjectWorktrees } from '../git.mjs';
18
18
  import { removeWorkspaceHookDefaults } from '../codex.mjs';
19
+ import { readHookManifest, pruneStaleHooks, removeHookManifest } from '../hook-cleanup.mjs';
19
20
 
20
21
  const HOME = homedir();
21
22
  const GLOBAL_AW_DIR = join(HOME, '.aw_registry');
@@ -129,43 +130,34 @@ function removeIdeSymlinks() {
129
130
  return removed;
130
131
  }
131
132
 
132
- // 3. Find and remove ALL .aw_registry symlinks from project directories
133
+ // 3. Find and remove ALL .aw_registry symlinks + legacy hooks from project directories.
134
+ // Uses a single batched find instead of 3 sequential ones for performance.
133
135
  async function removeProjectSymlinks() {
134
136
  let removed = 0;
137
+ let hooksRemoved = 0;
135
138
 
136
- // Use find command for thorough search.
137
- // Append "|| true" so find's exit code is always 0 (find exits 1 on permission-denied dirs).
138
- const { stdout: registryLinks } = await exec(
139
- `find "${HOME}" -maxdepth 4 -name ".aw_registry" -type l 2>/dev/null || true`,
139
+ // Single find: .aw_registry symlinks + legacy git hooks
140
+ const { stdout } = await exec(
141
+ `find "${HOME}" -maxdepth 5 \\( -name ".aw_registry" -type l -o -path "*/.git/hooks/post-checkout" -type f -o -path "*/.git/hooks/prepare-commit-msg" -type f \\) 2>/dev/null || true`,
140
142
  { encoding: 'utf8', timeout: 30000 }
141
143
  );
142
- for (const linkPath of registryLinks.trim().split('\n').filter(Boolean)) {
143
- try {
144
- removeWorkspaceHookDefaults(dirname(linkPath));
145
- unlinkSync(linkPath);
146
- removed++;
147
- } catch { /* best effort */ }
148
- }
149
144
 
150
- // Also remove legacy local .git/hooks/post-checkout installed by old aw versions
151
- // and prepare-commit-msg hooks installed by installLocalCommitHook
152
- let hooksRemoved = 0;
153
- const hookNames = ['post-checkout', 'prepare-commit-msg'];
154
- for (const hookName of hookNames) {
155
- const { stdout: hookFiles } = await exec(
156
- `find "${HOME}" -maxdepth 5 -path "*/.git/hooks/${hookName}" -type f 2>/dev/null || true`,
157
- { encoding: 'utf8', timeout: 30000 }
158
- );
159
- for (const hookPath of hookFiles.trim().split('\n').filter(Boolean)) {
160
- try {
161
- const content = readFileSync(hookPath, 'utf8');
162
- // Only remove hooks that AW installed — identified by our marker comment
145
+ for (const itemPath of stdout.trim().split('\n').filter(Boolean)) {
146
+ try {
147
+ if (itemPath.endsWith('.aw_registry')) {
148
+ // Registry symlink clean workspace hooks then remove
149
+ removeWorkspaceHookDefaults(dirname(itemPath));
150
+ unlinkSync(itemPath);
151
+ removed++;
152
+ } else if (itemPath.includes('.git/hooks/')) {
153
+ // Legacy git hook — only remove if AW-installed
154
+ const content = readFileSync(itemPath, 'utf8');
163
155
  if (content.includes('aw:') || content.includes('aw: auto-link registry') || content.includes('ln -s "$AW_REGISTRY"')) {
164
- unlinkSync(hookPath);
156
+ unlinkSync(itemPath);
165
157
  hooksRemoved++;
166
158
  }
167
- } catch { /* best effort */ }
168
- }
159
+ }
160
+ } catch { /* best effort */ }
169
161
  }
170
162
 
171
163
  return { removed, hooksRemoved };
@@ -232,6 +224,9 @@ export async function nukeCommand(args) {
232
224
 
233
225
  const manifest = loadManifest();
234
226
 
227
+ // Read hook manifest into memory before any deletion (manifest file lives in ~/.aw/hooks/)
228
+ const hookManifest = readHookManifest();
229
+
235
230
  // Use lstatSync (not existsSync) so a dangling symlink still shows as "found"
236
231
  let registryFound = false;
237
232
  try { lstatSync(GLOBAL_AW_DIR); registryFound = true; } catch { /* not present */ }
@@ -250,6 +245,9 @@ export async function nukeCommand(args) {
250
245
  const ideCount = removeIdeSymlinks();
251
246
  uninstallAwEcc();
252
247
  removeMcpConfig();
248
+ // Prune stale hook entries using manifest (catches entries not tracked by install-state)
249
+ if (hookManifest) pruneStaleHooks(hookManifest);
250
+ removeHookManifest();
253
251
  const totalIde = createdCount + ideCount;
254
252
  ideSpinner.stop(`IDE symlinks and commands removed${totalIde > 0 ? ` (${totalIde} files)` : ''}`);
255
253
 
@@ -294,45 +292,36 @@ export async function nukeCommand(args) {
294
292
  } catch { /* best effort */ }
295
293
  }
296
294
 
297
- // 4b. Remove new-style .aw symlinks pointing to ~/.aw
298
- // These are created by the symlink model and are NOT tracked by git worktree list.
295
+ // 4b+4c. Batched find for .aw symlinks and directories (single traversal instead of two).
299
296
  try {
300
- const { stdout: awLinks } = await exec(
301
- `find "${HOME}" -maxdepth 5 -name ".aw" -type l 2>/dev/null || true`,
297
+ const { stdout: awItems } = await exec(
298
+ `find "${HOME}" -maxdepth 5 -name ".aw" \\( -type l -o -type d \\) 2>/dev/null || true`,
302
299
  { encoding: 'utf8', timeout: 30000 }
303
300
  );
304
- for (const linkPath of awLinks.trim().split('\n').filter(Boolean)) {
301
+ for (const itemPath of awItems.trim().split('\n').filter(Boolean)) {
302
+ if (itemPath === AW_HOME) continue; // skip ~/.aw itself
305
303
  try {
306
- const target = readlinkSync(linkPath);
307
- if (target === AW_HOME || target.endsWith('/.aw')) {
308
- unlinkSync(linkPath);
309
- wtRemoved++;
310
- }
311
- } catch { /* best effort */ }
312
- }
313
- } catch { /* best effort */ }
314
-
315
- // 4c. Remove old-style .aw directories (git worktrees) that git worktree list missed.
316
- // After ~/.aw is deleted these become dangling — find and clean them up.
317
- try {
318
- const { stdout: awDirs } = await exec(
319
- `find "${HOME}" -maxdepth 5 -name ".aw" -type d 2>/dev/null || true`,
320
- { encoding: 'utf8', timeout: 30000 }
321
- );
322
- for (const dirPath of awDirs.trim().split('\n').filter(Boolean)) {
323
- if (dirPath === AW_HOME) continue; // skip ~/.aw itself
324
- try {
325
- const gitFile = join(dirPath, '.git');
326
- const stat = lstatSync(gitFile);
327
- if (stat.isFile()) {
328
- const content = readFileSync(gitFile, 'utf8').trim();
329
- // Old git worktree pointing into ~/.aw — safe to remove
330
- if (content.includes(`${AW_HOME}/.git/`)) {
331
- rmSync(dirPath, { recursive: true, force: true });
304
+ const stat = lstatSync(itemPath);
305
+ if (stat.isSymbolicLink()) {
306
+ // New-style symlink pointing to ~/.aw
307
+ const target = readlinkSync(itemPath);
308
+ if (target === AW_HOME || target.endsWith('/.aw')) {
309
+ unlinkSync(itemPath);
332
310
  wtRemoved++;
333
311
  }
312
+ } else if (stat.isDirectory()) {
313
+ // Old-style git worktree — check .git file
314
+ const gitFile = join(itemPath, '.git');
315
+ const gitStat = lstatSync(gitFile);
316
+ if (gitStat.isFile()) {
317
+ const content = readFileSync(gitFile, 'utf8').trim();
318
+ if (content.includes(`${AW_HOME}/.git/`)) {
319
+ rmSync(itemPath, { recursive: true, force: true });
320
+ wtRemoved++;
321
+ }
322
+ }
334
323
  }
335
- } catch { /* not a worktree or already gone */ }
324
+ } catch { /* not a worktree/symlink or already gone */ }
336
325
  }
337
326
  } catch { /* best effort */ }
338
327
 
package/ecc.mjs CHANGED
@@ -1,4 +1,6 @@
1
- import { execSync } from "node:child_process";
1
+ import { execSync, exec as execCb } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execAsync = promisify(execCb);
2
4
  import {
3
5
  existsSync, readFileSync, readdirSync,
4
6
  mkdirSync, rmSync, writeFileSync, renameSync,
@@ -10,7 +12,7 @@ import { applyStoredStartupPreferences } from "./startup.mjs";
10
12
 
11
13
  const AW_ECC_REPO_SSH = "git@github.com:shreyansh-ghl/aw-ecc.git";
12
14
  const AW_ECC_REPO_HTTPS = "https://github.com/shreyansh-ghl/aw-ecc.git";
13
- export const AW_ECC_TAG = "v1.4.38-beta.4";
15
+ export const AW_ECC_TAG = "v1.4.41-beta.5";
14
16
 
15
17
  const MARKETPLACE_NAME = "aw-marketplace";
16
18
  const PLUGIN_KEY = `aw@${MARKETPLACE_NAME}`;
@@ -63,6 +65,68 @@ function cloneWithRef(url, ref, dest) {
63
65
  }
64
66
  }
65
67
 
68
+ // Async variants — allow event loop to tick so spinners can render.
69
+ async function runA(cmd, opts = {}) {
70
+ const { stdout } = await execAsync(cmd, { ...opts, maxBuffer: 10 * 1024 * 1024 });
71
+ return stdout;
72
+ }
73
+
74
+ async function fetchOverrideRefAsync(dest, ref) {
75
+ await runA(`git -C ${dest} fetch --quiet --depth 1 origin ${ref}`);
76
+ await runA(`git -C ${dest} checkout --quiet FETCH_HEAD`);
77
+ }
78
+
79
+ async function cloneWithRefAsync(url, ref, dest) {
80
+ try {
81
+ await runA(`git clone --quiet --depth 1 --branch ${ref} "${url}" "${dest}"`);
82
+ } catch {
83
+ await runA(`git clone --quiet --depth 1 "${url}" "${dest}"`);
84
+ await fetchOverrideRefAsync(dest, ref);
85
+ }
86
+ }
87
+
88
+ async function cloneOrUpdateAsync(tag, dest) {
89
+ const overrideUrl = process.env.AW_ECC_CLONE_URL;
90
+ const overrideRef = process.env.AW_ECC_CLONE_REF?.trim();
91
+
92
+ if (existsSync(join(dest, ".git"))) {
93
+ try {
94
+ if (!overrideUrl && !overrideRef) {
95
+ await runA(`git -C ${dest} fetch --quiet --depth 1 origin tag ${tag}`);
96
+ await runA(`git -C ${dest} checkout --quiet ${tag}`);
97
+ } else {
98
+ if (overrideUrl) {
99
+ await runA(`git -C ${dest} remote set-url origin "${overrideUrl}"`);
100
+ }
101
+ await fetchOverrideRefAsync(dest, overrideRef || tag);
102
+ }
103
+ return;
104
+ } catch { /* fall through to fresh clone */ }
105
+ }
106
+ if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
107
+ if (overrideUrl) {
108
+ if (overrideRef) {
109
+ await cloneWithRefAsync(overrideUrl, overrideRef, dest);
110
+ } else {
111
+ await runA(`git clone --quiet --depth 1 "${overrideUrl}" "${dest}"`);
112
+ }
113
+ } else {
114
+ if (overrideRef) {
115
+ try {
116
+ await cloneWithRefAsync(AW_ECC_REPO_SSH, overrideRef, dest);
117
+ } catch {
118
+ await cloneWithRefAsync(AW_ECC_REPO_HTTPS, overrideRef, dest);
119
+ }
120
+ return;
121
+ }
122
+ try {
123
+ await runA(`git clone --quiet --depth 1 --branch ${tag} ${AW_ECC_REPO_SSH} ${dest}`);
124
+ } catch {
125
+ await runA(`git clone --quiet --depth 1 --branch ${tag} ${AW_ECC_REPO_HTTPS} ${dest}`);
126
+ }
127
+ }
128
+ }
129
+
66
130
  function readIfExists(path) {
67
131
  try { return existsSync(path) ? readFileSync(path, "utf8") : null; } catch { return null; }
68
132
  }
@@ -259,13 +323,16 @@ export async function installAwEcc(
259
323
  { targets = ["cursor", "claude", "codex"], silent = false } = {},
260
324
  ) {
261
325
  if (process.env.AW_NO_ECC === '1') return;
262
- if (!silent) fmt.logStep("Installing aw-ecc engine...");
263
326
 
264
327
  const repoDir = eccDir();
265
328
  const home = homedir();
266
329
 
330
+ const eccSpinner = silent ? null : fmt.spinner();
331
+
267
332
  try {
268
- cloneOrUpdate(AW_ECC_TAG, repoDir);
333
+ if (eccSpinner) eccSpinner.start('Cloning aw-ecc engine...');
334
+ await cloneOrUpdateAsync(AW_ECC_TAG, repoDir);
335
+ if (eccSpinner) eccSpinner.message('Installing aw-ecc dependencies...');
269
336
 
270
337
  // Claude Code: plugin install via marketplace CLI (proper agent dispatch)
271
338
  if (targets.includes("claude")) {
@@ -279,7 +346,7 @@ export async function installAwEcc(
279
346
  // Claude + Cursor + Codex: file-copy via install-apply.js
280
347
  const fileCopyTargets = targets.filter((t) => FILE_COPY_TARGETS.includes(t));
281
348
  if (fileCopyTargets.length > 0) {
282
- run("npm install --no-audit --no-fund --ignore-scripts --loglevel=error", {
349
+ await runA("npm install --no-audit --no-fund --ignore-scripts --loglevel=error", {
283
350
  cwd: repoDir,
284
351
  });
285
352
  // generate-aw-hooks.js produces hooks.json and hook script sources that
@@ -291,15 +358,14 @@ export async function installAwEcc(
291
358
  run(`node "${generateHooksScript}"`, { cwd: repoDir });
292
359
  } catch { /* best effort — older engine versions may not have this script */ }
293
360
  }
294
- for (const target of fileCopyTargets) {
361
+ if (eccSpinner) eccSpinner.message('Applying aw-ecc to IDE targets...');
362
+ // Each target writes to disjoint paths (~/.claude/, ~/.cursor/, ~/.codex/) — safe to parallelize.
363
+ await Promise.all(fileCopyTargets.map(async (target) => {
295
364
  try {
296
365
  const snapshot = snapshotProtectedConfigs(home, target);
297
366
 
298
367
  // Always use HOME as cwd so files land in ~/.<target>/ globally.
299
368
  const runCwd = homedir();
300
- // For claude: install the safe no-commands module set. The plugin
301
- // already owns /aw:* command registration, and broader profiles with
302
- // `--without baseline:commands` can fail on commands-core deps.
303
369
  const installArgs = target === "claude"
304
370
  ? `--target ${target} --modules ${CLAUDE_FILE_COPY_MODULES.join(",")}`
305
371
  : `--target ${target} --profile full`;
@@ -307,34 +373,24 @@ export async function installAwEcc(
307
373
  `node ${join(repoDir, "scripts/install-apply.js")} ${installArgs}`,
308
374
  { cwd: runCwd },
309
375
  );
310
- // Move cursor commands into aw/ subfolder for namespace consistency
311
- // so they're accessible as /aw:tdd, /aw:plan — same as Claude Code plugin.
312
376
  if (target === "cursor") {
313
377
  namespaceCursorCommands(runCwd);
314
- // Cursor commands use hyphens (commands/aw/plan.md -> /aw-plan)
315
- // but source skill/rule files use canonical colons (/aw:plan).
316
- // Transform /aw: -> /aw- in installed cursor skill/rule files.
317
378
  transformCursorAwRefs(home);
318
379
  }
319
- // Run sync script for codex: generates ~/.codex/prompts/*.md and
320
- // merges AGENTS.md — Codex has no slash commands, so prompts are the
321
- // equivalent of commands.
322
380
  if (target === "codex") {
323
381
  syncEccToCodex(repoDir);
324
382
  }
325
-
326
- // Critical: preserve user-owned config files if they existed before
327
- // running aw-ecc (aw should only add, never replace user settings).
328
383
  restoreProtectedConfigs(snapshot);
329
384
  } catch { /* target not supported — skip */ }
330
- }
385
+ }));
331
386
  }
332
387
 
333
388
  applyStoredStartupPreferences();
334
389
 
335
- if (!silent) fmt.logSuccess("aw-ecc engine installed");
390
+ if (eccSpinner) eccSpinner.stop('aw-ecc engine installed');
336
391
  } catch (err) {
337
- if (!silent) fmt.logWarn(`aw-ecc install failed: ${err.message}`);
392
+ if (eccSpinner) eccSpinner.stop(chalk.yellow(`aw-ecc install failed: ${err.message}`));
393
+ else if (!silent) fmt.logWarn(`aw-ecc install failed: ${err.message}`);
338
394
  }
339
395
  }
340
396
 
package/fmt.mjs CHANGED
@@ -6,6 +6,13 @@ import figlet from 'figlet';
6
6
 
7
7
  export { chalk };
8
8
 
9
+ // ─── Silent mode ───
10
+ // When enabled, all log/intro/outro/note/spinner output is suppressed.
11
+ // Used by `aw init --silent` to show only a single "Initializing..." spinner.
12
+ let _silent = false;
13
+ export function setSilent(v) { _silent = !!v; }
14
+ export function isSilent() { return _silent; }
15
+
9
16
  // ─── Banner ───
10
17
 
11
18
  // Big ASCII art icons — same height as ANSI Shadow font (6 lines)
@@ -22,6 +29,7 @@ const ICON_ART = {
22
29
  };
23
30
 
24
31
  export function banner(text, opts = {}) {
32
+ if (_silent) return;
25
33
  const {
26
34
  font = 'ANSI Shadow',
27
35
  color = chalk.hex('#FF6B35'),
@@ -65,12 +73,14 @@ export function banner(text, opts = {}) {
65
73
 
66
74
  // ─── Clack wrappers ───
67
75
 
68
- export const intro = (msg) => p.intro(chalk.bgHex('#FF6B35').black(` ⟁ ${msg} `));
69
- export const outro = (msg) => p.outro(chalk.green(msg));
76
+ export const intro = (msg) => { if (!_silent) p.intro(chalk.bgHex('#FF6B35').black(` ⟁ ${msg} `)); };
77
+ export const outro = (msg) => { if (!_silent) p.outro(chalk.green(msg)); };
70
78
  export const select = p.select;
71
79
  export const isCancel = p.isCancel;
72
80
 
73
- export const spinner = () => p.spinner();
81
+ // Returns a real spinner when not silent, or a no-op stub when silent.
82
+ const _noopSpinner = { start() {}, stop() {}, message() {} };
83
+ export const spinner = () => _silent ? _noopSpinner : p.spinner();
74
84
 
75
85
  export class CancelError extends Error {
76
86
  constructor(message, { exitCode = 1 } = {}) {
@@ -93,13 +103,13 @@ export function cancelAndExit(msg) {
93
103
 
94
104
  // ─── Log helpers (clack-styled) ───
95
105
 
96
- export const note = (msg, title) => p.note(msg, title);
97
- export const logInfo = (msg) => p.log.info(msg);
98
- export const logSuccess = (msg) => p.log.success(msg);
99
- export const logWarn = (msg) => p.log.warn(msg);
100
- export const logError = (msg) => p.log.error(msg);
101
- export const logStep = (msg) => p.log.step(msg);
102
- export const logMessage = (msg) => p.log.message(msg);
106
+ export const note = (msg, title) => { if (!_silent) p.note(msg, title); };
107
+ export const logInfo = (msg) => { if (!_silent) p.log.info(msg); };
108
+ export const logSuccess = (msg) => { if (!_silent) p.log.success(msg); };
109
+ export const logWarn = (msg) => { if (!_silent) p.log.warn(msg); };
110
+ export const logError = (msg) => { if (!_silent) p.log.error(msg); };
111
+ export const logStep = (msg) => { if (!_silent) p.log.step(msg); };
112
+ export const logMessage = (msg) => { if (!_silent) p.log.message(msg); };
103
113
 
104
114
  // ─── Styled text helpers ───
105
115
 
@@ -114,13 +124,13 @@ export const magenta = (s) => chalk.magenta(s);
114
124
 
115
125
  // ─── Backward compat aliases (used by existing commands) ───
116
126
 
117
- export function log(msg = '') { process.stderr.write(msg + '\n'); }
118
- export function info(msg) { p.log.info(msg); }
119
- export function success(msg) { p.log.success(msg); }
120
- export function warn(msg) { p.log.warn(msg); }
121
- export function error(msg) { p.log.error(msg); }
122
- export function heading(msg) { p.log.step(chalk.bold(msg)); }
123
- export function item(label, value) { p.log.message(`${chalk.dim(label)} ${value}`); }
127
+ export function log(msg = '') { if (!_silent) process.stderr.write(msg + '\n'); }
128
+ export function info(msg) { if (!_silent) p.log.info(msg); }
129
+ export function success(msg) { if (!_silent) p.log.success(msg); }
130
+ export function warn(msg) { if (!_silent) p.log.warn(msg); }
131
+ export function error(msg) { if (!_silent) p.log.error(msg); }
132
+ export function heading(msg) { if (!_silent) p.log.step(chalk.bold(msg)); }
133
+ export function item(label, value) { if (!_silent) p.log.message(`${chalk.dim(label)} ${value}`); }
124
134
 
125
135
  // ─── Action labels ───
126
136
 
@@ -0,0 +1,301 @@
1
+ // hook-cleanup.mjs — Hook cleanup manifest for safe version transitions.
2
+ //
3
+ // writeHookManifest() → snapshots all AW hook touchpoints to ~/.aw/hooks/manifest.json
4
+ // readHookManifest() → reads and validates the manifest
5
+ // pruneStaleHooks() → removes hook entries and runtime deps listed in a manifest
6
+ // removeHookManifest() → deletes the manifest file
7
+
8
+ import {
9
+ existsSync, mkdirSync, readFileSync, readdirSync,
10
+ rmSync, writeFileSync,
11
+ } from 'node:fs';
12
+ import { dirname, join } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+
15
+ const SCHEMA_VERSION = 'aw-hooks.v1';
16
+ const HOME = homedir();
17
+ const MANIFEST_PATH = join(HOME, '.aw', 'hooks', 'manifest.json');
18
+
19
+ // ── Patterns matching AW-managed hook entries ────────────────────────────
20
+
21
+ function isManagedClaudeEntry(entry) {
22
+ if (entry?.description === 'AW usage telemetry') return true;
23
+ const cmds = Array.isArray(entry?.hooks) ? entry.hooks.map(h => h?.command || '') : [];
24
+ return cmds.some(c => c.includes('aw-usage-'));
25
+ }
26
+
27
+ function isManagedCursorEntry(entry) {
28
+ const cmd = String(entry?.command || '');
29
+ return cmd.includes('.cursor/hooks/') || cmd.includes('aw-ecc');
30
+ }
31
+
32
+ function isManagedCodexEntry(entry) {
33
+ if (Array.isArray(entry?.hooks)) {
34
+ return entry.hooks.some(h => {
35
+ const cmd = String(h?.command || '');
36
+ return cmd.includes('.codex/hooks/') || cmd.includes('aw-ecc') || cmd.includes('aw-session-start');
37
+ });
38
+ }
39
+ return false;
40
+ }
41
+
42
+ // ── Read helpers ─────────────────────────────────────────────────────────
43
+
44
+ function readJson(filePath) {
45
+ if (!existsSync(filePath)) return null;
46
+ try { return JSON.parse(readFileSync(filePath, 'utf8')); } catch { return null; }
47
+ }
48
+
49
+ function writeJson(filePath, data) {
50
+ mkdirSync(dirname(filePath), { recursive: true });
51
+ writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
52
+ }
53
+
54
+ // ── Collect current hook state ───────────────────────────────────────────
55
+
56
+ function collectClaudeTouchpoints() {
57
+ const settingsPath = join(HOME, '.claude', 'settings.json');
58
+ const hooksJsonPath = join(HOME, '.claude', 'hooks', 'hooks.json');
59
+ const settings = readJson(settingsPath);
60
+ const phases = [];
61
+ if (settings?.hooks) {
62
+ for (const phase of Object.keys(settings.hooks)) {
63
+ const entries = settings.hooks[phase];
64
+ if (Array.isArray(entries) && entries.some(isManagedClaudeEntry)) {
65
+ phases.push(phase);
66
+ }
67
+ }
68
+ }
69
+ return {
70
+ settingsPath,
71
+ hooksJsonPath: existsSync(hooksJsonPath) ? hooksJsonPath : null,
72
+ managedPhases: phases,
73
+ };
74
+ }
75
+
76
+ function collectCursorTouchpoints() {
77
+ const hooksJsonPath = join(HOME, '.cursor', 'hooks.json');
78
+ const config = readJson(hooksJsonPath);
79
+ const phases = [];
80
+ if (config?.hooks) {
81
+ for (const phase of Object.keys(config.hooks)) {
82
+ const entries = config.hooks[phase];
83
+ if (Array.isArray(entries) && entries.some(isManagedCursorEntry)) {
84
+ phases.push(phase);
85
+ }
86
+ }
87
+ }
88
+ return { hooksJsonPath, managedPhases: phases };
89
+ }
90
+
91
+ function collectCodexTouchpoints() {
92
+ const hooksJsonPath = join(HOME, '.codex', 'hooks.json');
93
+ const configPath = join(HOME, '.codex', 'config.toml');
94
+ const config = readJson(hooksJsonPath);
95
+ const phases = [];
96
+ if (config?.hooks) {
97
+ for (const phase of Object.keys(config.hooks)) {
98
+ const entries = config.hooks[phase];
99
+ if (Array.isArray(entries) && entries.some(isManagedCodexEntry)) {
100
+ phases.push(phase);
101
+ }
102
+ }
103
+ }
104
+ return {
105
+ configPath: existsSync(configPath) ? configPath : null,
106
+ hooksJsonPath,
107
+ managedPhases: phases,
108
+ };
109
+ }
110
+
111
+ function collectGitTouchpoints() {
112
+ const hooksDir = join(HOME, '.aw', 'hooks');
113
+ const scripts = [];
114
+ if (existsSync(hooksDir)) {
115
+ try {
116
+ for (const entry of readdirSync(hooksDir)) {
117
+ // Exclude the manifest itself and hidden files
118
+ if (entry === 'manifest.json' || entry.startsWith('.')) continue;
119
+ scripts.push(entry);
120
+ }
121
+ } catch { /* best effort */ }
122
+ }
123
+ return { hooksDir, scripts };
124
+ }
125
+
126
+ function collectRuntimeDeps() {
127
+ const deps = [];
128
+ const hooksDir = join(HOME, '.aw-ecc', 'scripts', 'hooks');
129
+ const libDir = join(HOME, '.aw-ecc', 'scripts', 'lib');
130
+
131
+ if (existsSync(hooksDir)) {
132
+ try {
133
+ for (const entry of readdirSync(hooksDir)) {
134
+ if (entry.startsWith('aw-usage-')) {
135
+ deps.push(join(hooksDir, entry));
136
+ }
137
+ }
138
+ } catch { /* best effort */ }
139
+ }
140
+
141
+ const telemetryLib = join(libDir, 'aw-usage-telemetry.js');
142
+ if (existsSync(telemetryLib)) {
143
+ deps.push(telemetryLib);
144
+ }
145
+
146
+ return deps;
147
+ }
148
+
149
+ // ── Public API ───────────────────────────────────────────────────────────
150
+
151
+ /**
152
+ * Write a hook manifest capturing all current AW hook touchpoints.
153
+ * @param {{ eccVersion: string, awVersion: string }} opts
154
+ * @returns {object} the written manifest
155
+ */
156
+ export function writeHookManifest({ eccVersion, awVersion }) {
157
+ const manifest = {
158
+ schemaVersion: SCHEMA_VERSION,
159
+ createdAt: new Date().toISOString(),
160
+ eccVersion,
161
+ awVersion,
162
+ touchpoints: {
163
+ claude: collectClaudeTouchpoints(),
164
+ cursor: collectCursorTouchpoints(),
165
+ codex: collectCodexTouchpoints(),
166
+ git: collectGitTouchpoints(),
167
+ },
168
+ runtimeDeps: collectRuntimeDeps(),
169
+ };
170
+
171
+ writeJson(MANIFEST_PATH, manifest);
172
+ return manifest;
173
+ }
174
+
175
+ /**
176
+ * Read and validate the hook manifest.
177
+ * @returns {object|null} the manifest, or null if missing/invalid
178
+ */
179
+ export function readHookManifest() {
180
+ const data = readJson(MANIFEST_PATH);
181
+ if (!data || data.schemaVersion !== SCHEMA_VERSION) return null;
182
+ return data;
183
+ }
184
+
185
+ /**
186
+ * Remove hook entries and runtime deps listed in a manifest.
187
+ * Does NOT touch git hooks (handled by removeGlobalHooks).
188
+ * @param {object} manifest — from readHookManifest()
189
+ * @returns {number} count of items removed
190
+ */
191
+ export function pruneStaleHooks(manifest) {
192
+ if (!manifest?.touchpoints) return 0;
193
+ let removed = 0;
194
+
195
+ // Claude: remove managed telemetry entries from settings.json
196
+ const claudeSettings = manifest.touchpoints.claude;
197
+ if (claudeSettings?.settingsPath && claudeSettings.managedPhases?.length > 0) {
198
+ const config = readJson(claudeSettings.settingsPath);
199
+ if (config?.hooks) {
200
+ let changed = false;
201
+ for (const phase of claudeSettings.managedPhases) {
202
+ const entries = config.hooks[phase];
203
+ if (!Array.isArray(entries)) continue;
204
+ const filtered = entries.filter(e => !isManagedClaudeEntry(e));
205
+ if (filtered.length !== entries.length) {
206
+ changed = true;
207
+ removed += entries.length - filtered.length;
208
+ if (filtered.length > 0) {
209
+ config.hooks[phase] = filtered;
210
+ } else {
211
+ delete config.hooks[phase];
212
+ }
213
+ }
214
+ }
215
+ if (changed) {
216
+ if (Object.keys(config.hooks).length === 0) delete config.hooks;
217
+ writeJson(claudeSettings.settingsPath, config);
218
+ }
219
+ }
220
+ }
221
+
222
+ // Cursor: remove managed entries from hooks.json
223
+ const cursorHooks = manifest.touchpoints.cursor;
224
+ if (cursorHooks?.hooksJsonPath && cursorHooks.managedPhases?.length > 0) {
225
+ const config = readJson(cursorHooks.hooksJsonPath);
226
+ if (config?.hooks) {
227
+ let changed = false;
228
+ for (const phase of cursorHooks.managedPhases) {
229
+ const entries = config.hooks[phase];
230
+ if (!Array.isArray(entries)) continue;
231
+ const filtered = entries.filter(e => !isManagedCursorEntry(e));
232
+ if (filtered.length !== entries.length) {
233
+ changed = true;
234
+ removed += entries.length - filtered.length;
235
+ if (filtered.length > 0) {
236
+ config.hooks[phase] = filtered;
237
+ } else {
238
+ delete config.hooks[phase];
239
+ }
240
+ }
241
+ }
242
+ if (changed) {
243
+ if (Object.keys(config.hooks).length === 0) delete config.hooks;
244
+ writeJson(cursorHooks.hooksJsonPath, config);
245
+ }
246
+ }
247
+ }
248
+
249
+ // Codex: remove managed entries from hooks.json
250
+ const codexHooks = manifest.touchpoints.codex;
251
+ if (codexHooks?.hooksJsonPath && codexHooks.managedPhases?.length > 0) {
252
+ const config = readJson(codexHooks.hooksJsonPath);
253
+ if (config?.hooks) {
254
+ let changed = false;
255
+ for (const phase of codexHooks.managedPhases) {
256
+ const entries = config.hooks[phase];
257
+ if (!Array.isArray(entries)) continue;
258
+ const filtered = entries.filter(e => !isManagedCodexEntry(e));
259
+ if (filtered.length !== entries.length) {
260
+ changed = true;
261
+ removed += entries.length - filtered.length;
262
+ if (filtered.length > 0) {
263
+ config.hooks[phase] = filtered;
264
+ } else {
265
+ delete config.hooks[phase];
266
+ }
267
+ }
268
+ }
269
+ if (changed) {
270
+ if (Object.keys(config.hooks).length === 0) delete config.hooks;
271
+ writeJson(codexHooks.hooksJsonPath, config);
272
+ }
273
+ }
274
+ }
275
+
276
+ // Runtime deps: remove aw-usage-* files
277
+ if (Array.isArray(manifest.runtimeDeps)) {
278
+ for (const dep of manifest.runtimeDeps) {
279
+ if (existsSync(dep)) {
280
+ try {
281
+ rmSync(dep, { force: true });
282
+ removed++;
283
+ } catch { /* best effort */ }
284
+ }
285
+ }
286
+ }
287
+
288
+ return removed;
289
+ }
290
+
291
+ /**
292
+ * Delete the manifest file.
293
+ */
294
+ export function removeHookManifest() {
295
+ if (existsSync(MANIFEST_PATH)) {
296
+ rmSync(MANIFEST_PATH, { force: true });
297
+ }
298
+ }
299
+
300
+ // Export for testing
301
+ export { MANIFEST_PATH, SCHEMA_VERSION };
@@ -2,8 +2,7 @@ import { join } from 'node:path';
2
2
 
3
3
  import { getSupportedHarnessPhaseEntries } from '../hook-manifest.mjs';
4
4
  import {
5
- buildDelegatingPhaseScript,
6
- buildRegistryDelegatingPhaseScript,
5
+ buildRegistryDelegatingPhaseScriptWithTelemetry,
7
6
  buildReservedPhaseScript,
8
7
  } from './shared-phase-scripts.mjs';
9
8
 
@@ -19,7 +18,7 @@ const CODEX_HOME_PHASE_BLUEPRINTS = {
19
18
  scriptName: 'aw-session-start.sh',
20
19
  scriptMarker: '# aw-managed: codex-global-session-start',
21
20
  buildScriptContent() {
22
- return buildRegistryDelegatingPhaseScript({
21
+ return buildRegistryDelegatingPhaseScriptWithTelemetry({
23
22
  marker: this.scriptMarker,
24
23
  phase: 'SessionStart',
25
24
  targetCandidates: [
@@ -27,6 +26,8 @@ const CODEX_HOME_PHASE_BLUEPRINTS = {
27
26
  '$HOME/.aw/.aw_registry/platform/core/skills/using-aw-skills/hooks/session-start.sh',
28
27
  ],
29
28
  warningMessage: 'WARNING: AW using-aw-skills hook not found in ~/.aw_registry. Run aw init or aw pull platform.',
29
+ telemetryHookPath: '$HOME/.aw-ecc/scripts/hooks/aw-usage-session-start.js',
30
+ harnessEnv: 'codex',
30
31
  });
31
32
  },
32
33
  buildEntry(command) {
@@ -37,6 +37,53 @@ echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"${phase}\\",\\"additiona
37
37
  `;
38
38
  }
39
39
 
40
+ export function buildRegistryDelegatingPhaseScriptWithTelemetry({
41
+ marker,
42
+ phase,
43
+ targetCandidates,
44
+ warningMessage,
45
+ telemetryHookPath,
46
+ harnessEnv,
47
+ }) {
48
+ const targetsBlock = targetCandidates
49
+ .map(target => ` "${target}"`)
50
+ .join('\n');
51
+ const escapedWarning = escapeDoubleQuotes(warningMessage);
52
+
53
+ return `#!/usr/bin/env bash
54
+ ${marker}
55
+ set -euo pipefail
56
+
57
+ # Capture stdin so we can feed it to both telemetry and the AW router delegate.
58
+ STDIN=$(cat)
59
+
60
+ # Fire telemetry (non-blocking, all output suppressed).
61
+ TELEMETRY_HOOK="${telemetryHookPath}"
62
+ if [[ -f "$TELEMETRY_HOOK" ]] && command -v node >/dev/null 2>&1; then
63
+ printf '%s' "$STDIN" | AW_HARNESS=${harnessEnv} node "$TELEMETRY_HOOK" >/dev/null 2>&1 || true
64
+ fi
65
+
66
+ TARGETS=(
67
+ ${targetsBlock}
68
+ )
69
+
70
+ for target in "\${TARGETS[@]}"; do
71
+ if [[ -f "\$target" ]]; then
72
+ printf '%s' "$STDIN" | bash "\$target"
73
+ exit $?
74
+ fi
75
+ done
76
+
77
+ CONTEXT="# AW Session Context
78
+
79
+ ${escapedWarning}"
80
+
81
+ JSON_CONTEXT=$(printf '%s' "\$CONTEXT" | python3 -c 'import json, sys; print(json.dumps(sys.stdin.read()))')
82
+
83
+ echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"${phase}\\",\\"additionalContext\\":\${JSON_CONTEXT}}}"
84
+ `;
85
+ }
86
+
40
87
  export function buildDelegatingPhaseScript({
41
88
  marker,
42
89
  targetPath,
package/hooks.mjs CHANGED
@@ -95,6 +95,16 @@ if [ -f ".aw/.git" ] && command -v aw >/dev/null 2>&1; then
95
95
  AW_TRIGGER=hook:post-commit aw link >/dev/null 2>&1 &
96
96
  fi
97
97
 
98
+ # Fire commit_created telemetry if commit has AW co-author trailer
99
+ if git log -1 --format='%b' HEAD 2>/dev/null | grep -qF "Co-Authored-By: AW"; then
100
+ TELEMETRY_HOOK="$HOME/.aw-ecc/scripts/hooks/aw-usage-commit-created.js"
101
+ if command -v node >/dev/null 2>&1 && [ -f "$TELEMETRY_HOOK" ]; then
102
+ COMMIT_HASH="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
103
+ BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
104
+ node "$TELEMETRY_HOOK" "$COMMIT_HASH" "$BRANCH" >/dev/null 2>&1
105
+ fi
106
+ fi
107
+
98
108
  # Chain to previous hooksPath
99
109
  PREV_PATH_FILE="$HOME/.aw/hooks/.previous-hooks-path"
100
110
  if [ -f "$PREV_PATH_FILE" ]; then
@@ -171,11 +181,11 @@ export function installGlobalHooks() {
171
181
 
172
182
  if (previousPath && previousPath !== HOOKS_DIR) {
173
183
  writeFileSync(PREV_PATH_FILE, previousPath);
174
- fmt.logStep(`Saved previous core.hooksPath (${previousPath}) for chaining`);
184
+ fmt.logStep(`Chained existing git hooks (${previousPath})`);
175
185
  }
176
186
 
177
187
  execSync(`git config --global core.hooksPath "${HOOKS_DIR}"`, { stdio: 'pipe' });
178
- fmt.logStep('Global git hooks installed (auto-sync on pull/clone)');
188
+ fmt.logStep('Git hooks installed');
179
189
  return true;
180
190
  } catch (e) {
181
191
  fmt.logWarn(`Could not install global hooks: ${e.message}`);
@@ -258,10 +268,31 @@ fi
258
268
  exit 0
259
269
  `;
260
270
 
271
+ // Standalone post-commit for local .git/hooks/ installation.
272
+ // Fires commit_created telemetry when the commit has AW co-author trailer.
273
+ const LOCAL_POST_COMMIT = `#!/bin/sh
274
+ # aw: local post-commit hook (installed by aw init)
275
+
276
+ # Skip aw temp dirs
277
+ case "$(pwd)" in /tmp/aw-*|/var/folders/*/aw-*|*/.aw|*/.aw/*) exit 0 ;; esac
278
+
279
+ # Fire commit_created telemetry if commit has AW co-author trailer
280
+ if git log -1 --format='%b' HEAD 2>/dev/null | grep -qF "Co-Authored-By: AW"; then
281
+ TELEMETRY_HOOK="$HOME/.aw-ecc/scripts/hooks/aw-usage-commit-created.js"
282
+ if command -v node >/dev/null 2>&1 && [ -f "$TELEMETRY_HOOK" ]; then
283
+ COMMIT_HASH="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
284
+ BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
285
+ node "$TELEMETRY_HOOK" "$COMMIT_HASH" "$BRANCH" >/dev/null 2>&1
286
+ fi
287
+ fi
288
+
289
+ exit 0
290
+ `;
291
+
261
292
  /**
262
- * Install prepare-commit-msg hook into a project's local .git/hooks/.
263
- * This covers repos where another tool (e.g. Claude Code) sets a local
264
- * core.hooksPath that overrides the global one.
293
+ * Install prepare-commit-msg and post-commit hooks into a project's local
294
+ * .git/hooks/. This covers repos where another tool (e.g. Claude Code) sets
295
+ * a local core.hooksPath that overrides the global one.
265
296
  *
266
297
  * @param {string} projectDir — root of the project (must contain .git/)
267
298
  */
@@ -297,6 +328,15 @@ export function installLocalCommitHook(projectDir) {
297
328
 
298
329
  writeFileSync(hookPath, LOCAL_PREPARE_COMMIT_MSG);
299
330
  chmodSync(hookPath, '755');
331
+
332
+ // Also install post-commit for commit_created telemetry
333
+ const postCommitPath = join(hooksDir, 'post-commit');
334
+ const shouldWritePostCommit = !existsSync(postCommitPath)
335
+ || readFileSync(postCommitPath, 'utf8').includes('aw:');
336
+ if (shouldWritePostCommit) {
337
+ writeFileSync(postCommitPath, LOCAL_POST_COMMIT);
338
+ chmodSync(postCommitPath, '755');
339
+ }
300
340
  } catch { /* best effort */ }
301
341
  }
302
342
 
package/integrate.mjs CHANGED
@@ -246,7 +246,7 @@ export function copyInstructions(cwd, tempDir, namespace) {
246
246
 
247
247
  if (updated !== existing) {
248
248
  writeFileSync(dest, updated);
249
- fmt.logStep(`Stripped aw-managed sections from ${file} (now in ~/.claude/CLAUDE.md / ~/.codex/AGENTS.md)`);
249
+ fmt.logStep(`Migrated ${file} to global config`);
250
250
  }
251
251
  continue;
252
252
  }
@@ -643,7 +643,7 @@ No active tasks. Tasks are created during workflow execution.
643
643
  // _pending-sync.jsonl — sync queue (empty)
644
644
  writeFileSync(join(awDocsDir, 'learnings', '_pending-sync.jsonl'), '');
645
645
 
646
- fmt.logSuccess('Created .aw_docs/ (local orchestration state)');
646
+ fmt.logSuccess('Orchestration state ready');
647
647
  }
648
648
 
649
649
  /**
package/mcp.mjs CHANGED
@@ -52,7 +52,7 @@ function resolveGitHubToken(silent = false) {
52
52
  timeout: 5000,
53
53
  }).trim();
54
54
  if (token && (token.startsWith('ghp_') || token.startsWith('gho_') || token.startsWith('github_pat_'))) {
55
- if (!silent) fmt.logStep('Using GitHub token from gh CLI');
55
+ if (!silent) fmt.logStep('GitHub token found');
56
56
  return token;
57
57
  }
58
58
  } catch { /* not authenticated yet */ }
@@ -312,7 +312,8 @@ export async function setupMcp(cwd, namespace, { silent = false } = {}) {
312
312
  const unique = [...new Set(updatedFiles)];
313
313
 
314
314
  if (unique.length > 0) {
315
- fmt.logSuccess(`MCP configured in ${unique.map(f => fmt.chalk.cyan(f)).join(', ')}`);
315
+ const shortNames = unique.map(f => fmt.chalk.cyan(f.replace(HOME, '~')));
316
+ fmt.logSuccess(`MCP configured (${shortNames.join(', ')})`);
316
317
  } else {
317
318
  fmt.logInfo('MCP servers already configured — no changes needed');
318
319
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.42-beta.4",
3
+ "version": "0.1.42-beta.40",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,7 @@
26
26
  "slack-sim/",
27
27
  "file-tree.mjs",
28
28
  "apply.mjs",
29
+ "hook-cleanup.mjs",
29
30
  "hook-manifest.mjs",
30
31
  "update.mjs",
31
32
  "hooks.mjs",
package/render-rules.mjs CHANGED
@@ -351,6 +351,7 @@ For every non-trivial request, execute these steps in order before any substanti
351
351
  - /aw-review → Read aw-review/SKILL.md
352
352
  - /aw-deploy → Read aw-deploy/SKILL.md
353
353
  - /aw-ship → Read aw-ship/SKILL.md
354
+ - /aw-feature → Read aw-feature/SKILL.md
354
355
 
355
356
  4. **Follow the skill's behavior** — produce the artifacts the skill defines, not general-knowledge answers.
356
357
 
package/startup.mjs CHANGED
@@ -16,25 +16,30 @@ const DISABLED_MODE = 'disabled';
16
16
  const CLAUDE_DISABLE_DESCRIPTION = 'AW-managed override: disable automatic AW session routing';
17
17
  const CLAUDE_TELEMETRY_DESCRIPTION = 'AW usage telemetry';
18
18
  const CLAUDE_TELEMETRY_HOOKS = [
19
+ {
20
+ phase: 'SessionStart',
21
+ matcher: undefined,
22
+ command: 'test -f "$HOME/.aw-ecc/scripts/hooks/aw-usage-session-start.js" && node "$HOME/.aw-ecc/scripts/hooks/aw-usage-session-start.js" || true',
23
+ },
19
24
  {
20
25
  phase: 'PostToolUse',
21
26
  matcher: 'Skill|Agent|Shell|Bash',
22
- command: 'node "$HOME/.aw-ecc/scripts/hooks/aw-usage-post-tool-use.js"',
27
+ command: 'test -f "$HOME/.aw-ecc/scripts/hooks/aw-usage-post-tool-use.js" && node "$HOME/.aw-ecc/scripts/hooks/aw-usage-post-tool-use.js" || true',
23
28
  },
24
29
  {
25
30
  phase: 'Stop',
26
31
  matcher: undefined,
27
- command: 'node "$HOME/.aw-ecc/scripts/hooks/aw-usage-stop.js"',
32
+ command: 'test -f "$HOME/.aw-ecc/scripts/hooks/aw-usage-stop.js" && node "$HOME/.aw-ecc/scripts/hooks/aw-usage-stop.js" || true',
28
33
  },
29
34
  {
30
35
  phase: 'PostToolUseFailure',
31
36
  matcher: undefined,
32
- command: 'node "$HOME/.aw-ecc/scripts/hooks/aw-usage-post-tool-use-failure.js"',
37
+ command: 'test -f "$HOME/.aw-ecc/scripts/hooks/aw-usage-post-tool-use-failure.js" && node "$HOME/.aw-ecc/scripts/hooks/aw-usage-post-tool-use-failure.js" || true',
33
38
  },
34
39
  {
35
40
  phase: 'UserPromptSubmit',
36
41
  matcher: undefined,
37
- command: 'node "$HOME/.aw-ecc/scripts/hooks/aw-usage-prompt-submit.js"',
42
+ command: 'test -f "$HOME/.aw-ecc/scripts/hooks/aw-usage-prompt-submit.js" && node "$HOME/.aw-ecc/scripts/hooks/aw-usage-prompt-submit.js" || true',
38
43
  },
39
44
  ];
40
45
  const CURSOR_SESSION_START_COMMAND = 'node .cursor/hooks/session-start.js';
@@ -168,7 +173,10 @@ function hasCommandHook(entry, command) {
168
173
  }
169
174
 
170
175
  function isManagedClaudeTelemetryEntry(entry) {
171
- return entry?.description === CLAUDE_TELEMETRY_DESCRIPTION;
176
+ if (entry?.description === CLAUDE_TELEMETRY_DESCRIPTION) return true;
177
+ // Also match legacy entries (no description) by command pattern
178
+ const cmds = Array.isArray(entry?.hooks) ? entry.hooks.map(h => h?.command || '') : [];
179
+ return cmds.some(c => c.includes('aw-usage-'));
172
180
  }
173
181
 
174
182
  function buildClaudeTelemetryEntry(hookDef) {
@@ -187,6 +195,18 @@ function buildClaudeTelemetryEntry(hookDef) {
187
195
  return entry;
188
196
  }
189
197
 
198
+ /**
199
+ * Resolve the filesystem path from a telemetry hook command.
200
+ * Commands look like: node "$HOME/.aw-ecc/scripts/hooks/aw-usage-stop.js"
201
+ */
202
+ function resolveHookScriptPath(command, homeDir) {
203
+ const match = command.match(/"\$HOME\/([^"]+)"/);
204
+ if (match) return join(homeDir, match[1]);
205
+ const matchBare = command.match(/\$HOME\/(\S+)/);
206
+ if (matchBare) return join(homeDir, matchBare[1]);
207
+ return null;
208
+ }
209
+
190
210
  function enableClaudeTelemetryHooks(homeDir = homedir()) {
191
211
  const settingsPath = join(homeDir, '.claude', 'settings.json');
192
212
  const config = readJson(settingsPath, {});
@@ -196,15 +216,41 @@ function enableClaudeTelemetryHooks(homeDir = homedir()) {
196
216
 
197
217
  for (const hookDef of CLAUDE_TELEMETRY_HOOKS) {
198
218
  const current = Array.isArray(config.hooks[hookDef.phase]) ? config.hooks[hookDef.phase] : [];
199
- // Skip if already present
200
- if (current.some(isManagedClaudeTelemetryEntry)) continue;
219
+ const managed = current.filter(isManagedClaudeTelemetryEntry);
220
+
221
+ // Only register hooks whose script files actually exist on disk.
222
+ // On version downgrade the aw-ecc clone may not have them — registering
223
+ // hooks for missing scripts causes MODULE_NOT_FOUND crashes.
224
+ const scriptPath = resolveHookScriptPath(hookDef.command, homeDir);
225
+ if (scriptPath && !existsSync(scriptPath)) {
226
+ // Script missing — remove any stale telemetry entries for this phase
227
+ if (managed.length > 0) {
228
+ const cleaned = current.filter(e => !isManagedClaudeTelemetryEntry(e));
229
+ if (cleaned.length > 0) {
230
+ config.hooks[hookDef.phase] = cleaned;
231
+ } else {
232
+ delete config.hooks[hookDef.phase];
233
+ }
234
+ changed = true;
235
+ }
236
+ continue;
237
+ }
238
+
239
+ // If exactly one properly described entry exists, nothing to do
240
+ if (managed.length === 1 && managed[0].description === CLAUDE_TELEMETRY_DESCRIPTION) continue;
201
241
 
202
- config.hooks[hookDef.phase] = [...current, buildClaudeTelemetryEntry(hookDef)];
242
+ // Remove all legacy/duplicate telemetry entries, then add the canonical one
243
+ const cleaned = current.filter(e => !isManagedClaudeTelemetryEntry(e));
244
+ config.hooks[hookDef.phase] = [...cleaned, buildClaudeTelemetryEntry(hookDef)];
203
245
  changed = true;
204
246
  }
205
247
 
206
248
  if (!changed) return [];
207
249
 
250
+ if (isEmptyObject(config.hooks)) {
251
+ delete config.hooks;
252
+ }
253
+
208
254
  writeJson(settingsPath, config);
209
255
  return [settingsPath];
210
256
  }
package/telemetry.mjs CHANGED
@@ -192,7 +192,7 @@ export async function startSpan(command, args) {
192
192
  notice() {
193
193
  if (disabled || config.noticed) return;
194
194
  if (args?.['--silent'] || !process.stderr.isTTY) return;
195
- console.error('\u2139 Telemetry is on \u2014 anonymous usage stats help improve aw. Opt out: aw telemetry disable');
195
+ // Telemetry notice suppressed opt-out via `aw telemetry disable`
196
196
  config.noticed = true;
197
197
  saveConfig(config);
198
198
  },