@ghl-ai/aw 0.1.37-beta.8 → 0.1.37-beta.80

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.
Files changed (42) hide show
  1. package/bin.js +2 -2
  2. package/cli.mjs +12 -2
  3. package/commands/daemon.mjs +4 -4
  4. package/commands/init.mjs +31 -9
  5. package/commands/link-project.mjs +3 -0
  6. package/commands/nuke.mjs +19 -14
  7. package/commands/protocol.mjs +107 -0
  8. package/commands/pull.mjs +74 -32
  9. package/commands/push-rules.mjs +212 -0
  10. package/commands/push.mjs +27 -3
  11. package/commands/slack-sim.mjs +128 -0
  12. package/commands/telemetry.mjs +711 -15
  13. package/config.mjs +1 -1
  14. package/constants.mjs +52 -2
  15. package/ecc.mjs +80 -4
  16. package/file-tree.mjs +76 -0
  17. package/fmt.mjs +14 -0
  18. package/git.mjs +2 -1
  19. package/hooks/capabilities/telemetry.mjs +1290 -0
  20. package/hooks/manifest.mjs +26 -0
  21. package/hooks/shared/after-agent-response.mjs +5 -0
  22. package/hooks/shared/dispatch.mjs +83 -0
  23. package/hooks/shared/pre-compact.mjs +5 -0
  24. package/hooks/shared/session-end.mjs +5 -0
  25. package/hooks/shared/session-start.mjs +5 -0
  26. package/hooks/shared/stop.mjs +5 -0
  27. package/hooks.mjs +102 -1
  28. package/integrate.mjs +356 -13
  29. package/package.json +11 -4
  30. package/render-rules.mjs +483 -0
  31. package/slack-sim/fake-slack.mjs +200 -0
  32. package/slack-sim/http.mjs +170 -0
  33. package/slack-sim/in-process.mjs +263 -0
  34. package/slack-sim/render.mjs +42 -0
  35. package/slack-sim/scenario.mjs +64 -0
  36. package/slack-sim/scenarios/checkpoint-approve.json +21 -0
  37. package/slack-sim/scenarios/image-thread.json +27 -0
  38. package/slack-sim/scenarios/implementation-basic.json +18 -0
  39. package/slack-sim/scenarios/poll-webhook-race.json +18 -0
  40. package/slack-sim/scenarios/review-pr.json +14 -0
  41. package/telemetry.mjs +5 -3
  42. package/update.mjs +6 -1
package/bin.js CHANGED
@@ -1,3 +1,3 @@
1
1
  #!/usr/bin/env node
2
- // Standalone bin entrypoint for published npm package
3
- import('./cli.mjs').then(m => m.run(process.argv.slice(2)));
2
+ import { run } from './cli.mjs';
3
+ run(process.argv.slice(2));
package/cli.mjs CHANGED
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
4
4
  import { join, dirname } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import * as fmt from './fmt.mjs';
7
- import { chalk } from './fmt.mjs';
7
+ import { chalk, CancelError } from './fmt.mjs';
8
8
  import { checkForUpdate, notifyUpdate } from './update.mjs';
9
9
  import { startSpan } from './telemetry.mjs';
10
10
 
@@ -15,6 +15,7 @@ const COMMANDS = {
15
15
  init: () => import('./commands/init.mjs').then(m => m.initCommand),
16
16
  pull: () => import('./commands/pull.mjs').then(m => m.pullCommand),
17
17
  push: () => import('./commands/push.mjs').then(m => m.pushCommand),
18
+ 'push-rules': () => import('./commands/push-rules.mjs').then(m => m.pushRulesCommand),
18
19
  drop: () => import('./commands/drop.mjs').then(m => m.dropCommand),
19
20
  status: () => import('./commands/status.mjs').then(m => m.statusCommand),
20
21
  search: () => import('./commands/search.mjs').then(m => m.searchCommand),
@@ -88,6 +89,7 @@ function printHelp() {
88
89
  sec('Upload'),
89
90
  cmd('aw push', 'Push all modified files (creates one PR)'),
90
91
  cmd('aw push <path>', 'Push file, folder, or namespace to registry'),
92
+ cmd('aw push-rules [path]', 'Push platform rules to platform-docs'),
91
93
  cmd('aw push --dry-run [path]', 'Preview what would be pushed'),
92
94
 
93
95
  sec('Discover'),
@@ -102,6 +104,8 @@ function printHelp() {
102
104
  cmd('aw daemon install --interval 30m', 'Set custom interval (e.g. 30m, 2h, 3600)'),
103
105
  cmd('aw daemon uninstall', 'Stop the background daemon'),
104
106
  cmd('aw daemon status', 'Check if daemon is running'),
107
+ cmd('aw slack-sim run <scenario>', 'Replay Slack-like scenarios against real runtime'),
108
+ cmd('aw slack-sim list-scenarios', 'List built-in Slack simulator scenarios'),
105
109
 
106
110
  sec('Settings'),
107
111
  cmd('aw telemetry status', 'Show telemetry status'),
@@ -122,6 +126,8 @@ function printHelp() {
122
126
  cmd('aw push .aw_registry/<team>/', 'Push entire namespace (one PR)'),
123
127
  cmd('aw push .aw_registry/agents/<name>.md', 'Push a single agent'),
124
128
  cmd('aw push .aw_registry/skills/<name>/', 'Push a single skill folder'),
129
+ cmd('aw push .aw_rules', 'Auto-redirects to aw push-rules'),
130
+ cmd('aw push-rules', 'Pushes .aw_rules or .aw_registry/.aw_rules'),
125
131
  '',
126
132
  ` ${chalk.dim('# Remove content from workspace')}`,
127
133
  cmd('aw drop <team>', 'Stop syncing a namespace (removes all files)'),
@@ -162,6 +168,10 @@ export async function run(argv) {
162
168
  await handler(args);
163
169
  await span.end({ status: 'completed' });
164
170
  } catch (err) {
171
+ if (err instanceof CancelError) {
172
+ await span.end({ status: 'cancelled', error_type: 'CancelError' });
173
+ process.exit(err.exitCode ?? 1);
174
+ }
165
175
  await span.end({ status: 'failed', error_type: err.constructor.name });
166
176
  throw err;
167
177
  }
@@ -174,5 +184,5 @@ export async function run(argv) {
174
184
  process.exit(0);
175
185
  }
176
186
 
177
- fmt.cancel(`Unknown command: ${command}`);
187
+ fmt.cancelAndExit(`Unknown command: ${command}`);
178
188
  }
@@ -39,9 +39,9 @@ async function installLaunchd(intervalSeconds) {
39
39
 
40
40
  <key>ProgramArguments</key>
41
41
  <array>
42
- <string>${awBin}</string>
43
- <string>pull</string>
44
- <string>--silent</string>
42
+ <string>/bin/sh</string>
43
+ <string>-c</string>
44
+ <string>'${awBin}' pull --silent && '${awBin}' telemetry flush 2>/dev/null</string>
45
45
  </array>
46
46
 
47
47
  <key>StartInterval</key>
@@ -113,7 +113,7 @@ async function installCron(intervalSeconds) {
113
113
  if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
114
114
 
115
115
  const cronExpr = toCronExpression(intervalSeconds);
116
- const cronLine = `${cronExpr} ${awBin} pull --silent >> ${logDir}/pull.log 2>&1 # aw-daemon`;
116
+ const cronLine = `${cronExpr} /bin/sh -c '${awBin} pull --silent && ${awBin} telemetry flush 2>/dev/null' >> ${logDir}/pull.log 2>&1 # aw-daemon`;
117
117
 
118
118
  let current = '';
119
119
  try { current = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' }); } catch { /* empty */ }
package/commands/init.mjs CHANGED
@@ -4,7 +4,17 @@
4
4
  // Uses core.hooksPath (git-lfs pattern) for system-wide hook interception.
5
5
  // Uses IDE tasks for auto-pull on workspace open.
6
6
 
7
- import { existsSync, writeFileSync, symlinkSync, lstatSync, readdirSync, readFileSync, rmSync, realpathSync, appendFileSync } from 'node:fs';
7
+ import {
8
+ existsSync,
9
+ writeFileSync,
10
+ symlinkSync,
11
+ lstatSync,
12
+ readdirSync,
13
+ readFileSync,
14
+ rmSync,
15
+ realpathSync,
16
+ appendFileSync,
17
+ } from 'node:fs';
8
18
  import { execSync } from 'node:child_process';
9
19
  import { join, dirname, sep } from 'node:path';
10
20
  import { homedir } from 'node:os';
@@ -13,8 +23,9 @@ import * as config from '../config.mjs';
13
23
  import * as fmt from '../fmt.mjs';
14
24
  import { chalk } from '../fmt.mjs';
15
25
  import { linkWorkspace } from '../link.mjs';
16
- import { generateCommands, copyInstructions, initAwDocs } from '../integrate.mjs';
26
+ import { generateCommands, copyInstructions, initAwDocs, installIdeHooks } from '../integrate.mjs';
17
27
  import { setupMcp } from '../mcp.mjs';
28
+ import { installLocalCommitHook } from '../hooks.mjs';
18
29
  import { autoUpdate, promptUpdate } from '../update.mjs';
19
30
  import { installGlobalHooks } from '../hooks.mjs';
20
31
  import { installAwEcc } from '../ecc.mjs';
@@ -31,7 +42,8 @@ import {
31
42
  syncWorktreeSparseCheckout,
32
43
  findNearestWorktree,
33
44
  } from '../git.mjs';
34
- import { REGISTRY_DIR, REGISTRY_REPO, REGISTRY_URL } from '../constants.mjs';
45
+ import { REGISTRY_DIR, REGISTRY_REPO, REGISTRY_URL, RULES_SOURCE_DIR } from '../constants.mjs';
46
+ import { syncFileTree } from '../file-tree.mjs';
35
47
 
36
48
  const __dirname = dirname(fileURLToPath(import.meta.url));
37
49
  const VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version;
@@ -195,7 +207,7 @@ export async function initCommand(args) {
195
207
  }
196
208
 
197
209
  if (choice === 'platform-only') {
198
- namespace = null; team = null; subTeam = null; folderName = null;
210
+ namespace = 'platform'; team = 'platform'; subTeam = null; folderName = null;
199
211
  }
200
212
  }
201
213
 
@@ -210,7 +222,7 @@ export async function initCommand(args) {
210
222
  const isNewSubTeam = folderName && cfg && !cfg.include.includes(folderName);
211
223
  if (isNewSubTeam) {
212
224
  if (!silent) fmt.logStep(`Adding sub-team ${chalk.cyan(folderName)}...`);
213
- const newSparsePaths = [`.aw_registry/${folderName}`, `content`];
225
+ const newSparsePaths = [`.aw_registry/${folderName}`, 'content', RULES_SOURCE_DIR];
214
226
  addToSparseCheckout(AW_HOME, newSparsePaths);
215
227
  config.addPattern(GLOBAL_AW_DIR, folderName);
216
228
  } else {
@@ -235,6 +247,9 @@ export async function initCommand(args) {
235
247
 
236
248
  ensureAwGitignore(AW_HOME);
237
249
  const freshCfg = config.load(GLOBAL_AW_DIR);
250
+ if (existsSync(GLOBAL_AW_DIR)) {
251
+ syncFileTree(join(AW_HOME, RULES_SOURCE_DIR), join(GLOBAL_AW_DIR, RULES_SOURCE_DIR));
252
+ }
238
253
 
239
254
  // Ensure project worktree sparse checkout matches the global clone.
240
255
  // Covers the case where a namespace was added from HOME (or another project)
@@ -249,6 +264,7 @@ export async function initCommand(args) {
249
264
  initAwDocs(HOME);
250
265
  await setupMcp(HOME, freshCfg?.namespace || team, { silent });
251
266
  installGlobalHooks();
267
+ installIdeHooks();
252
268
 
253
269
  // Remove old local .git/hooks/post-checkout that pre-dates core.hooksPath (creates stale .aw_registry symlink)
254
270
  if (cwd !== HOME) {
@@ -279,6 +295,7 @@ export async function initCommand(args) {
279
295
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
280
296
  const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
281
297
  const commands = generateCommands(HOME, { silent: true });
298
+ if (cwd !== HOME) installLocalCommitHook(cwd);
282
299
 
283
300
  if (silent) {
284
301
  autoUpdate(await args._updateCheck);
@@ -312,12 +329,12 @@ export async function initCommand(args) {
312
329
 
313
330
  if (!user) {
314
331
  try {
315
- user = execSync('git config user.name', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
332
+ user = execSync('git config --global user.name', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
316
333
  } catch { /* git not configured */ }
317
334
  }
318
335
 
319
336
  // Determine sparse paths
320
- const sparsePaths = [`.aw_registry/platform`, `content`, `.aw_registry/AW-PROTOCOL.md`, `CODEOWNERS`];
337
+ const sparsePaths = [`.aw_registry/platform`, `content`, RULES_SOURCE_DIR, `.aw_registry/AW-PROTOCOL.md`, `CODEOWNERS`];
321
338
  if (folderName) {
322
339
  sparsePaths.push(`.aw_registry/${folderName}`);
323
340
  }
@@ -363,11 +380,14 @@ export async function initCommand(args) {
363
380
  }
364
381
  }
365
382
 
366
- // Create sync config
367
- const cfg = config.create(GLOBAL_AW_DIR, { namespace: team, user });
383
+ // Create sync config — default to 'platform' when no namespace specified
384
+ const cfg = config.create(GLOBAL_AW_DIR, { namespace: team || 'platform', user });
368
385
  if (folderName) {
369
386
  config.addPattern(GLOBAL_AW_DIR, folderName);
370
387
  }
388
+ if (existsSync(GLOBAL_AW_DIR)) {
389
+ syncFileTree(join(AW_HOME, RULES_SOURCE_DIR), join(GLOBAL_AW_DIR, RULES_SOURCE_DIR));
390
+ }
371
391
 
372
392
  // Step 3: Setup tasks, MCP, hooks
373
393
  await installAwEcc(cwd, { silent });
@@ -375,6 +395,7 @@ export async function initCommand(args) {
375
395
  initAwDocs(HOME);
376
396
  const mcpFiles = await setupMcp(HOME, team, { silent }) || [];
377
397
  const hooksInstalled = installGlobalHooks();
398
+ installIdeHooks();
378
399
  installIdeTasks();
379
400
 
380
401
  // Remove old local .git/hooks/post-checkout that pre-dates core.hooksPath
@@ -406,6 +427,7 @@ export async function initCommand(args) {
406
427
  const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
407
428
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
408
429
  const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
430
+ if (cwd !== HOME) installLocalCommitHook(cwd);
409
431
  ideSpinner.message('Generating commands...');
410
432
  const commands = generateCommands(HOME, { silent: true });
411
433
  ideSpinner.stop(`IDE wired — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`);
@@ -9,6 +9,7 @@ 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 { installLocalCommitHook } from '../hooks.mjs';
12
13
 
13
14
  const HOME = homedir();
14
15
  const AW_HOME = join(HOME, '.aw');
@@ -40,6 +41,7 @@ export function linkProjectCommand(args) {
40
41
  const awDirForLinks = existsSync(projectRegistryDir) ? projectRegistryDir : null;
41
42
  const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
42
43
  const commands = generateCommands(HOME, { silent: true });
44
+ installLocalCommitHook(cwd);
43
45
  fmt.logSuccess(`Already linked — refreshed ${chalk.bold(symlinks)} IDE symlinks · ${chalk.bold(commands)} commands`);
44
46
  return;
45
47
  }
@@ -50,6 +52,7 @@ export function linkProjectCommand(args) {
50
52
  const awDirForLinks = existsSync(projectRegistryDir) ? projectRegistryDir : null;
51
53
  const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
52
54
  const commands = generateCommands(HOME, { silent: true });
55
+ installLocalCommitHook(cwd);
53
56
  fmt.logSuccess([
54
57
  `Project linked — ${chalk.bold(symlinks)} IDE symlinks · ${chalk.bold(commands)} commands`,
55
58
  '',
package/commands/nuke.mjs CHANGED
@@ -143,19 +143,24 @@ async function removeProjectSymlinks() {
143
143
  }
144
144
 
145
145
  // Also remove legacy local .git/hooks/post-checkout installed by old aw versions
146
+ // and prepare-commit-msg hooks installed by installLocalCommitHook
146
147
  let hooksRemoved = 0;
147
- const { stdout: hookFiles } = await exec(
148
- `find "${HOME}" -maxdepth 5 -path "*/.git/hooks/post-checkout" -type f 2>/dev/null || true`,
149
- { encoding: 'utf8', timeout: 30000 }
150
- );
151
- for (const hookPath of hookFiles.trim().split('\n').filter(Boolean)) {
152
- try {
153
- const content = readFileSync(hookPath, 'utf8');
154
- if (content.includes('aw: auto-link registry') || content.includes('ln -s "$AW_REGISTRY"')) {
155
- unlinkSync(hookPath);
156
- hooksRemoved++;
157
- }
158
- } catch { /* best effort */ }
148
+ const hookNames = ['post-checkout', 'prepare-commit-msg'];
149
+ for (const hookName of hookNames) {
150
+ const { stdout: hookFiles } = await exec(
151
+ `find "${HOME}" -maxdepth 5 -path "*/.git/hooks/${hookName}" -type f 2>/dev/null || true`,
152
+ { encoding: 'utf8', timeout: 30000 }
153
+ );
154
+ for (const hookPath of hookFiles.trim().split('\n').filter(Boolean)) {
155
+ try {
156
+ const content = readFileSync(hookPath, 'utf8');
157
+ // Only remove hooks that AW installed — identified by our marker comment
158
+ if (content.includes('aw:') || content.includes('aw: auto-link registry') || content.includes('ln -s "$AW_REGISTRY"')) {
159
+ unlinkSync(hookPath);
160
+ hooksRemoved++;
161
+ }
162
+ } catch { /* best effort */ }
163
+ }
159
164
  }
160
165
 
161
166
  return { removed, hooksRemoved };
@@ -204,8 +209,8 @@ function removeIdeTasks() {
204
209
 
205
210
  export async function nukeCommand(args) {
206
211
  // Catch unhandled errors and surface them instead of letting clack show generic "Something went wrong"
207
- process.on('uncaughtException', (e) => { fmt.cancel(`Unexpected error: ${e.message}`); });
208
- process.on('unhandledRejection', (e) => { fmt.cancel(`Unexpected error: ${e?.message ?? e}`); });
212
+ process.on('uncaughtException', (e) => { fmt.cancelAndExit(`Unexpected error: ${e.message}`); });
213
+ process.on('unhandledRejection', (e) => { fmt.cancelAndExit(`Unexpected error: ${e?.message ?? e}`); });
209
214
 
210
215
  fmt.intro('aw nuke');
211
216
 
@@ -0,0 +1,107 @@
1
+ // commands/protocol.mjs — Write AW-PROTOCOL into machine-global AI tool files.
2
+ // Uses block-replacement markers so running multiple times is safe.
3
+
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
5
+ import { join, dirname } from 'node:path';
6
+ import { homedir } from 'node:os';
7
+ import { AW_PROTOCOL_START, AW_PROTOCOL_END } from '../constants.mjs';
8
+ import * as fmt from '../fmt.mjs';
9
+
10
+ const HOME = homedir();
11
+
12
+ /**
13
+ * Read AW-PROTOCOL.md from the global registry.
14
+ * Returns null if not found.
15
+ */
16
+ function readProtocol() {
17
+ const path = join(HOME, '.aw_registry', 'AW-PROTOCOL.md');
18
+ if (!existsSync(path)) return null;
19
+ return readFileSync(path, 'utf8');
20
+ }
21
+
22
+ /**
23
+ * Inject/replace the AW-PROTOCOL block in a target file.
24
+ * If file doesn't exist, creates it with just the block.
25
+ * If file exists, replaces content between markers or appends block.
26
+ * Returns true if file was written/updated.
27
+ */
28
+ function injectBlock(filePath, content, toolName) {
29
+ const block = `${AW_PROTOCOL_START}\n${content}\n${AW_PROTOCOL_END}`;
30
+
31
+ // Ensure parent directory exists
32
+ mkdirSync(dirname(filePath), { recursive: true });
33
+
34
+ if (!existsSync(filePath)) {
35
+ writeFileSync(filePath, block + '\n', 'utf8');
36
+ fmt.logStep(`Created ${toolName} global rules: ${filePath}`);
37
+ return true;
38
+ }
39
+
40
+ const existing = readFileSync(filePath, 'utf8');
41
+ const startIdx = existing.indexOf(AW_PROTOCOL_START);
42
+ const endIdx = existing.indexOf(AW_PROTOCOL_END);
43
+
44
+ if (startIdx !== -1 && endIdx !== -1) {
45
+ // Replace existing block
46
+ const before = existing.slice(0, startIdx);
47
+ const after = existing.slice(endIdx + AW_PROTOCOL_END.length);
48
+ const updated = before + block + after;
49
+ if (updated === existing) return false; // no change
50
+ writeFileSync(filePath, updated, 'utf8');
51
+ fmt.logStep(`Updated ${toolName} global rules: ${filePath}`);
52
+ return true;
53
+ }
54
+
55
+ // Append block to existing file
56
+ const updated = existing.trimEnd() + '\n\n' + block + '\n';
57
+ writeFileSync(filePath, updated, 'utf8');
58
+ fmt.logStep(`Appended to ${toolName} global rules: ${filePath}`);
59
+ return true;
60
+ }
61
+
62
+ /**
63
+ * Write AW-PROTOCOL into all supported AI tool global files.
64
+ * Called by aw init. Silent if AW-PROTOCOL.md not found.
65
+ *
66
+ * @param {string} [workspaceDir] - Optional workspace root. When provided,
67
+ * also writes to <workspaceDir>/.cursor/rules/aw-protocol.mdc so Cursor
68
+ * auto-loads it via its workspace rules mechanism (alwaysApply: true).
69
+ */
70
+ export function writeGlobalProtocolFiles(workspaceDir) {
71
+ const protocol = readProtocol();
72
+ if (!protocol) {
73
+ fmt.logWarn('AW-PROTOCOL.md not found in ~/.aw_registry — skipping global rules install');
74
+ return [];
75
+ }
76
+
77
+ const written = [];
78
+ const cursorContent = `---\nalwaysApply: true\n---\n\n${protocol}`;
79
+
80
+ // Claude Code + Claude CLI (machine-global)
81
+ const claudeMd = join(HOME, '.claude', 'CLAUDE.md');
82
+ if (injectBlock(claudeMd, protocol, 'Claude Code/CLI')) written.push(claudeMd);
83
+
84
+ // Cursor — workspace-level rules (auto-loaded by Cursor from .cursor/rules/)
85
+ // Machine-global ~/.cursor/rules/ is NOT auto-loaded by Cursor; workspace-level is.
86
+ if (workspaceDir) {
87
+ const wsCursorMdc = join(workspaceDir, '.cursor', 'rules', 'aw-protocol.mdc');
88
+ if (injectBlock(wsCursorMdc, cursorContent, 'Cursor workspace')) written.push(wsCursorMdc);
89
+ }
90
+
91
+ // Cursor — also write to ~/.cursor/rules/ as fallback for manual reference
92
+ const cursorMdc = join(HOME, '.cursor', 'rules', 'aw-protocol.mdc');
93
+ if (injectBlock(cursorMdc, cursorContent, 'Cursor (home)')) written.push(cursorMdc);
94
+
95
+ // Codex CLI (machine-global)
96
+ const codexAgents = join(HOME, '.codex', 'AGENTS.md');
97
+ if (injectBlock(codexAgents, protocol, 'Codex')) written.push(codexAgents);
98
+
99
+ // Windsurf (known reliability issues — warn user)
100
+ const windsurfRules = join(HOME, '.codeium', 'windsurf', 'memories', 'global_rules.md');
101
+ if (injectBlock(windsurfRules, protocol, 'Windsurf')) {
102
+ written.push(windsurfRules);
103
+ fmt.logWarn('Windsurf global_rules.md has known reliability issues. Verify in Cascade > Customizations > Rules.');
104
+ }
105
+
106
+ return written;
107
+ }
package/commands/pull.mjs CHANGED
@@ -1,17 +1,34 @@
1
1
  // commands/pull.mjs — Pull content from registry using persistent git clone
2
2
 
3
- import { existsSync, lstatSync } from 'node:fs';
3
+ import {
4
+ existsSync,
5
+ lstatSync,
6
+ } from 'node:fs';
4
7
  import { join, extname } from 'node:path';
5
8
  import { homedir } from 'node:os';
6
- import { exec as execCb } from 'node:child_process';
9
+ import { exec as execCb, execSync } from 'node:child_process';
7
10
  import { promisify } from 'node:util';
8
11
 
9
12
  const exec = promisify(execCb);
10
13
  import * as config from '../config.mjs';
11
14
  import * as fmt from '../fmt.mjs';
12
15
  import { chalk } from '../fmt.mjs';
13
- import { fetchAndMerge, addToSparseCheckout, removeFromSparseCheckout, syncWorktreeSparseCheckout, isValidClone, findNearestWorktree, rebaseOntoOriginMain } from '../git.mjs';
14
- import { REGISTRY_DIR, REGISTRY_REPO, REGISTRY_URL } from '../constants.mjs';
16
+ import {
17
+ fetchAndMerge,
18
+ addToSparseCheckout,
19
+ removeFromSparseCheckout,
20
+ syncWorktreeSparseCheckout,
21
+ isValidClone,
22
+ findNearestWorktree,
23
+ rebaseOntoOriginMain,
24
+ } from '../git.mjs';
25
+ import {
26
+ REGISTRY_DIR,
27
+ REGISTRY_URL,
28
+ DOCS_SOURCE_DIR,
29
+ RULES_SOURCE_DIR,
30
+ } from '../constants.mjs';
31
+ import { collectAllPaths, syncFileTree } from '../file-tree.mjs';
15
32
  import { linkWorkspace } from '../link.mjs';
16
33
  import { generateCommands, copyInstructions } from '../integrate.mjs';
17
34
 
@@ -25,7 +42,7 @@ export async function pullCommand(args) {
25
42
  const silent = args['--silent'] === true || args._silent === true;
26
43
 
27
44
  const log = {
28
- cancel: silent ? () => { process.exit(0); } : fmt.cancel,
45
+ cancel: silent ? (msg) => { throw new fmt.CancelError(msg || 'silent cancel', { exitCode: 0 }); } : fmt.cancel,
29
46
  logInfo: silent ? () => {} : fmt.logInfo,
30
47
  logSuccess: silent ? () => {} : fmt.logSuccess,
31
48
  logStep: silent ? () => {} : fmt.logStep,
@@ -52,6 +69,15 @@ export async function pullCommand(args) {
52
69
  return;
53
70
  }
54
71
 
72
+ // Ensure platform pulls also fetch docs and rules on older installs that
73
+ // pre-date the new sparse-checkout paths.
74
+ if (input === 'platform') {
75
+ addToSparseCheckout(AW_HOME, [`.aw_registry/platform`, DOCS_SOURCE_DIR, RULES_SOURCE_DIR]);
76
+ if (!cfg.include.includes('platform')) {
77
+ config.addPattern(GLOBAL_AW_DIR, 'platform');
78
+ }
79
+ }
80
+
55
81
  // If input is a new namespace to add
56
82
  let addedInput = null;
57
83
  let addedSparsePath = null;
@@ -64,7 +90,7 @@ export async function pullCommand(args) {
64
90
  const label = input.split('/').pop();
65
91
  if (!cfg.include.includes(input)) {
66
92
  log.logStep(`Adding ${chalk.cyan(label)} to sparse checkout...`);
67
- addToSparseCheckout(AW_HOME, [sparsePath, 'content']);
93
+ addToSparseCheckout(AW_HOME, [sparsePath, DOCS_SOURCE_DIR]);
68
94
  config.addPattern(GLOBAL_AW_DIR, input);
69
95
  addedInput = input;
70
96
  addedSparsePath = sparsePath;
@@ -79,33 +105,33 @@ export async function pullCommand(args) {
79
105
  const rebaseInProgress = existsSync(join(awGitDir, 'rebase-merge')) || existsSync(join(awGitDir, 'rebase-apply'));
80
106
  const mergeInProgress = existsSync(join(awGitDir, 'MERGE_HEAD'));
81
107
  if (rebaseInProgress || mergeInProgress) {
82
- // Check for still-unresolved files (conflict markers present in index)
83
108
  let unresolved = [];
84
109
  try {
85
110
  const { stdout } = await exec(`git -C "${AW_HOME}" diff --name-only --diff-filter=U`);
86
111
  unresolved = stdout.trim().split('\n').filter(Boolean);
87
- } catch { /* best effort */ }
112
+ } catch {
113
+ // best effort
114
+ }
88
115
 
89
116
  if (unresolved.length > 0) {
90
- // Still has conflicts user needs to finish resolving
91
- log.logWarn(`Rebase paused — resolve conflicts in your IDE, then run \`aw pull\` again.`);
117
+ log.logWarn('Rebase paused resolve conflicts in your IDE, then run `aw pull` again.');
92
118
  if (!silent) fmt.outro(chalk.yellow('Pull skipped'));
93
119
  return;
94
120
  }
95
121
 
96
- // All conflicts resolved (files are staged) — continue the rebase automatically
97
122
  try {
98
123
  await exec(`git -C "${AW_HOME}" rebase --continue`, { env: { ...process.env, GIT_EDITOR: 'true' } });
99
124
  log.logStep('Rebase continued after conflict resolution.');
100
- // Force-push if on a push branch so origin stays in sync
101
125
  const { stdout: branchOut } = await exec(`git -C "${AW_HOME}" rev-parse --abbrev-ref HEAD`);
102
126
  const resumedBranch = branchOut.trim();
103
- if (['upload/', 'remove/', 'sync/'].some(p => resumedBranch.startsWith(p))) {
104
- try { await exec(`git -C "${AW_HOME}" push --force-with-lease origin "${resumedBranch}"`); } catch { /* non-blocking */ }
127
+ if (['upload/', 'remove/', 'sync/'].some(prefix => resumedBranch.startsWith(prefix))) {
128
+ try {
129
+ await exec(`git -C "${AW_HOME}" push --force-with-lease origin "${resumedBranch}"`);
130
+ } catch {
131
+ // non-blocking
132
+ }
105
133
  }
106
134
  } catch {
107
- // Could happen if there are more conflicting commits in the rebase sequence,
108
- // or if the resolved changes result in an empty commit (skip it).
109
135
  try {
110
136
  await exec(`git -C "${AW_HOME}" rebase --skip`);
111
137
  log.logStep('Empty commit skipped during rebase continuation.');
@@ -115,10 +141,8 @@ export async function pullCommand(args) {
115
141
  return;
116
142
  }
117
143
  }
118
- // Fall through to re-link IDE dirs after successful rebase continuation
119
144
  }
120
145
 
121
- // Fetch + merge latest
122
146
  const s = log.spinner();
123
147
  s.start('Fetching latest from registry...');
124
148
  let fetchResult = { updated: false, conflicts: [] };
@@ -130,16 +154,15 @@ export async function pullCommand(args) {
130
154
  if (!silent) log.logWarn(`Fetch error: ${e.message}`);
131
155
  }
132
156
 
133
- // Validate that the requested path actually exists in the registry after fetch.
134
- // If not, undo the sparse checkout addition and inform the user.
135
157
  if (addedInput) {
136
158
  const localPath = join(AW_HOME, REGISTRY_DIR, addedInput);
137
159
  if (!existsSync(localPath)) {
138
- // Undo: remove from sparse checkout and config
139
160
  try {
140
161
  removeFromSparseCheckout(AW_HOME, [addedSparsePath]);
141
162
  config.removePattern(GLOBAL_AW_DIR, addedInput);
142
- } catch { /* best effort */ }
163
+ } catch {
164
+ // best effort
165
+ }
143
166
  log.cancel(`Path ${chalk.red(addedInput)} not found in the registry.\nCheck the exact path with ${chalk.bold('aw search <query>')} or browse ${chalk.dim('~/.aw_registry/')}.`);
144
167
  return;
145
168
  }
@@ -147,23 +170,31 @@ export async function pullCommand(args) {
147
170
 
148
171
  if (fetchResult.conflicts.length > 0) {
149
172
  if (!silent) {
150
- // Interactive mode: rebase is paused with conflict markers in the working tree.
151
- // Leave it for the user to resolve in their IDE, then re-run `aw pull`.
152
173
  log.logWarn(`Merge conflict in: ${fetchResult.conflicts.join(', ')}`);
153
174
  log.logWarn('Merge aborted — your branch is unchanged. Resolve conflicts and run `aw pull` again.');
154
175
  return;
155
176
  }
156
- // Silent mode: rebase was already aborted in fetchAndMerge; just report.
157
177
  log.logWarn(`Conflicts in: ${fetchResult.conflicts.join(', ')}`);
158
178
  }
159
179
 
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
+
160
187
  // Rebase project worktree branch onto origin/main — only for legacy git worktrees.
161
188
  // In the symlink model, <project>/.aw IS ~/.aw (same repo), so fetchAndMerge already
162
189
  // brought it up to date. Nothing to rebase.
163
190
  const localAw = cwd !== HOME ? findNearestWorktree(cwd, HOME) : null;
164
191
  let isSymlinkWorktree = false;
165
192
  if (localAw) {
166
- try { isSymlinkWorktree = lstatSync(localAw).isSymbolicLink(); } catch {}
193
+ try {
194
+ isSymlinkWorktree = lstatSync(localAw).isSymbolicLink();
195
+ } catch {
196
+ // ignore
197
+ }
167
198
  }
168
199
  if (localAw && !isSymlinkWorktree) {
169
200
  const rebaseSpinner = log.spinner();
@@ -173,7 +204,9 @@ export async function pullCommand(args) {
173
204
  rebaseSpinner.stop('Local branch up to date');
174
205
  } catch (e) {
175
206
  const isConflict = e.message?.includes('could not apply') || e.message?.includes('CONFLICT');
176
- const isAlreadyInProgress = e.message?.includes('rebase-merge') || e.message?.includes('rebase-apply') || e.message?.includes('already in progress');
207
+ const isAlreadyInProgress = e.message?.includes('rebase-merge')
208
+ || e.message?.includes('rebase-apply')
209
+ || e.message?.includes('already in progress');
177
210
 
178
211
  if (isAlreadyInProgress) {
179
212
  rebaseSpinner.stop(chalk.yellow('Rebase paused — conflicts pending'));
@@ -184,26 +217,31 @@ export async function pullCommand(args) {
184
217
  try {
185
218
  const { stdout } = await exec(`git -C "${localAw}" diff --name-only --diff-filter=U`);
186
219
  conflictedFiles = stdout.trim().split('\n').filter(Boolean);
187
- } catch { /* best effort */ }
220
+ } catch {
221
+ // best effort
222
+ }
188
223
 
189
224
  if (!silent) {
190
- try { await exec(`git -C "${localAw}" rebase --abort`); } catch { /* best effort */ }
225
+ try {
226
+ await exec(`git -C "${localAw}" rebase --abort`);
227
+ } catch {
228
+ // best effort
229
+ }
191
230
  log.logWarn('Rebase aborted — your branch is unchanged.');
192
231
  if (conflictedFiles.length > 0) {
193
232
  log.logWarn(`Conflicting file${conflictedFiles.length > 1 ? 's' : ''}:`);
194
- for (const f of conflictedFiles) log.logMessage(` ${chalk.red('✗')} ${f}`);
233
+ for (const file of conflictedFiles) log.logMessage(` ${chalk.red('✗')} ${file}`);
195
234
  }
196
235
  log.logWarn('Resolve the conflict and run `aw pull` again, or check with `aw status`.');
197
236
  }
198
237
  } else {
199
- const msg = e.message?.split('\n').find(l => l.trim()) ?? e.message;
238
+ const msg = e.message?.split('\n').find(line => line.trim()) ?? e.message;
200
239
  rebaseSpinner.stop(chalk.dim('Rebase skipped'));
201
240
  if (!silent) log.logWarn(`Rebase skipped: ${msg}`);
202
241
  }
203
242
  }
204
243
  }
205
244
 
206
- // Re-link IDE dirs
207
245
  if (!args._skipIntegrate) {
208
246
  const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
209
247
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
@@ -237,6 +275,10 @@ export async function pullAsync(args) {
237
275
  return { pattern: args._positional?.[0] || '', actions: [], conflictCount: 0 };
238
276
  }
239
277
 
278
+ export const __test__ = {
279
+ collectAllPaths,
280
+ syncFileTree,
281
+ };
240
282
 
241
283
  function registerMcp(namespace) {
242
284
  const mcpUrl = process.env.GHL_MCP_URL;