@ghl-ai/aw 0.1.39-beta.8 → 0.1.39

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
@@ -23,12 +23,14 @@ import * as config from '../config.mjs';
23
23
  import * as fmt from '../fmt.mjs';
24
24
  import { chalk } from '../fmt.mjs';
25
25
  import { linkWorkspace } from '../link.mjs';
26
- import { generateCommands, copyInstructions, initAwDocs } from '../integrate.mjs';
26
+ import { generateCommands, copyInstructions, initAwDocs, syncHomeHarnessInstructions } from '../integrate.mjs';
27
27
  import { setupMcp } from '../mcp.mjs';
28
+ import { applyStoredStartupPreferences, ensureAwRuntimeHook } from '../startup.mjs';
28
29
  import { installLocalCommitHook } from '../hooks.mjs';
29
30
  import { autoUpdate, promptUpdate } from '../update.mjs';
30
31
  import { installGlobalHooks } from '../hooks.mjs';
31
32
  import { installAwEcc } from '../ecc.mjs';
33
+ import { removeWorkspaceHookDefaults } from '../codex.mjs';
32
34
  import {
33
35
  initPersistentClone,
34
36
  isValidClone,
@@ -42,28 +44,11 @@ import {
42
44
  syncWorktreeSparseCheckout,
43
45
  findNearestWorktree,
44
46
  } from '../git.mjs';
45
- import { REGISTRY_DIR, REGISTRY_REPO, REGISTRY_URL, RULES_SOURCE_DIR } from '../constants.mjs';
47
+ import { REGISTRY_DIR, REGISTRY_REPO, REGISTRY_URL, RULES_SOURCE_DIR, RULES_RUNTIME_DIR } from '../constants.mjs';
46
48
  import { syncFileTree } from '../file-tree.mjs';
47
49
 
48
50
  const __dirname = dirname(fileURLToPath(import.meta.url));
49
51
  const VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version;
50
- const DEBUG_INIT_LOG = process.env.AW_DEBUG_INIT_LOG;
51
-
52
- function debugInit(message) {
53
- if (!DEBUG_INIT_LOG) return;
54
- try {
55
- appendFileSync(DEBUG_INIT_LOG, `${new Date().toISOString()} ${message}\n`);
56
- } catch { /* best effort */ }
57
- }
58
-
59
- function createSpinner(enabled) {
60
- if (enabled) return fmt.spinner();
61
- return {
62
- start() {},
63
- stop() {},
64
- message() {},
65
- };
66
- }
67
52
 
68
53
  // Resolve HOME to the real path — on macOS /var is a symlink to /private/var,
69
54
  // so homedir() returns /var/... while process.cwd() returns /private/var/...
@@ -74,6 +59,35 @@ const HOME = (() => { try { return realpathSync(_rawHome); } catch { return _raw
74
59
  const GLOBAL_AW_DIR = join(HOME, '.aw_registry');
75
60
  const AW_HOME = join(HOME, '.aw');
76
61
 
62
+ function syncRulesTargets(targetDir) {
63
+ const rulesSrc = join(AW_HOME, RULES_SOURCE_DIR);
64
+ if (!existsSync(rulesSrc)) return false;
65
+ syncFileTree(rulesSrc, join(targetDir, RULES_RUNTIME_DIR));
66
+ return true;
67
+ }
68
+
69
+ function removeLegacyRegistryRules() {
70
+ try {
71
+ rmSync(join(GLOBAL_AW_DIR, RULES_SOURCE_DIR), { recursive: true, force: true });
72
+ rmSync(join(HOME, RULES_SOURCE_DIR), { recursive: true, force: true });
73
+ } catch {
74
+ // best effort cleanup
75
+ }
76
+ }
77
+
78
+ function syncInstructionsAndAwDocs(targetDir, namespace) {
79
+ copyInstructions(targetDir, null, namespace);
80
+ initAwDocs(targetDir);
81
+ }
82
+
83
+ function syncHomeAndProjectInstructions(cwd, namespace) {
84
+ syncHomeHarnessInstructions(HOME);
85
+ initAwDocs(HOME);
86
+ if (cwd !== HOME) {
87
+ syncInstructionsAndAwDocs(cwd, namespace);
88
+ }
89
+ }
90
+
77
91
  // ── Ensure ~/.aw/.gitignore has personal/local entries ───────────────────
78
92
 
79
93
  const AW_GITIGNORE_ENTRIES = [
@@ -138,12 +152,8 @@ export async function initCommand(args) {
138
152
  let namespace = args['--namespace'] || null;
139
153
  let user = args['--user'] || '';
140
154
  const silent = args['--silent'] === true;
141
- const interactiveUi = !silent && Boolean(process.stdout.isTTY);
142
155
 
143
- if (interactiveUi) {
144
- fmt.intro(`aw init ${chalk.dim('v' + VERSION)}`);
145
- }
146
- debugInit('init:start');
156
+ fmt.intro(`aw init ${chalk.dim('v' + VERSION)}`);
147
157
 
148
158
  // ── Validate ──────────────────────────────────────────────────────────
149
159
 
@@ -191,7 +201,7 @@ export async function initCommand(args) {
191
201
 
192
202
  let namespaceExistsInRemote = false;
193
203
  if (folderName && !silent && !isGitNative && !isLegacy) {
194
- const probeSpinner = createSpinner(interactiveUi);
204
+ const probeSpinner = fmt.spinner();
195
205
  probeSpinner.start(`Checking namespace ${chalk.cyan(folderName)} in registry...`);
196
206
  try {
197
207
  const probePaths = includeToSparsePaths([folderName]);
@@ -204,15 +214,11 @@ export async function initCommand(args) {
204
214
  } finally {
205
215
  cleanup(probeDir);
206
216
  }
207
- if (interactiveUi) {
208
- probeSpinner.stop(namespaceExistsInRemote
209
- ? `Namespace ${chalk.cyan(folderName)} found in registry`
210
- : `Namespace ${chalk.cyan(folderName)} not yet in registry`);
211
- }
217
+ probeSpinner.stop(namespaceExistsInRemote
218
+ ? `Namespace ${chalk.cyan(folderName)} found in registry`
219
+ : `Namespace ${chalk.cyan(folderName)} not yet in registry`);
212
220
  } catch {
213
- if (interactiveUi) {
214
- probeSpinner.stop(chalk.dim('Could not verify namespace (continuing)'));
215
- }
221
+ probeSpinner.stop(chalk.dim('Could not verify namespace (continuing)'));
216
222
  namespaceExistsInRemote = true;
217
223
  }
218
224
  }
@@ -254,7 +260,7 @@ export async function initCommand(args) {
254
260
  if (!silent) fmt.logStep('Already initialized — syncing...');
255
261
  }
256
262
 
257
- const s = createSpinner(interactiveUi);
263
+ const s = fmt.spinner();
258
264
  if (!silent) s.start('Fetching latest from registry...');
259
265
  try {
260
266
  const { conflicts } = await fetchAndMerge(AW_HOME, { silent });
@@ -272,9 +278,11 @@ export async function initCommand(args) {
272
278
 
273
279
  ensureAwGitignore(AW_HOME);
274
280
  const freshCfg = config.load(GLOBAL_AW_DIR);
275
- if (existsSync(GLOBAL_AW_DIR)) {
276
- syncFileTree(join(AW_HOME, RULES_SOURCE_DIR), join(GLOBAL_AW_DIR, RULES_SOURCE_DIR));
281
+ syncRulesTargets(HOME);
282
+ if (cwd !== HOME) {
283
+ syncRulesTargets(cwd);
277
284
  }
285
+ removeLegacyRegistryRules();
278
286
 
279
287
  // Ensure project worktree sparse checkout matches the global clone.
280
288
  // Covers the case where a namespace was added from HOME (or another project)
@@ -285,9 +293,11 @@ export async function initCommand(args) {
285
293
  }
286
294
 
287
295
  await installAwEcc(cwd, { silent });
288
- copyInstructions(HOME, null, freshCfg?.namespace || team) || [];
289
- initAwDocs(HOME);
296
+ ensureAwRuntimeHook(HOME);
297
+ syncHomeAndProjectInstructions(cwd, freshCfg?.namespace || team);
290
298
  await setupMcp(HOME, freshCfg?.namespace || team, { silent });
299
+ applyStoredStartupPreferences(HOME);
300
+ const removedLegacyStartupFiles = cwd !== HOME ? removeWorkspaceHookDefaults(cwd) : [];
291
301
  installGlobalHooks();
292
302
 
293
303
  // Remove old local .git/hooks/post-checkout that pre-dates core.hooksPath (creates stale .aw_registry symlink)
@@ -329,6 +339,9 @@ export async function initCommand(args) {
329
339
  '',
330
340
  ` ${chalk.green('✓')} Registry synced`,
331
341
  ` ${chalk.green('✓')} IDE refreshed — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`,
342
+ removedLegacyStartupFiles.length > 0
343
+ ? ` ${chalk.green('✓')} Removed ${removedLegacyStartupFiles.length} legacy repo startup file${removedLegacyStartupFiles.length > 1 ? 's' : ''}`
344
+ : null,
332
345
  cwd !== HOME && isWorktree(join(cwd, '.aw')) ? ` ${chalk.green('✓')} Project linked` : null,
333
346
  ].filter(Boolean).join('\n'));
334
347
  }
@@ -363,26 +376,21 @@ export async function initCommand(args) {
363
376
  sparsePaths.push(`.aw_registry/${folderName}`);
364
377
  }
365
378
 
366
- if (interactiveUi) {
367
- fmt.note([
368
- `${chalk.dim('source:')} ~/.aw/ ~/.aw_registry/ (symlink)`,
369
- folderName ? `${chalk.dim('namespace:')} ${folderName}` : `${chalk.dim('namespace:')} ${chalk.dim('none')}`,
370
- user ? `${chalk.dim('user:')} ${user}` : null,
371
- `${chalk.dim('version:')} v${VERSION}`,
372
- ].filter(Boolean).join('\n'), 'Config');
373
- }
379
+ fmt.note([
380
+ `${chalk.dim('source:')} ~/.aw/ → ~/.aw_registry/ (symlink)`,
381
+ folderName ? `${chalk.dim('namespace:')} ${folderName}` : `${chalk.dim('namespace:')} ${chalk.dim('none')}`,
382
+ user ? `${chalk.dim('user:')} ${user}` : null,
383
+ `${chalk.dim('version:')} v${VERSION}`,
384
+ ].filter(Boolean).join('\n'), 'Config');
374
385
 
375
- const s = createSpinner(interactiveUi);
386
+ const s = fmt.spinner();
376
387
  s.start(`Cloning registry...`);
377
388
 
378
389
  try {
379
- debugInit(`clone:start repo=${repoUrl}`);
380
390
  await initPersistentClone(repoUrl, AW_HOME, sparsePaths);
381
- debugInit('clone:done');
382
391
  ensureAwGitignore(AW_HOME);
383
392
  s.stop('Registry cloned');
384
393
  } catch (e) {
385
- debugInit(`clone:error ${e.message}`);
386
394
  s.stop(chalk.red('Clone failed'));
387
395
  fmt.cancel(e.message);
388
396
  }
@@ -393,55 +401,42 @@ export async function initCommand(args) {
393
401
  try { awRegistryLstat = lstatSync(GLOBAL_AW_DIR); } catch { /* doesn't exist */ }
394
402
  if (!awRegistryLstat) {
395
403
  try {
396
- debugInit('symlink:create:start');
397
404
  symlinkSync(join(AW_HOME, REGISTRY_DIR), GLOBAL_AW_DIR);
398
- debugInit('symlink:create:done');
399
405
  fmt.logStep('Created ~/.aw_registry/ symlink');
400
406
  } catch (e) {
401
- debugInit(`symlink:create:error ${e.message}`);
402
407
  fmt.logWarn(`Could not create symlink ~/.aw_registry/: ${e.message}`);
403
408
  }
404
409
  } else if (awRegistryLstat.isSymbolicLink()) {
405
410
  // Stale or dangling — re-point to the new clone
406
411
  try {
407
- debugInit('symlink:update:start');
408
412
  rmSync(GLOBAL_AW_DIR);
409
413
  symlinkSync(join(AW_HOME, REGISTRY_DIR), GLOBAL_AW_DIR);
410
- debugInit('symlink:update:done');
411
414
  fmt.logStep('Updated ~/.aw_registry/ symlink');
412
415
  } catch (e) {
413
- debugInit(`symlink:update:error ${e.message}`);
414
416
  fmt.logWarn(`Could not update symlink ~/.aw_registry/: ${e.message}`);
415
417
  }
416
418
  }
417
419
 
418
420
  // Create sync config — default to 'platform' when no namespace specified
419
421
  const cfg = config.create(GLOBAL_AW_DIR, { namespace: team || 'platform', user });
420
- debugInit('config:create:done');
421
422
  if (folderName) {
422
423
  config.addPattern(GLOBAL_AW_DIR, folderName);
423
- debugInit('config:add-pattern:done');
424
424
  }
425
- if (existsSync(GLOBAL_AW_DIR)) {
426
- debugInit('sync-file-tree:start');
427
- syncFileTree(join(AW_HOME, RULES_SOURCE_DIR), join(GLOBAL_AW_DIR, RULES_SOURCE_DIR));
428
- debugInit('sync-file-tree:done');
425
+ syncRulesTargets(HOME);
426
+ if (cwd !== HOME) {
427
+ syncRulesTargets(cwd);
429
428
  }
429
+ removeLegacyRegistryRules();
430
430
 
431
431
  // Step 3: Setup tasks, MCP, hooks
432
- debugInit('ecc:start');
433
432
  await installAwEcc(cwd, { silent });
434
- debugInit('ecc:done');
435
- const instructionFiles = copyInstructions(HOME, null, team) || [];
436
- debugInit(`instructions:done count=${instructionFiles.length}`);
437
- initAwDocs(HOME);
438
- debugInit('aw-docs:done');
433
+ ensureAwRuntimeHook(HOME);
434
+ syncHomeAndProjectInstructions(cwd, team);
439
435
  const mcpFiles = await setupMcp(HOME, team, { silent }) || [];
440
- debugInit(`mcp:done count=${mcpFiles.length}`);
436
+ applyStoredStartupPreferences(HOME);
437
+ const removedLegacyStartupFiles = cwd !== HOME ? removeWorkspaceHookDefaults(cwd) : [];
441
438
  const hooksInstalled = installGlobalHooks();
442
- debugInit(`hooks:done installed=${hooksInstalled}`);
443
439
  installIdeTasks();
444
- debugInit('ide-tasks:done');
445
440
 
446
441
  // Remove old local .git/hooks/post-checkout that pre-dates core.hooksPath
447
442
  if (cwd !== HOME) {
@@ -460,33 +455,24 @@ export async function initCommand(args) {
460
455
  const isAlreadySymlinkFresh = (() => { try { return lstatSync(awLinkFresh).isSymbolicLink() && existsSync(awLinkFresh); } catch { return false; } })();
461
456
  if (cwd !== HOME && !isInsideAw && !isAlreadySymlinkFresh) {
462
457
  try {
463
- debugInit('link-project:start');
464
458
  addProjectWorktree(AW_HOME, cwd);
465
- debugInit('link-project:done');
466
459
  fmt.logStep('Linked current project');
467
460
  } catch { /* best effort */ }
468
461
  }
469
462
 
470
463
  // Step 5: Wire ~/.claude/.cursor/.codex to the project's registry when in a project,
471
464
  // so edits to project/.aw/.aw_registry/ are instantly visible in global IDE dirs.
472
- const ideSpinner = createSpinner(interactiveUi);
465
+ const ideSpinner = fmt.spinner();
473
466
  ideSpinner.start('Wiring IDE symlinks...');
474
- debugInit('ide-wire:start');
475
467
  const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
476
468
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
477
469
  const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
478
470
  if (cwd !== HOME) installLocalCommitHook(cwd);
479
471
  ideSpinner.message('Generating commands...');
480
472
  const commands = generateCommands(HOME, { silent: true });
481
- debugInit(`ide-wire:done symlinks=${symlinks} commands=${commands}`);
482
473
  ideSpinner.stop(`IDE wired — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`);
483
474
 
484
475
  // Offer to update if a newer version is available
485
- if (silent) {
486
- autoUpdate(await args._updateCheck);
487
- return;
488
- }
489
-
490
476
  await promptUpdate(await args._updateCheck);
491
477
 
492
478
  fmt.outro([
@@ -495,6 +481,10 @@ export async function initCommand(args) {
495
481
  ` ${chalk.green('✓')} Source of truth: ~/.aw/ (git clone)`,
496
482
  ` ${chalk.green('✓')} Symlink: ~/.aw_registry/ → ~/.aw/.aw_registry/`,
497
483
  ` ${chalk.green('✓')} IDE integration: ~/.claude/, ~/.cursor/, ~/.codex/`,
484
+ cwd !== HOME ? ` ${chalk.green('✓')} Global startup managed from ~/.claude/, ~/.cursor/, ~/.codex/` : null,
485
+ removedLegacyStartupFiles.length > 0
486
+ ? ` ${chalk.green('✓')} Removed ${removedLegacyStartupFiles.length} legacy repo startup file${removedLegacyStartupFiles.length > 1 ? 's' : ''}`
487
+ : null,
498
488
  hooksInstalled ? ` ${chalk.green('✓')} Git hooks: auto-sync on pull/clone (core.hooksPath)` : null,
499
489
  ` ${chalk.green('✓')} IDE task: auto-sync on workspace open`,
500
490
  cwd !== HOME && isWorktree(join(cwd, '.aw')) ? ` ${chalk.green('✓')} Linked in current project` : null,
@@ -9,6 +9,8 @@ import { addProjectWorktree, isWorktree, isValidClone } from '../git.mjs';
9
9
  import { REGISTRY_DIR, REGISTRY_URL } from '../constants.mjs';
10
10
  import { linkWorkspace } from '../link.mjs';
11
11
  import { generateCommands } from '../integrate.mjs';
12
+ import { removeWorkspaceHookDefaults } from '../codex.mjs';
13
+ import { applyStoredStartupPreferences } from '../startup.mjs';
12
14
  import { installLocalCommitHook } from '../hooks.mjs';
13
15
 
14
16
  const HOME = homedir();
@@ -41,8 +43,11 @@ export function linkProjectCommand(args) {
41
43
  const awDirForLinks = existsSync(projectRegistryDir) ? projectRegistryDir : null;
42
44
  const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
43
45
  const commands = generateCommands(HOME, { silent: true });
46
+ applyStoredStartupPreferences(HOME);
44
47
  installLocalCommitHook(cwd);
45
- fmt.logSuccess(`Already linked refreshed ${chalk.bold(symlinks)} IDE symlinks · ${chalk.bold(commands)} commands`);
48
+ const removedLegacyStartupFiles = removeWorkspaceHookDefaults(cwd);
49
+ fmt.logSuccess(`Already linked — refreshed ${chalk.bold(symlinks)} IDE symlinks · ${chalk.bold(commands)} commands${removedLegacyStartupFiles.length > 0 ? ` · removed ${removedLegacyStartupFiles.length} legacy repo startup file${removedLegacyStartupFiles.length > 1 ? 's' : ''}` : ''}`);
50
+
46
51
  return;
47
52
  }
48
53
 
@@ -52,13 +57,19 @@ export function linkProjectCommand(args) {
52
57
  const awDirForLinks = existsSync(projectRegistryDir) ? projectRegistryDir : null;
53
58
  const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
54
59
  const commands = generateCommands(HOME, { silent: true });
60
+ applyStoredStartupPreferences(HOME);
55
61
  installLocalCommitHook(cwd);
62
+ const removedLegacyStartupFiles = removeWorkspaceHookDefaults(cwd);
56
63
  fmt.logSuccess([
57
64
  `Project linked — ${chalk.bold(symlinks)} IDE symlinks · ${chalk.bold(commands)} commands`,
58
65
  '',
59
66
  ` ${chalk.green('✓')} ${chalk.dim('.aw/')} git worktree (IDE git panel enabled)`,
60
67
  ` ${chalk.green('✓')} ${chalk.dim(`.aw/${REGISTRY_DIR}/`)} registry content`,
61
68
  ` ${chalk.green('✓')} ${chalk.dim('.claude/.cursor/.codex/')} IDE symlinks wired`,
69
+ ` ${chalk.green('✓')} ${chalk.dim('startup mode')} global-first via ~/.claude/, ~/.cursor/, ~/.codex/`,
70
+ removedLegacyStartupFiles.length > 0
71
+ ? ` ${chalk.green('✓')} ${chalk.dim('legacy repo hooks')} removed ${removedLegacyStartupFiles.length} AW-managed file${removedLegacyStartupFiles.length > 1 ? 's' : ''}`
72
+ : null,
62
73
  ].join('\n'));
63
74
  } catch (e) {
64
75
  fmt.cancel(`Failed to link project: ${e.message}`);
package/commands/nuke.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Safety guarantee: NEVER deletes files that AW didn't create.
4
4
 
5
- import { join } from 'node:path';
5
+ import { dirname, join } from 'node:path';
6
6
  import { existsSync, rmSync, lstatSync, unlinkSync, readdirSync, readFileSync, readlinkSync, writeFileSync } from 'node:fs';
7
7
  import { homedir } from 'node:os';
8
8
  import { execSync, exec as execCb } from 'node:child_process';
@@ -15,13 +15,14 @@ import { removeGlobalHooks } from '../hooks.mjs';
15
15
  import { uninstallAwEcc } from '../ecc.mjs';
16
16
  import { removeMcpConfig } from '../mcp.mjs';
17
17
  import { listProjectWorktrees } from '../git.mjs';
18
+ import { removeWorkspaceHookDefaults } from '../codex.mjs';
18
19
 
19
20
  const HOME = homedir();
20
21
  const GLOBAL_AW_DIR = join(HOME, '.aw_registry');
21
22
  const MANIFEST_PATH = join(GLOBAL_AW_DIR, '.aw-manifest.json');
22
23
 
23
24
  const IDE_DIRS = ['.claude', '.cursor', '.codex', '.agents'];
24
- const CONTENT_TYPES = ['agents', 'skills', 'commands', 'evals'];
25
+ const CONTENT_TYPES = ['agents', 'skills', 'commands', 'evals', 'references'];
25
26
 
26
27
  function loadManifest() {
27
28
  if (!existsSync(MANIFEST_PATH)) return null;
@@ -139,7 +140,11 @@ async function removeProjectSymlinks() {
139
140
  { encoding: 'utf8', timeout: 30000 }
140
141
  );
141
142
  for (const linkPath of registryLinks.trim().split('\n').filter(Boolean)) {
142
- try { unlinkSync(linkPath); removed++; } catch { /* best effort */ }
143
+ try {
144
+ removeWorkspaceHookDefaults(dirname(linkPath));
145
+ unlinkSync(linkPath);
146
+ removed++;
147
+ } catch { /* best effort */ }
143
148
  }
144
149
 
145
150
  // Also remove legacy local .git/hooks/post-checkout installed by old aw versions
@@ -217,7 +222,12 @@ export async function nukeCommand(args) {
217
222
  // Remove stale local .aw_registry symlink if present (skip if cwd IS home — that's the global one)
218
223
  if (process.cwd() !== HOME) {
219
224
  const local = join(process.cwd(), '.aw_registry');
220
- try { if (lstatSync(local).isSymbolicLink()) unlinkSync(local); } catch { /* fine */ }
225
+ try {
226
+ if (lstatSync(local).isSymbolicLink()) {
227
+ removeWorkspaceHookDefaults(process.cwd());
228
+ unlinkSync(local);
229
+ }
230
+ } catch { /* fine */ }
221
231
  }
222
232
 
223
233
  const manifest = loadManifest();
package/commands/pull.mjs CHANGED
@@ -3,8 +3,9 @@
3
3
  import {
4
4
  existsSync,
5
5
  lstatSync,
6
+ rmSync,
6
7
  } from 'node:fs';
7
- import { join, extname } from 'node:path';
8
+ import { dirname, join, extname } from 'node:path';
8
9
  import { homedir } from 'node:os';
9
10
  import { exec as execCb, execSync } from 'node:child_process';
10
11
  import { promisify } from 'node:util';
@@ -27,15 +28,34 @@ import {
27
28
  REGISTRY_URL,
28
29
  DOCS_SOURCE_DIR,
29
30
  RULES_SOURCE_DIR,
31
+ RULES_RUNTIME_DIR,
30
32
  } from '../constants.mjs';
31
33
  import { collectAllPaths, syncFileTree } from '../file-tree.mjs';
32
34
  import { linkWorkspace } from '../link.mjs';
33
- import { generateCommands, copyInstructions } from '../integrate.mjs';
35
+ import { generateCommands, copyInstructions, syncHomeHarnessInstructions } from '../integrate.mjs';
36
+ import { removeWorkspaceHookDefaults } from '../codex.mjs';
37
+ import { applyStoredStartupPreferences, ensureAwRuntimeHook } from '../startup.mjs';
34
38
 
35
39
  const HOME = homedir();
36
40
  const AW_HOME = join(HOME, '.aw');
37
41
  const GLOBAL_AW_DIR = join(HOME, '.aw_registry');
38
42
 
43
+ function syncRulesTargets(targetDir) {
44
+ const rulesSrc = join(AW_HOME, RULES_SOURCE_DIR);
45
+ if (!existsSync(rulesSrc)) return false;
46
+ syncFileTree(rulesSrc, join(targetDir, RULES_RUNTIME_DIR));
47
+ return true;
48
+ }
49
+
50
+ function removeLegacyRegistryRules() {
51
+ try {
52
+ rmSync(join(GLOBAL_AW_DIR, RULES_SOURCE_DIR), { recursive: true, force: true });
53
+ rmSync(join(HOME, RULES_SOURCE_DIR), { recursive: true, force: true });
54
+ } catch {
55
+ // best effort cleanup
56
+ }
57
+ }
58
+
39
59
  export async function pullCommand(args) {
40
60
  const input = args._positional?.[0] || '';
41
61
  const cwd = process.cwd();
@@ -177,13 +197,6 @@ export async function pullCommand(args) {
177
197
  log.logWarn(`Conflicts in: ${fetchResult.conflicts.join(', ')}`);
178
198
  }
179
199
 
180
- const rulesSrc = join(AW_HOME, RULES_SOURCE_DIR);
181
- if (existsSync(rulesSrc)) {
182
- const rulesDest = join(GLOBAL_AW_DIR, RULES_SOURCE_DIR);
183
- syncFileTree(rulesSrc, rulesDest);
184
- if (!silent) log.logSuccess('Synced .aw_rules');
185
- }
186
-
187
200
  // Rebase project worktree branch onto origin/main — only for legacy git worktrees.
188
201
  // In the symlink model, <project>/.aw IS ~/.aw (same repo), so fetchAndMerge already
189
202
  // brought it up to date. Nothing to rebase.
@@ -242,9 +255,20 @@ export async function pullCommand(args) {
242
255
  }
243
256
  }
244
257
 
258
+ let rulesSynced = syncRulesTargets(HOME);
259
+ const workspaceRoot = localAw ? dirname(localAw) : (cwd !== HOME ? cwd : null);
260
+ if (workspaceRoot && workspaceRoot !== HOME) {
261
+ rulesSynced = syncRulesTargets(workspaceRoot) || rulesSynced;
262
+ }
263
+ removeLegacyRegistryRules();
264
+ if (rulesSynced && !silent) {
265
+ log.logSuccess('Synced .aw_rules');
266
+ }
267
+
245
268
  if (!args._skipIntegrate) {
246
269
  const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
247
270
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
271
+ const startupCleanupDir = localAw ? dirname(localAw) : (cwd !== HOME ? cwd : null);
248
272
 
249
273
  if (!silent) {
250
274
  const ideSpinner = log.spinner();
@@ -252,12 +276,18 @@ export async function pullCommand(args) {
252
276
  const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
253
277
  ideSpinner.message('Generating commands...');
254
278
  const commands = generateCommands(HOME, { silent: true });
255
- copyInstructions(HOME, null, cfg.namespace);
279
+ syncHomeHarnessInstructions(HOME);
280
+ ensureAwRuntimeHook(HOME);
281
+ applyStoredStartupPreferences(HOME);
282
+ if (startupCleanupDir) removeWorkspaceHookDefaults(startupCleanupDir);
256
283
  ideSpinner.stop(`IDE wired — ${chalk.bold(symlinks)} symlink${symlinks !== 1 ? 's' : ''}, ${chalk.bold(commands)} command${commands !== 1 ? 's' : ''}`);
257
284
  } else {
258
285
  linkWorkspace(HOME, awDirForLinks, { silent: true });
259
286
  generateCommands(HOME, { silent: true });
260
- copyInstructions(HOME, null, cfg.namespace);
287
+ syncHomeHarnessInstructions(HOME);
288
+ ensureAwRuntimeHook(HOME);
289
+ applyStoredStartupPreferences(HOME);
290
+ if (startupCleanupDir) removeWorkspaceHookDefaults(startupCleanupDir);
261
291
  }
262
292
  }
263
293
 
@@ -290,3 +320,73 @@ function registerMcp(namespace) {
290
320
  fmt.logWarn('MCP registration failed (pull still successful)');
291
321
  }
292
322
  }
323
+ function printDryRun(actions, verbose) {
324
+ const counts = { ADD: 0, UPDATE: 0, CONFLICT: 0, ORPHAN: 0, UNCHANGED: 0 };
325
+ const lines = [];
326
+
327
+ for (const type of ['agents', 'skills', 'commands', 'evals', 'references']) {
328
+ const items = actions.filter(a => a.type === type);
329
+ if (items.length === 0) continue;
330
+
331
+ lines.push(chalk.bold(`${type}/`));
332
+ for (const act of items.sort((a, b) => a.targetFilename.localeCompare(b.targetFilename))) {
333
+ counts[act.action] = (counts[act.action] || 0) + 1;
334
+ if (!verbose && act.action === 'UNCHANGED') continue;
335
+ const ns = act.namespacePath ? chalk.dim(` [${act.namespacePath}]`) : '';
336
+ lines.push(` ${fmt.actionLabel(act.action)} ${act.targetFilename}${ns}`);
337
+ }
338
+ }
339
+
340
+ if (lines.length > 0) {
341
+ fmt.note(lines.join('\n'), 'Dry Run');
342
+ }
343
+
344
+ fmt.logInfo(`Summary: ${fmt.countSummary(counts)}`);
345
+ fmt.logWarn('No files modified (--dry-run)');
346
+ }
347
+
348
+ function printSummary(actions, verbose, conflictCount) {
349
+ const conflicts = actions.filter(a => a.action === 'CONFLICT');
350
+
351
+ for (const type of ['agents', 'skills', 'commands', 'evals', 'references']) {
352
+ const typeActions = actions.filter(a => a.type === type);
353
+ if (typeActions.length === 0) continue;
354
+
355
+ const counts = { ADD: 0, UPDATE: 0, CONFLICT: 0, ORPHAN: 0, UNCHANGED: 0 };
356
+ for (const a of typeActions) counts[a.action]++;
357
+
358
+ const parts = [];
359
+ if (counts.ADD > 0) parts.push(chalk.green(`${counts.ADD} new`));
360
+ if (counts.UPDATE > 0) parts.push(chalk.cyan(`${counts.UPDATE} updated`));
361
+ if (counts.CONFLICT > 0) parts.push(chalk.red(`${counts.CONFLICT} conflict`));
362
+ if (counts.ORPHAN > 0) parts.push(chalk.yellow(`${counts.ORPHAN} removed`));
363
+ const detail = parts.length > 0 ? ` (${parts.join(', ')})` : '';
364
+
365
+ fmt.logSuccess(`${typeActions.length} ${type} pulled${detail}`);
366
+
367
+ if (verbose) {
368
+ for (const a of typeActions.filter(a => a.action !== 'UNCHANGED')) {
369
+ const ns = a.namespacePath ? chalk.dim(` [${a.namespacePath}]`) : '';
370
+ fmt.logMessage(` ${fmt.actionLabel(a.action)} ${a.targetFilename}${ns}`);
371
+ }
372
+ }
373
+ }
374
+
375
+ if (conflicts.length > 0) {
376
+ const conflictLines = conflicts.map(c => {
377
+ return `${chalk.red('both modified:')} ${c.type}/${c.targetFilename}`;
378
+ }).join('\n');
379
+
380
+ fmt.note(
381
+ conflictLines + '\n\n' +
382
+ chalk.dim('Fix conflicts, then re-run pull to verify.\n') +
383
+ chalk.dim('grep -r "<<<<<<< " .aw_registry/'),
384
+ chalk.red('Merge Conflicts')
385
+ );
386
+
387
+ fmt.outro(chalk.red('Pull completed with conflicts — resolve and re-run'));
388
+ process.exit(1);
389
+ }
390
+
391
+ fmt.outro('Pull complete');
392
+ }
@@ -23,11 +23,9 @@ export function isRulesPushInput(input) {
23
23
 
24
24
  export function resolveRulesPushSource(input, cwd = process.cwd()) {
25
25
  const localRulesRoot = join(cwd, RULES_SOURCE_DIR);
26
- const syncedRulesRoot = join(cwd, '.aw_registry', RULES_SOURCE_DIR);
27
26
 
28
27
  if (!input) {
29
28
  if (existsSync(localRulesRoot)) return { sourceRoot: localRulesRoot, sourceType: 'local' };
30
- if (existsSync(syncedRulesRoot)) return { sourceRoot: syncedRulesRoot, sourceType: 'synced' };
31
29
  return null;
32
30
  }
33
31
 
@@ -39,22 +37,19 @@ export function resolveRulesPushSource(input, cwd = process.cwd()) {
39
37
  }
40
38
 
41
39
  if (normalizedInput === `.aw_registry/${RULES_SOURCE_DIR}` || normalizedInput.startsWith(`.aw_registry/${RULES_SOURCE_DIR}/`)) {
42
- if (!existsSync(syncedRulesRoot)) return null;
43
-
44
40
  const relativeRulesPath = normalizedInput.slice(`.aw_registry/${RULES_SOURCE_DIR}`.length).replace(/^\/+/, '');
45
41
  const localOverridePath = relativeRulesPath ? join(localRulesRoot, relativeRulesPath) : localRulesRoot;
46
42
  if (existsSync(localOverridePath)) {
47
43
  return { sourceRoot: localRulesRoot, sourceType: 'local' };
48
44
  }
49
-
50
- return { sourceRoot: syncedRulesRoot, sourceType: 'synced' };
45
+ return null;
51
46
  }
52
47
 
53
48
  return null;
54
49
  }
55
50
 
56
51
  export function hasRulesChanges(cwd = process.cwd()) {
57
- const candidateDirs = [RULES_SOURCE_DIR, `.aw_registry/${RULES_SOURCE_DIR}`]
52
+ const candidateDirs = [RULES_SOURCE_DIR]
58
53
  .filter(rel => existsSync(join(cwd, rel)));
59
54
 
60
55
  if (candidateDirs.length === 0) return false;
@@ -141,9 +136,7 @@ function pushRulesTree(sourceRoot, { repo, dryRun, cwd }) {
141
136
  fmt.cancel('Nothing to push — remote rules already match local content.');
142
137
  }
143
138
 
144
- const sourceType = normalize(sourceRoot).includes(normalize(join('.aw_registry', RULES_SOURCE_DIR)))
145
- ? 'synced'
146
- : 'local';
139
+ const sourceType = 'local';
147
140
  const prTitle = buildRulesPrTitle(sourceRoot, cwd);
148
141
  const prBody = buildRulesPrBody(sourceRoot, sourceType, cwd);
149
142
 
@@ -191,16 +184,12 @@ export function pushRulesCommand(args) {
191
184
  fmt.cancel([
192
185
  'Could not find a rules source to push.',
193
186
  '',
194
- ` Checked ${chalk.cyan('.aw_rules/')} and ${chalk.cyan('.aw_registry/.aw_rules/')}.`,
187
+ ` Checked ${chalk.cyan('.aw_rules/')}.`,
195
188
  '',
196
189
  ' Use `aw pull platform` first or create a local `.aw_rules/` authoring tree.',
197
190
  ].join('\n'));
198
191
  }
199
192
 
200
- if (resolved.sourceType === 'synced') {
201
- fmt.logWarn('Pushing from synced `.aw_registry/.aw_rules/`. Local `.aw_rules/` is safer for authoring.');
202
- }
203
-
204
193
  pushRulesTree(resolved.sourceRoot, { repo, dryRun, cwd });
205
194
  }
206
195