@ghl-ai/aw 0.1.42-beta.3 → 0.1.42-beta.31

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
@@ -28,8 +28,10 @@ import { applyStoredStartupPreferences, ensureAwRuntimeHook } from '../startup.m
28
28
  import { installLocalCommitHook } from '../hooks.mjs';
29
29
  import { autoUpdate, promptUpdate } from '../update.mjs';
30
30
  import { installGlobalHooks } from '../hooks.mjs';
31
- import { installAwEcc } from '../ecc.mjs';
31
+ import { loadConfig as ensureTelemetryConfig } from '../telemetry.mjs';
32
+ import { installAwEcc, AW_ECC_TAG } from '../ecc.mjs';
32
33
  import { removeWorkspaceHookDefaults } from '../codex.mjs';
34
+ import { readHookManifest, pruneStaleHooks, writeHookManifest } from '../hook-cleanup.mjs';
33
35
  import {
34
36
  initPersistentClone,
35
37
  isValidClone,
@@ -323,6 +325,7 @@ export async function initCommand(args) {
323
325
  syncRulesTargets(cwd);
324
326
  }
325
327
  removeLegacyRegistryRules();
328
+ if (!silent) fmt.logStep('Rules synced');
326
329
 
327
330
  // Ensure project worktree sparse checkout matches the global clone.
328
331
  // Covers the case where a namespace was added from HOME (or another project)
@@ -332,13 +335,19 @@ export async function initCommand(args) {
332
335
  try { syncWorktreeSparseCheckout(AW_HOME, localAw); } catch { /* best effort */ }
333
336
  }
334
337
 
338
+ // Prune stale hooks from prior version before installing new ones
339
+ const oldManifest = readHookManifest();
340
+ if (oldManifest) pruneStaleHooks(oldManifest);
341
+
335
342
  await installAwEcc(cwd, { silent });
343
+
336
344
  ensureAwRuntimeHook(HOME);
337
345
  syncHomeAndProjectInstructions(cwd, freshCfg?.namespace || team);
338
346
  await setupMcp(HOME, freshCfg?.namespace || team, { silent });
339
347
  applyStoredStartupPreferences(HOME);
340
348
  const removedLegacyStartupFiles = cwd !== HOME ? removeWorkspaceHookDefaults(cwd) : [];
341
349
  installGlobalHooks();
350
+ if (!silent) fmt.logStep('Hooks and IDE integration configured');
342
351
 
343
352
  // Remove old local .git/hooks/post-checkout that pre-dates core.hooksPath (creates stale .aw_registry symlink)
344
353
  if (cwd !== HOME) {
@@ -365,11 +374,16 @@ export async function initCommand(args) {
365
374
 
366
375
  // Wire ~/.claude/.cursor/.codex to the project's registry when in a project,
367
376
  // so edits to project/.aw/.aw_registry/ are instantly visible in global IDE dirs.
377
+ if (!silent) fmt.logStep('Wiring IDE symlinks...');
368
378
  const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
369
379
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
370
380
  const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
371
381
  const commands = generateCommands(HOME, { silent: true });
372
382
  if (cwd !== HOME) installLocalCommitHook(cwd);
383
+ if (!silent) fmt.logStep(`IDE wired — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`);
384
+
385
+ // Write hook manifest after all hook installation is complete
386
+ try { writeHookManifest({ eccVersion: AW_ECC_TAG, awVersion: VERSION }); } catch { /* best effort */ }
373
387
 
374
388
  if (silent) {
375
389
  autoUpdate(await args._updateCheck);
@@ -462,21 +476,32 @@ export async function initCommand(args) {
462
476
  if (folderName) {
463
477
  config.addPattern(GLOBAL_AW_DIR, folderName);
464
478
  }
479
+ // Parallel batch A: rules sync (HOME + cwd are independent targets)
465
480
  syncRulesTargets(HOME);
466
- if (cwd !== HOME) {
467
- syncRulesTargets(cwd);
468
- }
481
+ if (cwd !== HOME) syncRulesTargets(cwd);
469
482
  removeLegacyRegistryRules();
483
+ if (!silent) fmt.logStep('Rules synced');
470
484
 
471
485
  // Step 3: Setup tasks, MCP, hooks
486
+ // Prune stale hooks from prior version before installing new ones
487
+ const oldManifestFresh = readHookManifest();
488
+ if (oldManifestFresh) pruneStaleHooks(oldManifestFresh);
489
+
472
490
  await installAwEcc(cwd, { silent });
491
+
473
492
  ensureAwRuntimeHook(HOME);
474
- syncHomeAndProjectInstructions(cwd, team);
475
- const mcpFiles = await setupMcp(HOME, team, { silent }) || [];
493
+
494
+ // Parallel batch B: post-ECC setup (instructions and MCP are independent)
495
+ const [, mcpFiles] = await Promise.all([
496
+ Promise.resolve(syncHomeAndProjectInstructions(cwd, team)),
497
+ setupMcp(HOME, team, { silent }),
498
+ ]);
499
+ // applyStoredStartupPreferences reads settings written by ECC — keep after batch B
476
500
  applyStoredStartupPreferences(HOME);
477
501
  const removedLegacyStartupFiles = cwd !== HOME ? removeWorkspaceHookDefaults(cwd) : [];
478
502
  const hooksInstalled = installGlobalHooks();
479
503
  installIdeTasks();
504
+ if (!silent) fmt.logStep('Hooks and IDE integration configured');
480
505
 
481
506
  // Remove old local .git/hooks/post-checkout that pre-dates core.hooksPath
482
507
  if (cwd !== HOME) {
@@ -502,15 +527,22 @@ export async function initCommand(args) {
502
527
 
503
528
  // Step 5: Wire ~/.claude/.cursor/.codex to the project's registry when in a project,
504
529
  // 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...');
530
+ if (!silent) fmt.logStep('Wiring IDE symlinks...');
507
531
  const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
508
532
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
509
- const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
533
+ // Parallel batch C: symlinks + commands are independent
510
534
  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`);
535
+ const [symlinks, commands] = [
536
+ linkWorkspace(HOME, awDirForLinks, { silent: true }),
537
+ generateCommands(HOME, { silent: true }),
538
+ ];
539
+ if (!silent) fmt.logStep(`IDE wired — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`);
540
+
541
+ // Write hook manifest after all hook installation is complete
542
+ try { writeHookManifest({ eccVersion: AW_ECC_TAG, awVersion: VERSION }); } catch { /* best effort */ }
543
+
544
+ // Ensure telemetry config exists (generates machine_id on first run)
545
+ ensureTelemetryConfig();
514
546
 
515
547
  // Offer to update if a newer version is available
516
548
  await promptUpdate(await args._updateCheck);
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.3";
15
+ export const AW_ECC_TAG = "v1.4.41-beta.1";
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
 
@@ -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
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.42-beta.3",
3
+ "version": "0.1.42-beta.31",
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,6 +16,11 @@ 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: 'node "$HOME/.aw-ecc/scripts/hooks/aw-usage-session-start.js"',
23
+ },
19
24
  {
20
25
  phase: 'PostToolUse',
21
26
  matcher: 'Skill|Agent|Shell|Bash',
@@ -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) {
@@ -196,10 +204,14 @@ function enableClaudeTelemetryHooks(homeDir = homedir()) {
196
204
 
197
205
  for (const hookDef of CLAUDE_TELEMETRY_HOOKS) {
198
206
  const current = Array.isArray(config.hooks[hookDef.phase]) ? config.hooks[hookDef.phase] : [];
199
- // Skip if already present
200
- if (current.some(isManagedClaudeTelemetryEntry)) continue;
207
+ const managed = current.filter(isManagedClaudeTelemetryEntry);
208
+
209
+ // If exactly one properly described entry exists, nothing to do
210
+ if (managed.length === 1 && managed[0].description === CLAUDE_TELEMETRY_DESCRIPTION) continue;
201
211
 
202
- config.hooks[hookDef.phase] = [...current, buildClaudeTelemetryEntry(hookDef)];
212
+ // Remove all legacy/duplicate telemetry entries, then add the canonical one
213
+ const cleaned = current.filter(e => !isManagedClaudeTelemetryEntry(e));
214
+ config.hooks[hookDef.phase] = [...cleaned, buildClaudeTelemetryEntry(hookDef)];
203
215
  changed = true;
204
216
  }
205
217