@ghl-ai/aw 0.1.89 → 0.1.92

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/cli.mjs CHANGED
@@ -38,7 +38,7 @@ const COMMANDS = {
38
38
 
39
39
  const COMMAND_NAMES = Object.keys(COMMANDS);
40
40
 
41
- function parseArgs(argv) {
41
+ export function parseArgs(argv) {
42
42
  const args = { _positional: [] };
43
43
  let command = null;
44
44
  let i = 0;
@@ -67,6 +67,9 @@ function parseArgs(argv) {
67
67
  } else if (arg === '--yes' || arg === '--non-interactive' || arg === '--headless') {
68
68
  args[arg] = true;
69
69
  i++;
70
+ } else if (arg === '--discard-local') {
71
+ args['--discard-local'] = true;
72
+ i++;
70
73
  } else if (arg === '--version') {
71
74
  args['--version'] = true;
72
75
  i++;
@@ -154,6 +157,7 @@ function printHelp() {
154
157
  cmd('aw pull', 'Re-pull all synced paths (like git pull)'),
155
158
  cmd('aw pull <path>', 'Pull agents, skills & more from registry'),
156
159
  cmd('aw pull --dry-run <path>', 'Preview what would be downloaded'),
160
+ cmd('aw pull --discard-local', 'On registry conflict, take remote and continue (agent-safe)'),
157
161
 
158
162
  sec('Upload'),
159
163
  cmd('aw push', 'Push all modified files (creates one PR)'),
package/commands/init.mjs CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  fetchAndMerge,
44
44
  addToSparseCheckout,
45
45
  addProjectWorktree,
46
+ pruneAwSelfLinks,
46
47
  isWorktree,
47
48
  includeToSparsePaths,
48
49
  sparseCheckoutAsync,
@@ -460,6 +461,24 @@ export async function initCommand(args) {
460
461
  }
461
462
 
462
463
  ensureAwGitignore(AW_HOME);
464
+ // Heal installs where an older `aw link` run from inside ~/.aw left a
465
+ // cycle-forming symlink, which makes recursive walkers die with
466
+ // ENAMETOOLONG. A fresh clone cannot have one, so only this path needs it.
467
+ const selfLinks = pruneAwSelfLinks(AW_HOME);
468
+ if (selfLinks.removed.length > 0 && !silent) {
469
+ const n = selfLinks.removed.length;
470
+ fmt.logStep(`Removed ${n} cycle-forming .aw symlink${n > 1 ? 's' : ''}`);
471
+ }
472
+ if (selfLinks.failed.length > 0 && !silent) {
473
+ // A cycle may still be in place — say so, with the paths, rather than
474
+ // letting the next `vite`/`tsc`/`eslint` run fail with ENAMETOOLONG and
475
+ // no explanation of why.
476
+ fmt.logWarn(
477
+ `Could not clear ${selfLinks.failed.length} cycle-forming .aw symlink(s) under ${AW_HOME}. `
478
+ + 'Recursive tools may fail with ENAMETOOLONG until these are removed:\n'
479
+ + selfLinks.failed.map(f => ` ${f.path} (${f.shape}: ${f.reason})`).join('\n')
480
+ );
481
+ }
463
482
  const freshCfg = config.load(GLOBAL_AW_DIR);
464
483
  syncRulesTargets(HOME);
465
484
  if (cwd !== HOME) {
package/commands/pull.mjs CHANGED
@@ -22,6 +22,10 @@ import {
22
22
  isValidClone,
23
23
  findNearestWorktree,
24
24
  rebaseOntoOriginMain,
25
+ resolveRegistryConflicts,
26
+ continuePausedRebase,
27
+ pushForceWithLease,
28
+ getCurrentBranch,
25
29
  } from '../git.mjs';
26
30
  import {
27
31
  REGISTRY_DIR,
@@ -57,10 +61,71 @@ function removeLegacyRegistryRules() {
57
61
  }
58
62
  }
59
63
 
64
+ function envConflictAction() {
65
+ const raw = String(process.env.AW_PULL_ON_CONFLICT || '').trim().toLowerCase();
66
+ if (raw === 'discard' || raw === 'abort' || raw === 'leave') return raw;
67
+ return '';
68
+ }
69
+
70
+ function isPushBranchName(branch) {
71
+ return /^(upload|sync|remove)\//.test(String(branch || ''));
72
+ }
73
+
74
+ async function forcePushPushBranchIfNeeded(awHome, log) {
75
+ const branch = getCurrentBranch(awHome);
76
+ if (!isPushBranchName(branch)) return { ok: true, skipped: true };
77
+ try {
78
+ await pushForceWithLease(awHome, branch);
79
+ if (log?.logStep) log.logStep(`Synced push branch ${branch} after conflict discard`);
80
+ return { ok: true, skipped: false };
81
+ } catch (e) {
82
+ const msg = e?.message || 'push failed';
83
+ if (log?.logWarn) {
84
+ log.logWarn(`Could not sync push branch ${branch} after conflict recovery: ${msg}`);
85
+ }
86
+ return { ok: false, error: msg };
87
+ }
88
+ }
89
+
90
+ function printConflictHint(conflicts) {
91
+ const paths = (conflicts || []).filter(Boolean).join(',') || '(unknown)';
92
+ // Always emit — agents in --silent/CI must still parse this line.
93
+ console.error(`AW_PULL_CONFLICT paths=${paths} next=aw pull --discard-local`);
94
+ }
95
+
96
+ async function chooseConflictAction({ discardLocal, silent, headless, conflicts, log }) {
97
+ if (discardLocal) return 'discard';
98
+ const fromEnv = envConflictAction();
99
+ if (fromEnv) return fromEnv;
100
+ if (silent || headless) return 'abort';
101
+
102
+ const branch = getCurrentBranch(AW_HOME);
103
+ if (isPushBranchName(branch)) {
104
+ log.logWarn(`You are on push branch ${chalk.bold(branch)} — discard may drop unpushed registry commits.`);
105
+ }
106
+
107
+ log.logWarn(`Merge conflict in: ${conflicts.join(', ')}`);
108
+ const choice = await fmt.select({
109
+ message: 'How do you want to resolve the registry pull conflict?',
110
+ options: [
111
+ { value: 'discard', label: 'Discard conflicting local changes and sync to remote', hint: 'recommended if you did not edit these files' },
112
+ { value: 'abort', label: 'Cancel aw pull (leave local state unchanged)' },
113
+ { value: 'leave', label: 'Keep local and resolve conflicts manually' },
114
+ ],
115
+ });
116
+
117
+ if (fmt.isCancel(choice) || !choice) return 'abort';
118
+ return choice;
119
+ }
120
+
60
121
  export async function pullCommand(args) {
61
122
  const input = args._positional?.[0] || '';
62
123
  const cwd = process.cwd();
63
- const silent = args['--silent'] === true || args._silent === true;
124
+ const interaction = fmt.configureInteractionMode(args);
125
+ const silent = interaction.silent || args['--silent'] === true || args._silent === true;
126
+ const headless = interaction.headless || silent;
127
+ const discardLocal = args['--discard-local'] === true
128
+ || String(process.env.AW_PULL_ON_CONFLICT || '').trim().toLowerCase() === 'discard';
64
129
 
65
130
  const log = {
66
131
  cancel: silent ? (msg) => { throw new fmt.CancelError(msg || 'silent cancel', { exitCode: 0 }); } : fmt.cancel,
@@ -135,32 +200,76 @@ export async function pullCommand(args) {
135
200
  }
136
201
 
137
202
  if (unresolved.length > 0) {
138
- log.logWarn('Rebase paused — resolve conflicts in your IDE, then run `aw pull` again.');
139
- if (!silent) fmt.outro(chalk.yellow('Pull skipped'));
140
- return;
141
- }
142
-
143
- try {
144
- await exec(`git -C "${AW_HOME}" rebase --continue`, { env: { ...process.env, GIT_EDITOR: 'true' } });
145
- log.logStep('Rebase continued after conflict resolution.');
146
- const { stdout: branchOut } = await exec(`git -C "${AW_HOME}" rev-parse --abbrev-ref HEAD`);
147
- const resumedBranch = branchOut.trim();
148
- if (['upload/', 'remove/', 'sync/'].some(prefix => resumedBranch.startsWith(prefix))) {
149
- try {
150
- await exec(`git -C "${AW_HOME}" push --force-with-lease origin "${resumedBranch}"`);
151
- } catch {
152
- // non-blocking
203
+ const action = await chooseConflictAction({
204
+ discardLocal,
205
+ silent,
206
+ headless,
207
+ conflicts: unresolved,
208
+ log,
209
+ });
210
+ const resolved = await resolveRegistryConflicts(AW_HOME, {
211
+ action,
212
+ conflicts: unresolved,
213
+ });
214
+
215
+ if (action === 'discard') {
216
+ if (!resolved.ok) {
217
+ printConflictHint(unresolved);
218
+ throw new fmt.CancelError(
219
+ `Could not discard local conflicts: ${resolved.error || 'unknown error'}`,
220
+ { exitCode: 1 },
221
+ );
222
+ }
223
+ if (!silent) log.logSuccess('Discarded conflicting local changes and synced to remote');
224
+ await forcePushPushBranchIfNeeded(AW_HOME, log);
225
+ // Fall through to continue the normal pull after a clean discard.
226
+ } else if (action === 'leave') {
227
+ if (!silent) {
228
+ log.logWarn(`Merge conflict in: ${unresolved.join(', ')}`);
229
+ log.logWarn('Rebase left in progress — resolve conflict markers, then run `aw pull` again.');
230
+ fmt.outro(chalk.yellow('Pull paused for manual conflict resolution'));
231
+ } else {
232
+ printConflictHint(unresolved);
153
233
  }
234
+ return;
235
+ } else {
236
+ if (!silent) {
237
+ log.logWarn(`Merge conflict in: ${unresolved.join(', ')}`);
238
+ const kind = resolved.kind === 'merge' ? 'merge' : 'rebase';
239
+ log.logWarn(`Pull cancelled — local registry left unchanged (${kind} aborted).`);
240
+ printConflictHint(unresolved);
241
+ fmt.outro(chalk.yellow('Pull cancelled'));
242
+ } else {
243
+ printConflictHint(unresolved);
244
+ }
245
+ throw new fmt.CancelError('Registry pull conflict — unresolved', { exitCode: 2 });
154
246
  }
155
- } catch {
247
+ } else if (mergeInProgress && !rebaseInProgress) {
156
248
  try {
157
- await exec(`git -C "${AW_HOME}" rebase --skip`);
158
- log.logStep('Empty commit skipped during rebase continuation.');
249
+ await exec(`git -C "${AW_HOME}" -c core.editor=true commit --no-edit`);
250
+ log.logStep('Merge continued after conflict resolution.');
159
251
  } catch {
160
- log.logWarn('Rebase continuation failed — check `~/.aw` and resolve manually.');
161
- if (!silent) fmt.outro(chalk.yellow('Pull skipped'));
162
- return;
252
+ printConflictHint(unresolved);
253
+ throw new fmt.CancelError(
254
+ 'Merge continuation failed — resolve manually or run: git -C ~/.aw merge --abort',
255
+ { exitCode: 2 },
256
+ );
257
+ }
258
+ } else {
259
+ const continued = await continuePausedRebase(AW_HOME);
260
+ if (!continued.ok) {
261
+ printConflictHint(continued.conflicts);
262
+ throw new fmt.CancelError(
263
+ 'Rebase continuation failed — resolve manually in ~/.aw',
264
+ { exitCode: 2 },
265
+ );
163
266
  }
267
+ log.logStep(
268
+ continued.skipped
269
+ ? 'Empty commit skipped during rebase continuation.'
270
+ : 'Rebase continued after conflict resolution.',
271
+ );
272
+ await forcePushPushBranchIfNeeded(AW_HOME, log);
164
273
  }
165
274
  }
166
275
 
@@ -168,7 +277,7 @@ export async function pullCommand(args) {
168
277
  s.start('Fetching latest from registry...');
169
278
  let fetchResult = { updated: false, conflicts: [] };
170
279
  try {
171
- fetchResult = await fetchAndMerge(AW_HOME, { silent });
280
+ fetchResult = await fetchAndMerge(AW_HOME, { silent, onConflict: 'leave' });
172
281
  s.stop(fetchResult.updated ? 'Registry updated' : 'Already up to date');
173
282
  } catch (e) {
174
283
  s.stop(chalk.yellow('Fetch failed'));
@@ -190,12 +299,54 @@ export async function pullCommand(args) {
190
299
  }
191
300
 
192
301
  if (fetchResult.conflicts.length > 0) {
193
- if (!silent) {
194
- log.logWarn(`Merge conflict in: ${fetchResult.conflicts.join(', ')}`);
195
- log.logWarn('Merge aborted — your branch is unchanged. Resolve conflicts and run `aw pull` again.');
302
+ const action = await chooseConflictAction({
303
+ discardLocal,
304
+ silent,
305
+ headless,
306
+ conflicts: fetchResult.conflicts,
307
+ log,
308
+ });
309
+
310
+ const resolved = await resolveRegistryConflicts(AW_HOME, {
311
+ action,
312
+ conflicts: fetchResult.conflicts,
313
+ });
314
+
315
+ if (action === 'discard') {
316
+ if (!resolved.ok) {
317
+ printConflictHint(fetchResult.conflicts);
318
+ throw new fmt.CancelError(
319
+ `Could not discard local conflicts: ${resolved.error || 'unknown error'}`,
320
+ { exitCode: 1 },
321
+ );
322
+ }
323
+ if (!silent) log.logSuccess('Discarded conflicting local changes and synced to remote');
324
+ await forcePushPushBranchIfNeeded(AW_HOME, log);
325
+ } else if (action === 'leave') {
326
+ if (!silent) {
327
+ log.logWarn(`Merge conflict in: ${fetchResult.conflicts.join(', ')}`);
328
+ log.logWarn('Rebase left in progress — resolve conflict markers, then run `aw pull` again.');
329
+ log.logMessage(chalk.dim('Tip: grep -r "<<<<<<< " ~/.aw/.aw_registry/'));
330
+ fmt.outro(chalk.yellow('Pull paused for manual conflict resolution'));
331
+ } else {
332
+ printConflictHint(fetchResult.conflicts);
333
+ }
196
334
  return;
335
+ } else {
336
+ // abort / cancel
337
+ if (!silent) {
338
+ log.logWarn(`Merge conflict in: ${fetchResult.conflicts.join(', ')}`);
339
+ const kind = resolved.kind === 'merge' ? 'merge' : 'rebase';
340
+ log.logWarn(`Pull cancelled — local registry left unchanged (${kind} aborted).`);
341
+ log.logMessage(chalk.dim('Tip: if you have intentional skill edits, run `aw push <path>` then `aw pull`.'));
342
+ log.logMessage(chalk.dim('Or discard with: aw pull --discard-local'));
343
+ printConflictHint(fetchResult.conflicts);
344
+ fmt.outro(chalk.yellow('Pull cancelled'));
345
+ } else {
346
+ printConflictHint(fetchResult.conflicts);
347
+ }
348
+ throw new fmt.CancelError('Registry pull conflict — unresolved', { exitCode: 2 });
197
349
  }
198
- log.logWarn(`Conflicts in: ${fetchResult.conflicts.join(', ')}`);
199
350
  }
200
351
 
201
352
  // Rebase project worktree branch onto origin/main — only for legacy git worktrees.
package/commands/push.mjs CHANGED
@@ -155,10 +155,12 @@ function normalizeRelPath(value) {
155
155
 
156
156
  // Root namespaces publish at the AW docs root (aw_docs/<ns>/...), mirroring the
157
157
  // local .aw_docs/<ns>/... subtree 1:1 — NO per-workspace <repo>/<user> segment
158
- // and full nested folders. Use for shared, repo-keyed doc trees such as PR
159
- // reviews (pr-reviews/<owner>/<repo>/pr-<n>/<run>) so they do not land under the
160
- // reviewer's workspace namespace as one mangled flat slug.
161
- const AW_DOCS_ROOT_NAMESPACES = ['pr-reviews', 'security-audits'];
158
+ // and full nested folders. Use for shared, resource-keyed doc trees such as PR
159
+ // reviews (pr-reviews/<owner>/<repo>/pr-<n>/<run>), security audits
160
+ // (security-audits/<repo>/<date>), support ticket investigations
161
+ // (support-tickets/<ticketId>), and infra incident RCAs (incidents/<fingerprint>)
162
+ // so they do not land under the runner's workspace namespace as one mangled flat slug.
163
+ const AW_DOCS_ROOT_NAMESPACES = ['pr-reviews', 'security-audits', 'support-tickets', 'incidents'];
162
164
 
163
165
  // Compound root prefixes (multi-segment). Longest match wins in matchRootPrefix().
164
166
  const AW_DOCS_ROOT_PREFIX_RULES = [
@@ -174,9 +176,13 @@ const AW_DOCS_ROOT_PREFIX_RULES = [
174
176
  // pr-reviews/<repo>/pr-<number>/<run> → 4 segments
175
177
  // security-audits/<repo>/<date> → 3 segments
176
178
  // security/audit/<repo>/<run-id> → 4 segments
179
+ // support-tickets/<ticketId> → 2 segments
180
+ // incidents/<fingerprint> → 2 segments
177
181
  const AW_DOCS_ROOT_MIN_SEGMENTS = {
178
182
  'pr-reviews': 4,
179
183
  'security-audits': 3,
184
+ 'support-tickets': 2,
185
+ 'incidents': 2,
180
186
  };
181
187
 
182
188
  function normalizeAwDocsRootInput(stripped) {
@@ -214,7 +220,8 @@ function awDocsRootScope(relPath) {
214
220
  if (!rule || segments.length < rule.minSegments) {
215
221
  throw new Error(
216
222
  'Root-scope docs publish must target pr-reviews/<repo>/pr-<number>/<run>, ' +
217
- 'security-audits/<repo>/<date>, or security/audit/<repo>/<run-id>, ' +
223
+ 'security-audits/<repo>/<date>, security/audit/<repo>/<run-id>, ' +
224
+ 'support-tickets/<ticketId>, or incidents/<fingerprint>, ' +
218
225
  `got "${relPath}".`
219
226
  );
220
227
  }
@@ -237,7 +244,7 @@ function featureScopeFromInput(input) {
237
244
 
238
245
  const match = value.match(/^(?:\.aw_docs\/)?features\/([^/]+)$/);
239
246
  if (!match) {
240
- throw new Error('Docs-only publish path must be .aw_docs/features/<feature-slug>, .aw_docs/pr-reviews/<...>, .aw_docs/security-audits/<...> (or legacy .aw_docs/security/audit/<...>), or use --feature <feature-slug>.');
247
+ throw new Error('Docs-only publish path must be .aw_docs/features/<feature-slug>, .aw_docs/pr-reviews/<...>, .aw_docs/security-audits/<...>, .aw_docs/support-tickets/<...>, .aw_docs/incidents/<...> (or legacy .aw_docs/security/audit/<...>), or use --feature <feature-slug>.');
241
248
  }
242
249
  return awDocsFeatureScope(match[1]);
243
250
  }
package/git.mjs CHANGED
@@ -1,13 +1,19 @@
1
1
  // git.mjs — Git helpers: sparse checkout (temp) + persistent clone operations.
2
2
 
3
- import { execSync, exec as execCb } from 'node:child_process';
4
- import { mkdtempSync, existsSync, lstatSync, rmSync, readFileSync, symlinkSync, unlinkSync } from 'node:fs';
5
- import { join, basename, dirname } from 'node:path';
3
+ import { execSync, exec as execCb, execFile as execFileCb } from 'node:child_process';
4
+ import { mkdtempSync, existsSync, lstatSync, readdirSync, realpathSync, rmSync, readFileSync, statSync, symlinkSync, unlinkSync } from 'node:fs';
5
+ import { join, basename, dirname, sep } from 'node:path';
6
6
  import { homedir, tmpdir } from 'node:os';
7
7
  import { promisify } from 'node:util';
8
8
  import { REGISTRY_BASE_BRANCH, REGISTRY_DIR, DOCS_SOURCE_DIR, AW_DOCS_DIR, RULES_SOURCE_DIR } from './constants.mjs';
9
9
 
10
10
  const exec = promisify(execCb);
11
+ const execFileAsync = promisify(execFileCb);
12
+
13
+ /** Run git with argv array (no shell) in awHome. */
14
+ async function gitIn(awHome, args, opts = {}) {
15
+ return execFileAsync('git', ['-C', awHome, ...args], { encoding: 'utf8', ...opts });
16
+ }
11
17
 
12
18
  /**
13
19
  * Env vars applied to every git command that touches the network.
@@ -306,15 +312,17 @@ export function removeFromSparseCheckout(awHome, removePaths) {
306
312
  * 2. Fetch from origin.
307
313
  * 3. Fast-forward if possible (clean case, no local commits).
308
314
  * 4. Rebase local commits on top of remote if ff-only fails.
309
- * 5. On rebase conflict in silent mode: abort to keep repo usable.
310
- * In interactive mode: leave conflict markers for the user.
315
+ * 5. On rebase conflict: default policy is abort when silent (keep repo usable
316
+ * for init/doctor/daemon). Callers that need to discard/leave (aw pull)
317
+ * pass `{ onConflict: 'leave' }` so the rebase stays paused for policy.
311
318
  * NEVER uses --no-edit merge — a merge commit on blob:none + sparse-checkout
312
319
  * disables sparse checkout, emptying the working tree.
313
320
  *
314
321
  * @param {string} awHome
315
- * @param {{ silent?: boolean }} opts
322
+ * @param {{ silent?: boolean, onConflict?: 'abort'|'leave' }} opts
316
323
  */
317
- export async function fetchAndMerge(awHome, { silent = true } = {}) {
324
+ export async function fetchAndMerge(awHome, { silent = true, onConflict } = {}) {
325
+ const conflictPolicy = onConflict ?? (silent ? 'abort' : 'leave');
318
326
  // ── 1. Branch classification ──────────────────────────────────────────────
319
327
  // There are three possible states for ~/.aw:
320
328
  //
@@ -417,16 +425,232 @@ export async function fetchAndMerge(awHome, { silent = true } = {}) {
417
425
  conflicts.push(...stdout.trim().split('\n').filter(Boolean));
418
426
  } catch { /* best effort */ }
419
427
 
420
- if (silent) {
421
- // Background: abort so repo stays usable; surface on next explicit aw init
422
- try { await exec(`git -C "${awHome}" rebase --abort`); } catch { /* best effort */ }
428
+ // Default silent/init path aborts so ~/.aw stays usable. aw pull passes
429
+ // onConflict:'leave' so it can discard / menu / abort itself.
430
+ if (conflictPolicy === 'abort') {
431
+ try { await gitIn(awHome, ['rebase', '--abort']); } catch { /* best effort */ }
423
432
  }
424
- // Interactive: leave conflict markers for the user to resolve
425
433
  }
426
434
 
427
435
  return { updated, conflicts };
428
436
  }
429
437
 
438
+ function rebaseInProgress(awHome) {
439
+ return existsSync(join(awHome, '.git', 'rebase-merge'))
440
+ || existsSync(join(awHome, '.git', 'rebase-apply'));
441
+ }
442
+
443
+ function mergeInProgress(awHome) {
444
+ return existsSync(join(awHome, '.git', 'MERGE_HEAD'));
445
+ }
446
+
447
+ export async function listUnresolvedConflicts(awHome) {
448
+ try {
449
+ const { stdout } = await gitIn(awHome, ['diff', '--name-only', '--diff-filter=U']);
450
+ return stdout.trim().split('\n').filter(Boolean);
451
+ } catch {
452
+ return [];
453
+ }
454
+ }
455
+
456
+ function gitErrorText(err) {
457
+ return [err?.stderr, err?.stdout, err?.message, err]
458
+ .filter(Boolean)
459
+ .map(String)
460
+ .join('\n')
461
+ .toLowerCase();
462
+ }
463
+
464
+ /** True when git rebase --continue failed because the current patch is empty. */
465
+ export function isEmptyRebasePatchError(err) {
466
+ const msg = gitErrorText(err);
467
+ return (
468
+ msg.includes('nothing to commit')
469
+ || msg.includes('no changes - did you forget')
470
+ || msg.includes('no changes')
471
+ || msg.includes('now empty')
472
+ || msg.includes('you might want to skip this patch')
473
+ || msg.includes('please use \'git rebase --skip\'')
474
+ || msg.includes('please use "git rebase --skip"')
475
+ || (msg.includes('empty') && (msg.includes('patch') || msg.includes('cherry-pick')))
476
+ );
477
+ }
478
+
479
+ /**
480
+ * Force-push current branch tip with lease (no shell). Used after conflict discard /
481
+ * rebase continuation on upload|sync|remove branches.
482
+ */
483
+ export async function pushForceWithLease(awHome, branch) {
484
+ await gitIn(awHome, ['push', '--force-with-lease', 'origin', branch], { env: GIT_NET_ENV });
485
+ }
486
+
487
+ /**
488
+ * Continue a paused rebase by one commit-step. After a failed continue, re-list
489
+ * conflicts and only `--skip` when Git indicates an empty patch. Never skip when
490
+ * continue stopped because a later commit conflicted or another non-empty error.
491
+ * Call again on the next `aw pull` after each new conflict is resolved.
492
+ *
493
+ * @returns {Promise<{ ok: boolean, skipped?: boolean, conflicts: string[], error?: string }>}
494
+ */
495
+ export async function continuePausedRebase(awHome) {
496
+ try {
497
+ await gitIn(awHome, ['-c', 'core.editor=true', 'rebase', '--continue'], {
498
+ env: { ...process.env, GIT_EDITOR: 'true' },
499
+ });
500
+ return { ok: true, skipped: false, conflicts: [] };
501
+ } catch (continueErr) {
502
+ const conflicts = await listUnresolvedConflicts(awHome);
503
+ if (conflicts.length > 0) {
504
+ return {
505
+ ok: false,
506
+ skipped: false,
507
+ conflicts,
508
+ error: continueErr.message || 'Rebase continue stopped on conflicts',
509
+ };
510
+ }
511
+ if (!isEmptyRebasePatchError(continueErr)) {
512
+ return {
513
+ ok: false,
514
+ skipped: false,
515
+ conflicts: [],
516
+ error: continueErr.message || 'Rebase continue failed',
517
+ };
518
+ }
519
+ try {
520
+ await gitIn(awHome, ['rebase', '--skip']);
521
+ return { ok: true, skipped: true, conflicts: [] };
522
+ } catch (skipErr) {
523
+ return {
524
+ ok: false,
525
+ skipped: false,
526
+ conflicts: await listUnresolvedConflicts(awHome),
527
+ error: skipErr.message || continueErr.message || 'Rebase continue/skip failed',
528
+ };
529
+ }
530
+ }
531
+ }
532
+
533
+ const REBASE_CONTINUE_LOOP_LIMIT = 50;
534
+
535
+ /**
536
+ * Resolve an in-progress registry rebase or merge conflict in ~/.aw.
537
+ *
538
+ * During rebase onto origin/main, `--ours` is upstream (remote).
539
+ * During merge, `--theirs` is the incoming/remote side.
540
+ *
541
+ * @param {string} awHome
542
+ * @param {{ action?: 'discard'|'abort'|'leave', conflicts?: string[] }} opts
543
+ * @returns {Promise<{ ok: boolean, action: string, conflicts: string[], kind?: string, error?: string }>}
544
+ */
545
+ export async function resolveRegistryConflicts(awHome, { action = 'abort', conflicts = [] } = {}) {
546
+ const initialConflicts = conflicts.length > 0 ? [...conflicts] : await listUnresolvedConflicts(awHome);
547
+ const isMerge = mergeInProgress(awHome);
548
+ const isRebase = rebaseInProgress(awHome);
549
+ const kind = isMerge && !isRebase ? 'merge' : 'rebase';
550
+
551
+ if (action === 'leave') {
552
+ return { ok: true, action: 'leave', conflicts: initialConflicts, kind };
553
+ }
554
+
555
+ if (action === 'abort') {
556
+ try {
557
+ if (kind === 'merge') await gitIn(awHome, ['merge', '--abort']);
558
+ else await gitIn(awHome, ['rebase', '--abort']);
559
+ } catch { /* best effort */ }
560
+ return { ok: true, action: 'abort', conflicts: initialConflicts, kind };
561
+ }
562
+
563
+ if (action !== 'discard') {
564
+ return { ok: false, action, conflicts: initialConflicts, kind, error: `Unknown conflict action: ${action}` };
565
+ }
566
+
567
+ let pending = initialConflicts.length > 0 ? initialConflicts : await listUnresolvedConflicts(awHome);
568
+ if (pending.length === 0 && !isRebase && !isMerge) {
569
+ return { ok: true, action: 'discard', conflicts: [], kind };
570
+ }
571
+
572
+ // Merge-in-progress: remote side is --theirs; finish with commit --no-edit.
573
+ if (kind === 'merge') {
574
+ for (const file of pending) {
575
+ try {
576
+ await gitIn(awHome, ['checkout', '--theirs', '--', file]);
577
+ await gitIn(awHome, ['add', '--', file]);
578
+ } catch (e) {
579
+ try { await gitIn(awHome, ['merge', '--abort']); } catch { /* best effort */ }
580
+ return { ok: false, action: 'discard', conflicts: pending, kind, error: e.message };
581
+ }
582
+ }
583
+ try {
584
+ await gitIn(awHome, ['-c', 'core.editor=true', 'commit', '--no-edit']);
585
+ return { ok: true, action: 'discard', conflicts: initialConflicts, kind };
586
+ } catch (e) {
587
+ try { await gitIn(awHome, ['merge', '--abort']); } catch { /* best effort */ }
588
+ return { ok: false, action: 'discard', conflicts: initialConflicts, kind, error: e.message };
589
+ }
590
+ }
591
+
592
+ // Rebase-in-progress: remote/upstream is --ours.
593
+ for (let guard = 0; guard < REBASE_CONTINUE_LOOP_LIMIT; guard++) {
594
+ pending = pending.length > 0 ? pending : await listUnresolvedConflicts(awHome);
595
+ for (const file of pending) {
596
+ try {
597
+ await gitIn(awHome, ['checkout', '--ours', '--', file]);
598
+ await gitIn(awHome, ['add', '--', file]);
599
+ } catch (e) {
600
+ try { await gitIn(awHome, ['rebase', '--abort']); } catch { /* best effort */ }
601
+ return { ok: false, action: 'discard', conflicts: pending, kind, error: e.message };
602
+ }
603
+ }
604
+
605
+ if (!rebaseInProgress(awHome)) {
606
+ return { ok: true, action: 'discard', conflicts: initialConflicts, kind };
607
+ }
608
+
609
+ try {
610
+ await gitIn(awHome, ['-c', 'core.editor=true', 'rebase', '--continue']);
611
+ if (!rebaseInProgress(awHome)) {
612
+ return { ok: true, action: 'discard', conflicts: initialConflicts, kind };
613
+ }
614
+ pending = await listUnresolvedConflicts(awHome);
615
+ } catch (e) {
616
+ pending = await listUnresolvedConflicts(awHome);
617
+ if (pending.length === 0 && isEmptyRebasePatchError(e)) {
618
+ try {
619
+ await gitIn(awHome, ['rebase', '--skip']);
620
+ if (!rebaseInProgress(awHome)) {
621
+ return { ok: true, action: 'discard', conflicts: initialConflicts, kind };
622
+ }
623
+ pending = await listUnresolvedConflicts(awHome);
624
+ continue;
625
+ } catch (skipErr) {
626
+ try { await gitIn(awHome, ['rebase', '--abort']); } catch { /* best effort */ }
627
+ return {
628
+ ok: false,
629
+ action: 'discard',
630
+ conflicts: initialConflicts,
631
+ kind,
632
+ error: skipErr.message || e.message,
633
+ };
634
+ }
635
+ }
636
+ if (pending.length === 0) {
637
+ try { await gitIn(awHome, ['rebase', '--abort']); } catch { /* best effort */ }
638
+ return {
639
+ ok: false,
640
+ action: 'discard',
641
+ conflicts: initialConflicts,
642
+ kind,
643
+ error: e.message || 'Rebase continue failed with a non-empty patch',
644
+ };
645
+ }
646
+ // Later commit conflicted during continue — keep looping with new pending.
647
+ }
648
+ }
649
+
650
+ try { await gitIn(awHome, ['rebase', '--abort']); } catch { /* best effort */ }
651
+ return { ok: false, action: 'discard', conflicts: initialConflicts, kind, error: 'Discard exceeded rebase continue limit' };
652
+ }
653
+
430
654
  /**
431
655
  * Detect local changes in registryDir/ within awHome.
432
656
  * Returns { modified: [], added: [], deleted: [], untracked: [] }
@@ -764,6 +988,119 @@ export function isWorktree(dir) {
764
988
  }
765
989
  }
766
990
 
991
+ /** Error message for reporting, without leaking a stack into CLI output. */
992
+ function errText(error) {
993
+ return error instanceof Error ? error.message : String(error);
994
+ }
995
+
996
+ /** Resolve to a real path, falling back to the input when it is not on disk. */
997
+ function realPathOrSelf(path) {
998
+ try {
999
+ return realpathSync(path);
1000
+ } catch (err) {
1001
+ void err; // not on disk (yet) — compare the literal path
1002
+ return path;
1003
+ }
1004
+ }
1005
+
1006
+ /** True iff `candidate` is `root` itself or lives anywhere beneath it. */
1007
+ function isWithin(root, candidate) {
1008
+ return candidate === root || candidate.startsWith(root + sep);
1009
+ }
1010
+
1011
+ /**
1012
+ * True iff `path` is awHome itself or lives anywhere inside it.
1013
+ *
1014
+ * Compares realpaths, which also covers a path that reaches inside awHome
1015
+ * through a symlink. A lexical comparison is deliberately not added on top:
1016
+ * callers pass `process.cwd()`, which the OS already returns resolved, so a
1017
+ * lexical form that differs from the resolved one never reaches this function.
1018
+ * The remaining cycle shape — an alias *under* awHome pointing at an already
1019
+ * linked project — cannot be seen from these two arguments at all and is
1020
+ * handled by pruneAwSelfLinks instead.
1021
+ */
1022
+ function isInsideAwHome(awHome, path) {
1023
+ return isWithin(realPathOrSelf(awHome), realPathOrSelf(path));
1024
+ }
1025
+
1026
+ /**
1027
+ * Remove symlinks under `awHome` that make its tree cyclic.
1028
+ *
1029
+ * Two shapes qualify:
1030
+ *
1031
+ * 1. A direct self-link — e.g. ~/.aw/.aw_registry/…/skills/.aw -> ~/.aw.
1032
+ * 2. An alias to a linked project — ~/.aw/external -> /path/to/project,
1033
+ * where /path/to/project/.aw -> ~/.aw. Walking ~/.aw/external/.aw then
1034
+ * re-enters ~/.aw. Here the *alias* is removed, never the project's own
1035
+ * .aw link, which is how every linked repo is legitimately wired.
1036
+ *
1037
+ * Either shape makes recursive walkers that follow symlinks and keep no
1038
+ * visited set — vite/chokidar, tsc, eslint — re-descend the same segments
1039
+ * until the path exceeds PATH_MAX and the walk dies with ENAMETOOLONG.
1040
+ *
1041
+ * Only descends real directories, never symlinks, so the prune cannot be
1042
+ * caught by the cycle it is removing.
1043
+ *
1044
+ * Returns `{ removed, failed }`. A non-empty `failed` means a cycle may still
1045
+ * be in place, so callers must surface it rather than assume the heal worked.
1046
+ */
1047
+ export function pruneAwSelfLinks(awHome) {
1048
+ const root = realPathOrSelf(awHome);
1049
+ const removed = [];
1050
+ const failed = [];
1051
+ if (!existsSync(root)) return { removed, failed };
1052
+
1053
+ /** Remove one cycle-forming link, recording success or the reason it failed. */
1054
+ const drop = (linkPath, shape) => {
1055
+ try {
1056
+ unlinkSync(linkPath);
1057
+ removed.push(linkPath);
1058
+ } catch (error) {
1059
+ failed.push({ path: linkPath, shape, reason: errText(error) });
1060
+ }
1061
+ };
1062
+
1063
+ const walk = (dir) => {
1064
+ let entries;
1065
+ try {
1066
+ entries = readdirSync(dir, { withFileTypes: true });
1067
+ } catch (error) {
1068
+ // Cannot inspect this subtree — a cycle may be hiding in it, so report
1069
+ // rather than swallow.
1070
+ failed.push({ path: dir, shape: 'unreadable-directory', reason: errText(error) });
1071
+ return;
1072
+ }
1073
+ for (const entry of entries) {
1074
+ if (entry.name === '.git') continue; // never touch the registry's own git dir
1075
+ const full = join(dir, entry.name);
1076
+ if (entry.isSymbolicLink()) {
1077
+ let target;
1078
+ try {
1079
+ target = realpathSync(full);
1080
+ } catch (error) {
1081
+ void error; // dangling link — cannot form a cycle, leave it alone
1082
+ continue;
1083
+ }
1084
+ if (target === root) {
1085
+ drop(full, 'self-link');
1086
+ continue;
1087
+ }
1088
+ // Alias shape: does the target directory link back to awHome?
1089
+ try {
1090
+ if (!statSync(target).isDirectory()) continue;
1091
+ if (realpathSync(join(target, '.aw')) === root) drop(full, 'alias-cycle');
1092
+ } catch (error) {
1093
+ void error; // no .aw inside the target, or target vanished — not a cycle
1094
+ }
1095
+ } else if (entry.isDirectory()) {
1096
+ walk(full);
1097
+ }
1098
+ }
1099
+ };
1100
+ walk(root);
1101
+ return { removed, failed };
1102
+ }
1103
+
767
1104
  /**
768
1105
  * Create project/.aw/ as a symlink to awHome (~/.aw).
769
1106
  *
@@ -774,6 +1111,16 @@ export function isWorktree(dir) {
774
1111
  * Safe to call multiple times — skips if already set up correctly.
775
1112
  */
776
1113
  export function addProjectWorktree(awHome, projectDir) {
1114
+ // Refuse to link awHome into itself or into anything inside it. Such a link
1115
+ // is a cycle (see pruneAwSelfLinks) that breaks every recursive walker with
1116
+ // ENAMETOOLONG. Compare realpaths, not strings: on macOS homedir() can report
1117
+ // /var/... while process.cwd() reports /private/var/... for the same dir.
1118
+ if (isInsideAwHome(awHome, projectDir)) {
1119
+ throw new Error(
1120
+ `Refusing to link ${projectDir}: it is inside ${awHome}. Run this from a project directory.`
1121
+ );
1122
+ }
1123
+
777
1124
  const worktreeDir = join(projectDir, '.aw');
778
1125
 
779
1126
  // Clean up stale project-root .aw_registry symlink from old installs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.89",
3
+ "version": "0.1.92",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {