devlyn-cli 4.3.14 → 4.3.16
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 +312 -157
- package/bin/skill-history.json +3 -0
- package/config/skills/_shared/task-complete.py +102 -1
- package/config/skills/_shared/task-completion.md +3 -0
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/task-complete.py +102 -1
- package/native/claude/skills/_shared/task-completion.md +3 -0
- 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 +102 -1
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +3 -0
- 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) {
|
|
@@ -2091,19 +2254,8 @@ function cleanupDeliver(scope, items, current) {
|
|
|
2091
2254
|
if (baseHead.error || baseHead.status !== 0) return release(cleanupReason('repository-unreadable', { detail: baseHead.error?.message || baseHead.stderr.trim() }));
|
|
2092
2255
|
const base = baseHead.stdout.trim();
|
|
2093
2256
|
let prepared;
|
|
2094
|
-
try { prepared = cleanupDeliveryCommit(worktree, items, current, release,
|
|
2257
|
+
try { prepared = cleanupDeliveryCommit(worktree, items, current, release, scope, base); }
|
|
2095
2258
|
catch (error) { return release(cleanupReason('apply-failed', { detail: error.message })); }
|
|
2096
|
-
if (prepared.error?.code === 'stale-plan') {
|
|
2097
|
-
const head = cleanupGit(scope.root, ['rev-parse', 'HEAD']);
|
|
2098
|
-
// A stale review must see the base's bytes next time. Local commits keep today's refusal.
|
|
2099
|
-
if (!head.error && head.status === 0 && head.stdout.trim() !== base) {
|
|
2100
|
-
const ancestor = cleanupGit(scope.root, ['merge-base', '--is-ancestor', head.stdout.trim(), base]);
|
|
2101
|
-
if (!ancestor.error && ancestor.status === 0) {
|
|
2102
|
-
const updated = cleanupUpdateCheckout(scope);
|
|
2103
|
-
if (updated.error) return updated;
|
|
2104
|
-
}
|
|
2105
|
-
}
|
|
2106
|
-
}
|
|
2107
2259
|
if (prepared.error) return prepared;
|
|
2108
2260
|
// The remote base already settled every reviewed item: the checkout only has to take it.
|
|
2109
2261
|
if (!prepared.acceptance) return cleanupUpdateCheckout(scope);
|
|
@@ -2115,44 +2267,13 @@ function cleanupDeliver(scope, items, current) {
|
|
|
2115
2267
|
return { error: cleanupReason(failureCode(reason), { detail: reason, ...(completed?.resume ? { resume: completed.resume } : {}) }) };
|
|
2116
2268
|
}
|
|
2117
2269
|
|
|
2118
|
-
// With no fresh counterpart, the base settled an item only if it changed the path's content or
|
|
2119
|
-
// removed it in every spelling, while the checkout holds it under its tracked spelling unchanged
|
|
2120
|
-
// since the fork point. A copy committed or changed only here is not one the base settled.
|
|
2121
|
-
function cleanupSettledUpstream(root, base) {
|
|
2122
|
-
const read = (cwd, args, options) => {
|
|
2123
|
-
const result = cleanupGit(cwd, args, options);
|
|
2124
|
-
return !result.error && result.status === 0 ? result.stdout : null;
|
|
2125
|
-
};
|
|
2126
|
-
const object = (spec) => read(root, ['rev-parse', '--verify', '--quiet', spec])?.trim() || null;
|
|
2127
|
-
const fork = read(root, ['merge-base', 'HEAD', base])?.trim();
|
|
2128
|
-
let upstream; // the base's paths, folded to lower case, read once and only when needed
|
|
2129
|
-
return (relative) => {
|
|
2130
|
-
if (!fork) return false;
|
|
2131
|
-
if (upstream === undefined) {
|
|
2132
|
-
upstream = read(root, ['ls-tree', '-r', '-z', '--name-only', base], { maxBuffer: 1 << 30 })
|
|
2133
|
-
?.split('\0').filter(Boolean).map((name) => name.toLowerCase()) ?? null;
|
|
2134
|
-
}
|
|
2135
|
-
const lower = relative.toLowerCase();
|
|
2136
|
-
if (!upstream) return false;
|
|
2137
|
-
// The tracked spelling, which a case-insensitive file system lets differ from the one on disk.
|
|
2138
|
-
const tracked = read(root, ['ls-files', '-z', '--', `:(icase,literal)${relative}`])?.split('\0').find(Boolean);
|
|
2139
|
-
const spelled = tracked?.slice(0, relative.length);
|
|
2140
|
-
if (!spelled || spelled.toLowerCase() !== lower) return false;
|
|
2141
|
-
const before = object(`${fork}:${spelled}`);
|
|
2142
|
-
if (before === null || before !== object(`HEAD:${spelled}`)) return false;
|
|
2143
|
-
const after = object(`${base}:${spelled}`);
|
|
2144
|
-
return after !== null ? after !== before
|
|
2145
|
-
: !upstream.some((name) => name === lower || name.startsWith(`${lower}/`));
|
|
2146
|
-
};
|
|
2147
|
-
}
|
|
2148
|
-
|
|
2149
2270
|
// The remote base's cleanup or changed copies reach this checkout only when it takes that base: a checkout on
|
|
2150
2271
|
// the base branch is fast-forwarded to it, as a task's owner reconciles after delivery. The base is
|
|
2151
2272
|
// fetched into a ref this call owns (FETCH_HEAD can be rewritten by another fetch meanwhile) and
|
|
2152
2273
|
// merged by its commit, only while the checkout is still the one checked before the fetch, and the
|
|
2153
2274
|
// result must be that commit. Another branch, or a fast-forward Git refuses (local changes or ignored
|
|
2154
2275
|
// files in the way, local commits), is left as it is.
|
|
2155
|
-
function cleanupUpdateCheckout(scope) {
|
|
2276
|
+
function cleanupUpdateCheckout(scope, fetchedCommit = null) {
|
|
2156
2277
|
const failed = (detail) => ({ error: cleanupReason('update-checkout', { detail }) });
|
|
2157
2278
|
const read = (args) => {
|
|
2158
2279
|
const result = cleanupGit(scope.root, args);
|
|
@@ -2161,14 +2282,9 @@ function cleanupUpdateCheckout(scope) {
|
|
|
2161
2282
|
const checkout = () => [read(['symbolic-ref', '--quiet', '--short', 'HEAD']), read(['rev-parse', 'HEAD'])];
|
|
2162
2283
|
const [branch, head] = checkout();
|
|
2163
2284
|
if (branch !== scope.remote.base || !head) return failed(`${branch || 'a detached HEAD'} is checked out`);
|
|
2164
|
-
const
|
|
2165
|
-
try {
|
|
2166
|
-
const fetched = cleanupGit(scope.root, ['fetch', '--quiet', '--no-write-fetch-head', 'origin', `+refs/heads/${scope.remote.base}:${ref}`],
|
|
2167
|
-
{ timeout: 30000, env: { GIT_TERMINAL_PROMPT: '0' } });
|
|
2168
|
-
if (fetched.error || fetched.status !== 0) return failed(fetched.error?.message || fetched.stderr.trim());
|
|
2169
|
-
const target = read(['rev-parse', '--verify', `${ref}^{commit}`]);
|
|
2285
|
+
const merge = (target) => {
|
|
2170
2286
|
const [now, at] = checkout();
|
|
2171
|
-
if (
|
|
2287
|
+
if (now !== branch || at !== head) return failed('the checkout changed while the base was fetched');
|
|
2172
2288
|
// The branch's own merge options (`--squash`, `-s ours`) would turn this into something else.
|
|
2173
2289
|
const merged = cleanupGit(scope.root, ['-c', `branch.${branch}.mergeOptions=`,
|
|
2174
2290
|
'merge', '--ff-only', '--no-squash', '--no-autostash', '--no-overwrite-ignore', '--quiet', target]);
|
|
@@ -2176,33 +2292,58 @@ function cleanupUpdateCheckout(scope) {
|
|
|
2176
2292
|
const [after, landed] = checkout();
|
|
2177
2293
|
if (after !== branch || landed !== target) return failed('the fast-forward did not land on the base');
|
|
2178
2294
|
return { changed: landed !== head };
|
|
2179
|
-
}
|
|
2180
|
-
|
|
2181
|
-
}
|
|
2295
|
+
};
|
|
2296
|
+
try { return merge(fetchedCommit || cleanupFetchBase(scope)); }
|
|
2297
|
+
catch (error) { return failed(error.message); }
|
|
2182
2298
|
}
|
|
2183
2299
|
|
|
2184
2300
|
// Inside the allocated worktree: prove every reviewed item, remove them, commit, and write the
|
|
2185
2301
|
// acceptance completion binds. A failure releases the worktree; so does a throw, in the caller.
|
|
2186
2302
|
// With every reviewed item already settled by the remote base, the worktree is released with
|
|
2187
2303
|
// nothing to deliver.
|
|
2188
|
-
function cleanupDeliveryCommit(worktree, items, current, release,
|
|
2189
|
-
const
|
|
2190
|
-
const
|
|
2191
|
-
const
|
|
2192
|
-
const pairs = items
|
|
2193
|
-
|
|
2194
|
-
|
|
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;
|
|
2195
2329
|
});
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
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)));
|
|
2206
2347
|
if (!present.length) return release();
|
|
2207
2348
|
const applied = [];
|
|
2208
2349
|
for (const { reviewed, fresh: settings } of present.filter(({ fresh }) => fresh.hookScripts?.length)) {
|
|
@@ -2218,7 +2359,7 @@ function cleanupDeliveryCommit(worktree, items, current, release, settledUpstrea
|
|
|
2218
2359
|
if (error) return release(cleanupReason(error.code, { ...error.params, detail: reviewed.relative }));
|
|
2219
2360
|
applied.push(fresh.relative);
|
|
2220
2361
|
}
|
|
2221
|
-
for (const dir of
|
|
2362
|
+
for (const dir of CLEANUP_SKILL_ROOTS) {
|
|
2222
2363
|
const full = path.join(worktree, dir);
|
|
2223
2364
|
if (fresh.items.some((item) => item.keepRoot && cleanupSameFile(path.dirname(item.path), full))) continue;
|
|
2224
2365
|
try { if (fs.lstatSync(full).isDirectory() && fs.readdirSync(full).length === 0) fs.rmdirSync(full); } catch {}
|
|
@@ -2227,65 +2368,79 @@ function cleanupDeliveryCommit(worktree, items, current, release, settledUpstrea
|
|
|
2227
2368
|
const commit = add.status === 0 && cleanupGit(worktree, ['-c', 'user.useConfigOnly=true', 'commit', '-q', '-m',
|
|
2228
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.`]);
|
|
2229
2370
|
if (!commit || commit.status !== 0) return release(cleanupReason('delivery-failed', { detail: (commit || add).stderr.trim() }));
|
|
2230
|
-
|
|
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
|
+
}] };
|
|
2231
2386
|
const evidence = path.join(worktree, '.devlyn', 'cleanup-check.json');
|
|
2232
2387
|
fs.mkdirSync(path.dirname(evidence), { recursive: true });
|
|
2233
|
-
fs.writeFileSync(evidence, check
|
|
2234
|
-
let left = [];
|
|
2235
|
-
try { left = JSON.parse(check.stdout.trim().split('\n').at(-1)).data.scopes[0].items.filter((item) =>
|
|
2236
|
-
cleanupAction({ ...item, included: items.find((reviewed) => reviewed.relative === item.relative)?.included }) !== 'skip' && applied.includes(item.relative)); }
|
|
2237
|
-
catch { return release(cleanupReason('delivery-failed', { detail: 'the check after cleanup could not run' })); }
|
|
2238
|
-
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');
|
|
2239
2389
|
const head = cleanupGit(worktree, ['rev-parse', 'HEAD']);
|
|
2240
2390
|
if (head.error || head.status !== 0) return release(cleanupReason('repository-unreadable', { detail: head.error?.message || head.stderr.trim() }));
|
|
2241
2391
|
const acceptance = path.join(worktree, '.devlyn', 'acceptance.json');
|
|
2242
2392
|
fs.writeFileSync(acceptance, JSON.stringify({ kind: 'direct', task: `devlyn-cli ${PKG.version} legacy cleanup`,
|
|
2243
2393
|
source_sha: head.stdout.trim(),
|
|
2244
|
-
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' }] }));
|
|
2245
2395
|
return { acceptance };
|
|
2246
2396
|
}
|
|
2247
2397
|
|
|
2248
2398
|
function cleanupApply(plan, { events = false } = {}) {
|
|
2249
2399
|
const current = cleanupCurrentFingerprints();
|
|
2250
|
-
const
|
|
2251
|
-
const includedHooks = new Set(
|
|
2252
|
-
.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));
|
|
2253
2402
|
// Explicit selection of a reviewed edited script also selects its reviewed settings preimage.
|
|
2254
2403
|
for (const scope of plan.scopes) for (const item of scope.items) {
|
|
2255
2404
|
if (!item.hookCandidates || (item.action === 'skip' && item.reasons[0]?.code !== 'dependent-copy')) continue;
|
|
2256
|
-
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)));
|
|
2257
2406
|
if (selected.length) Object.assign(item, { action: 'edit', reasons: [], hookScripts: [...new Set([...item.hookScripts, ...selected])].sort() });
|
|
2258
2407
|
}
|
|
2259
|
-
const fresh = new Map(cleanupScopes(plan.inputs, current,
|
|
2408
|
+
const fresh = new Map(cleanupScopes(plan.inputs, current, includedHooks).map((scope) => [scope.id, scope]));
|
|
2260
2409
|
let seq = 0;
|
|
2261
2410
|
const emit = (scope) => { if (events) process.stdout.write(JSON.stringify({ schemaVersion: 1, event: 'scope', seq: ++seq, scope }) + '\n'); };
|
|
2262
2411
|
const scopes = [];
|
|
2263
2412
|
for (const reviewed of plan.scopes) {
|
|
2264
|
-
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 })));
|
|
2265
2418
|
const progress = { scopeId: reviewed.id, kind: reviewed.kind, name: reviewed.name, total: pairs.length, state: 'running', delivery: null, error: null };
|
|
2266
2419
|
if (!pairs.length) { scopes.push({ ...progress, state: 'skipped' }); emit(scopes.at(-1)); continue; }
|
|
2267
2420
|
emit(progress);
|
|
2268
2421
|
try {
|
|
2269
|
-
|
|
2270
|
-
if (reviewed.kind !== observed?.kind
|
|
2422
|
+
if (reviewed.kind !== observed?.kind || reviewed.root !== observed?.root
|
|
2271
2423
|
|| (observed.kind === 'project' && cleanupCanonical(reviewed.remote) !== cleanupCanonical(observed.remote))) {
|
|
2272
2424
|
Object.assign(progress, { state: 'failed', error: cleanupReason('stale-plan', { detail: 'delivery identity changed' }) });
|
|
2273
2425
|
scopes.push(progress); emit(progress); continue;
|
|
2274
2426
|
}
|
|
2275
2427
|
const refused = pairs.find((pair) => !pair.fresh);
|
|
2276
|
-
|
|
2277
|
-
const
|
|
2278
|
-
.map((pair) => path.dirname(pair.reviewed.path)));
|
|
2279
|
-
const proven = pairs.filter((pair) => pair.fresh && !(cleanupSupport(pair.fresh) && held.has(path.dirname(pair.fresh.path))))
|
|
2280
|
-
.map((pair) => pair.fresh);
|
|
2281
|
-
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'));
|
|
2282
2430
|
const failedLocal = [...local.values()].find((reason) => reason);
|
|
2283
|
-
//
|
|
2284
|
-
|
|
2285
|
-
const
|
|
2286
|
-
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;
|
|
2287
2442
|
progress.delivery = delivery?.delivery || null;
|
|
2288
|
-
const error = delivery?.error || failedLocal || (refused && cleanupReason('stale-plan'));
|
|
2443
|
+
const error = delivery?.error || failedLocal || ((refused || retained) && cleanupReason('stale-plan'));
|
|
2289
2444
|
Object.assign(progress, error ? { state: 'failed', error } : { state: 'done' });
|
|
2290
2445
|
} catch (error) {
|
|
2291
2446
|
Object.assign(progress, { state: 'failed', error: error instanceof InstructionError
|