devlyn-cli 4.3.13 → 4.3.15

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/bin/devlyn.js CHANGED
@@ -1430,13 +1430,16 @@ function showHelp() {
1430
1430
  // ---------------------------------------------------------------------------
1431
1431
  // Legacy cleanup for the built-in harness: Devlyn delivers devlyn-cli per session, so every copy an
1432
1432
  // earlier install left in a skill root, and the files those installs added, become legacy. A plan
1433
- // takes the remote base by fast-forward when possible, then lists each item with its proof. Apply
1434
- // plans again from disk and acts only on items both plans name the same way. A plan can narrow
1433
+ // reviews tracked items at the fetched base and untracked items in the caller's checkout. Apply
1434
+ // proves each item again where it will act. A plan can narrow
1435
1435
  // cleanup but never widen it: untracked copies go in place, tracked ones through the project's delivery policy.
1436
1436
  // Removals need shipped-content proof or an explicit choice of a reviewed modified copy.
1437
1437
  // ---------------------------------------------------------------------------
1438
1438
 
1439
1439
  const CLEANUP_CORE = ['devlyn-ideate', 'devlyn-engines'];
1440
+ const CLEANUP_SKILL_ROOTS = ['.agents/skills', '.claude/skills'];
1441
+ const CLEANUP_CANDIDATE_PATHS = [...CLEANUP_SKILL_ROOTS, ...CLEANUP_HISTORY.commandDirectories,
1442
+ ...Object.keys(CLEANUP_HISTORY.files), ...Object.keys(CLEANUP_HISTORY.hooks)].map((name) => `:(icase,literal)${name}`);
1440
1443
 
1441
1444
  function cleanupReason(code, params = {}) { return { code, params }; }
1442
1445
 
@@ -1505,7 +1508,7 @@ function cleanupMarkerOwned(file, current) {
1505
1508
  return text === JSON.stringify({ schemaVersion: 1, package: PKG.name, version: marker.version, skills: marker.skills }, null, 2) + '\n';
1506
1509
  }
1507
1510
 
1508
- function cleanupSkillRoots(roots, current, includeModified = false) {
1511
+ function cleanupSkillRoots(roots, current, callerBase = null) {
1509
1512
  const resolved = new Map();
1510
1513
  const linked = new Set();
1511
1514
  const active = new Set();
@@ -1527,17 +1530,20 @@ function cleanupSkillRoots(roots, current, includeModified = false) {
1527
1530
  }
1528
1531
  } else if (parents && stat?.isDirectory()) target = fs.realpathSync.native(root);
1529
1532
  if (!resolved.has(target)) resolved.set(target, base);
1530
- if (cleanupInUse(root) || cleanupInUse(target)) active.add(target);
1533
+ const callerPath = (file) => callerBase ? path.join(callerBase, path.relative(base, file)) : file;
1534
+ if (cleanupInUse(callerPath(root)) || cleanupInUse(callerPath(target))) active.add(target);
1531
1535
  }
1532
1536
  return [...resolved].flatMap(([root, base]) => {
1533
1537
  const items = cleanupSkillRoot(base, root, current);
1534
- const occupied = includeModified && items.some((item) => item.kind === 'install-lock');
1538
+ const callerRoot = callerBase ? path.join(callerBase, path.relative(base, root)) : root;
1539
+ const occupied = fs.lstatSync(callerRoot, { throwIfNoEntry: false })?.isDirectory()
1540
+ && Boolean(fs.lstatSync(path.join(callerRoot, DEVLYN_INSTALL_LOCK), { throwIfNoEntry: false }));
1535
1541
  const protectedCopies = new Set(items.filter((item) => [...linked].some((target) => target === item.path || target.startsWith(item.path + path.sep))));
1536
1542
  for (const item of items) {
1537
1543
  if ((protectedCopies.size && cleanupSupport(item)) || protectedCopies.has(item)) {
1538
1544
  Object.assign(item, { action: 'skip', reasons: [cleanupReason('dependent-copy')] });
1539
1545
  }
1540
- if (cleanupAction(item, includeModified) === 'remove' && (active.has(root) || occupied)) {
1546
+ if (cleanupAction(item, true) === 'remove' && (active.has(root) || occupied)) {
1541
1547
  Object.assign(item, { action: 'skip', reasons: [cleanupReason(occupied ? 'occupied-lock' : 'in-use')] });
1542
1548
  }
1543
1549
  }
@@ -1577,7 +1583,7 @@ function cleanupSkillRoot(base, root, current) {
1577
1583
  kept++;
1578
1584
  continue;
1579
1585
  }
1580
- const core = CLEANUP_CORE.includes(name) || name === '_shared';
1586
+ const core = CLEANUP_CORE.includes(lower) || lower === '_shared';
1581
1587
  if (!core && !deprecated.has(lower) && !renamed.has(lower) && !RETIRED_SKILL_MD_SHA256[lower]) continue;
1582
1588
  if (stat.isSymbolicLink()) {
1583
1589
  items.push({ kind: 'core-skill', path: file, base, action: 'skip', reasons: [cleanupReason('outside-link')] });
@@ -1586,9 +1592,9 @@ function cleanupSkillRoot(base, root, current) {
1586
1592
  }
1587
1593
  if (core) {
1588
1594
  let proven = false;
1589
- try { proven = fingerprint(file) === current[name] || historicalSkill(file); }
1595
+ try { proven = fingerprint(file) === current[lower] || historicalSkill(file); }
1590
1596
  catch { /* A link or special file inside is not an unedited copy. */ }
1591
- if (name === '_shared') { if (proven) support.push({ kind: 'core-skill', path: file, base, action: 'remove', reasons: [] }); continue; }
1597
+ if (lower === '_shared') { if (proven) support.push({ kind: 'core-skill', path: file, base, action: 'remove', reasons: [] }); continue; }
1592
1598
  if (proven) items.push({ kind: 'core-skill', path: file, base, action: 'remove', reasons: [] });
1593
1599
  else { items.push({ kind: 'core-skill', path: file, base, action: 'skip', reasons: [cleanupReason('modified-copy')] }); kept++; }
1594
1600
  continue;
@@ -1646,7 +1652,7 @@ function cleanupCommands(base) {
1646
1652
  });
1647
1653
  }
1648
1654
 
1649
- function cleanupProjectFiles(base, includedHooks = new Set()) {
1655
+ function cleanupProjectFiles(base, includedHooks = new Set(), callerBase = base) {
1650
1656
  const items = [];
1651
1657
  const hash = (file) => crypto.createHash('sha256')
1652
1658
  .update(fs.readFileSync(file).toString('latin1').replace(/\r\n/g, '\n'), 'latin1').digest('hex');
@@ -1659,6 +1665,7 @@ function cleanupProjectFiles(base, includedHooks = new Set()) {
1659
1665
  const settingsFile = path.join(base, '.claude', 'settings.json');
1660
1666
  const { safe, settings } = cleanupReadSettings(base);
1661
1667
  const localSettings = cleanupReadSettings(base, 'settings.local.json');
1668
+ const callerSettings = callerBase === base ? localSettings : cleanupReadSettings(callerBase, 'settings.local.json');
1662
1669
  const hookScripts = [], hookCandidates = [];
1663
1670
  for (const [name, history] of Object.entries(CLEANUP_HISTORY.hooks)) {
1664
1671
  const file = path.join(base, name);
@@ -1669,7 +1676,8 @@ function cleanupProjectFiles(base, includedHooks = new Set()) {
1669
1676
  if (!cleanupRealParents(base, file) || !stat.isFile() || stat.nlink !== 1) continue;
1670
1677
  const proven = history.hashes.includes(contentHash(file));
1671
1678
  Object.assign(item, { action: proven ? 'remove' : 'skip', reasons: proven ? [] : [cleanupReason('modified-copy')] });
1672
- if (!safe || !localSettings.safe || cleanupHookReferenced(localSettings.settings, name)) {
1679
+ if (!safe || !localSettings.safe || !callerSettings.safe || cleanupHookReferenced(localSettings.settings, name)
1680
+ || cleanupHookReferenced(callerSettings.settings, name)) {
1673
1681
  Object.assign(item, { action: 'skip', reasons: [cleanupReason('dependent-copy')] });
1674
1682
  continue;
1675
1683
  }
@@ -1805,7 +1813,115 @@ function cleanupRetainSupport(items) {
1805
1813
  return items;
1806
1814
  }
1807
1815
 
1808
- function cleanupProjectScope(project, current, includeModified = false, includedHooks = new Set()) {
1816
+ // Filesystem proof is independent of scope discovery and remote-base orchestration.
1817
+ function cleanupProjectScan(base, current, includedHooks = new Set(), callerBase = base) {
1818
+ return [
1819
+ ...cleanupSkillRoots(CLEANUP_SKILL_ROOTS.map((dir) => [base, path.join(base, dir)]), current, callerBase),
1820
+ ...cleanupCommands(base), ...cleanupProjectFiles(base, includedHooks, callerBase), ...cleanupInstructions(base),
1821
+ ].map((item) => {
1822
+ let tracked = false;
1823
+ try { tracked = cleanupTracked(item.path); }
1824
+ catch (error) { Object.assign(item, { action: 'skip', reasons: [cleanupReason('repository-unreadable', { detail: error.message })] }); }
1825
+ return cleanupProof({ ...item, relative: path.relative(base, item.path).split(path.sep).join('/'), route: tracked ? 'delivery' : 'local' });
1826
+ });
1827
+ }
1828
+
1829
+ function cleanupProof(item) {
1830
+ const before = item.before || cleanupPreimage(item.path, cleanupCanInclude(item));
1831
+ if (cleanupCanInclude(item) && before.sha256 === null) {
1832
+ Object.assign(item, { action: 'skip', reasons: [cleanupReason('outside-link')] });
1833
+ }
1834
+ return { ...item, before };
1835
+ }
1836
+
1837
+ function cleanupReroot(item, from, to) {
1838
+ const move = (file) => path.join(to, path.relative(from, file));
1839
+ return { ...item, path: move(item.path), base: move(item.base), ...(item.hookSettings ? { hookSettings: move(item.hookSettings) } : {}) };
1840
+ }
1841
+
1842
+ function cleanupGitRead(root, args, options) {
1843
+ const result = cleanupGit(root, args, options);
1844
+ if (result.error || result.status !== 0) throw new Error(result.error?.message || result.stderr.trim());
1845
+ return result.stdout;
1846
+ }
1847
+
1848
+ function cleanupFetchBase(scope) {
1849
+ const ref = `refs/devlyn/cleanup/${crypto.randomBytes(8).toString('hex')}`;
1850
+ try {
1851
+ cleanupGitRead(scope.root, ['fetch', '--quiet', '--no-write-fetch-head', 'origin', `+refs/heads/${scope.remote.base}:${ref}`],
1852
+ { timeout: 30000, env: { GIT_TERMINAL_PROMPT: '0' } });
1853
+ return cleanupGitRead(scope.root, ['rev-parse', '--verify', `${ref}^{commit}`]).trim();
1854
+ } finally {
1855
+ cleanupGitRead(scope.root, ['update-ref', '-d', ref]);
1856
+ }
1857
+ }
1858
+
1859
+ function cleanupRemoveSnapshot(root, snapshot) {
1860
+ const removed = cleanupGit(root, ['worktree', 'remove', '--force', snapshot]);
1861
+ if (removed.error || removed.status !== 0) {
1862
+ fs.rmSync(snapshot, { recursive: true, force: true });
1863
+ cleanupGitRead(root, ['worktree', 'prune']);
1864
+ }
1865
+ }
1866
+
1867
+ // A private full index supplies attributes; only scanner inputs are materialized. Git performs
1868
+ // the same conversions as delivery, without receipts, task allocation or caller-index writes.
1869
+ function cleanupSnapshot(root, commit) {
1870
+ const snapshot = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'dc-')));
1871
+ try {
1872
+ cleanupGitRead(root, ['worktree', 'add', '--detach', '--no-checkout', snapshot, commit]);
1873
+ cleanupGitRead(snapshot, ['read-tree', commit]);
1874
+ const roots = cleanupGitRead(snapshot, ['ls-files', '--stage', '-z', '--', ...CLEANUP_SKILL_ROOTS.map((name) => `:(icase,literal)${name}`)])
1875
+ .split('\0').filter(Boolean)
1876
+ .map((entry) => { const tab = entry.indexOf('\t'); return { mode: entry.slice(0, 6), name: entry.slice(tab + 1) }; });
1877
+ const paths = [...CLEANUP_CANDIDATE_PATHS, ...['.claude/settings.json', '.claude/settings.local.json', 'CLAUDE.md', 'AGENTS.md']
1878
+ .map((name) => `:(icase,literal)${name}`)];
1879
+ for (const { name, mode } of roots) {
1880
+ if (mode !== '120000' || !CLEANUP_SKILL_ROOTS.includes(name.toLowerCase())) continue;
1881
+ const link = cleanupGitRead(root, ['show', `${commit}:${name}`]);
1882
+ // Absolute links cannot resolve inside this private checkout. For relative links, preserve
1883
+ // every directory hop (including those before `..`) that a full checkout would create.
1884
+ if (path.isAbsolute(link)) continue;
1885
+ let directory = '';
1886
+ for (const part of [...path.dirname(name).split(path.sep), ...link.split(process.platform === 'win32' ? /[/\\]/ : '/')]
1887
+ .filter((part) => part && part !== '.')) {
1888
+ if (part === '..') {
1889
+ if (!directory) { directory = null; break; }
1890
+ directory = path.posix.dirname(directory);
1891
+ if (directory === '.') directory = '';
1892
+ continue;
1893
+ }
1894
+ const children = cleanupGitRead(root, ['ls-tree', '-d', '-z', `${commit}:${directory}`]).split('\0').filter(Boolean)
1895
+ .map((entry) => entry.slice(entry.indexOf('\t') + 1));
1896
+ const child = children.find((name) => name === part) || children.find((name) => name.toLowerCase() === part.toLowerCase());
1897
+ if (!child) { directory = null; break; }
1898
+ directory = path.posix.join(directory, child);
1899
+ // Explicit empty trees have no indexed descendants and create no checkout directory.
1900
+ if (!cleanupGitRead(snapshot, ['ls-files', '-z', '--', `:(literal)${directory}/`], { maxBuffer: 1 << 30 })) {
1901
+ directory = null; break;
1902
+ }
1903
+ fs.mkdirSync(path.join(snapshot, directory), { recursive: true });
1904
+ }
1905
+ if (directory) paths.push(`:(icase,literal)${directory}`);
1906
+ }
1907
+ const names = cleanupGitRead(snapshot, ['ls-files', '-z', '--', ...paths], { maxBuffer: 1 << 30 });
1908
+ cleanupGitRead(snapshot, ['checkout-index', '-z', '--stdin'], { input: names });
1909
+ return snapshot;
1910
+ } catch (error) {
1911
+ try { cleanupRemoveSnapshot(root, snapshot); }
1912
+ catch (cleanupError) { throw new AggregateError([error, cleanupError], `${error.message}; ${cleanupError.message}`); }
1913
+ throw error;
1914
+ }
1915
+ }
1916
+
1917
+ function cleanupObject(root, commit, relative) {
1918
+ const result = cleanupGit(root, ['rev-parse', '--verify', '--quiet', `${commit}:${relative}`]);
1919
+ if (!result.error && result.status === 1) return null;
1920
+ if (result.error || result.status !== 0) throw new Error(result.error?.message || result.stderr.trim());
1921
+ return result.stdout.trim();
1922
+ }
1923
+
1924
+ function cleanupProjectScope(project, current, includedHooks = new Set(), baseViews = null) {
1809
1925
  const { repositoryIdentity } = require('./harness-status');
1810
1926
  const { physicalRoot } = require('./release');
1811
1927
  const root = physicalRoot(project);
@@ -1814,34 +1930,73 @@ function cleanupProjectScope(project, current, includeModified = false, included
1814
1930
  const scope = { kind: 'project', root: base, name: path.basename(base), remote: identity ? cleanupRemote(base) : null, items: [] };
1815
1931
  const whole = (code) => [{ kind: 'project', path: base, base, relative: '', route: 'local', action: 'skip', reasons: [cleanupReason(code)] }];
1816
1932
  if (identity?.sourceRepository) return { ...scope, items: whole('source-repository') };
1817
- // The home folder's roots are this machine's: the machine scope cleans them under the user-root locks.
1818
1933
  if (cleanupSameFile(base, os.homedir())) return { ...scope, items: whole('home-folder') };
1819
- const items = [
1820
- ...cleanupSkillRoots(['.agents', '.claude'].map((dir) => [base, path.join(base, dir, 'skills')]), current, includeModified),
1821
- ...cleanupCommands(base), ...cleanupProjectFiles(base, includedHooks), ...cleanupInstructions(base),
1822
- ];
1823
- const busy = identity && items.some((item) => cleanupAction(item, includeModified) !== 'skip') ? cleanupQueueBusy(base) : null;
1824
- for (const item of items) {
1825
- if (busy && cleanupAction(item, includeModified) !== 'skip') Object.assign(item, { action: 'skip', reasons: [busy] });
1826
- const relative = path.relative(base, item.path).split(path.sep).join('/');
1827
- let tracked = false;
1828
- try { tracked = cleanupTracked(item.path); }
1829
- catch (error) { Object.assign(item, { action: 'skip', reasons: [cleanupReason('repository-unreadable', { detail: error.message })] }); }
1830
- scope.items.push({ ...item, relative, route: tracked ? 'delivery' : 'local' });
1934
+ let caller = cleanupProjectScan(base, current, includedHooks);
1935
+ let callerContext = caller;
1936
+ let delivery = [];
1937
+ if (scope.remote && baseViews && (caller.some((item) => item.route === 'delivery')
1938
+ || cleanupGitRead(base, ['ls-files', '-z', '--', ...CLEANUP_CANDIDATE_PATHS], { maxBuffer: 1 << 30 }).length > 0)) {
1939
+ // Reuse only within a checkout: even identical relative origins can resolve differently across worktrees.
1940
+ const key = JSON.stringify([scope.root, scope.remote.repository, scope.remote.base]);
1941
+ if (!baseViews.has(key)) {
1942
+ let view;
1943
+ try {
1944
+ const commit = cleanupFetchBase(scope);
1945
+ view = { commit, snapshot: cleanupSnapshot(base, commit), root: base };
1946
+ }
1947
+ catch (error) { view = { error }; }
1948
+ baseViews.set(key, view);
1949
+ }
1950
+ const view = baseViews.get(key);
1951
+ try {
1952
+ if (view.error) throw view.error;
1953
+ delivery = cleanupProjectScan(view.snapshot, current, new Set(), base)
1954
+ .map((item) => ({ ...cleanupReroot(item, view.snapshot, base), route: 'delivery' }));
1955
+ scope.reviewBase = view.commit;
1956
+ if ([...caller, ...delivery].some((item) => item.route === 'delivery' && ['remove', 'edit'].includes(item.action))) {
1957
+ if (cleanupUpdateCheckout(scope, view.commit).changed) caller = cleanupProjectScan(base, current, includedHooks);
1958
+ }
1959
+ callerContext = caller;
1960
+ const keys = new Set(delivery.map((item) => `${item.kind}\0${item.relative.toLowerCase()}`));
1961
+ const fork = cleanupGit(base, ['merge-base', 'HEAD', view.commit]);
1962
+ caller = caller.filter((item) => item.route === 'local' || !keys.has(`${item.kind}\0${item.relative.toLowerCase()}`))
1963
+ .map((item) => {
1964
+ if (item.route === 'local') return item;
1965
+ const before = fork.status === 0 ? cleanupObject(base, fork.stdout.trim(), item.relative) : null;
1966
+ const settled = before && cleanupObject(base, view.commit, item.relative) === null
1967
+ && cleanupObject(base, 'HEAD', item.relative) === before;
1968
+ return { ...item, action: 'skip', reasons: [cleanupReason(settled ? 'update-checkout' : 'tracked-copy')] };
1969
+ });
1970
+ } catch (error) {
1971
+ delivery = [];
1972
+ delete scope.reviewBase;
1973
+ caller = caller.map((item) => item.route === 'delivery'
1974
+ ? { ...item, action: 'skip', reasons: [cleanupReason('repository-unreadable', { detail: error.message })] } : item);
1975
+ }
1831
1976
  }
1832
- if (!scope.remote) {
1833
- for (const item of scope.items) {
1834
- if (item.route === 'delivery' && cleanupAction(item, includeModified) !== 'skip') Object.assign(item, { action: 'skip', reasons: [cleanupReason('no-remote')] });
1977
+ // Caller skips include branch-only leftovers and edited untracked copies. Both need support.
1978
+ const kept = new Set([...callerContext, ...caller].filter((item) => item.action === 'skip' && !cleanupSupport(item))
1979
+ .map((item) => path.dirname(item.path).toLowerCase()));
1980
+ for (const item of delivery) {
1981
+ if (cleanupSupport(item) && kept.has(path.dirname(item.path).toLowerCase())) {
1982
+ Object.assign(item, { action: 'skip', reasons: [cleanupReason('dependent-copy')] });
1835
1983
  }
1836
1984
  }
1985
+ scope.items = [...delivery, ...caller];
1986
+ const busy = identity && scope.items.some((item) => cleanupAction(item, true) !== 'skip') ? cleanupQueueBusy(base) : null;
1987
+ for (const item of scope.items) {
1988
+ if (cleanupAction(item, true) === 'skip') continue;
1989
+ if (busy) Object.assign(item, { action: 'skip', reasons: [busy] });
1990
+ else if (item.route === 'delivery' && !scope.remote) Object.assign(item, { action: 'skip', reasons: [cleanupReason('no-remote')] });
1991
+ }
1837
1992
  cleanupRetainSupport(scope.items);
1838
1993
  return scope;
1839
1994
  }
1840
1995
 
1841
- function cleanupMachineScope(home, accountHomes, current, includeModified = false) {
1996
+ function cleanupMachineScope(home, accountHomes, current) {
1842
1997
  const roots = [...['.agents', '.codex', '.claude', '.grok'].map((dir) => [home, path.join(home, dir, 'skills')]),
1843
1998
  ...accountHomes.map((account) => [path.dirname(account), path.join(account, 'skills')])];
1844
- const items = cleanupSkillRoots(roots, current, includeModified);
1999
+ const items = cleanupSkillRoots(roots, current);
1845
2000
  items.push(...cleanupCommands(home));
1846
2001
  for (const item of items) {
1847
2002
  try {
@@ -1854,13 +2009,13 @@ function cleanupMachineScope(home, accountHomes, current, includeModified = fals
1854
2009
  return { kind: 'machine', root: home, name: null, remote: null, items: cleanupRetainSupport(items) };
1855
2010
  }
1856
2011
 
1857
- function cleanupScopes(inputs, current, includeModified = false, includedHooks = new Set()) {
2012
+ function cleanupScopes(inputs, current, includedHooks = new Set(), baseViews = null) {
1858
2013
  const { sha256 } = require('./release');
1859
2014
  const scopes = [];
1860
- if (inputs.machine) scopes.push({ id: 'machine', ...cleanupMachineScope(os.homedir(), inputs.accountHomes, current, includeModified) });
2015
+ if (inputs.machine) scopes.push({ id: 'machine', ...cleanupMachineScope(os.homedir(), inputs.accountHomes, current) });
1861
2016
  for (const project of inputs.projects) {
1862
2017
  let scope;
1863
- try { scope = cleanupProjectScope(project, current, includeModified, includedHooks); }
2018
+ try { scope = cleanupProjectScope(project, current, includedHooks, baseViews); }
1864
2019
  catch (error) {
1865
2020
  scope = { kind: 'project', root: project, name: path.basename(project), remote: null, items: [{ kind: 'project', path: project, base: project,
1866
2021
  relative: '', route: 'local', action: 'skip', reasons: [cleanupReason(error.reason?.code === 'repository-unreadable' ? 'repository-unreadable' : 'project-unreadable', { detail: error.message })] }] };
@@ -1868,25 +2023,27 @@ function cleanupScopes(inputs, current, includeModified = false, includedHooks =
1868
2023
  scopes.push({ id: `project:${sha256(Buffer.from(project)).slice(0, 12)}`, ...scope });
1869
2024
  }
1870
2025
  for (const scope of scopes) {
1871
- scope.items = scope.items.map((item, index) => ({ id: `${scope.id}:${index}`, ...item, before: cleanupPreimage(item.path, cleanupCanInclude(item)) }));
2026
+ scope.items = scope.items.map((item, index) => ({ id: `${scope.id}:${index}`, ...cleanupProof(item) }));
1872
2027
  }
1873
2028
  return scopes;
1874
2029
  }
1875
2030
 
1876
2031
  function cleanupPlan(inputs) {
1877
2032
  const current = cleanupCurrentFingerprints();
1878
- let scopes = cleanupScopes(inputs, current);
1879
- let changed = false;
1880
- for (const scope of scopes) {
1881
- if (scope.kind === 'project' && scope.remote && scope.items.some((item) => item.route === 'delivery' && ['remove', 'edit'].includes(item.action))) {
1882
- const updated = cleanupUpdateCheckout(scope);
1883
- if (updated.changed) changed = true;
2033
+ const baseViews = new Map();
2034
+ try {
2035
+ // The review covers the bytes delivery acts on, including scopes sharing a repository.
2036
+ const scopes = cleanupScopes(inputs, current, new Set(), baseViews);
2037
+ const plan = { schemaVersion: 1, version: PKG.version, inputs, scopes };
2038
+ return { ...plan, digest: cleanupDigest(plan) };
2039
+ } finally {
2040
+ const errors = [];
2041
+ for (const view of baseViews.values()) {
2042
+ if (!view.snapshot) continue;
2043
+ try { cleanupRemoveSnapshot(view.root, view.snapshot); } catch (error) { errors.push(error); }
1884
2044
  }
2045
+ if (errors.length) throw new AggregateError(errors, errors.map((error) => error.message).join('; '));
1885
2046
  }
1886
- // The review covers the bytes delivery acts on, including scopes sharing a checkout.
1887
- if (changed) scopes = cleanupScopes(inputs, current);
1888
- const plan = { schemaVersion: 1, version: PKG.version, inputs, scopes };
1889
- return { ...plan, digest: cleanupDigest(plan) };
1890
2047
  }
1891
2048
 
1892
2049
  // JSON text with sorted keys: a plan the app stored with its own key order still reads the same.
@@ -1939,36 +2096,42 @@ function cleanupApplyItem(item) {
1939
2096
 
1940
2097
  // A reviewed item's counterpart in the fresh plan, when both name the same action on the same bytes.
1941
2098
  function cleanupProven(reviewed, fresh) {
1942
- const key = (item) => `${item.kind}\u0000${item.path}`;
2099
+ const key = (item) => `${item.route}\0${item.kind}\0${item.path}`;
1943
2100
  const now = new Map(fresh.items.map((item) => [key(item), item]));
1944
- const asked = new Set(reviewed.items.filter((item) => ['remove', 'edit'].includes(cleanupAction(item))).map(key));
1945
- // A copy left out of the review stays, so the `_shared` and marker beside it stay too.
1946
- const narrowed = new Set(fresh.items.filter((item) => item.action === 'remove' && !cleanupSupport(item) && !asked.has(key(item)))
1947
- .map((item) => path.dirname(item.path)));
1948
- return reviewed.items.filter((item) => ['remove', 'edit'].includes(cleanupAction(item))).map((item) => {
2101
+ const pairs = reviewed.items.filter((item) => ['remove', 'edit'].includes(cleanupAction(item))).map((item) => {
1949
2102
  const match = now.get(key(item));
1950
2103
  const same = match && match.action === item.action && (!item.included || (cleanupCanInclude(match) && item.before.sha256 !== null))
1951
2104
  && match.relative === item.relative && match.route === item.route
1952
2105
  && match.before.type === item.before.type && match.before.sha256 === item.before.sha256
1953
- && cleanupCanonical(match.hookScripts || []) === cleanupCanonical(item.hookScripts || [])
1954
- && !(cleanupSupport(item) && narrowed.has(path.dirname(item.path)));
1955
- return { reviewed: item, fresh: same ? { ...match, included: item.included } : null };
2106
+ && cleanupCanonical(match.hookScripts || []) === cleanupCanonical(item.hookScripts || []);
2107
+ return { reviewed: item, fresh: same ? { ...match, id: item.id, included: item.included } : null };
1956
2108
  });
2109
+ // Support stays beside any fresh copy this exact batch will not remove, including unreviewed
2110
+ // siblings and reviewed copies whose proof failed.
2111
+ const removing = new Set(pairs.filter((pair) => pair.fresh && cleanupAction(pair.fresh) === 'remove').map((pair) => key(pair.fresh)));
2112
+ const kept = new Set(fresh.items.filter((item) => !cleanupSupport(item) && !removing.has(key(item)))
2113
+ .map((item) => path.dirname(item.path).toLowerCase()));
2114
+ for (const pair of pairs) {
2115
+ if (cleanupSupport(pair.reviewed) && kept.has(path.dirname(pair.reviewed.path).toLowerCase())) pair.fresh = null;
2116
+ }
2117
+ return pairs;
1957
2118
  }
1958
2119
 
1959
2120
  // A narrowed or stale review may not split a settings edit from any script it disables.
1960
2121
  function cleanupCoupledPairs(pairs) {
1961
- const byPath = new Map(pairs.map((pair) => [pair.reviewed.path, pair]));
2122
+ const key = (item, file) => `${item.route}\u0000${file}`;
2123
+ const byPath = new Map(pairs.map((pair) => [key(pair.reviewed, pair.reviewed.path), pair]));
1962
2124
  for (const pair of pairs.filter(({ reviewed }) => reviewed.kind === 'settings-entry')) {
1963
- const scripts = (pair.reviewed.hookScripts || []).map((name) => byPath.get(path.join(pair.reviewed.base, name)));
2125
+ const scripts = (pair.reviewed.hookScripts || []).map((name) => byPath.get(key(pair.reviewed, path.join(pair.reviewed.base, name))));
1964
2126
  if (!pair.fresh || scripts.some((script) => !script?.fresh || cleanupAction(script.fresh) !== 'remove')) {
1965
2127
  pair.fresh = null;
1966
2128
  for (const script of scripts) if (script) script.fresh = null;
1967
2129
  }
1968
2130
  }
1969
2131
  for (const pair of pairs) {
1970
- if (pair.reviewed.hookSettings && (!byPath.get(pair.reviewed.hookSettings)?.fresh
1971
- || !byPath.get(pair.reviewed.hookSettings).fresh.hookScripts.includes(pair.reviewed.relative))) pair.fresh = null;
2132
+ const settings = byPath.get(key(pair.reviewed, pair.reviewed.hookSettings));
2133
+ if (pair.reviewed.hookSettings && (!settings?.fresh
2134
+ || !settings.fresh.hookScripts.includes(pair.reviewed.relative))) pair.fresh = null;
1972
2135
  }
1973
2136
  return pairs;
1974
2137
  }
@@ -2002,7 +2165,7 @@ function cleanupApplyHookGroup(settings, scripts) {
2002
2165
 
2003
2166
  // Support entries (`_shared`, the marker) go last, and only when nothing beside them was refused.
2004
2167
  function cleanupSupport(item) {
2005
- return path.basename(item.path) === '_shared' || item.kind === 'install-marker';
2168
+ return path.basename(item.path).toLowerCase() === '_shared' || item.kind === 'install-marker';
2006
2169
  }
2007
2170
 
2008
2171
  function cleanupCanInclude(item) {
@@ -2067,13 +2230,17 @@ function cleanupDeliver(scope, items, current) {
2067
2230
  if (!python) return { error: cleanupReason('requirement-missing', { name: 'Python 3.11' }) };
2068
2231
  const run = (args, cwd) => spawnSync(python, ['-B', helper, ...args], { cwd, encoding: 'utf8', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
2069
2232
  const lastJson = (text) => { try { return JSON.parse(text.trim().split('\n').at(-1)); } catch { return null; } };
2233
+ const failureCode = (reason) => /\b(authentication|unauthorized|gh auth login|could not read Username|HTTP 40[13])\b/i.test(reason) ? 'auth-failed' : 'delivery-failed';
2070
2234
  const short = crypto.randomBytes(4).toString('hex');
2071
2235
  const branch = `devlyn/cleanup-${short}`;
2072
2236
  const worktree = path.join(path.dirname(scope.root), `${path.basename(scope.root)}.devlyn.noindex`, `devlyn-cleanup-${short}`);
2073
2237
  const allocated = lastJson(run(['allocate', '--repo', scope.root, '--task', `devlyn-cli ${PKG.version} legacy cleanup`,
2074
2238
  '--branch', branch, '--worktree', worktree, '--repository', scope.remote.repository,
2075
2239
  '--remote', 'origin', '--base', scope.remote.base], scope.root).stdout);
2076
- if (allocated?.status !== 'ALLOCATED') return { error: cleanupReason('delivery-failed', { detail: allocated?.reason || 'allocation failed' }) };
2240
+ if (allocated?.status !== 'ALLOCATED') {
2241
+ const reason = allocated?.reason || 'allocation failed';
2242
+ return { error: cleanupReason(failureCode(reason), { detail: reason }) };
2243
+ }
2077
2244
  const release = (error = null) => {
2078
2245
  for (const args of [['worktree', 'remove', '--force', worktree], ['branch', '-D', branch]]) {
2079
2246
  const result = cleanupGit(scope.root, args);
@@ -2087,19 +2254,8 @@ function cleanupDeliver(scope, items, current) {
2087
2254
  if (baseHead.error || baseHead.status !== 0) return release(cleanupReason('repository-unreadable', { detail: baseHead.error?.message || baseHead.stderr.trim() }));
2088
2255
  const base = baseHead.stdout.trim();
2089
2256
  let prepared;
2090
- try { prepared = cleanupDeliveryCommit(worktree, items, current, release, cleanupSettledUpstream(scope.root, base)); }
2257
+ try { prepared = cleanupDeliveryCommit(worktree, items, current, release, scope, base); }
2091
2258
  catch (error) { return release(cleanupReason('apply-failed', { detail: error.message })); }
2092
- if (prepared.error?.code === 'stale-plan') {
2093
- const head = cleanupGit(scope.root, ['rev-parse', 'HEAD']);
2094
- // A stale review must see the base's bytes next time. Local commits keep today's refusal.
2095
- if (!head.error && head.status === 0 && head.stdout.trim() !== base) {
2096
- const ancestor = cleanupGit(scope.root, ['merge-base', '--is-ancestor', head.stdout.trim(), base]);
2097
- if (!ancestor.error && ancestor.status === 0) {
2098
- const updated = cleanupUpdateCheckout(scope);
2099
- if (updated.error) return updated;
2100
- }
2101
- }
2102
- }
2103
2259
  if (prepared.error) return prepared;
2104
2260
  // The remote base already settled every reviewed item: the checkout only has to take it.
2105
2261
  if (!prepared.acceptance) return cleanupUpdateCheckout(scope);
@@ -2108,39 +2264,7 @@ function cleanupDeliver(scope, items, current) {
2108
2264
  if (status === 'COMPLETE' || status === 'MERGED') return { ...cleanupUpdateCheckout(scope), delivery: { state: 'merged', url: completed.pr || null } };
2109
2265
  if (completed?.pr) return { delivery: { state: 'pr-open', url: completed.pr } };
2110
2266
  const reason = completed?.reason || 'delivery did not complete';
2111
- const auth = /\b(authentication|unauthorized|gh auth login|could not read Username|HTTP 40[13])\b/i.test(reason);
2112
- return { error: cleanupReason(auth ? 'auth-failed' : 'delivery-failed', { detail: reason, ...(completed?.resume ? { resume: completed.resume } : {}) }) };
2113
- }
2114
-
2115
- // With no fresh counterpart, the base settled an item only if it changed the path's content or
2116
- // removed it in every spelling, while the checkout holds it under its tracked spelling unchanged
2117
- // since the fork point. A copy committed or changed only here is not one the base settled.
2118
- function cleanupSettledUpstream(root, base) {
2119
- const read = (cwd, args, options) => {
2120
- const result = cleanupGit(cwd, args, options);
2121
- return !result.error && result.status === 0 ? result.stdout : null;
2122
- };
2123
- const object = (spec) => read(root, ['rev-parse', '--verify', '--quiet', spec])?.trim() || null;
2124
- const fork = read(root, ['merge-base', 'HEAD', base])?.trim();
2125
- let upstream; // the base's paths, folded to lower case, read once and only when needed
2126
- return (relative) => {
2127
- if (!fork) return false;
2128
- if (upstream === undefined) {
2129
- upstream = read(root, ['ls-tree', '-r', '-z', '--name-only', base], { maxBuffer: 1 << 30 })
2130
- ?.split('\0').filter(Boolean).map((name) => name.toLowerCase()) ?? null;
2131
- }
2132
- const lower = relative.toLowerCase();
2133
- if (!upstream) return false;
2134
- // The tracked spelling, which a case-insensitive file system lets differ from the one on disk.
2135
- const tracked = read(root, ['ls-files', '-z', '--', `:(icase,literal)${relative}`])?.split('\0').find(Boolean);
2136
- const spelled = tracked?.slice(0, relative.length);
2137
- if (!spelled || spelled.toLowerCase() !== lower) return false;
2138
- const before = object(`${fork}:${spelled}`);
2139
- if (before === null || before !== object(`HEAD:${spelled}`)) return false;
2140
- const after = object(`${base}:${spelled}`);
2141
- return after !== null ? after !== before
2142
- : !upstream.some((name) => name === lower || name.startsWith(`${lower}/`));
2143
- };
2267
+ return { error: cleanupReason(failureCode(reason), { detail: reason, ...(completed?.resume ? { resume: completed.resume } : {}) }) };
2144
2268
  }
2145
2269
 
2146
2270
  // The remote base's cleanup or changed copies reach this checkout only when it takes that base: a checkout on
@@ -2149,7 +2273,7 @@ function cleanupSettledUpstream(root, base) {
2149
2273
  // merged by its commit, only while the checkout is still the one checked before the fetch, and the
2150
2274
  // result must be that commit. Another branch, or a fast-forward Git refuses (local changes or ignored
2151
2275
  // files in the way, local commits), is left as it is.
2152
- function cleanupUpdateCheckout(scope) {
2276
+ function cleanupUpdateCheckout(scope, fetchedCommit = null) {
2153
2277
  const failed = (detail) => ({ error: cleanupReason('update-checkout', { detail }) });
2154
2278
  const read = (args) => {
2155
2279
  const result = cleanupGit(scope.root, args);
@@ -2158,14 +2282,9 @@ function cleanupUpdateCheckout(scope) {
2158
2282
  const checkout = () => [read(['symbolic-ref', '--quiet', '--short', 'HEAD']), read(['rev-parse', 'HEAD'])];
2159
2283
  const [branch, head] = checkout();
2160
2284
  if (branch !== scope.remote.base || !head) return failed(`${branch || 'a detached HEAD'} is checked out`);
2161
- const ref = `refs/devlyn/cleanup/${crypto.randomBytes(8).toString('hex')}`;
2162
- try {
2163
- const fetched = cleanupGit(scope.root, ['fetch', '--quiet', '--no-write-fetch-head', 'origin', `+refs/heads/${scope.remote.base}:${ref}`],
2164
- { timeout: 30000, env: { GIT_TERMINAL_PROMPT: '0' } });
2165
- if (fetched.error || fetched.status !== 0) return failed(fetched.error?.message || fetched.stderr.trim());
2166
- const target = read(['rev-parse', '--verify', `${ref}^{commit}`]);
2285
+ const merge = (target) => {
2167
2286
  const [now, at] = checkout();
2168
- if (!target || now !== branch || at !== head) return failed('the checkout changed while the base was fetched');
2287
+ if (now !== branch || at !== head) return failed('the checkout changed while the base was fetched');
2169
2288
  // The branch's own merge options (`--squash`, `-s ours`) would turn this into something else.
2170
2289
  const merged = cleanupGit(scope.root, ['-c', `branch.${branch}.mergeOptions=`,
2171
2290
  'merge', '--ff-only', '--no-squash', '--no-autostash', '--no-overwrite-ignore', '--quiet', target]);
@@ -2173,33 +2292,58 @@ function cleanupUpdateCheckout(scope) {
2173
2292
  const [after, landed] = checkout();
2174
2293
  if (after !== branch || landed !== target) return failed('the fast-forward did not land on the base');
2175
2294
  return { changed: landed !== head };
2176
- } finally {
2177
- cleanupGit(scope.root, ['update-ref', '-d', ref]);
2178
- }
2295
+ };
2296
+ try { return merge(fetchedCommit || cleanupFetchBase(scope)); }
2297
+ catch (error) { return failed(error.message); }
2179
2298
  }
2180
2299
 
2181
2300
  // Inside the allocated worktree: prove every reviewed item, remove them, commit, and write the
2182
2301
  // acceptance completion binds. A failure releases the worktree; so does a throw, in the caller.
2183
2302
  // With every reviewed item already settled by the remote base, the worktree is released with
2184
2303
  // nothing to deliver.
2185
- function cleanupDeliveryCommit(worktree, items, current, release, settledUpstream) {
2186
- const includedHooks = new Set(items.filter((item) => item.kind === 'legacy-hook' && item.included).map((item) => path.join(worktree, item.relative)));
2187
- const fresh = cleanupProjectScope(worktree, current, items.some((item) => item.included), includedHooks);
2188
- const available = new Map(fresh.items.map((item) => [`${item.kind}\u0000${item.relative}`, item]));
2189
- const pairs = items.map((reviewed) => {
2190
- const fresh = available.get(`${reviewed.kind}\u0000${reviewed.relative}`);
2191
- return { reviewed, fresh, before: fresh && cleanupPreimage(fresh.path, Boolean(reviewed.included)) };
2304
+ function cleanupDeliveryCommit(worktree, items, current, release, scope, base) {
2305
+ const reviewed = items.map((item) => cleanupReroot(item, scope.root, worktree));
2306
+ const includedHooks = new Set(reviewed.filter((item) => item.kind === 'legacy-hook' && item.included).map((item) => item.path));
2307
+ const fresh = { items: cleanupProjectScan(worktree, current, includedHooks, scope.root) };
2308
+ const pairs = cleanupProven({ items: reviewed }, fresh);
2309
+ // On a case-insensitive filesystem a literal file scan can see an upstream rename through its
2310
+ // old spelling. The allocated tree must still hold the exact reviewed path before we act on it.
2311
+ if (base !== scope.reviewBase) for (const pair of pairs) {
2312
+ if (pair.fresh && cleanupObject(worktree, base, pair.reviewed.relative) === null) pair.fresh = null;
2313
+ }
2314
+ const mismatch = (paths) => cleanupReason(base === scope.reviewBase ? 'delivery-failed' : 'stale-plan', { detail: paths.join(', ') });
2315
+ let basePaths;
2316
+ const stale = pairs.filter((pair) => {
2317
+ if (pair.fresh) return false;
2318
+ const item = pair.reviewed;
2319
+ // A case-only rename is still a counterpart, never upstream settlement. An absent item
2320
+ // settles only when the path's Git object changed since the reviewed base.
2321
+ if (fresh.items.some((other) => other.kind === item.kind && other.relative.toLowerCase() === item.relative.toLowerCase())) return true;
2322
+ const after = cleanupObject(worktree, base, item.relative);
2323
+ if (after === null) {
2324
+ basePaths ??= cleanupGitRead(worktree, ['ls-tree', '-r', '--name-only', '-z', base], { maxBuffer: 1 << 30 }).toLowerCase().split('\0').filter(Boolean);
2325
+ const relative = item.relative.toLowerCase();
2326
+ if (basePaths.some((name) => name === relative || (item.before.type === 'directory' && name.startsWith(relative + '/')))) return true;
2327
+ }
2328
+ return !scope.reviewBase || cleanupObject(worktree, scope.reviewBase, item.relative) === after;
2192
2329
  });
2193
- const stale = pairs.filter(({ reviewed, fresh, before }) => (fresh
2194
- ? fresh.action !== reviewed.action || fresh.route !== 'delivery'
2195
- || (reviewed.included && (!cleanupCanInclude(fresh) || before.sha256 === null))
2196
- || before.type !== reviewed.before.type || before.sha256 !== reviewed.before.sha256
2197
- || cleanupCanonical(fresh.hookScripts || []) !== cleanupCanonical(reviewed.hookScripts || [])
2198
- : !settledUpstream(reviewed.relative)));
2199
- // Check the entire batch before mutating even one item; the remote base cannot replace the reviewed preimage.
2200
- if (stale.length) return release(cleanupReason('stale-plan', { detail: stale.map(({ reviewed }) => reviewed.relative).join(', ') }));
2201
- if (cleanupCoupledPairs(pairs.filter(({ fresh }) => fresh)).some((pair) => !pair.fresh)) return release(cleanupReason('stale-plan'));
2202
- const present = pairs.filter(({ fresh }) => fresh);
2330
+ let guard;
2331
+ for (const { reviewed } of stale) {
2332
+ const counterpart = fresh.items.find((item) => item.kind === reviewed.kind && item.relative === reviewed.relative && item.route === reviewed.route);
2333
+ // A caller hook reference can suppress its paired settings edit from the scan even though
2334
+ // the settings bytes remain identical. Actual byte/presence changes still require new proof.
2335
+ const before = cleanupPreimage(reviewed.path, Boolean(reviewed.included));
2336
+ if (before.type !== reviewed.before.type || before.sha256 !== reviewed.before.sha256) {
2337
+ return release(mismatch(stale.map(({ reviewed }) => reviewed.relative)));
2338
+ }
2339
+ if (counterpart?.action === 'skip' && cleanupAction(reviewed) !== 'skip') {
2340
+ guard ??= counterpart.reasons.find((reason) => ['in-use', 'occupied-lock', 'legacy-queue', 'queue-unreadable', 'requirement-missing', 'dependent-copy'].includes(reason.code));
2341
+ }
2342
+ }
2343
+ if (guard) return release(guard);
2344
+ if (stale.length) return release(mismatch(stale.map(({ reviewed }) => reviewed.relative)));
2345
+ const present = cleanupCoupledPairs(pairs.filter(({ fresh }) => fresh));
2346
+ if (present.some((pair) => !pair.fresh)) return release(mismatch(present.filter((pair) => !pair.fresh).map((pair) => pair.reviewed.relative)));
2203
2347
  if (!present.length) return release();
2204
2348
  const applied = [];
2205
2349
  for (const { reviewed, fresh: settings } of present.filter(({ fresh }) => fresh.hookScripts?.length)) {
@@ -2215,7 +2359,7 @@ function cleanupDeliveryCommit(worktree, items, current, release, settledUpstrea
2215
2359
  if (error) return release(cleanupReason(error.code, { ...error.params, detail: reviewed.relative }));
2216
2360
  applied.push(fresh.relative);
2217
2361
  }
2218
- for (const dir of ['.agents/skills', '.claude/skills']) {
2362
+ for (const dir of CLEANUP_SKILL_ROOTS) {
2219
2363
  const full = path.join(worktree, dir);
2220
2364
  if (fresh.items.some((item) => item.keepRoot && cleanupSameFile(path.dirname(item.path), full))) continue;
2221
2365
  try { if (fs.lstatSync(full).isDirectory() && fs.readdirSync(full).length === 0) fs.rmdirSync(full); } catch {}
@@ -2224,65 +2368,79 @@ function cleanupDeliveryCommit(worktree, items, current, release, settledUpstrea
2224
2368
  const commit = add.status === 0 && cleanupGit(worktree, ['-c', 'user.useConfigOnly=true', 'commit', '-q', '-m',
2225
2369
  `chore(devlyn): remove legacy devlyn-cli copies\n\nDevlyn now delivers devlyn-cli ${PKG.version} to each session it starts, so the copies earlier\ninstalls committed here are no longer used. The managed principles block stays for teammates.`]);
2226
2370
  if (!commit || commit.status !== 0) return release(cleanupReason('delivery-failed', { detail: (commit || add).stderr.trim() }));
2227
- const check = spawnSync(process.execPath, [path.join(__dirname, 'devlyn.js'), 'cleanup', '--plan', '--project', worktree, '--json'], { encoding: 'utf8' });
2371
+ // This check must observe the committed delivery worktree, not project the still-unclean remote.
2372
+ const inputs = { machine: false, projects: [worktree], accountHomes: [] };
2373
+ const id = `project:${require('./release').sha256(Buffer.from(worktree)).slice(0, 12)}`;
2374
+ let checked;
2375
+ try { checked = cleanupProjectScan(worktree, current); }
2376
+ catch (error) { return release(cleanupReason('delivery-failed', { detail: `cleanupProjectScan: ${error.message}` })); }
2377
+ const unreadable = checked.find((item) => item.reasons.some((reason) => reason.code === 'repository-unreadable'));
2378
+ if (unreadable) return release(cleanupReason('delivery-failed', { detail: `cleanupProjectScan: ${unreadable.path}: ${unreadable.reasons[0].params.detail}` }));
2379
+ const left = checked.filter((item) =>
2380
+ cleanupAction({ ...item, included: items.find((reviewed) => reviewed.relative === item.relative)?.included }) !== 'skip' && applied.includes(item.relative));
2381
+ if (left.length) return release(cleanupReason('delivery-failed', { detail: 'items remain after cleanup' }));
2382
+ const check = { schemaVersion: 1, version: PKG.version, inputs, scopes: [{
2383
+ id, kind: 'project', root: worktree, name: path.basename(worktree), remote: scope.remote,
2384
+ items: checked.map((item, index) => ({ id: `${id}:${index}`, ...item })),
2385
+ }] };
2228
2386
  const evidence = path.join(worktree, '.devlyn', 'cleanup-check.json');
2229
2387
  fs.mkdirSync(path.dirname(evidence), { recursive: true });
2230
- fs.writeFileSync(evidence, check.stdout);
2231
- let left = [];
2232
- try { left = JSON.parse(check.stdout.trim().split('\n').at(-1)).data.scopes[0].items.filter((item) =>
2233
- cleanupAction({ ...item, included: items.find((reviewed) => reviewed.relative === item.relative)?.included }) !== 'skip' && applied.includes(item.relative)); }
2234
- catch { return release(cleanupReason('delivery-failed', { detail: 'the check after cleanup could not run' })); }
2235
- if (left.length) return release(cleanupReason('delivery-failed', { detail: 'items remain after cleanup' }));
2388
+ fs.writeFileSync(evidence, JSON.stringify({ schemaVersion: 1, ok: true, data: { ...check, digest: cleanupDigest(check) } }) + '\n');
2236
2389
  const head = cleanupGit(worktree, ['rev-parse', 'HEAD']);
2237
2390
  if (head.error || head.status !== 0) return release(cleanupReason('repository-unreadable', { detail: head.error?.message || head.stderr.trim() }));
2238
2391
  const acceptance = path.join(worktree, '.devlyn', 'acceptance.json');
2239
2392
  fs.writeFileSync(acceptance, JSON.stringify({ kind: 'direct', task: `devlyn-cli ${PKG.version} legacy cleanup`,
2240
2393
  source_sha: head.stdout.trim(),
2241
- checks: [{ command: `devlyn cleanup --plan --project ${worktree} --json`, evidence: '.devlyn/cleanup-check.json' }] }));
2394
+ checks: [{ command: `in-process cleanupProjectScan(${JSON.stringify(worktree)}, current): verify readable scan and no actionable applied items`, evidence: '.devlyn/cleanup-check.json' }] }));
2242
2395
  return { acceptance };
2243
2396
  }
2244
2397
 
2245
2398
  function cleanupApply(plan, { events = false } = {}) {
2246
2399
  const current = cleanupCurrentFingerprints();
2247
- const includeModified = plan.scopes.some((scope) => scope.items.some((item) => item.included));
2248
- const includedHooks = new Set(plan.scopes.flatMap((scope) => scope.items)
2249
- .filter((item) => item.kind === 'legacy-hook' && item.included).map((item) => item.path));
2400
+ const hooks = plan.scopes.flatMap((scope) => scope.items).filter((item) => item.kind === 'legacy-hook' && item.included);
2401
+ const includedHooks = new Set(hooks.filter((item) => item.route === 'local').map((item) => item.path));
2250
2402
  // Explicit selection of a reviewed edited script also selects its reviewed settings preimage.
2251
2403
  for (const scope of plan.scopes) for (const item of scope.items) {
2252
2404
  if (!item.hookCandidates || (item.action === 'skip' && item.reasons[0]?.code !== 'dependent-copy')) continue;
2253
- const selected = item.hookCandidates.filter((name) => includedHooks.has(path.join(item.base, name)));
2405
+ const selected = item.hookCandidates.filter((name) => hooks.some((hook) => hook.route === item.route && hook.path === path.join(item.base, name)));
2254
2406
  if (selected.length) Object.assign(item, { action: 'edit', reasons: [], hookScripts: [...new Set([...item.hookScripts, ...selected])].sort() });
2255
2407
  }
2256
- const fresh = new Map(cleanupScopes(plan.inputs, current, includeModified, includedHooks).map((scope) => [scope.id, scope]));
2408
+ const fresh = new Map(cleanupScopes(plan.inputs, current, includedHooks).map((scope) => [scope.id, scope]));
2257
2409
  let seq = 0;
2258
2410
  const emit = (scope) => { if (events) process.stdout.write(JSON.stringify({ schemaVersion: 1, event: 'scope', seq: ++seq, scope }) + '\n'); };
2259
2411
  const scopes = [];
2260
2412
  for (const reviewed of plan.scopes) {
2261
- const pairs = cleanupCoupledPairs(cleanupProven(reviewed, fresh.get(reviewed.id) || { items: [] }));
2413
+ const observed = fresh.get(reviewed.id);
2414
+ const localReview = { items: reviewed.items.filter((item) => item.route === 'local') };
2415
+ const pairs = cleanupCoupledPairs(cleanupProven(localReview, observed || { items: [] }));
2416
+ pairs.push(...reviewed.items.filter((item) => item.route === 'delivery' && ['remove', 'edit'].includes(cleanupAction(item)))
2417
+ .map((item) => ({ reviewed: item, fresh: item })));
2262
2418
  const progress = { scopeId: reviewed.id, kind: reviewed.kind, name: reviewed.name, total: pairs.length, state: 'running', delivery: null, error: null };
2263
2419
  if (!pairs.length) { scopes.push({ ...progress, state: 'skipped' }); emit(scopes.at(-1)); continue; }
2264
2420
  emit(progress);
2265
2421
  try {
2266
- const observed = fresh.get(reviewed.id);
2267
- if (reviewed.kind !== observed?.kind
2422
+ if (reviewed.kind !== observed?.kind || reviewed.root !== observed?.root
2268
2423
  || (observed.kind === 'project' && cleanupCanonical(reviewed.remote) !== cleanupCanonical(observed.remote))) {
2269
2424
  Object.assign(progress, { state: 'failed', error: cleanupReason('stale-plan', { detail: 'delivery identity changed' }) });
2270
2425
  scopes.push(progress); emit(progress); continue;
2271
2426
  }
2272
2427
  const refused = pairs.find((pair) => !pair.fresh);
2273
- // A reviewed copy refused now stays on disk, so the support beside it stays too.
2274
- const held = new Set(pairs.filter((pair) => !pair.fresh && !cleanupSupport(pair.reviewed))
2275
- .map((pair) => path.dirname(pair.reviewed.path)));
2276
- const proven = pairs.filter((pair) => pair.fresh && !(cleanupSupport(pair.fresh) && held.has(path.dirname(pair.fresh.path))))
2277
- .map((pair) => pair.fresh);
2278
- const local = cleanupLocal(fresh.get(reviewed.id), proven.filter((item) => item.route === 'local'));
2428
+ const proven = pairs.filter((pair) => pair.fresh).map((pair) => pair.fresh);
2429
+ const local = cleanupLocal(observed, proven.filter((item) => item.route === 'local'));
2279
2430
  const failedLocal = [...local.values()].find((reason) => reason);
2280
- // A local copy that could not go keeps the tracked support beside it, as cleanupLocal keeps local support.
2281
- const keptRoots = new Set(proven.filter((item) => item.route === 'local' && local.get(item.id)).map((item) => path.dirname(item.path)));
2282
- const deliverable = proven.filter((item) => item.route === 'delivery' && !(cleanupSupport(item) && keptRoots.has(path.dirname(item.path))));
2283
- const delivery = deliverable.length ? cleanupDeliver(fresh.get(reviewed.id), deliverable, current) : null;
2431
+ // Every caller copy survives unless this apply removed it locally, or delivers the tracked
2432
+ // copy at the same path and of the same kind. New and unselected copies retain support too.
2433
+ const copyKey = (item) => `${item.route}\0${item.kind}\0${item.route === 'delivery' ? item.relative.toLowerCase() : item.path}`;
2434
+ const removing = new Set(proven.filter((item) => cleanupAction(item) === 'remove'
2435
+ && (item.route === 'delivery' || local.get(item.id) === null)).map(copyKey));
2436
+ const keptRoots = new Set(observed.items.filter((item) => !cleanupSupport(item) && item.kind !== 'install-lock' && !removing.has(copyKey(item)))
2437
+ .map((item) => path.dirname(item.path).toLowerCase()));
2438
+ const deliverable = proven.filter((item) => item.route === 'delivery' && !(cleanupSupport(item) && keptRoots.has(path.dirname(item.path).toLowerCase())));
2439
+ const retained = proven.some((item) => item.route === 'delivery' && !deliverable.includes(item));
2440
+ const busy = deliverable.length ? cleanupQueueBusy(reviewed.root) : null;
2441
+ const delivery = busy ? { error: busy } : deliverable.length ? cleanupDeliver(reviewed, deliverable, current) : null;
2284
2442
  progress.delivery = delivery?.delivery || null;
2285
- const error = delivery?.error || failedLocal || (refused && cleanupReason('stale-plan'));
2443
+ const error = delivery?.error || failedLocal || ((refused || retained) && cleanupReason('stale-plan'));
2286
2444
  Object.assign(progress, error ? { state: 'failed', error } : { state: 'done' });
2287
2445
  } catch (error) {
2288
2446
  Object.assign(progress, { state: 'failed', error: error instanceof InstructionError