@miller-tech/uap 1.165.0 → 1.168.0

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 (41) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/bin/cli.js +47 -2
  3. package/dist/bin/cli.js.map +1 -1
  4. package/dist/cli/coord.d.ts +1 -1
  5. package/dist/cli/coord.d.ts.map +1 -1
  6. package/dist/cli/coord.js +59 -0
  7. package/dist/cli/coord.js.map +1 -1
  8. package/dist/cli/merge-queue.d.ts +53 -0
  9. package/dist/cli/merge-queue.d.ts.map +1 -0
  10. package/dist/cli/merge-queue.js +239 -0
  11. package/dist/cli/merge-queue.js.map +1 -0
  12. package/dist/cli/worktree.d.ts +69 -1
  13. package/dist/cli/worktree.d.ts.map +1 -1
  14. package/dist/cli/worktree.js +560 -26
  15. package/dist/cli/worktree.js.map +1 -1
  16. package/dist/config/policy-recommendations.d.ts.map +1 -1
  17. package/dist/config/policy-recommendations.js +4 -0
  18. package/dist/config/policy-recommendations.js.map +1 -1
  19. package/dist/coordination/index.d.ts +1 -0
  20. package/dist/coordination/index.d.ts.map +1 -1
  21. package/dist/coordination/index.js +1 -0
  22. package/dist/coordination/index.js.map +1 -1
  23. package/dist/coordination/ownership.d.ts +51 -0
  24. package/dist/coordination/ownership.d.ts.map +1 -0
  25. package/dist/coordination/ownership.js +175 -0
  26. package/dist/coordination/ownership.js.map +1 -0
  27. package/docs/INDEX.md +2 -1
  28. package/docs/guides/PARALLEL_AGENTS.md +209 -0
  29. package/docs/guides/POLICIES.md +1 -0
  30. package/docs/guides/WORKTREE_WORKFLOW.md +5 -2
  31. package/docs/reference/CLI.md +24 -1
  32. package/package.json +1 -1
  33. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  34. package/src/policies/enforcers/branch_freshness.py +134 -0
  35. package/src/policies/enforcers/coord_overlap.py +126 -47
  36. package/src/policies/schemas/policies/branch-freshness.md +81 -0
  37. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  38. package/templates/hooks/coordinate-file.sh +153 -3
  39. package/templates/hooks/session-end.sh +15 -0
  40. package/templates/hooks/session-start.sh +24 -2
  41. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
@@ -1,11 +1,40 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
3
  import { cpSync, existsSync, readdirSync, mkdirSync } from 'fs';
4
- import { join } from 'path';
4
+ import { join, dirname, resolve, sep } from 'path';
5
5
  import { simpleGit } from 'simple-git';
6
6
  import Database from 'better-sqlite3';
7
- import { execSync } from 'child_process';
7
+ import { execSync, execFileSync } from 'child_process';
8
+ /** Commits behind the integration ref at which a worktree reads as abandoned. */
9
+ export const STALE_BEHIND_LIMIT = 200;
8
10
  let worktreeDb = null;
11
+ /**
12
+ * The MAIN checkout — where .worktrees/ and the shared coordination DB live.
13
+ * git-common-dir resolves it even when called from inside a linked worktree.
14
+ */
15
+ async function mainRootOf(git) {
16
+ try {
17
+ const common = (await git.raw(['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim();
18
+ if (common)
19
+ return dirname(common);
20
+ }
21
+ catch {
22
+ // older git without --path-format, or not a repo
23
+ }
24
+ try {
25
+ // NOT --show-toplevel: inside a linked worktree that returns the WORKTREE's
26
+ // root, which has no agents/ dir — silently disabling liveness in exactly the
27
+ // case this helper exists for. The relative form of --git-common-dir has
28
+ // existed since git 2.5 and still points at the MAIN .git.
29
+ const rel = (await git.raw(['rev-parse', '--git-common-dir'])).trim();
30
+ if (rel)
31
+ return dirname(resolve(process.cwd(), rel));
32
+ }
33
+ catch {
34
+ // not a repo
35
+ }
36
+ return process.cwd();
37
+ }
9
38
  function getWorktreeDb(cwd) {
10
39
  if (worktreeDb)
11
40
  return worktreeDb;
@@ -53,7 +82,13 @@ export async function worktreeCommand(action, options = {}) {
53
82
  }
54
83
  switch (action) {
55
84
  case 'create':
56
- await createWorktree(cwd, git, options.slug, options.from);
85
+ await createWorktree(cwd, git, options.slug, options.from, options.noFetch);
86
+ break;
87
+ case 'sync':
88
+ await syncWorktree(cwd, git, { id: options.id, all: options.all });
89
+ break;
90
+ case 'hygiene':
91
+ await worktreeHygiene(cwd, git, { brief: options.brief });
57
92
  break;
58
93
  case 'list':
59
94
  await listWorktrees(cwd, git);
@@ -79,7 +114,7 @@ export async function worktreeCommand(action, options = {}) {
79
114
  break;
80
115
  }
81
116
  }
82
- async function createWorktree(cwd, git, slug, baseBranch) {
117
+ async function createWorktree(cwd, git, slug, baseBranch, noFetch) {
83
118
  const spinner = ora('Creating worktree...').start();
84
119
  try {
85
120
  // Get next ID atomically from DB
@@ -88,11 +123,23 @@ async function createWorktree(cwd, git, slug, baseBranch) {
88
123
  const worktreeName = `${paddedId}-${slug}`;
89
124
  const branchName = `feature/${worktreeName}`;
90
125
  const worktreePath = join(cwd, '.worktrees', worktreeName);
91
- // Get current branch (base)
126
+ // FRESH-BASE GATE. A worktree used to be cut from whatever the main checkout
127
+ // happened to have at HEAD — so if local master was stale (or the operator was
128
+ // parked on some unrelated branch), the agent started N commits behind and every
129
+ // file it touched was at risk of silently reverting work that had already landed.
130
+ // Observed live: a worktree created while local master was 1 behind origin was
131
+ // born stale, and the oldest worktrees in this repo drifted to 1241 commits behind.
132
+ // Base on the REMOTE tip by default; --from still wins for deliberate stacking.
92
133
  spinner.text = 'Resolving base branch...';
93
- const currentBranch = await git.revparse(['--abbrev-ref', 'HEAD']);
94
- let branchBase = currentBranch.trim();
134
+ let branchBase;
95
135
  if (baseBranch) {
136
+ // Explicit base: fetch it first so `--from origin/x` (and a local ref that
137
+ // tracks it) resolves to the current remote tip rather than a stale copy.
138
+ // Strip the remote prefix — `git fetch origin origin/x` is not a valid
139
+ // refspec, so it failed silently and left the stale local ref in place.
140
+ if (!noFetch) {
141
+ await fetchQuietly(git, baseBranch.replace(/^origin\//, ''));
142
+ }
96
143
  try {
97
144
  await git.revparse([baseBranch]);
98
145
  branchBase = baseBranch;
@@ -102,6 +149,9 @@ async function createWorktree(cwd, git, slug, baseBranch) {
102
149
  process.exit(1);
103
150
  }
104
151
  }
152
+ else {
153
+ branchBase = await resolveFreshBase(git, { noFetch, spinner });
154
+ }
105
155
  // Create worktree with new branch
106
156
  spinner.text = `Creating branch ${branchName}...`;
107
157
  await git.raw(['worktree', 'add', '-b', branchName, worktreePath, branchBase]);
@@ -140,7 +190,8 @@ async function createWorktree(cwd, git, slug, baseBranch) {
140
190
  `).run(slug, branchName, worktreePath);
141
191
  spinner.succeed(`Created worktree: ${worktreeName}`);
142
192
  console.log(chalk.dim(` Branch: ${branchName}`));
143
- console.log(chalk.dim(` Path: ${worktreePath}`));
193
+ console.log(chalk.dim(` Base: ${branchBase}`));
194
+ console.log(chalk.dim(` Path: ${worktreePath}`));
144
195
  console.log('');
145
196
  console.log(chalk.bold('Next steps:'));
146
197
  console.log(` cd .worktrees/${worktreeName}`);
@@ -275,17 +326,418 @@ function ensurePrNumber(worktreePath) {
275
326
  }
276
327
  }
277
328
  async function syncBranchWithMaster(worktreeGit, branch) {
278
- await worktreeGit.fetch(['origin', 'master']);
279
- const behindRaw = await worktreeGit.raw(['rev-list', '--count', `${branch}..origin/master`]);
329
+ const base = await resolveIntegrationRef(worktreeGit);
330
+ await fetchQuietly(worktreeGit, base.replace(/^origin\//, ''));
331
+ const behindRaw = await worktreeGit.raw(['rev-list', '--count', `${branch}..${base}`]);
280
332
  const behind = parseRevListCount(behindRaw);
281
333
  if (behind === 0) {
282
334
  return;
283
335
  }
284
336
  try {
285
- await worktreeGit.raw(['merge', '--no-edit', 'origin/master']);
337
+ await worktreeGit.raw(['merge', '--no-edit', base]);
338
+ }
339
+ catch {
340
+ throw new Error(`Worktree branch is behind ${base} and automatic sync failed. Resolve merge conflicts in the worktree, then rerun the command.`);
341
+ }
342
+ }
343
+ /** Best-effort fetch — never fails the caller (offline, no remote, auth prompt). */
344
+ async function fetchQuietly(git, ref) {
345
+ try {
346
+ await git.fetch(ref ? ['origin', ref] : ['origin']);
347
+ return true;
348
+ }
349
+ catch {
350
+ return false;
351
+ }
352
+ }
353
+ /**
354
+ * Name of the repo's integration branch, without the remote prefix.
355
+ * Prefers origin/HEAD (what the remote itself calls default), then the usual
356
+ * suspects, then the current local branch. Hard-coding "master" broke every
357
+ * main-branch repo that installed UAP.
358
+ */
359
+ export async function resolveDefaultBranch(git) {
360
+ try {
361
+ const head = await git.raw(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']);
362
+ const name = head.trim().replace('refs/remotes/origin/', '');
363
+ if (name)
364
+ return name;
365
+ }
366
+ catch {
367
+ // origin/HEAD not set (common on clones made with --single-branch) — fall through.
368
+ }
369
+ for (const candidate of ['master', 'main']) {
370
+ try {
371
+ await git.revparse([`refs/remotes/origin/${candidate}`]);
372
+ return candidate;
373
+ }
374
+ catch {
375
+ // not present — try the next one
376
+ }
377
+ }
378
+ try {
379
+ return (await git.revparse(['--abbrev-ref', 'HEAD'])).trim();
380
+ }
381
+ catch {
382
+ return 'master';
383
+ }
384
+ }
385
+ /** Fully-qualified ref to integrate against: `origin/<default>` when the remote has it. */
386
+ export async function resolveIntegrationRef(git) {
387
+ const branch = await resolveDefaultBranch(git);
388
+ try {
389
+ await git.revparse([`refs/remotes/origin/${branch}`]);
390
+ return `origin/${branch}`;
391
+ }
392
+ catch {
393
+ return branch;
394
+ }
395
+ }
396
+ /**
397
+ * The ref a new worktree should be cut from: the freshly-fetched remote tip.
398
+ * Falls back to the local branch when there is no reachable remote, so this
399
+ * still works offline and in never-pushed repos.
400
+ */
401
+ async function resolveFreshBase(git, opts = {}) {
402
+ const defaultBranch = await resolveDefaultBranch(git);
403
+ if (!opts.noFetch) {
404
+ if (opts.spinner)
405
+ opts.spinner.text = `Fetching origin/${defaultBranch}...`;
406
+ await fetchQuietly(git, defaultBranch);
407
+ }
408
+ try {
409
+ await git.revparse([`refs/remotes/origin/${defaultBranch}`]);
410
+ return `origin/${defaultBranch}`;
411
+ }
412
+ catch {
413
+ // No remote-tracking ref (offline first-run, local-only repo): use the local
414
+ // branch if it exists, else whatever HEAD is. Never fail worktree creation.
415
+ }
416
+ try {
417
+ await git.revparse([defaultBranch]);
418
+ return defaultBranch;
419
+ }
420
+ catch {
421
+ return 'HEAD';
422
+ }
423
+ }
424
+ /**
425
+ * Branches a LIVE agent is currently announcing work on.
426
+ *
427
+ * Git metadata alone cannot tell "abandoned" from "someone is working here this
428
+ * second": both look like an old branch with uncommitted files. That ambiguity is
429
+ * not academic — a worktree in this repo was read as abandoned WIP and merged
430
+ * forward while its owning agent was mid-session, because the drift report only
431
+ * ever consulted git. The coordination DB knows the difference; join against it.
432
+ *
433
+ * Liveness uses the same heartbeat window as coordinate-file.sh so "live" means
434
+ * one thing across the system. Fail-open: an unreadable DB marks nothing active.
435
+ */
436
+ export function liveAgentBranches(mainRoot) {
437
+ // Validate like coordinate-file.sh does, or the same env var means "120s" to
438
+ // the hook and "protection off" to us: parseInt('-1') is truthy, and a
439
+ // negative window matches no rows.
440
+ const raw = Number.parseInt(process.env.UAP_COORD_LIVE_SECONDS ?? '', 10);
441
+ const windowSeconds = Number.isFinite(raw) && raw > 0 ? raw : 120;
442
+ const branches = new Set();
443
+ const dbPath = join(mainRoot, 'agents', 'data', 'coordination', 'coordination.db');
444
+ if (!existsSync(dbPath))
445
+ return { branches, readable: true }; // genuinely no coordination: idle
446
+ let db = null;
447
+ try {
448
+ db = new Database(dbPath, { readonly: true, fileMustExist: true });
449
+ // Readers rarely block in WAL, but SQLITE_BUSY is likeliest exactly when
450
+ // agents ARE live — the moment a fail-open would be most wrong.
451
+ db.pragma('busy_timeout = 10000');
452
+ // UNION the two writers. An agent that has registered and is heartbeating
453
+ // but has no OPEN announcement — just started, only reading, or its
454
+ // announcements were completed at session end — is still live.
455
+ const rows = db
456
+ .prepare(`SELECT DISTINCT branch FROM (
457
+ SELECT wa.worktree_branch AS branch
458
+ FROM work_announcements wa
459
+ LEFT JOIN agent_registry ar ON ar.id = wa.agent_id
460
+ WHERE wa.completed_at IS NULL
461
+ AND wa.worktree_branch IS NOT NULL
462
+ AND COALESCE(ar.status, 'active') = 'active'
463
+ AND (strftime('%s','now')
464
+ - strftime('%s', COALESCE(ar.last_heartbeat, wa.announced_at))) < :win
465
+ UNION
466
+ SELECT ar2.worktree_branch AS branch
467
+ FROM agent_registry ar2
468
+ WHERE ar2.status = 'active'
469
+ AND ar2.worktree_branch IS NOT NULL
470
+ AND (strftime('%s','now') - strftime('%s', ar2.last_heartbeat)) < :win
471
+ )`)
472
+ .all({ win: windowSeconds });
473
+ for (const r of rows)
474
+ if (r.branch)
475
+ branches.add(r.branch);
476
+ return { branches, readable: true };
477
+ }
478
+ catch {
479
+ // Could not read it. Say so — do not let "unknown" masquerade as "idle".
480
+ return { branches, readable: false };
481
+ }
482
+ finally {
483
+ try {
484
+ db?.close();
485
+ }
486
+ catch {
487
+ /* ignore */
488
+ }
489
+ }
490
+ }
491
+ /**
492
+ * Measure one worktree's drift against the integration ref. Never throws.
493
+ *
494
+ * Kept to ONE git process on the common path. The first version spent four
495
+ * (`rev-parse`, two `rev-list`, `status`) which, across this repo's 152
496
+ * worktrees, ran ~600 sequential git invocations — comfortably past the 15s
497
+ * budget the session banner allows, so the advisory was always killed before it
498
+ * printed while still costing the full 15 seconds.
499
+ *
500
+ * @param branch pre-resolved from `git worktree list --porcelain`, which already
501
+ * reports it — re-asking per worktree was a free process we were paying for.
502
+ * @param withDirty `git status` is by far the most expensive call (it stats the
503
+ * whole tree). Only worth it for worktrees that already look interesting.
504
+ */
505
+ export async function measureDrift(worktreePath, integrationRef, branch, withDirty = true) {
506
+ try {
507
+ const wtGit = simpleGit(worktreePath);
508
+ const resolved = branch ?? (await wtGit.revparse(['--abbrev-ref', 'HEAD'])).trim();
509
+ // One symmetric-difference call replaces two rev-lists. Note the THREE dots:
510
+ // `--left-right --count a...b` emits "<behind>\t<ahead>" relative to the merge base.
511
+ const raw = await wtGit.raw([
512
+ 'rev-list',
513
+ '--left-right',
514
+ '--count',
515
+ `${integrationRef}...HEAD`,
516
+ ]);
517
+ const [behindRaw, aheadRaw] = raw.trim().split(/\s+/);
518
+ const behind = parseRevListCount(behindRaw ?? '0');
519
+ const ahead = parseRevListCount(aheadRaw ?? '0');
520
+ let dirty = 0;
521
+ if (withDirty) {
522
+ const status = await wtGit.status();
523
+ // status.files is the authoritative entry list; summing the category arrays
524
+ // double-counts, because a staged-and-modified file appears in both.
525
+ dirty = status.files.length;
526
+ }
527
+ const name = worktreePath.split('.worktrees/')[1] || worktreePath;
528
+ return { name, path: worktreePath, branch: resolved, behind, ahead, dirty };
529
+ }
530
+ catch {
531
+ return null;
532
+ }
533
+ }
534
+ /** All linked worktrees under .worktrees/, with their branches. */
535
+ async function listWorktreeRefs(git) {
536
+ try {
537
+ const raw = await git.raw(['worktree', 'list', '--porcelain']);
538
+ const refs = [];
539
+ let current = null;
540
+ for (const line of raw.split('\n')) {
541
+ if (line.startsWith('worktree ')) {
542
+ current = line.replace('worktree ', '').trim();
543
+ }
544
+ else if (line.startsWith('branch ') && current) {
545
+ if (current.includes('.worktrees')) {
546
+ refs.push({ path: current, branch: line.replace('branch refs/heads/', '').trim() });
547
+ }
548
+ current = null;
549
+ }
550
+ }
551
+ return refs;
552
+ }
553
+ catch {
554
+ return [];
555
+ }
556
+ }
557
+ /**
558
+ * Run an async mapper over items with bounded concurrency.
559
+ * Unbounded would spawn 152×N git processes at once and thrash the machine;
560
+ * sequential leaves the CPU idle while each git process does IO.
561
+ */
562
+ async function mapLimit(items, limit, fn) {
563
+ const out = new Array(items.length);
564
+ let next = 0;
565
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
566
+ while (next < items.length) {
567
+ const i = next++;
568
+ out[i] = await fn(items[i]);
569
+ }
570
+ });
571
+ await Promise.all(workers);
572
+ return out;
573
+ }
574
+ /**
575
+ * `uap worktree sync` — pull the integration branch into a worktree MID-FLIGHT.
576
+ * The old flow only synced at `finish`, which is the most expensive possible
577
+ * moment to discover a conflict. This makes re-basing cheap and routine.
578
+ */
579
+ async function syncWorktree(cwd, git, opts = {}) {
580
+ const integrationRef = await resolveIntegrationRef(git);
581
+ const spinner = ora(`Syncing with ${integrationRef}...`).start();
582
+ let targets;
583
+ if (opts.all) {
584
+ targets = (await listWorktreeRefs(git)).map((r) => r.path);
585
+ }
586
+ else if (opts.id) {
587
+ const found = findWorktreeById(cwd, opts.id);
588
+ if (!found) {
589
+ spinner.fail(`Worktree with ID ${opts.id} not found`);
590
+ return;
591
+ }
592
+ targets = [found];
593
+ }
594
+ else {
595
+ // Default: the worktree we are standing in.
596
+ targets = [cwd];
597
+ }
598
+ await fetchQuietly(git, integrationRef.replace(/^origin\//, ''));
599
+ spinner.stop();
600
+ let synced = 0;
601
+ let conflicted = 0;
602
+ let already = 0;
603
+ let skipped = 0;
604
+ // Probe concurrently, then merge serially: merges mutate working trees and
605
+ // their output should stay readable and attributable.
606
+ const drifts = await mapLimit(targets, 8, (t) => measureDrift(t, integrationRef));
607
+ for (const drift of drifts) {
608
+ if (!drift)
609
+ continue;
610
+ if (drift.behind === 0) {
611
+ already++;
612
+ continue;
613
+ }
614
+ // Merging into a dirty tree aborts with "local changes would be overwritten",
615
+ // which the old code reported as a CONFLICT — misleading, and with --all it
616
+ // would say that about every dirty worktree in the repo.
617
+ if (drift.dirty > 0) {
618
+ skipped++;
619
+ console.log(chalk.yellow(` ⏭ ${drift.name}: ${drift.dirty} uncommitted change(s) — commit or stash first ` +
620
+ `(${drift.behind} behind)`));
621
+ continue;
622
+ }
623
+ const wtGit = simpleGit(drift.path);
624
+ try {
625
+ await wtGit.raw(['merge', '--no-edit', integrationRef]);
626
+ synced++;
627
+ console.log(chalk.green(` ✔ ${drift.name}: merged ${drift.behind} commit(s) from ${integrationRef}`));
628
+ }
629
+ catch {
630
+ conflicted++;
631
+ console.log(chalk.red(` ✖ ${drift.name}: CONFLICT merging ${integrationRef} ` +
632
+ `(${drift.behind} behind). Resolve in ${drift.path}, then commit.`));
633
+ }
634
+ }
635
+ console.log('');
636
+ console.log(chalk.bold(`Sync: ${synced} updated, ${already} already current, ` +
637
+ `${skipped} skipped (dirty), ${conflicted} need manual resolution`));
638
+ if (conflicted > 0) {
639
+ process.exitCode = 1;
640
+ }
641
+ }
642
+ /**
643
+ * `uap worktree hygiene` — surface drift and at-risk work across ALL worktrees.
644
+ * Silent accumulation is how work gets lost: a branch nobody re-synced for a
645
+ * thousand commits will either conflict violently or be quietly abandoned.
646
+ */
647
+ async function worktreeHygiene(_cwd, git, opts = {}) {
648
+ const integrationRef = await resolveIntegrationRef(git);
649
+ // Refresh the remote tip first: measuring against a stale origin/master
650
+ // under-reports drift, which is precisely what this report exists to catch.
651
+ await fetchQuietly(git, integrationRef.replace(/^origin\//, ''));
652
+ const refs = await listWorktreeRefs(git);
653
+ // Two passes so `git status` — the expensive call — runs only where it can
654
+ // change the verdict. Everything else needs one git process per worktree.
655
+ const cheap = await mapLimit(refs, 8, (r) => measureDrift(r.path, integrationRef, r.branch, false));
656
+ const measured = await mapLimit(cheap.filter((d) => d !== null), 8, async (d) => (d.ahead > 0 ? ((await measureDrift(d.path, integrationRef, d.branch, true)) ?? d) : d));
657
+ // Mark the worktrees someone is actually working in, so "old" is never read as
658
+ // "abandoned". A live worktree must never be offered up for pruning.
659
+ const live = liveAgentBranches(await mainRootOf(git)).branches;
660
+ const drifts = measured.map((d) => ({ ...d, active: live.has(d.branch) }));
661
+ const atRisk = drifts.filter((d) => d.ahead > 0 || d.dirty > 0);
662
+ const summary = summarizeHygiene(drifts, integrationRef);
663
+ if (opts.brief) {
664
+ if (summary)
665
+ console.log(summary);
666
+ return;
667
+ }
668
+ console.log(chalk.bold(`\n🧹 Worktree hygiene (vs ${integrationRef})\n`));
669
+ if (drifts.length === 0) {
670
+ console.log(chalk.dim('No linked worktrees.'));
671
+ return;
672
+ }
673
+ drifts.sort((a, b) => b.behind - a.behind);
674
+ console.log('| Worktree | Behind | Ahead | Dirty | Status |');
675
+ console.log('|----------|--------|-------|-------|--------|');
676
+ for (const d of drifts) {
677
+ // ACTIVE outranks every other label: an old, drifted, dirty worktree with a
678
+ // live agent in it is not stale work, it is work in progress.
679
+ const status = d.active
680
+ ? chalk.cyan('ACTIVE — agent working here')
681
+ : d.ahead > 0 || d.dirty > 0
682
+ ? chalk.yellow('UNMERGED WORK')
683
+ : d.behind > STALE_BEHIND_LIMIT
684
+ ? chalk.red('STALE — safe to prune')
685
+ : chalk.dim('clean');
686
+ console.log(`| ${d.name} | ${d.behind} | ${d.ahead} | ${d.dirty} | ${status} |`);
687
+ }
688
+ console.log('');
689
+ if (summary)
690
+ console.log(summary);
691
+ if (atRisk.length > 0) {
692
+ console.log(chalk.dim(' Reconcile with: uap worktree sync --id <id> then uap worktree pr <id>'));
693
+ console.log(chalk.dim(' Abandon with: uap worktree cleanup <id>'));
694
+ }
695
+ console.log(chalk.dim(' Bulk prune merged//stale worktrees: uap worktree prune --older-than 30'));
696
+ console.log('');
697
+ }
698
+ /**
699
+ * One-line advisory for session-start. Returns '' when nothing needs attention,
700
+ * so the caller can stay silent on a healthy repo.
701
+ */
702
+ export function summarizeHygiene(drifts, integrationRef) {
703
+ if (drifts.length === 0)
704
+ return '';
705
+ // Active worktrees are excluded from BOTH risk counts: they are neither
706
+ // abandoned nor at risk, they are being worked on. Counting them as drift is
707
+ // how a live worktree gets mistaken for salvage.
708
+ const idle = drifts.filter((d) => !d.active);
709
+ const active = drifts.filter((d) => d.active);
710
+ const atRisk = idle.filter((d) => d.ahead > 0 || d.dirty > 0);
711
+ const stale = idle.filter((d) => d.behind > STALE_BEHIND_LIMIT);
712
+ if (atRisk.length === 0 && stale.length === 0 && active.length === 0)
713
+ return '';
714
+ const parts = [];
715
+ if (atRisk.length > 0) {
716
+ parts.push(`${atRisk.length} worktree(s) hold unmerged or uncommitted work`);
717
+ }
718
+ if (stale.length > 0) {
719
+ parts.push(`${stale.length} are >${STALE_BEHIND_LIMIT} commits behind ${integrationRef}`);
720
+ }
721
+ if (active.length > 0) {
722
+ parts.push(`${active.length} have a LIVE agent working in them (do not reclaim)`);
723
+ }
724
+ if (parts.length === 0)
725
+ return '';
726
+ const ranked = idle.length > 0 ? idle : drifts;
727
+ const worst = ranked.reduce((a, b) => (b.behind > a.behind ? b : a));
728
+ return (`⚠️ Worktree drift: ${parts.join('; ')} ` +
729
+ `(worst: ${worst.name} at ${worst.behind} behind). Run \`uap worktree hygiene\`.`);
730
+ }
731
+ /** Resolve a worktree directory from its numeric ID. */
732
+ function findWorktreeById(cwd, id) {
733
+ try {
734
+ const worktreesDir = join(cwd, '.worktrees');
735
+ const entries = readdirSync(worktreesDir);
736
+ const match = entries.find((e) => e.startsWith(`${id.padStart(3, '0')}-`));
737
+ return match ? join(worktreesDir, match) : null;
286
738
  }
287
739
  catch {
288
- throw new Error('Worktree branch is behind origin/master and automatic sync failed. Resolve merge conflicts in the worktree, then rerun the command.');
740
+ return null;
289
741
  }
290
742
  }
291
743
  export function parseRevListCount(output) {
@@ -485,21 +937,101 @@ async function pruneStaleWorktrees(cwd, options) {
485
937
  const { rmSync } = await import('fs');
486
938
  const db = getWorktreeDb(cwd);
487
939
  const days = options.olderThan;
488
- const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
489
- // List stale worktrees (status='cleaned' or older than threshold)
940
+ // created_at is stored in SECONDS (`strftime('%s','now')`). Comparing it to a
941
+ // MILLISECOND cutoff made `created_at < cutoff` true for every row that will
942
+ // ever exist, so `--older-than` filtered nothing: a worktree created a minute
943
+ // ago was always a prune candidate. Verified against this repo's registry —
944
+ // 117/117 rows selected even at `--older-than 3650`. Compare in one unit.
945
+ const cutoffSeconds = Math.floor(Date.now() / 1000) - days * 24 * 60 * 60;
490
946
  const stale = db.prepare(`
491
- SELECT id, slug, worktree_path, created_at, status
947
+ SELECT id, slug, branch_name, worktree_path, created_at, status
492
948
  FROM worktrees
493
949
  WHERE created_at < ?
494
- `).all(cutoff);
950
+ `).all(cutoffSeconds);
495
951
  if (stale.length === 0) {
496
952
  console.log(chalk.green(`No worktrees older than ${days} days found`));
497
953
  return;
498
954
  }
955
+ // NEVER reclaim a worktree an agent is live in. Age is not abandonment: a
956
+ // month-old worktree can have someone working in it right now, and prune
957
+ // deletes the directory — uncommitted work included. This is the one place in
958
+ // the system where getting "stale" wrong is unrecoverable.
959
+ const liveness = liveAgentBranches(await mainRootOf(simpleGit(cwd)));
960
+ if (!liveness.readable) {
961
+ // FAIL CLOSED. An empty set means both "nobody is live" and "I could not
962
+ // tell" — and the consequence of guessing wrong here is rm -rf of somebody's
963
+ // uncommitted work. Refuse rather than assume the repo is idle.
964
+ console.log(chalk.red('Refusing to prune: the coordination DB could not be read, so live agents cannot be ruled out.'));
965
+ console.log(chalk.dim(' Re-run with --force to prune anyway.'));
966
+ if (!options.force)
967
+ return;
968
+ }
969
+ const liveBranches = liveness.branches;
970
+ // Containment: only ever delete inside <cwd>/.worktrees/. worktree_path comes
971
+ // from a registry under .uap/, which is exempt from the worktree write guard —
972
+ // so a planted row must not be able to aim rmSync at the main checkout.
973
+ const worktreesRoot = resolve(cwd, '.worktrees');
974
+ const protectedRows = [];
975
+ const outOfScope = [];
976
+ const prunable = stale.filter((row) => {
977
+ const abs = resolve(row.worktree_path);
978
+ if (abs !== worktreesRoot && !abs.startsWith(worktreesRoot + sep)) {
979
+ outOfScope.push(row);
980
+ return false;
981
+ }
982
+ // Prefer the branch the registry recorded; only consult git if it is absent.
983
+ // Strip GIT_* from the child env: git resolves GIT_DIR before cwd, so an
984
+ // ambient value makes every worktree report the SAME branch — which would
985
+ // mark every live worktree prunable in one shot. (This repo has hit GIT_DIR
986
+ // poisoning before; see the enforcers' _clean_env.)
987
+ let branch = row.branch_name ?? '';
988
+ if (!branch) {
989
+ try {
990
+ const env = { ...process.env };
991
+ for (const k of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_PREFIX']) {
992
+ delete env[k];
993
+ }
994
+ branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
995
+ cwd: row.worktree_path,
996
+ encoding: 'utf-8',
997
+ stdio: ['ignore', 'pipe', 'ignore'],
998
+ timeout: 10_000,
999
+ env,
1000
+ }).trim();
1001
+ }
1002
+ catch {
1003
+ // Cannot determine the branch => cannot prove it is idle => protect it.
1004
+ protectedRows.push(row);
1005
+ return false;
1006
+ }
1007
+ }
1008
+ if (branch && liveBranches.has(branch)) {
1009
+ protectedRows.push(row);
1010
+ return false;
1011
+ }
1012
+ return true;
1013
+ });
1014
+ if (outOfScope.length > 0) {
1015
+ console.log(chalk.red(`Refusing ${outOfScope.length} registry row(s) whose path is outside ${worktreesRoot}: ` +
1016
+ `${outOfScope.map((r) => r.worktree_path).join(', ')}`));
1017
+ }
1018
+ if (protectedRows.length > 0) {
1019
+ console.log(chalk.cyan(`Skipping ${protectedRows.length} worktree(s) with a LIVE agent working in them: ` +
1020
+ `${protectedRows.map((r) => r.slug).join(', ')}`));
1021
+ }
1022
+ if (prunable.length === 0) {
1023
+ console.log(chalk.green('Nothing prunable — every candidate has a live agent in it.'));
1024
+ return;
1025
+ }
1026
+ stale.length = 0;
1027
+ stale.push(...prunable);
499
1028
  console.log(chalk.bold(`Found ${stale.length} stale worktree(s) older than ${days} days:`));
500
1029
  console.log('');
501
1030
  for (const wt of stale) {
502
- const age = Math.floor((Date.now() - wt.created_at) / (1000 * 60 * 60 * 24));
1031
+ // created_at is SECONDS; dividing a seconds-vs-milliseconds difference by a
1032
+ // day in ms printed every worktree as ~20,600 days old — the visible symptom
1033
+ // of the cutoff bug fixed above.
1034
+ const age = Math.floor((Date.now() / 1000 - wt.created_at) / (60 * 60 * 24));
503
1035
  const statusColor = wt.status === 'cleaned' ? chalk.yellow : chalk.dim;
504
1036
  console.log(` ${wt.id}: ${wt.slug} (${age} days old) - ${statusColor(wt.status)}`);
505
1037
  }
@@ -516,24 +1048,26 @@ async function pruneStaleWorktrees(cwd, options) {
516
1048
  return;
517
1049
  }
518
1050
  }
519
- // Prune
1051
+ // DRY RUN: report and return BEFORE touching anything. This check used to sit
1052
+ // AFTER the delete loop, so `--dry-run` deleted every directory and then
1053
+ // printed "[DRY RUN] Would prune" — and because dryRun also skipped the
1054
+ // confirmation prompt, it did so without asking. The flag documented as
1055
+ // "Preview without making changes" was the most destructive way to invoke it.
1056
+ if (options.dryRun) {
1057
+ console.log(chalk.yellow(`[DRY RUN] Would prune ${stale.length} worktree(s) and remove ` +
1058
+ `${stale.filter((wt) => existsSync(wt.worktree_path)).length} directory(ies). Nothing was changed.`));
1059
+ return;
1060
+ }
520
1061
  let pruned = 0;
521
1062
  let directoriesRemoved = 0;
522
1063
  for (const wt of stale) {
523
- // Remove from DB
524
1064
  db.prepare('DELETE FROM worktrees WHERE id = ?').run(wt.id);
525
1065
  pruned++;
526
- // Remove directory
527
1066
  if (existsSync(wt.worktree_path)) {
528
1067
  rmSync(wt.worktree_path, { recursive: true, force: true });
529
1068
  directoriesRemoved++;
530
1069
  }
531
1070
  }
532
- if (options.dryRun) {
533
- console.log(chalk.yellow(`[DRY RUN] Would prune ${pruned} worktree(s), remove ${directoriesRemoved} directory(ies)`));
534
- }
535
- else {
536
- console.log(chalk.green(`Pruned ${pruned} worktree(s), removed ${directoriesRemoved} directory(ies)`));
537
- }
1071
+ console.log(chalk.green(`Pruned ${pruned} worktree(s), removed ${directoriesRemoved} directory(ies)`));
538
1072
  }
539
1073
  //# sourceMappingURL=worktree.js.map