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 +318 -160
- package/bin/skill-history.json +3 -0
- package/config/skills/_shared/task-complete.py +81 -19
- package/config/skills/_shared/task-completion.md +3 -1
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/task-complete.py +81 -19
- package/native/claude/skills/_shared/task-completion.md +3 -1
- package/native/codex/.agents/plugins/marketplace.json +1 -1
- package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
- package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +81 -19
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +3 -1
- package/package.json +1 -1
- package/release.json +14 -14
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
|
-
//
|
|
1434
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
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(
|
|
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[
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
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
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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}`, ...
|
|
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
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
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}\
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1971
|
-
|
|
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')
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
}
|
|
2177
|
-
|
|
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,
|
|
2186
|
-
const
|
|
2187
|
-
const
|
|
2188
|
-
const
|
|
2189
|
-
const pairs = items
|
|
2190
|
-
|
|
2191
|
-
|
|
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
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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: `
|
|
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
|
|
2248
|
-
const includedHooks = new Set(
|
|
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) =>
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2274
|
-
const
|
|
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
|
-
//
|
|
2281
|
-
|
|
2282
|
-
const
|
|
2283
|
-
const
|
|
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
|