devlyn-cli 4.3.9 → 4.3.11

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/README.md CHANGED
@@ -232,7 +232,7 @@ Only `__pycache__` directories containing exclusively regular `*.pyc` files coun
232
232
 
233
233
  On native Windows, use native Node/npm and Python plus Git for Windows Bash for the shipped shell wrapper. Run `npx devlyn-cli -y` in the project (add `--claude` for Claude Code). Skill folders follow the Agent Skills naming standard (for example `devlyn-ideate`), so a project that commits them checks out on Windows. Harness text is UTF-8 without requiring `PYTHONUTF8`.
234
234
 
235
- New default drain roots disable Windows content indexing for new children; names stay indexed and Microsoft Defender scanning is unaffected. For Defender performance, use a Dev Drive via `--worktree-root` or an administrator's exclusion for the root; see [loop options](config/skills/devlyn-ideate/references/loop.md).
235
+ Drain worktrees default to `<repo parent>/.devlyn-worktrees/<repo name>/<loop-id>/<task-id>`. On Windows, a newly created shared container is marked hidden, and each worktree disables content indexing for new children; names stay indexed and Microsoft Defender scanning is unaffected. For Defender performance, use a Dev Drive via `--worktree-root` or an administrator's exclusion for the root; see [loop options](config/skills/devlyn-ideate/references/loop.md).
236
236
 
237
237
  Windows completion preserves the workspace, owned refs and recovery receipt when writer cessation cannot be proved, even after merge; resume guidance and delivery status remain separate from product verification. The portability workflow checks out the repository on native Windows and tests the POSIX-packed npm artifact there. A POSIX pass alone does not establish Windows support: acceptance requires the passing Windows job for the exact source SHA/artifact hashes.
238
238
 
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
 
@@ -57,6 +57,7 @@
57
57
  "4ecb004b24a8adfe41c7416c3fa24e62cf76baa3b276ae1ce5091caef77e57b8",
58
58
  "4fed91aed4dc1cd28a9e07229dbe9b7f6d8127e5f763dde8f76bb1b6f6855cce",
59
59
  "514bfaf5667af3c9d4d36f391e6567a5eb759c6dfb06a5c3ea23cf29692844ef",
60
+ "54290506813442e788ada1e344f596c5751870c1e17b193e7fbb04d3b0d611dd",
60
61
  "565aa53ab153a37137661a089a8f90d55ea746136e12948e082a35e9fb8bfe52",
61
62
  "56c2a60ab143ca59a1d961367d0acdae8f7e24c7722a3ee43659d3f9db619b58",
62
63
  "572d4b0d94d5eb1c96b624097fc8a4cb9827eca6b34d3227a08928cff42c844b",
@@ -336,6 +337,7 @@
336
337
  "5536f8391ec4442f1b793dba8dd5d7820d45f4b6dadf1111cbac9d54469a01ca",
337
338
  "573a6059a7383b37affd9c2e8bc6c88a4fb54117628a5a0746406cc32111e9aa",
338
339
  "5824f07e455fe1c3942bf595a12e935258d4c0a851ca1bf741db915603d1c4e0",
340
+ "59261610d60ca0f94cc8fa33af61f8ddafd8e5616f9358591547dd23fcdc6328",
339
341
  "5954e371659ae1010757f0eec4b80dd65a899f9fcf76d4d84d9a75476a963218",
340
342
  "65770e3f20c3a3afd2822e9acdcfc80b827f84a7737010b79332da70d431b05d",
341
343
  "65c781764633b912e5947362eba0837c8027461a904975401d79ad4fe8714084",
@@ -763,6 +765,8 @@
763
765
  "4.2.9",
764
766
  "4.3.0",
765
767
  "4.3.1",
768
+ "4.3.10",
769
+ "4.3.11",
766
770
  "4.3.2",
767
771
  "4.3.3",
768
772
  "4.3.4",
@@ -136,6 +136,15 @@ def mark_not_content_indexed(path):
136
136
  _kernel.SetFileAttributesW(str(path), attrs | stat.FILE_ATTRIBUTE_NOT_CONTENT_INDEXED)
137
137
 
138
138
 
139
+ def mark_hidden(path):
140
+ """Hide a newly created directory on Windows, preserving its other attributes."""
141
+ if os.name != "nt":
142
+ return
143
+ attrs = _kernel.GetFileAttributesW(str(path))
144
+ _checked(attrs != 0xFFFFFFFF, _kernel.GetFileAttributesW, (str(path),))
145
+ _kernel.SetFileAttributesW(str(path), attrs | stat.FILE_ATTRIBUTE_HIDDEN)
146
+
147
+
139
148
  if os.name == "nt":
140
149
  import _winapi
141
150
  import ctypes