@ghl-ai/aw 0.1.42-beta.25 → 0.1.42-beta.26

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
@@ -29,8 +29,9 @@ import { installLocalCommitHook } from '../hooks.mjs';
29
29
  import { autoUpdate, promptUpdate } from '../update.mjs';
30
30
  import { installGlobalHooks } from '../hooks.mjs';
31
31
  import { loadConfig as ensureTelemetryConfig } from '../telemetry.mjs';
32
- import { installAwEcc } from '../ecc.mjs';
32
+ import { installAwEcc, AW_ECC_TAG } from '../ecc.mjs';
33
33
  import { removeWorkspaceHookDefaults } from '../codex.mjs';
34
+ import { readHookManifest, pruneStaleHooks, writeHookManifest } from '../hook-cleanup.mjs';
34
35
  import {
35
36
  initPersistentClone,
36
37
  isValidClone,
@@ -333,6 +334,10 @@ export async function initCommand(args) {
333
334
  try { syncWorktreeSparseCheckout(AW_HOME, localAw); } catch { /* best effort */ }
334
335
  }
335
336
 
337
+ // Prune stale hooks from prior version before installing new ones
338
+ const oldManifest = readHookManifest();
339
+ if (oldManifest) pruneStaleHooks(oldManifest);
340
+
336
341
  await installAwEcc(cwd, { silent });
337
342
  ensureAwRuntimeHook(HOME);
338
343
  syncHomeAndProjectInstructions(cwd, freshCfg?.namespace || team);
@@ -372,6 +377,9 @@ export async function initCommand(args) {
372
377
  const commands = generateCommands(HOME, { silent: true });
373
378
  if (cwd !== HOME) installLocalCommitHook(cwd);
374
379
 
380
+ // Write hook manifest after all hook installation is complete
381
+ try { writeHookManifest({ eccVersion: AW_ECC_TAG, awVersion: VERSION }); } catch { /* best effort */ }
382
+
375
383
  if (silent) {
376
384
  autoUpdate(await args._updateCheck);
377
385
  } else {
@@ -463,17 +471,25 @@ export async function initCommand(args) {
463
471
  if (folderName) {
464
472
  config.addPattern(GLOBAL_AW_DIR, folderName);
465
473
  }
474
+ // Parallel batch A: rules sync (HOME + cwd are independent targets)
466
475
  syncRulesTargets(HOME);
467
- if (cwd !== HOME) {
468
- syncRulesTargets(cwd);
469
- }
476
+ if (cwd !== HOME) syncRulesTargets(cwd);
470
477
  removeLegacyRegistryRules();
471
478
 
472
479
  // Step 3: Setup tasks, MCP, hooks
480
+ // Prune stale hooks from prior version before installing new ones
481
+ const oldManifestFresh = readHookManifest();
482
+ if (oldManifestFresh) pruneStaleHooks(oldManifestFresh);
483
+
473
484
  await installAwEcc(cwd, { silent });
474
485
  ensureAwRuntimeHook(HOME);
475
- syncHomeAndProjectInstructions(cwd, team);
476
- const mcpFiles = await setupMcp(HOME, team, { silent }) || [];
486
+
487
+ // Parallel batch B: post-ECC setup (instructions and MCP are independent)
488
+ const [, mcpFiles] = await Promise.all([
489
+ Promise.resolve(syncHomeAndProjectInstructions(cwd, team)),
490
+ setupMcp(HOME, team, { silent }),
491
+ ]);
492
+ // applyStoredStartupPreferences reads settings written by ECC — keep after batch B
477
493
  applyStoredStartupPreferences(HOME);
478
494
  const removedLegacyStartupFiles = cwd !== HOME ? removeWorkspaceHookDefaults(cwd) : [];
479
495
  const hooksInstalled = installGlobalHooks();
@@ -507,12 +523,17 @@ export async function initCommand(args) {
507
523
  ideSpinner.start('Wiring IDE symlinks...');
508
524
  const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
509
525
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
510
- const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
526
+ // Parallel batch C: symlinks + commands are independent
511
527
  if (cwd !== HOME) installLocalCommitHook(cwd);
512
- ideSpinner.message('Generating commands...');
513
- const commands = generateCommands(HOME, { silent: true });
528
+ const [symlinks, commands] = [
529
+ linkWorkspace(HOME, awDirForLinks, { silent: true }),
530
+ generateCommands(HOME, { silent: true }),
531
+ ];
514
532
  ideSpinner.stop(`IDE wired — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`);
515
533
 
534
+ // Write hook manifest after all hook installation is complete
535
+ try { writeHookManifest({ eccVersion: AW_ECC_TAG, awVersion: VERSION }); } catch { /* best effort */ }
536
+
516
537
  // Ensure telemetry config exists (generates machine_id on first run)
517
538
  ensureTelemetryConfig();
518
539
 
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
@@ -10,7 +10,7 @@ import { applyStoredStartupPreferences } from "./startup.mjs";
10
10
 
11
11
  const AW_ECC_REPO_SSH = "git@github.com:shreyansh-ghl/aw-ecc.git";
12
12
  const AW_ECC_REPO_HTTPS = "https://github.com/shreyansh-ghl/aw-ecc.git";
13
- export const AW_ECC_TAG = "v1.4.39";
13
+ export const AW_ECC_TAG = "v1.4.41-beta.1";
14
14
 
15
15
  const MARKETPLACE_NAME = "aw-marketplace";
16
16
  const PLUGIN_KEY = `aw@${MARKETPLACE_NAME}`;
@@ -291,15 +291,13 @@ export async function installAwEcc(
291
291
  run(`node "${generateHooksScript}"`, { cwd: repoDir });
292
292
  } catch { /* best effort — older engine versions may not have this script */ }
293
293
  }
294
- for (const target of fileCopyTargets) {
294
+ // Each target writes to disjoint paths (~/.claude/, ~/.cursor/, ~/.codex/) — safe to parallelize.
295
+ await Promise.all(fileCopyTargets.map(async (target) => {
295
296
  try {
296
297
  const snapshot = snapshotProtectedConfigs(home, target);
297
298
 
298
299
  // Always use HOME as cwd so files land in ~/.<target>/ globally.
299
300
  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
301
  const installArgs = target === "claude"
304
302
  ? `--target ${target} --modules ${CLAUDE_FILE_COPY_MODULES.join(",")}`
305
303
  : `--target ${target} --profile full`;
@@ -307,27 +305,16 @@ export async function installAwEcc(
307
305
  `node ${join(repoDir, "scripts/install-apply.js")} ${installArgs}`,
308
306
  { cwd: runCwd },
309
307
  );
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
308
  if (target === "cursor") {
313
309
  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
310
  transformCursorAwRefs(home);
318
311
  }
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
312
  if (target === "codex") {
323
313
  syncEccToCodex(repoDir);
324
314
  }
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
315
  restoreProtectedConfigs(snapshot);
329
316
  } catch { /* target not supported — skip */ }
330
- }
317
+ }));
331
318
  }
332
319
 
333
320
  applyStoredStartupPreferences();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.42-beta.25",
3
+ "version": "0.1.42-beta.26",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {
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