devlyn-cli 4.3.8 → 4.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/devlyn.js CHANGED
@@ -1344,10 +1344,10 @@ function showHelp() {
1344
1344
  // ---------------------------------------------------------------------------
1345
1345
  // Legacy cleanup for the built-in harness: Devlyn delivers devlyn-cli per session, so every copy an
1346
1346
  // earlier install left in a skill root, and the files those installs added, become legacy. A plan
1347
- // is read-only and lists each item with its proof. Apply plans again from what is on disk now and
1348
- // acts only on items both plans name the same way, so a plan file can narrow a cleanup but never
1349
- // widen it: untracked copies go in place, tracked ones through the project's delivery policy.
1350
- // Every removal needs shipped-content proof; names only identify candidates.
1347
+ // takes the remote base by fast-forward when possible, then lists each item with its proof. Apply
1348
+ // plans again from disk and acts only on items both plans name the same way. A plan can narrow
1349
+ // cleanup but never widen it: untracked copies go in place, tracked ones through the project's delivery policy.
1350
+ // Removals need shipped-content proof or an explicit choice of a reviewed modified copy.
1351
1351
  // ---------------------------------------------------------------------------
1352
1352
 
1353
1353
  const CLEANUP_CORE = ['devlyn-ideate', 'devlyn-engines'];
@@ -1355,12 +1355,17 @@ const CLEANUP_CORE = ['devlyn-ideate', 'devlyn-engines'];
1355
1355
  function cleanupReason(code, params = {}) { return { code, params }; }
1356
1356
 
1357
1357
  // What a path is now, so apply can refuse an item that changed after it was reviewed.
1358
- function cleanupPreimage(file) {
1358
+ function cleanupPreimage(file, exact = false) {
1359
1359
  const stat = fs.lstatSync(file, { throwIfNoEntry: false });
1360
1360
  if (!stat) return { type: 'missing', sha256: null };
1361
1361
  if (stat.isSymbolicLink()) return { type: 'link', sha256: null };
1362
1362
  if (stat.isDirectory()) {
1363
- try { return { type: 'directory', sha256: fingerprint(file) }; }
1363
+ try {
1364
+ if (!exact) return { type: 'directory', sha256: fingerprint(file) };
1365
+ const entries = fs.readdirSync(file).sort().map((name) => [name, cleanupPreimage(path.join(file, name), true)]);
1366
+ return { type: 'directory', sha256: entries.some(([, before]) => before.sha256 === null) ? null
1367
+ : crypto.createHash('sha256').update(JSON.stringify(entries)).digest('hex') };
1368
+ }
1364
1369
  catch { return { type: 'directory', sha256: null }; }
1365
1370
  }
1366
1371
  if (stat.isFile()) return { type: 'file', sha256: crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') };
@@ -1414,6 +1419,46 @@ function cleanupMarkerOwned(file, current) {
1414
1419
  return text === JSON.stringify({ schemaVersion: 1, package: PKG.name, version: marker.version, skills: marker.skills }, null, 2) + '\n';
1415
1420
  }
1416
1421
 
1422
+ function cleanupSkillRoots(roots, current, includeModified = false) {
1423
+ const resolved = new Map();
1424
+ const linked = new Set();
1425
+ const active = new Set();
1426
+ for (const [base, root] of roots) {
1427
+ let target = root;
1428
+ const stat = fs.lstatSync(root, { throwIfNoEntry: false });
1429
+ const parents = cleanupRealParents(base, root);
1430
+ if (parents && stat?.isSymbolicLink()) {
1431
+ const link = fs.readlinkSync(root);
1432
+ let dir = path.isAbsolute(link) ? path.parse(link).root : path.dirname(root);
1433
+ // Check each hop before normalizing `..`, so even a link followed by `..` is refused.
1434
+ for (const part of link.slice(path.parse(link).root.length).split(path.sep).filter(Boolean)) {
1435
+ dir = path.join(dir, part);
1436
+ if (!fs.lstatSync(dir, { throwIfNoEntry: false })?.isDirectory()) { dir = null; break; }
1437
+ }
1438
+ if (dir && cleanupRealParents(base, dir)) {
1439
+ target = fs.realpathSync.native(dir);
1440
+ linked.add(target);
1441
+ }
1442
+ } else if (parents && stat?.isDirectory()) target = fs.realpathSync.native(root);
1443
+ if (!resolved.has(target)) resolved.set(target, base);
1444
+ if (cleanupInUse(root) || cleanupInUse(target)) active.add(target);
1445
+ }
1446
+ return [...resolved].flatMap(([root, base]) => {
1447
+ const items = cleanupSkillRoot(base, root, current);
1448
+ const occupied = includeModified && items.some((item) => item.kind === 'install-lock');
1449
+ const protectedCopies = new Set(items.filter((item) => [...linked].some((target) => target === item.path || target.startsWith(item.path + path.sep))));
1450
+ for (const item of items) {
1451
+ if ((protectedCopies.size && cleanupSupport(item)) || protectedCopies.has(item)) {
1452
+ Object.assign(item, { action: 'skip', reasons: [cleanupReason('dependent-copy')] });
1453
+ }
1454
+ if (cleanupAction(item, includeModified) === 'remove' && (active.has(root) || occupied)) {
1455
+ Object.assign(item, { action: 'skip', reasons: [cleanupReason(occupied ? 'occupied-lock' : 'in-use')] });
1456
+ }
1457
+ }
1458
+ return items.map((item) => linked.has(root) ? { ...item, keepRoot: true } : item);
1459
+ });
1460
+ }
1461
+
1417
1462
  // The legacy items of one skill root, each with its proof and the base its parents are checked from.
1418
1463
  // `_shared` and the install marker go only when every copy beside them goes too: a kept, edited
1419
1464
  // devlyn-ideate needs both.
@@ -1496,9 +1541,6 @@ function cleanupSkillRoot(base, root, current) {
1496
1541
  reasons: proven ? [] : [cleanupReason('modified-copy')] });
1497
1542
  }
1498
1543
  }
1499
- if (cleanupInUse(root)) {
1500
- for (const item of items) if (item.action === 'remove') Object.assign(item, { action: 'skip', reasons: [cleanupReason('in-use')] });
1501
- }
1502
1544
  return items;
1503
1545
  }
1504
1546
 
@@ -1565,7 +1607,8 @@ function cleanupCurrentFingerprints() {
1565
1607
  }
1566
1608
 
1567
1609
  function cleanupGit(root, args, options = {}) {
1568
- return spawnSync('git', ['-C', root, ...args], { env: { ...gitEnvironment(), GIT_OPTIONAL_LOCKS: '0' }, encoding: 'utf8', ...options });
1610
+ return spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', ...options,
1611
+ env: { ...gitEnvironment(), GIT_OPTIONAL_LOCKS: '0', ...options.env } });
1569
1612
  }
1570
1613
 
1571
1614
  let cleanupPythonBinary;
@@ -1647,7 +1690,7 @@ function cleanupRetainSupport(items) {
1647
1690
  return items;
1648
1691
  }
1649
1692
 
1650
- function cleanupProjectScope(project, current) {
1693
+ function cleanupProjectScope(project, current, includeModified = false) {
1651
1694
  const { repositoryIdentity } = require('./harness-status');
1652
1695
  const { physicalRoot } = require('./release');
1653
1696
  const root = physicalRoot(project);
@@ -1659,12 +1702,12 @@ function cleanupProjectScope(project, current) {
1659
1702
  // The home folder's roots are this machine's: the machine scope cleans them under the user-root locks.
1660
1703
  if (cleanupSameFile(base, os.homedir())) return { ...scope, items: whole('home-folder') };
1661
1704
  const items = [
1662
- ...['.agents', '.claude'].flatMap((dir) => cleanupSkillRoot(base, path.join(base, dir, 'skills'), current)),
1705
+ ...cleanupSkillRoots(['.agents', '.claude'].map((dir) => [base, path.join(base, dir, 'skills')]), current, includeModified),
1663
1706
  ...cleanupCommands(base), ...cleanupProjectFiles(base), ...cleanupInstructions(base),
1664
1707
  ];
1665
- const busy = identity && items.some((item) => item.action !== 'skip') ? cleanupQueueBusy(base) : null;
1708
+ const busy = identity && items.some((item) => cleanupAction(item, includeModified) !== 'skip') ? cleanupQueueBusy(base) : null;
1666
1709
  for (const item of items) {
1667
- if (busy && item.action !== 'skip') Object.assign(item, { action: 'skip', reasons: [busy] });
1710
+ if (busy && cleanupAction(item, includeModified) !== 'skip') Object.assign(item, { action: 'skip', reasons: [busy] });
1668
1711
  const relative = path.relative(base, item.path).split(path.sep).join('/');
1669
1712
  let tracked = false;
1670
1713
  try { tracked = cleanupTracked(item.path); }
@@ -1673,23 +1716,17 @@ function cleanupProjectScope(project, current) {
1673
1716
  }
1674
1717
  if (!scope.remote) {
1675
1718
  for (const item of scope.items) {
1676
- if (item.route === 'delivery' && item.action !== 'skip') Object.assign(item, { action: 'skip', reasons: [cleanupReason('no-remote')] });
1719
+ if (item.route === 'delivery' && cleanupAction(item, includeModified) !== 'skip') Object.assign(item, { action: 'skip', reasons: [cleanupReason('no-remote')] });
1677
1720
  }
1678
1721
  }
1679
1722
  cleanupRetainSupport(scope.items);
1680
1723
  return scope;
1681
1724
  }
1682
1725
 
1683
- function cleanupMachineScope(home, accountHomes, current) {
1726
+ function cleanupMachineScope(home, accountHomes, current, includeModified = false) {
1684
1727
  const roots = [...['.agents', '.codex', '.claude', '.grok'].map((dir) => [home, path.join(home, dir, 'skills')]),
1685
1728
  ...accountHomes.map((account) => [path.dirname(account), path.join(account, 'skills')])];
1686
- const seen = new Set();
1687
- const items = [];
1688
- for (const [base, root] of roots) {
1689
- if (seen.has(root)) continue;
1690
- seen.add(root);
1691
- items.push(...cleanupSkillRoot(base, root, current));
1692
- }
1729
+ const items = cleanupSkillRoots(roots, current, includeModified);
1693
1730
  items.push(...cleanupCommands(home));
1694
1731
  for (const item of items) {
1695
1732
  try {
@@ -1702,13 +1739,13 @@ function cleanupMachineScope(home, accountHomes, current) {
1702
1739
  return { kind: 'machine', root: home, name: null, remote: null, items: cleanupRetainSupport(items) };
1703
1740
  }
1704
1741
 
1705
- function cleanupScopes(inputs, current) {
1742
+ function cleanupScopes(inputs, current, includeModified = false) {
1706
1743
  const { sha256 } = require('./release');
1707
1744
  const scopes = [];
1708
- if (inputs.machine) scopes.push({ id: 'machine', ...cleanupMachineScope(os.homedir(), inputs.accountHomes, current) });
1745
+ if (inputs.machine) scopes.push({ id: 'machine', ...cleanupMachineScope(os.homedir(), inputs.accountHomes, current, includeModified) });
1709
1746
  for (const project of inputs.projects) {
1710
1747
  let scope;
1711
- try { scope = cleanupProjectScope(project, current); }
1748
+ try { scope = cleanupProjectScope(project, current, includeModified); }
1712
1749
  catch (error) {
1713
1750
  scope = { kind: 'project', root: project, name: path.basename(project), remote: null, items: [{ kind: 'project', path: project, base: project,
1714
1751
  relative: '', route: 'local', action: 'skip', reasons: [cleanupReason(error.reason?.code === 'repository-unreadable' ? 'repository-unreadable' : 'project-unreadable', { detail: error.message })] }] };
@@ -1716,13 +1753,24 @@ function cleanupScopes(inputs, current) {
1716
1753
  scopes.push({ id: `project:${sha256(Buffer.from(project)).slice(0, 12)}`, ...scope });
1717
1754
  }
1718
1755
  for (const scope of scopes) {
1719
- scope.items = scope.items.map((item, index) => ({ id: `${scope.id}:${index}`, ...item, before: cleanupPreimage(item.path) }));
1756
+ scope.items = scope.items.map((item, index) => ({ id: `${scope.id}:${index}`, ...item, before: cleanupPreimage(item.path, cleanupCanInclude(item)) }));
1720
1757
  }
1721
1758
  return scopes;
1722
1759
  }
1723
1760
 
1724
1761
  function cleanupPlan(inputs) {
1725
- const plan = { schemaVersion: 1, version: PKG.version, inputs, scopes: cleanupScopes(inputs, cleanupCurrentFingerprints()) };
1762
+ const current = cleanupCurrentFingerprints();
1763
+ let scopes = cleanupScopes(inputs, current);
1764
+ let changed = false;
1765
+ for (const scope of scopes) {
1766
+ if (scope.kind === 'project' && scope.remote && scope.items.some((item) => item.route === 'delivery' && ['remove', 'edit'].includes(item.action))) {
1767
+ const updated = cleanupUpdateCheckout(scope);
1768
+ if (updated.changed) changed = true;
1769
+ }
1770
+ }
1771
+ // The review covers the bytes delivery acts on, including scopes sharing a checkout.
1772
+ if (changed) scopes = cleanupScopes(inputs, current);
1773
+ const plan = { schemaVersion: 1, version: PKG.version, inputs, scopes };
1726
1774
  return { ...plan, digest: cleanupDigest(plan) };
1727
1775
  }
1728
1776
 
@@ -1742,10 +1790,10 @@ function cleanupDigest(plan) {
1742
1790
 
1743
1791
  // Apply one item a fresh plan proves right now; returns a reason when it is not what was proven.
1744
1792
  function cleanupApplyItem(item) {
1745
- const now = cleanupPreimage(item.path);
1793
+ const now = cleanupPreimage(item.path, Boolean(item.included));
1746
1794
  if (now.type !== item.before.type || now.sha256 !== item.before.sha256) return cleanupReason('stale-plan');
1747
1795
  if (!cleanupRealParents(item.base, item.path)) return cleanupReason('outside-link');
1748
- if (item.action === 'remove') {
1796
+ if (cleanupAction(item) === 'remove') {
1749
1797
  fs.rmSync(item.path, { recursive: true }); // lstat/realParents never follow a link; rm removes the entry itself.
1750
1798
  return null;
1751
1799
  }
@@ -1765,16 +1813,17 @@ function cleanupApplyItem(item) {
1765
1813
  function cleanupProven(reviewed, fresh) {
1766
1814
  const key = (item) => `${item.kind}\u0000${item.path}`;
1767
1815
  const now = new Map(fresh.items.map((item) => [key(item), item]));
1768
- const asked = new Set(reviewed.items.filter((item) => item.action === 'remove' || item.action === 'edit').map(key));
1816
+ const asked = new Set(reviewed.items.filter((item) => ['remove', 'edit'].includes(cleanupAction(item))).map(key));
1769
1817
  // A copy left out of the review stays, so the `_shared` and marker beside it stay too.
1770
1818
  const narrowed = new Set(fresh.items.filter((item) => item.action === 'remove' && !cleanupSupport(item) && !asked.has(key(item)))
1771
1819
  .map((item) => path.dirname(item.path)));
1772
- return reviewed.items.filter((item) => item.action === 'remove' || item.action === 'edit').map((item) => {
1820
+ return reviewed.items.filter((item) => ['remove', 'edit'].includes(cleanupAction(item))).map((item) => {
1773
1821
  const match = now.get(key(item));
1774
- const same = match && match.action === item.action && match.route === item.route
1822
+ const same = match && match.action === item.action && (!item.included || (cleanupCanInclude(match) && item.before.sha256 !== null))
1823
+ && match.relative === item.relative && match.route === item.route
1775
1824
  && match.before.type === item.before.type && match.before.sha256 === item.before.sha256
1776
1825
  && !(cleanupSupport(item) && narrowed.has(path.dirname(item.path)));
1777
- return { reviewed: item, fresh: same ? match : null };
1826
+ return { reviewed: item, fresh: same ? { ...match, included: item.included } : null };
1778
1827
  });
1779
1828
  }
1780
1829
 
@@ -1783,10 +1832,19 @@ function cleanupSupport(item) {
1783
1832
  return path.basename(item.path) === '_shared' || item.kind === 'install-marker';
1784
1833
  }
1785
1834
 
1835
+ function cleanupCanInclude(item) {
1836
+ return !cleanupSupport(item) && item.action === 'skip'
1837
+ && item.reasons.length === 1 && item.reasons[0].code === 'modified-copy';
1838
+ }
1839
+
1840
+ function cleanupAction(item, includeModified = false) {
1841
+ return item.included || (includeModified && cleanupCanInclude(item)) ? 'remove' : item.action;
1842
+ }
1843
+
1786
1844
  function cleanupLocal(scope, items) {
1787
1845
  const results = new Map();
1788
- const roots = [...new Set(items.map((item) => path.dirname(item.path)).filter((dir) => path.basename(dir) === 'skills'
1789
- && fs.lstatSync(dir, { throwIfNoEntry: false })?.isDirectory()))];
1846
+ const roots = [...new Set(items.filter((item) => item.keepRoot || path.basename(path.dirname(item.path)) === 'skills')
1847
+ .map((item) => path.dirname(item.path)).filter((dir) => fs.lstatSync(dir, { throwIfNoEntry: false })?.isDirectory()))];
1790
1848
  const act = (item) => {
1791
1849
  try {
1792
1850
  if (cleanupTracked(item.path)) { results.set(item.id, cleanupReason('tracked-copy')); return; }
@@ -1812,7 +1870,7 @@ function cleanupLocal(scope, items) {
1812
1870
  // After the locks are released, so a root holds nothing but what stayed.
1813
1871
  for (const root of new Set(items.filter((item) => results.get(item.id) === null && item.kind !== 'instructions')
1814
1872
  .map((item) => path.dirname(item.path)))) {
1815
- if (path.basename(root) !== 'skills') continue;
1873
+ if (path.basename(root) !== 'skills' || scope.items.some((item) => item.keepRoot && path.dirname(item.path) === root)) continue;
1816
1874
  try { if (fs.lstatSync(root).isDirectory() && fs.readdirSync(root).length === 0) fs.rmdirSync(root); } catch {}
1817
1875
  }
1818
1876
  return results;
@@ -1920,7 +1978,8 @@ function cleanupUpdateCheckout(scope) {
1920
1978
  if (branch !== scope.remote.base || !head) return failed(`${branch || 'a detached HEAD'} is checked out`);
1921
1979
  const ref = `refs/devlyn/cleanup/${crypto.randomBytes(8).toString('hex')}`;
1922
1980
  try {
1923
- const fetched = cleanupGit(scope.root, ['fetch', '--quiet', '--no-write-fetch-head', 'origin', `+refs/heads/${scope.remote.base}:${ref}`]);
1981
+ const fetched = cleanupGit(scope.root, ['fetch', '--quiet', '--no-write-fetch-head', 'origin', `+refs/heads/${scope.remote.base}:${ref}`],
1982
+ { timeout: 30000, env: { GIT_TERMINAL_PROMPT: '0' } });
1924
1983
  if (fetched.error || fetched.status !== 0) return failed(fetched.error?.message || fetched.stderr.trim());
1925
1984
  const target = read(['rev-parse', '--verify', `${ref}^{commit}`]);
1926
1985
  const [now, at] = checkout();
@@ -1930,7 +1989,8 @@ function cleanupUpdateCheckout(scope) {
1930
1989
  'merge', '--ff-only', '--no-squash', '--no-autostash', '--no-overwrite-ignore', '--quiet', target]);
1931
1990
  if (merged.error || merged.status !== 0) return failed(merged.error?.message || merged.stderr.trim());
1932
1991
  const [after, landed] = checkout();
1933
- return after === branch && landed === target ? {} : failed('the fast-forward did not land on the base');
1992
+ if (after !== branch || landed !== target) return failed('the fast-forward did not land on the base');
1993
+ return { changed: landed !== head };
1934
1994
  } finally {
1935
1995
  cleanupGit(scope.root, ['update-ref', '-d', ref]);
1936
1996
  }
@@ -1941,14 +2001,15 @@ function cleanupUpdateCheckout(scope) {
1941
2001
  // With every reviewed item already settled by the remote base, the worktree is released with
1942
2002
  // nothing to deliver.
1943
2003
  function cleanupDeliveryCommit(worktree, items, current, release, settledUpstream) {
1944
- const fresh = cleanupProjectScope(worktree, current);
2004
+ const fresh = cleanupProjectScope(worktree, current, items.some((item) => item.included));
1945
2005
  const available = new Map(fresh.items.map((item) => [`${item.kind}\u0000${item.relative}`, item]));
1946
2006
  const pairs = items.map((reviewed) => {
1947
2007
  const fresh = available.get(`${reviewed.kind}\u0000${reviewed.relative}`);
1948
- return { reviewed, fresh, before: fresh && cleanupPreimage(fresh.path) };
2008
+ return { reviewed, fresh, before: fresh && cleanupPreimage(fresh.path, Boolean(reviewed.included)) };
1949
2009
  });
1950
2010
  const stale = pairs.filter(({ reviewed, fresh, before }) => (fresh
1951
2011
  ? fresh.action !== reviewed.action || fresh.route !== 'delivery'
2012
+ || (reviewed.included && (!cleanupCanInclude(fresh) || before.sha256 === null))
1952
2013
  || before.type !== reviewed.before.type || before.sha256 !== reviewed.before.sha256
1953
2014
  : !settledUpstream(reviewed.relative)));
1954
2015
  // Check the entire batch before mutating even one item; the remote base cannot replace the reviewed preimage.
@@ -1957,12 +2018,14 @@ function cleanupDeliveryCommit(worktree, items, current, release, settledUpstrea
1957
2018
  if (!present.length) return release();
1958
2019
  const applied = [];
1959
2020
  for (const { reviewed, fresh } of present) {
1960
- const error = cleanupApplyItem({ ...fresh, before: reviewed.before });
2021
+ const error = cleanupApplyItem({ ...fresh, before: reviewed.before, included: reviewed.included });
1961
2022
  if (error) return release(cleanupReason(error.code, { ...error.params, detail: reviewed.relative }));
1962
2023
  applied.push(fresh.relative);
1963
2024
  }
1964
2025
  for (const dir of ['.agents/skills', '.claude/skills']) {
1965
- try { const full = path.join(worktree, dir); if (fs.readdirSync(full).length === 0) fs.rmdirSync(full); } catch {}
2026
+ const full = path.join(worktree, dir);
2027
+ if (fresh.items.some((item) => item.keepRoot && cleanupSameFile(path.dirname(item.path), full))) continue;
2028
+ try { if (fs.lstatSync(full).isDirectory() && fs.readdirSync(full).length === 0) fs.rmdirSync(full); } catch {}
1966
2029
  }
1967
2030
  const add = cleanupGit(worktree, ['add', '-A', '--', ...applied]);
1968
2031
  const commit = add.status === 0 && cleanupGit(worktree, ['-c', 'user.useConfigOnly=true', 'commit', '-q', '-m',
@@ -1973,7 +2036,8 @@ function cleanupDeliveryCommit(worktree, items, current, release, settledUpstrea
1973
2036
  fs.mkdirSync(path.dirname(evidence), { recursive: true });
1974
2037
  fs.writeFileSync(evidence, check.stdout);
1975
2038
  let left = [];
1976
- try { left = JSON.parse(check.stdout.trim().split('\n').at(-1)).data.scopes[0].items.filter((item) => item.action !== 'skip' && applied.includes(item.relative)); }
2039
+ try { left = JSON.parse(check.stdout.trim().split('\n').at(-1)).data.scopes[0].items.filter((item) =>
2040
+ cleanupAction({ ...item, included: items.find((reviewed) => reviewed.relative === item.relative)?.included }) !== 'skip' && applied.includes(item.relative)); }
1977
2041
  catch { return release(cleanupReason('delivery-failed', { detail: 'the check after cleanup could not run' })); }
1978
2042
  if (left.length) return release(cleanupReason('delivery-failed', { detail: 'items remain after cleanup' }));
1979
2043
  const head = cleanupGit(worktree, ['rev-parse', 'HEAD']);
@@ -1987,7 +2051,8 @@ function cleanupDeliveryCommit(worktree, items, current, release, settledUpstrea
1987
2051
 
1988
2052
  function cleanupApply(plan, { events = false } = {}) {
1989
2053
  const current = cleanupCurrentFingerprints();
1990
- const fresh = new Map(cleanupScopes(plan.inputs, current).map((scope) => [scope.id, scope]));
2054
+ const includeModified = plan.scopes.some((scope) => scope.items.some((item) => item.included));
2055
+ const fresh = new Map(cleanupScopes(plan.inputs, current, includeModified).map((scope) => [scope.id, scope]));
1991
2056
  let seq = 0;
1992
2057
  const emit = (scope) => { if (events) process.stdout.write(JSON.stringify({ schemaVersion: 1, event: 'scope', seq: ++seq, scope }) + '\n'); };
1993
2058
  const scopes = [];
@@ -2035,9 +2100,10 @@ function cleanupCommand(args) {
2035
2100
  const opts = options(args, {
2036
2101
  plan: { type: 'boolean' }, apply: { type: 'boolean' }, machine: { type: 'boolean' }, events: { type: 'boolean' },
2037
2102
  project: { type: 'string', multiple: true }, 'account-home': { type: 'string', multiple: true },
2038
- 'plan-file': { type: 'string' }, digest: { type: 'string' },
2103
+ 'plan-file': { type: 'string' }, digest: { type: 'string' }, include: { type: 'string', multiple: true },
2039
2104
  });
2040
2105
  if (opts.plan === opts.apply) fail('invalid-arguments', { argument: 'exactly one of --plan or --apply' }, 2);
2106
+ if (opts.plan && opts.include) fail('invalid-arguments', { argument: '--include requires --apply' }, 2);
2041
2107
  for (const dir of [...(opts.project || []), ...(opts['account-home'] || [])]) {
2042
2108
  if (!path.isAbsolute(dir)) fail('invalid-arguments', { argument: 'absolute paths required' }, 2);
2043
2109
  }
@@ -2055,6 +2121,16 @@ function cleanupCommand(args) {
2055
2121
  fail('stale-plan', {}, 3);
2056
2122
  }
2057
2123
  if (plan.version !== PKG.version) fail('stale-plan', { version: plan.version }, 3);
2124
+ const reviewed = plan.scopes.flatMap((scope) => scope.items);
2125
+ for (const item of reviewed) delete item.included;
2126
+ const items = new Map(reviewed.map((item) => [item.id, item]));
2127
+ for (const id of opts.include || []) {
2128
+ const item = items.get(id);
2129
+ if (!item || !cleanupCanInclude(item) || reviewed.filter((item) => item.id === id).length !== 1) {
2130
+ fail('invalid-arguments', { argument: `--include ${id}` }, 2);
2131
+ }
2132
+ item.included = true;
2133
+ }
2058
2134
  return cleanupApply(plan, { events: Boolean(opts.events) });
2059
2135
  }
2060
2136
 
@@ -160,6 +160,33 @@ function discoveryRoots(context, env, config) {
160
160
  return [...new Set(roots)];
161
161
  }
162
162
 
163
+ function kimiSkillRoots(context, env) {
164
+ const home = env.HOME || env.USERPROFILE || os.homedir();
165
+ let project = path.resolve(context.cwd);
166
+ const roots = new Set(), errors = [];
167
+ for (let dir = project; ; dir = path.dirname(dir)) {
168
+ const git = path.join(dir, '.git');
169
+ try {
170
+ if (fs.statSync(git, { throwIfNoEntry: false })) { project = dir; break; }
171
+ } catch (error) {
172
+ errors.push(reason('config-unreadable', { path: git, detail: error.message }));
173
+ break;
174
+ }
175
+ if (dir === path.dirname(dir)) break;
176
+ }
177
+ // --skills-dir replaces auto discovery and makes every root user-priority:
178
+ // first-name wins, so preserve workspace precedence by putting it first.
179
+ for (const root of [path.join(project, '.kimi-code/skills'), path.join(project, '.agents/skills'),
180
+ path.resolve(env.KIMI_CODE_HOME || path.join(home, '.kimi-code'), 'skills'), path.resolve(home, '.agents/skills')]) {
181
+ try {
182
+ if (fs.statSync(root, { throwIfNoEntry: false })?.isDirectory()) roots.add(fs.realpathSync(root));
183
+ } catch (error) {
184
+ errors.push(reason('config-unreadable', { path: root, detail: error.message }));
185
+ }
186
+ }
187
+ return { roots: [...roots], errors };
188
+ }
189
+
163
190
  function inspectSkills(context, release, env, config, active) {
164
191
  const found = [], errors = [], hashes = new Map();
165
192
  for (const root of discoveryRoots(context, env, config)) {
@@ -420,7 +447,12 @@ function planActivation(releaseRoot, input, env = process.env) {
420
447
  status.skills = channel('partial', null, [reason('skills-unavailable', { launchMode: context.launchMode, ownerAgent: config.ownerAgent })]);
421
448
  } else {
422
449
  plan.engineArgs.push('--agent-file', file('devlyn-kimi.md', joinText('${base_prompt}', instructions)));
423
- principles('agent-file'); fallback();
450
+ principles('agent-file');
451
+ const kimi = kimiSkillRoots(context, env);
452
+ if (!collisions.length && !errors.length && !kimi.errors.length) {
453
+ for (const root of [...kimi.roots, path.join(release.root, 'config/skills')]) plan.engineArgs.push('--skills-dir', root);
454
+ native('--skills-dir', 'read-file', `Read ${path.join(release.root, 'config/skills/devlyn-ideate/SKILL.md')}`);
455
+ } else fallback([...collisions, ...errors, ...kimi.errors]);
424
456
  }
425
457
  break;
426
458
  case 'antigravity':
@@ -763,12 +763,14 @@
763
763
  "4.2.9",
764
764
  "4.3.0",
765
765
  "4.3.1",
766
+ "4.3.10",
766
767
  "4.3.2",
767
768
  "4.3.3",
768
769
  "4.3.4",
769
770
  "4.3.5",
770
771
  "4.3.6",
771
772
  "4.3.7",
772
- "4.3.8"
773
+ "4.3.8",
774
+ "4.3.9"
773
775
  ]
774
776
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn",
3
- "version": "4.3.8",
3
+ "version": "4.3.10",
4
4
  "description": "Devlyn harness skills",
5
5
  "skills": "./skills/"
6
6
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "devlyn-harness-v4-3-8",
2
+ "name": "devlyn-harness-v4-3-10",
3
3
  "plugins": [
4
4
  {
5
5
  "name": "devlyn",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn",
3
- "version": "4.3.8",
3
+ "version": "4.3.10",
4
4
  "description": "Devlyn harness skills",
5
5
  "skills": "./skills/"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "4.3.8",
3
+ "version": "4.3.10",
4
4
  "description": "Loop & harness engineering for AI coding agents (Claude Code, Codex, and any AGENTS.md-driven agent) — core engineering principles for CLAUDE.md/AGENTS.md and a loop designer that splits one intent into tasks agents drain hands-free",
5
5
  "homepage": "https://github.com/fysoul17/devlyn-cli#readme",
6
6
  "bin": {
package/release.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "package": "devlyn-cli",
4
- "version": "4.3.8",
4
+ "version": "4.3.10",
5
5
  "principles": {
6
6
  "defaults": "config/skills/_shared/runtime-principles.md",
7
7
  "memory": {
@@ -129,7 +129,7 @@
129
129
  },
130
130
  {
131
131
  "path": "bin/devlyn.js",
132
- "sha256": "5841ad32184de3095edffc5d05ae78dded34dc0ce829e8f805218de8b0085b1f",
132
+ "sha256": "ae93be603c8a7511a40c82a7cfa337be05345f0876b83204027cec1ffeafd2ba",
133
133
  "executable": true
134
134
  },
135
135
  {
@@ -139,7 +139,7 @@
139
139
  },
140
140
  {
141
141
  "path": "bin/harness-activation.js",
142
- "sha256": "980f251957d9093b47ba2c5229b77145233a7253938220b69069468f7b4f8979",
142
+ "sha256": "fecb47b04e7f6e72735a9ea50b6aa524eca611d42c6db1b03c1260ddf2259bbc",
143
143
  "executable": false
144
144
  },
145
145
  {
@@ -164,7 +164,7 @@
164
164
  },
165
165
  {
166
166
  "path": "bin/skill-history.json",
167
- "sha256": "9012124a107faba4ee1a0719462920590ff04bcdf82d5bc40d178ef8f182ea82",
167
+ "sha256": "56d9ff1ce32f33e82806864a305bf2bbff35babe9952d46e3f434f88dabc3a2d",
168
168
  "executable": false
169
169
  },
170
170
  {
@@ -309,7 +309,7 @@
309
309
  },
310
310
  {
311
311
  "path": "native/claude/.claude-plugin/plugin.json",
312
- "sha256": "54f89d39a32c9d822321ae2a2805c8fe071c2b78c6b84f1c29ac85e4ae59667f",
312
+ "sha256": "1f9fdd6fc824ceba8f7baac7ef900f17a7bc46f48089606b563297da52bd2070",
313
313
  "executable": false
314
314
  },
315
315
  {
@@ -439,12 +439,12 @@
439
439
  },
440
440
  {
441
441
  "path": "native/codex/.agents/plugins/marketplace.json",
442
- "sha256": "6c507f8668ee2df5322e6bc0a7f1f7303410d2566b24709eaf4a9a581a25755c",
442
+ "sha256": "03d5b28a6f058d46f14fdae526a71021ff1bc5248ece0a473a94ca830b321bcf",
443
443
  "executable": false
444
444
  },
445
445
  {
446
446
  "path": "native/codex/plugins/devlyn/.codex-plugin/plugin.json",
447
- "sha256": "54f89d39a32c9d822321ae2a2805c8fe071c2b78c6b84f1c29ac85e4ae59667f",
447
+ "sha256": "1f9fdd6fc824ceba8f7baac7ef900f17a7bc46f48089606b563297da52bd2070",
448
448
  "executable": false
449
449
  },
450
450
  {
@@ -1619,12 +1619,12 @@
1619
1619
  },
1620
1620
  {
1621
1621
  "path": "package.json",
1622
- "sha256": "42de8c1332266cae7209808646f51e3b97f3c8e2dc2e4775e0e0ab9daa52bfd1",
1622
+ "sha256": "66f0ad6c0d8c86ce76b5c68a54412f0e398cba4ebc0f19d3257c3597e9ba490f",
1623
1623
  "executable": false
1624
1624
  }
1625
1625
  ],
1626
1626
  "digest": {
1627
1627
  "algorithm": "sha256",
1628
- "value": "cac9f8a9d305a77ecff9139fe06bad8a9932a47dc1f800c20520c5a79b969070"
1628
+ "value": "f4e978a00a488c4dd8274e1344285c6d8dfaa8201589bb46dd1f7d2588a5e2b1"
1629
1629
  }
1630
1630
  }