devlyn-cli 4.2.26 → 4.3.0

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
@@ -1341,6 +1341,513 @@ function showHelp() {
1341
1341
  log('');
1342
1342
  }
1343
1343
 
1344
+ // ---------------------------------------------------------------------------
1345
+ // Legacy cleanup for the built-in harness: Devlyn delivers devlyn-cli per session, so every copy an
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
+ // Nothing is removed by name alone except what the installer itself already removes.
1351
+ // ---------------------------------------------------------------------------
1352
+
1353
+ const CLEANUP_CORE = ['devlyn-ideate', 'devlyn-engines'];
1354
+
1355
+ function cleanupReason(code, params = {}) { return { code, params }; }
1356
+
1357
+ // What a path is now, so apply can refuse an item that changed after it was reviewed.
1358
+ function cleanupPreimage(file) {
1359
+ const stat = fs.lstatSync(file, { throwIfNoEntry: false });
1360
+ if (!stat) return { type: 'missing', sha256: null };
1361
+ if (stat.isSymbolicLink()) return { type: 'link', sha256: null };
1362
+ if (stat.isDirectory()) {
1363
+ try { return { type: 'directory', sha256: fingerprint(file) }; }
1364
+ catch { return { type: 'directory', sha256: null }; }
1365
+ }
1366
+ if (stat.isFile()) return { type: 'file', sha256: crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') };
1367
+ return { type: 'special', sha256: null };
1368
+ }
1369
+
1370
+ // Whether every directory from `base` down to `file`'s parent is a real directory (no link is followed).
1371
+ function cleanupRealParents(base, file) {
1372
+ let dir = base;
1373
+ if (!fs.lstatSync(dir, { throwIfNoEntry: false })?.isDirectory()) return false;
1374
+ for (const part of path.relative(base, path.dirname(file)).split(path.sep).filter(Boolean)) {
1375
+ if (part === '..') return false;
1376
+ dir = path.join(dir, part);
1377
+ if (!fs.lstatSync(dir, { throwIfNoEntry: false })?.isDirectory()) return false;
1378
+ }
1379
+ return true;
1380
+ }
1381
+
1382
+ let cleanupProcesses;
1383
+ // Whether a running process names `dir` on its command line (a drain started from a global copy runs
1384
+ // its queue.py and acceptance.py by absolute path). Read once per command; unreadable means unknown.
1385
+ function cleanupInUse(dir) {
1386
+ if (cleanupProcesses === undefined) {
1387
+ const listing = process.platform === 'win32'
1388
+ ? spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command',
1389
+ 'Get-CimInstance Win32_Process | ForEach-Object { $_.CommandLine }'], { encoding: 'utf8', timeout: 20000 })
1390
+ : spawnSync('ps', ['-axww', '-o', 'command='], { encoding: 'utf8', timeout: 20000 });
1391
+ cleanupProcesses = listing.status === 0 ? listing.stdout.split(/\r?\n/).filter((line) => line && !line.includes(' cleanup --')) : null;
1392
+ }
1393
+ // A list that cannot be read cannot clear anything: the roots stay until it can.
1394
+ return cleanupProcesses === null || cleanupProcesses.some((line) => line.includes(dir + path.sep));
1395
+ }
1396
+
1397
+ // The legacy items of one skill root, each with its proof and the base its parents are checked from.
1398
+ // `_shared` and the install marker go only when every copy beside them goes too: a kept, edited
1399
+ // devlyn-ideate needs both.
1400
+ function cleanupSkillRoot(base, root, current) {
1401
+ const items = [];
1402
+ const rootStat = fs.lstatSync(root, { throwIfNoEntry: false });
1403
+ if (!rootStat) return items;
1404
+ if (!cleanupRealParents(base, root) || rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
1405
+ if (rootStat.isSymbolicLink() || rootStat.isDirectory()) {
1406
+ items.push({ kind: 'core-skill', path: root, base, action: 'skip', reasons: [cleanupReason('outside-link')] });
1407
+ }
1408
+ return items;
1409
+ }
1410
+ const renamed = new Map();
1411
+ for (const [old, current] of Object.entries(RENAMED_SKILLS)) {
1412
+ for (const spelling of legacySkillPaths(root, old)) renamed.set(path.basename(spelling).toLowerCase(), current);
1413
+ }
1414
+ const deprecated = new Set([...deprecatedSkillPaths(root)].map((file) => path.basename(file).toLowerCase()));
1415
+ const support = [];
1416
+ let kept = 0;
1417
+ let marker = false;
1418
+ for (const name of fs.readdirSync(root).sort()) {
1419
+ const file = path.join(root, name);
1420
+ const stat = fs.lstatSync(file);
1421
+ const lower = name.toLowerCase();
1422
+ if (name === DEVLYN_INSTALL_MARKER) { marker = true; continue; }
1423
+ if (name === DEVLYN_INSTALL_LOCK) {
1424
+ items.push({ kind: 'install-lock', path: file, base, action: 'skip', reasons: [cleanupReason('occupied-lock')] });
1425
+ kept++;
1426
+ continue;
1427
+ }
1428
+ const core = CLEANUP_CORE.includes(name) || name === '_shared';
1429
+ if (!core && !deprecated.has(lower) && !renamed.has(lower) && !RETIRED_SKILL_MD_SHA256[lower]) continue;
1430
+ if (stat.isSymbolicLink()) {
1431
+ items.push({ kind: 'core-skill', path: file, base, action: 'skip', reasons: [cleanupReason('outside-link')] });
1432
+ kept++;
1433
+ continue;
1434
+ }
1435
+ if (core) {
1436
+ let proven = false;
1437
+ try { proven = fingerprint(file) === current[name] || historicalSkill(file); }
1438
+ catch { /* A link or special file inside is not an unedited copy. */ }
1439
+ if (name === '_shared') { if (proven) support.push({ kind: 'core-skill', path: file, base, action: 'remove', reasons: [] }); continue; }
1440
+ if (proven) items.push({ kind: 'core-skill', path: file, base, action: 'remove', reasons: [] });
1441
+ else { items.push({ kind: 'core-skill', path: file, base, action: 'skip', reasons: [cleanupReason('modified-copy')] }); kept++; }
1442
+ continue;
1443
+ }
1444
+ if (renamed.has(lower)) {
1445
+ // An optional skill under its pre-4.0 name: the installer moves it to its new name; cleanup keeps it.
1446
+ if (CLEANUP_CORE.includes(renamed.get(lower))) items.push({ kind: 'renamed-skill', path: file, base, action: 'remove', reasons: [] });
1447
+ else { items.push({ kind: 'renamed-skill', path: file, base, action: 'skip', reasons: [cleanupReason('optional-skill')] }); kept++; }
1448
+ continue;
1449
+ }
1450
+ if (deprecated.has(lower)) { items.push({ kind: 'retired-skill', path: file, base, action: 'remove', reasons: [] }); continue; }
1451
+ if (isShippedCopy(file, RETIRED_SKILL_MD_SHA256[lower])) items.push({ kind: 'retired-standard', path: file, base, action: 'remove', reasons: [] });
1452
+ }
1453
+ if (kept === 0) {
1454
+ items.push(...support);
1455
+ if (marker && items.some((item) => item.action === 'remove')) {
1456
+ items.push({ kind: 'install-marker', path: path.join(root, DEVLYN_INSTALL_MARKER), base, action: 'remove', reasons: [] });
1457
+ }
1458
+ }
1459
+ if (cleanupInUse(root)) {
1460
+ for (const item of items) if (item.action === 'remove') Object.assign(item, { action: 'skip', reasons: [cleanupReason('in-use')] });
1461
+ }
1462
+ return items;
1463
+ }
1464
+
1465
+ function cleanupCommands(base) {
1466
+ const commands = path.join(base, '.claude', 'commands');
1467
+ if (!cleanupRealParents(base, path.join(commands, 'x')) || !fs.lstatSync(commands, { throwIfNoEntry: false })?.isDirectory()) return [];
1468
+ const names = new Set(DEPRECATED_FILES.map((file) => path.basename(file).toLowerCase()));
1469
+ return fs.readdirSync(commands).sort().filter((name) => names.has(name.toLowerCase())
1470
+ && fs.lstatSync(path.join(commands, name)).isFile())
1471
+ .map((name) => ({ kind: 'legacy-command', path: path.join(commands, name), base, action: 'remove', reasons: [] }));
1472
+ }
1473
+
1474
+ function cleanupProjectFiles(base) {
1475
+ const items = [];
1476
+ const hash = (file) => crypto.createHash('sha256')
1477
+ .update(fs.readFileSync(file).toString('latin1').replace(/\r\n/g, '\n'), 'latin1').digest('hex');
1478
+ for (const [name, hashes] of Object.entries(CLEANUP_HISTORY.files)) {
1479
+ const file = path.join(base, name);
1480
+ if (cleanupRealParents(base, file) && fs.lstatSync(file, { throwIfNoEntry: false })?.isFile() && hashes.includes(hash(file))) {
1481
+ items.push({ kind: 'legacy-file', path: file, base, action: 'remove', reasons: [] });
1482
+ }
1483
+ }
1484
+ const settingsFile = path.join(base, '.claude', 'settings.json');
1485
+ if (cleanupRealParents(base, settingsFile) && fs.lstatSync(settingsFile, { throwIfNoEntry: false })?.isFile()) {
1486
+ try {
1487
+ const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8').replace(/^/, ''));
1488
+ const stop = Array.isArray(settings?.hooks?.Stop) ? settings.hooks.Stop : [];
1489
+ if (stop.some((entry) => Array.isArray(entry?.hooks) && entry.hooks.some((hook) => hook?.type === 'command' && hook.command === RETIRED_STOP_HOOK))) {
1490
+ items.push({ kind: 'settings-entry', path: settingsFile, base, action: 'edit', reasons: [] });
1491
+ }
1492
+ } catch (error) { if (!(error instanceof SyntaxError)) throw error; }
1493
+ }
1494
+ return items;
1495
+ }
1496
+
1497
+ // An intact managed block from an earlier release is brought up to date; owner text around it stays
1498
+ // byte-for-byte (updateInstructions). A current block is the team's principles and stays.
1499
+ function cleanupInstructions(base) {
1500
+ const { inspectInstruction } = require('./harness-status');
1501
+ const items = [];
1502
+ for (const name of ['CLAUDE.md', 'AGENTS.md']) {
1503
+ const file = path.join(base, name);
1504
+ const stat = fs.lstatSync(file, { throwIfNoEntry: false });
1505
+ if (!stat?.isFile() || stat.nlink !== 1) continue;
1506
+ let text;
1507
+ try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(file)); }
1508
+ catch { continue; }
1509
+ if (!/<!-- devlyn:instructions:begin sha256=/.test(text)) continue;
1510
+ const template = fs.readFileSync(path.join(CONFIG_SOURCE, '..', name), 'utf8');
1511
+ if (inspectInstruction(text, template, name).ownership === 'outdated') {
1512
+ items.push({ kind: 'instructions', path: file, base, action: 'edit', reasons: [] });
1513
+ }
1514
+ }
1515
+ return items;
1516
+ }
1517
+
1518
+ function cleanupCurrentFingerprints() {
1519
+ return Object.fromEntries([...CLEANUP_CORE, '_shared'].map((name) => [name, fingerprint(path.join(CONFIG_SOURCE, 'skills', name))]));
1520
+ }
1521
+
1522
+ function cleanupGit(root, args) {
1523
+ return spawnSync('git', ['-C', root, ...args], { env: { ...gitEnvironment(), GIT_OPTIONAL_LOCKS: '0' }, encoding: 'utf8' });
1524
+ }
1525
+
1526
+ let cleanupPythonBinary;
1527
+ function cleanupPython() {
1528
+ if (cleanupPythonBinary === undefined) {
1529
+ cleanupPythonBinary = ['python3', 'python'].find((name) => {
1530
+ const probe = spawnSync(name, ['--version'], { encoding: 'utf8', timeout: 10000 });
1531
+ return probe.status === 0 && /Python 3\.(1[1-9]|[2-9]\d)/.test(probe.stdout + probe.stderr);
1532
+ }) || null;
1533
+ }
1534
+ return cleanupPythonBinary;
1535
+ }
1536
+
1537
+ // The GitHub repository and base branch a project's tracked changes are delivered to, or null.
1538
+ function cleanupRemote(root) {
1539
+ const url = cleanupGit(root, ['remote', 'get-url', 'origin']);
1540
+ if (url.status !== 0) return null;
1541
+ const match = url.stdout.trim().match(/github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
1542
+ if (!match) return null;
1543
+ const head = cleanupGit(root, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
1544
+ // origin/HEAD names the default; without it, the remote-tracking branch that exists. With neither
1545
+ // fetched, `main`: an allocation against a missing base fails visibly, before any commit.
1546
+ const base = head.status === 0 ? head.stdout.trim().replace(/^origin\//, '')
1547
+ : ['main', 'master'].find((name) => cleanupGit(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${name}`]).status === 0)
1548
+ || 'main';
1549
+ return { repository: `${match[1]}/${match[2]}`, base, named: head.status === 0 };
1550
+ }
1551
+
1552
+ // Whether a task is in flight on the installed copies: an allocated task's packet runs their exact
1553
+ // paths, so those copies stay until it settles. Pending rows and legacy rows run on any release.
1554
+ function cleanupQueueBusy(root) {
1555
+ const python = cleanupPython();
1556
+ if (!python) return cleanupReason('requirement-missing', { name: 'Python 3.11' });
1557
+ const queue = path.join(CONFIG_SOURCE, 'skills', 'devlyn-ideate', 'scripts', 'queue.py');
1558
+ const result = spawnSync(python, ['-B', queue, 'inspect', '--repo', root, '--json'],
1559
+ { encoding: 'utf8', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' }, timeout: 60000 });
1560
+ let data;
1561
+ try { data = JSON.parse(result.stdout); } catch { return cleanupReason('queue-unreadable'); }
1562
+ if (data.status === 'BLOCKED') return cleanupReason('queue-unreadable');
1563
+ return (data.counts?.active || 0) > 0 ? cleanupReason('legacy-queue') : null;
1564
+ }
1565
+
1566
+ function cleanupSameFile(a, b) {
1567
+ const x = fs.statSync(a, { throwIfNoEntry: false });
1568
+ const y = fs.statSync(b, { throwIfNoEntry: false });
1569
+ return Boolean(x && y && x.dev === y.dev && x.ino === y.ino);
1570
+ }
1571
+
1572
+ function cleanupProjectScope(project, current) {
1573
+ const { repositoryIdentity } = require('./harness-status');
1574
+ const { physicalRoot } = require('./release');
1575
+ const root = physicalRoot(project);
1576
+ const identity = repositoryIdentity(root);
1577
+ const base = identity?.root || root;
1578
+ const scope = { kind: 'project', root: base, name: path.basename(base), remote: identity ? cleanupRemote(base) : null, items: [] };
1579
+ const whole = (code) => [{ kind: 'project', path: base, base, relative: '', route: 'local', action: 'skip', reasons: [cleanupReason(code)] }];
1580
+ if (identity?.sourceRepository) return { ...scope, items: whole('source-repository') };
1581
+ // The home folder's roots are this machine's: the machine scope cleans them under the user-root locks.
1582
+ if (cleanupSameFile(base, os.homedir())) return { ...scope, items: whole('home-folder') };
1583
+ const items = [
1584
+ ...['.agents', '.claude'].flatMap((dir) => cleanupSkillRoot(base, path.join(base, dir, 'skills'), current)),
1585
+ ...cleanupCommands(base), ...cleanupProjectFiles(base), ...cleanupInstructions(base),
1586
+ ];
1587
+ const busy = identity && items.some((item) => item.action !== 'skip') ? cleanupQueueBusy(base) : null;
1588
+ for (const item of items) {
1589
+ if (busy && item.action !== 'skip') Object.assign(item, { action: 'skip', reasons: [busy] });
1590
+ const relative = path.relative(base, item.path).split(path.sep).join('/');
1591
+ const tracked = Boolean(identity && relative && cleanupGit(base, ['ls-files', '-z', '--', relative]).stdout.length > 0);
1592
+ scope.items.push({ ...item, relative, route: tracked ? 'delivery' : 'local' });
1593
+ }
1594
+ if (!scope.remote) {
1595
+ for (const item of scope.items) {
1596
+ if (item.route === 'delivery' && item.action !== 'skip') Object.assign(item, { action: 'skip', reasons: [cleanupReason('no-remote')] });
1597
+ }
1598
+ }
1599
+ return scope;
1600
+ }
1601
+
1602
+ function cleanupMachineScope(home, accountHomes, current) {
1603
+ const roots = [...['.agents', '.codex', '.claude', '.grok'].map((dir) => [home, path.join(home, dir, 'skills')]),
1604
+ ...accountHomes.map((account) => [path.dirname(account), path.join(account, 'skills')])];
1605
+ const seen = new Set();
1606
+ const items = [];
1607
+ for (const [base, root] of roots) {
1608
+ if (seen.has(root)) continue;
1609
+ seen.add(root);
1610
+ items.push(...cleanupSkillRoot(base, root, current));
1611
+ }
1612
+ items.push(...cleanupCommands(home));
1613
+ return { kind: 'machine', root: home, name: null, remote: null, items: items.map((item) => ({ ...item, relative: null, route: 'local' })) };
1614
+ }
1615
+
1616
+ function cleanupScopes(inputs, current) {
1617
+ const { sha256 } = require('./release');
1618
+ const scopes = [];
1619
+ if (inputs.machine) scopes.push({ id: 'machine', ...cleanupMachineScope(os.homedir(), inputs.accountHomes, current) });
1620
+ for (const project of inputs.projects) {
1621
+ let scope;
1622
+ try { scope = cleanupProjectScope(project, current); }
1623
+ catch (error) {
1624
+ scope = { kind: 'project', root: project, name: path.basename(project), remote: null, items: [{ kind: 'project', path: project, base: project,
1625
+ relative: '', route: 'local', action: 'skip', reasons: [cleanupReason('project-unreadable', { detail: error.message })] }] };
1626
+ }
1627
+ scopes.push({ id: `project:${sha256(Buffer.from(project)).slice(0, 12)}`, ...scope });
1628
+ }
1629
+ for (const scope of scopes) {
1630
+ scope.items = scope.items.map((item, index) => ({ id: `${scope.id}:${index}`, ...item, before: cleanupPreimage(item.path) }));
1631
+ }
1632
+ return scopes;
1633
+ }
1634
+
1635
+ function cleanupPlan(inputs) {
1636
+ const plan = { schemaVersion: 1, version: PKG.version, inputs, scopes: cleanupScopes(inputs, cleanupCurrentFingerprints()) };
1637
+ return { ...plan, digest: cleanupDigest(plan) };
1638
+ }
1639
+
1640
+ function cleanupDigest(plan) {
1641
+ const { sha256 } = require('./release');
1642
+ const canonical = (value) => Array.isArray(value) ? `[${value.map(canonical).join(',')}]`
1643
+ : value && typeof value === 'object'
1644
+ ? `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`
1645
+ : JSON.stringify(value);
1646
+ const { digest, ...body } = plan;
1647
+ return sha256(Buffer.from(canonical(body)));
1648
+ }
1649
+
1650
+ // Apply one item a fresh plan proves right now; returns a reason when it is not what was proven.
1651
+ function cleanupApplyItem(item) {
1652
+ const now = cleanupPreimage(item.path);
1653
+ if (now.type !== item.before.type || now.sha256 !== item.before.sha256) return cleanupReason('stale-plan');
1654
+ if (!cleanupRealParents(item.base, item.path)) return cleanupReason('outside-link');
1655
+ if (item.action === 'remove') {
1656
+ fs.rmSync(item.path, { recursive: true }); // lstat/realParents never follow a link; rm removes the entry itself.
1657
+ return null;
1658
+ }
1659
+ if (item.kind === 'settings-entry') {
1660
+ const settings = JSON.parse(fs.readFileSync(item.path, 'utf8').replace(/^/, ''));
1661
+ if (cleanRetiredSettings(settings)) replaceFile(item.path, JSON.stringify(settings, null, 2) + '\n');
1662
+ return null;
1663
+ }
1664
+ if (item.kind === 'instructions') {
1665
+ updateInstructions(path.basename(item.path), '', true, true, path.dirname(item.path));
1666
+ return null;
1667
+ }
1668
+ return cleanupReason('unknown-item');
1669
+ }
1670
+
1671
+ // A reviewed item's counterpart in the fresh plan, when both name the same action on the same bytes.
1672
+ function cleanupProven(reviewed, fresh) {
1673
+ const key = (item) => `${item.kind}\u0000${item.path}`;
1674
+ const now = new Map(fresh.items.map((item) => [key(item), item]));
1675
+ const asked = new Set(reviewed.items.filter((item) => item.action === 'remove' || item.action === 'edit').map(key));
1676
+ // A copy left out of the review stays, so the `_shared` and marker beside it stay too.
1677
+ const narrowed = new Set(fresh.items.filter((item) => item.action === 'remove' && !cleanupSupport(item) && !asked.has(key(item)))
1678
+ .map((item) => path.dirname(item.path)));
1679
+ return reviewed.items.filter((item) => item.action === 'remove' || item.action === 'edit').map((item) => {
1680
+ const match = now.get(key(item));
1681
+ const same = match && match.action === item.action && match.route === item.route
1682
+ && match.before.type === item.before.type && match.before.sha256 === item.before.sha256
1683
+ && !(cleanupSupport(item) && narrowed.has(path.dirname(item.path)));
1684
+ return { reviewed: item, fresh: same ? match : null };
1685
+ });
1686
+ }
1687
+
1688
+ // Support entries (`_shared`, the marker) go last, and only when nothing beside them was refused.
1689
+ function cleanupSupport(item) {
1690
+ return path.basename(item.path) === '_shared' || item.kind === 'install-marker';
1691
+ }
1692
+
1693
+ function cleanupLocal(scope, items) {
1694
+ const results = new Map();
1695
+ const roots = [...new Set(items.map((item) => path.dirname(item.path)).filter((dir) => path.basename(dir) === 'skills'
1696
+ && fs.lstatSync(dir, { throwIfNoEntry: false })?.isDirectory()))];
1697
+ const act = (item) => {
1698
+ try { results.set(item.id, cleanupApplyItem(item)); }
1699
+ catch (error) { results.set(item.id, cleanupReason('apply-failed', { detail: error.message })); }
1700
+ };
1701
+ const run = () => {
1702
+ for (const item of items.filter((item) => !cleanupSupport(item))) act(item);
1703
+ for (const item of items.filter(cleanupSupport)) {
1704
+ const refused = items.some((other) => !cleanupSupport(other) && path.dirname(other.path) === path.dirname(item.path)
1705
+ && results.get(other.id) !== null);
1706
+ if (refused) results.set(item.id, cleanupReason('stale-plan'));
1707
+ else act(item);
1708
+ }
1709
+ };
1710
+ if (scope.kind === 'machine' && roots.length) withUserSkillLocks(roots, run, { existingOnly: true });
1711
+ else run();
1712
+ // A root that vanished under its lock ran nothing: every item it held is refused, never reported done.
1713
+ for (const item of items) if (!results.has(item.id)) results.set(item.id, cleanupReason('stale-plan'));
1714
+ // After the locks are released, so a root holds nothing but what stayed.
1715
+ for (const root of new Set(items.filter((item) => results.get(item.id) === null && item.kind !== 'instructions')
1716
+ .map((item) => path.dirname(item.path)))) {
1717
+ if (path.basename(root) !== 'skills') continue;
1718
+ try { if (fs.lstatSync(root).isDirectory() && fs.readdirSync(root).length === 0) fs.rmdirSync(root); } catch {}
1719
+ }
1720
+ return results;
1721
+ }
1722
+
1723
+ // Tracked items go through the project's own delivery: a fresh worktree from the remote base, the
1724
+ // same items proven there, one commit, and task-complete's PR and merge by project policy. Anything
1725
+ // it allocated is released again when it stops before that.
1726
+ function cleanupDeliver(scope, items, current) {
1727
+ const helper = path.join(CONFIG_SOURCE, 'skills', '_shared', 'task-complete.py');
1728
+ const python = cleanupPython();
1729
+ if (!python) return { error: cleanupReason('requirement-missing', { name: 'Python 3.11' }) };
1730
+ const run = (args, cwd) => spawnSync(python, ['-B', helper, ...args], { cwd, encoding: 'utf8', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
1731
+ const lastJson = (text) => { try { return JSON.parse(text.trim().split('\n').at(-1)); } catch { return null; } };
1732
+ // Without origin/HEAD the base was a guess: GitHub names the default branch before anything is committed.
1733
+ let base = scope.remote.base;
1734
+ if (!scope.remote.named) {
1735
+ const asked = spawnSync('gh', ['repo', 'view', scope.remote.repository, '--json', 'defaultBranchRef', '-q', '.defaultBranchRef.name'],
1736
+ { encoding: 'utf8', timeout: 30000 });
1737
+ if (asked.status === 0 && asked.stdout.trim()) base = asked.stdout.trim();
1738
+ }
1739
+ const short = crypto.randomBytes(4).toString('hex');
1740
+ const branch = `devlyn/cleanup-${short}`;
1741
+ const worktree = path.join(path.dirname(scope.root), `${path.basename(scope.root)}.devlyn.noindex`, `devlyn-cleanup-${short}`);
1742
+ const allocated = lastJson(run(['allocate', '--repo', scope.root, '--task', `devlyn-cli ${PKG.version} legacy cleanup`,
1743
+ '--branch', branch, '--worktree', worktree, '--repository', scope.remote.repository,
1744
+ '--remote', 'origin', '--base', base], scope.root).stdout);
1745
+ if (allocated?.status !== 'ALLOCATED') return { error: cleanupReason('delivery-failed', { detail: allocated?.reason || 'allocation failed' }) };
1746
+ const release = (error) => {
1747
+ cleanupGit(scope.root, ['worktree', 'remove', '--force', worktree]);
1748
+ cleanupGit(scope.root, ['branch', '-D', branch]);
1749
+ try { fs.rmSync(path.dirname(allocated.receipt), { recursive: true, force: true }); } catch {}
1750
+ return { error };
1751
+ };
1752
+ const fresh = cleanupProjectScope(worktree, current);
1753
+ const wanted = new Map(items.map((item) => [`${item.kind}\u0000${item.relative}`, item]));
1754
+ const applied = [];
1755
+ for (const item of fresh.items) {
1756
+ const key = `${item.kind}\u0000${item.relative}`;
1757
+ if (item.action === 'skip' || item.route !== 'delivery' || !wanted.has(key)) continue;
1758
+ if (cleanupApplyItem({ ...item, before: cleanupPreimage(item.path) }) === null) { applied.push(item.relative); wanted.delete(key); }
1759
+ }
1760
+ // A reviewed item the remote base does not hold as reviewed (not pushed, already gone) is not delivered.
1761
+ if (wanted.size) return release(cleanupReason('stale-plan', { detail: [...wanted.values()].map((item) => item.relative).join(', ') }));
1762
+ for (const dir of ['.agents/skills', '.claude/skills']) {
1763
+ try { const full = path.join(worktree, dir); if (fs.readdirSync(full).length === 0) fs.rmdirSync(full); } catch {}
1764
+ }
1765
+ const add = cleanupGit(worktree, ['add', '-A', '--', ...applied]);
1766
+ const commit = add.status === 0 && cleanupGit(worktree, ['-c', 'user.useConfigOnly=true', 'commit', '-q', '-m',
1767
+ `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.`]);
1768
+ if (!commit || commit.status !== 0) return release(cleanupReason('delivery-failed', { detail: (commit || add).stderr.trim() }));
1769
+ const check = spawnSync(process.execPath, [path.join(__dirname, 'devlyn.js'), 'cleanup', '--plan', '--project', worktree, '--json'], { encoding: 'utf8' });
1770
+ const evidence = path.join(worktree, '.devlyn', 'cleanup-check.json');
1771
+ fs.mkdirSync(path.dirname(evidence), { recursive: true });
1772
+ fs.writeFileSync(evidence, check.stdout);
1773
+ let left = [];
1774
+ try { left = JSON.parse(check.stdout.trim().split('\n').at(-1)).data.scopes[0].items.filter((item) => item.action !== 'skip' && applied.includes(item.relative)); }
1775
+ catch { return release(cleanupReason('delivery-failed', { detail: 'the check after cleanup could not run' })); }
1776
+ if (left.length) return release(cleanupReason('delivery-failed', { detail: 'items remain after cleanup' }));
1777
+ const acceptance = path.join(worktree, '.devlyn', 'acceptance.json');
1778
+ fs.writeFileSync(acceptance, JSON.stringify({ kind: 'direct', task: `devlyn-cli ${PKG.version} legacy cleanup`,
1779
+ source_sha: cleanupGit(worktree, ['rev-parse', 'HEAD']).stdout.trim(),
1780
+ checks: [{ command: `devlyn cleanup --plan --project ${worktree} --json`, evidence: '.devlyn/cleanup-check.json' }] }));
1781
+ const completed = lastJson(run(['complete', '--receipt', allocated.receipt, '--acceptance', acceptance, '--writers-stopped'], scope.root).stdout);
1782
+ const status = completed?.delivery_status || completed?.status;
1783
+ if (status === 'COMPLETE' || status === 'MERGED') return { delivery: { state: 'merged', url: completed.pr || null } };
1784
+ if (completed?.pr) return { delivery: { state: 'pr-open', url: completed.pr } };
1785
+ const reason = completed?.reason || 'delivery did not complete';
1786
+ const auth = /\b(authentication|unauthorized|gh auth login|could not read Username|HTTP 40[13])\b/i.test(reason);
1787
+ return { error: cleanupReason(auth ? 'auth-failed' : 'delivery-failed', { detail: reason, ...(completed?.resume ? { resume: completed.resume } : {}) }) };
1788
+ }
1789
+
1790
+ function cleanupApply(plan, { events = false } = {}) {
1791
+ const current = cleanupCurrentFingerprints();
1792
+ const fresh = new Map(cleanupScopes(plan.inputs, current).map((scope) => [scope.id, scope]));
1793
+ let seq = 0;
1794
+ const emit = (scope) => { if (events) process.stdout.write(JSON.stringify({ schemaVersion: 1, event: 'scope', seq: ++seq, scope }) + '\n'); };
1795
+ const scopes = [];
1796
+ for (const reviewed of plan.scopes) {
1797
+ const pairs = cleanupProven(reviewed, fresh.get(reviewed.id) || { items: [] });
1798
+ const progress = { scopeId: reviewed.id, kind: reviewed.kind, name: reviewed.name, total: pairs.length, state: 'running', delivery: null, error: null };
1799
+ if (!pairs.length) { scopes.push({ ...progress, state: 'skipped' }); emit(scopes.at(-1)); continue; }
1800
+ emit(progress);
1801
+ try {
1802
+ const refused = pairs.find((pair) => !pair.fresh);
1803
+ const proven = pairs.filter((pair) => pair.fresh).map((pair) => pair.fresh);
1804
+ const local = cleanupLocal(fresh.get(reviewed.id), proven.filter((item) => item.route === 'local'));
1805
+ const failedLocal = [...local.values()].find((reason) => reason);
1806
+ const deliverable = proven.filter((item) => item.route === 'delivery');
1807
+ const delivery = deliverable.length ? cleanupDeliver(fresh.get(reviewed.id), deliverable, current) : null;
1808
+ progress.delivery = delivery?.delivery || null;
1809
+ const error = delivery?.error || failedLocal || (refused && cleanupReason('stale-plan'));
1810
+ Object.assign(progress, error ? { state: 'failed', error } : { state: 'done' });
1811
+ } catch (error) {
1812
+ Object.assign(progress, { state: 'failed', error: error instanceof InstructionError
1813
+ ? cleanupReason('occupied-lock', { detail: error.message }) : cleanupReason('apply-failed', { detail: error.message }) });
1814
+ }
1815
+ scopes.push(progress);
1816
+ emit(progress);
1817
+ }
1818
+ const failed = scopes.filter((scope) => scope.state === 'failed').length;
1819
+ const done = scopes.filter((scope) => scope.state === 'done').length;
1820
+ return { state: failed === 0 ? 'succeeded' : done > 0 ? 'partial' : 'failed', scopes };
1821
+ }
1822
+
1823
+ function cleanupCommand(args) {
1824
+ const { options, fail } = require('./release');
1825
+ const opts = options(args, {
1826
+ plan: { type: 'boolean' }, apply: { type: 'boolean' }, machine: { type: 'boolean' }, events: { type: 'boolean' },
1827
+ project: { type: 'string', multiple: true }, 'account-home': { type: 'string', multiple: true },
1828
+ 'plan-file': { type: 'string' }, digest: { type: 'string' },
1829
+ });
1830
+ if (opts.plan === opts.apply) fail('invalid-arguments', { argument: 'exactly one of --plan or --apply' }, 2);
1831
+ for (const dir of [...(opts.project || []), ...(opts['account-home'] || [])]) {
1832
+ if (!path.isAbsolute(dir)) fail('invalid-arguments', { argument: 'absolute paths required' }, 2);
1833
+ }
1834
+ // The installer's progress lines would corrupt this command's JSON on stdout.
1835
+ console.log = (...parts) => process.stderr.write(`${parts.join(' ')}\n`);
1836
+ if (opts.plan) {
1837
+ return cleanupPlan({ machine: Boolean(opts.machine), projects: opts.project || [], accountHomes: opts['account-home'] || [] });
1838
+ }
1839
+ if (!opts['plan-file'] || !opts.digest) fail('invalid-arguments', { argument: '--plan-file and --digest' }, 2);
1840
+ const plan = JSON.parse(fs.readFileSync(opts['plan-file'], 'utf8'));
1841
+ const inputs = plan?.inputs;
1842
+ if (plan?.schemaVersion !== 1 || plan.digest !== opts.digest || cleanupDigest(plan) !== plan.digest
1843
+ || typeof inputs?.machine !== 'boolean' || !Array.isArray(inputs.projects) || !Array.isArray(inputs.accountHomes)
1844
+ || ![...inputs.projects, ...inputs.accountHomes].every((dir) => typeof dir === 'string' && path.isAbsolute(dir))) {
1845
+ fail('stale-plan', {}, 3);
1846
+ }
1847
+ if (plan.version !== PKG.version) fail('stale-plan', { version: plan.version }, 3);
1848
+ return cleanupApply(plan, { events: Boolean(opts.events) });
1849
+ }
1850
+
1344
1851
  // Main
1345
1852
  const args = process.argv.slice(2);
1346
1853
 
@@ -1350,6 +1857,9 @@ async function main() {
1350
1857
  const command = args[0];
1351
1858
 
1352
1859
  switch (command) {
1860
+ case 'cleanup':
1861
+ require('./release').runJson(() => cleanupCommand(args.slice(1)));
1862
+ break;
1353
1863
  case 'activation':
1354
1864
  require('./release').runJson(() => require('./harness-activation').activationCommand(args.slice(1)));
1355
1865
  break;
package/bin/engines.js CHANGED
@@ -4,29 +4,34 @@ const ENGINE_IDS = Object.freeze([
4
4
  'claude', 'codex', 'grok', 'omp', 'antigravity', 'cursor', 'opencode', 'kimi', 'copilot', 'qwen',
5
5
  ]);
6
6
 
7
- // Examined versions, not historical feature-introduction claims. Pre-1.0
8
- // engines are bounded to the examined minor; Cursor to the examined build.
7
+ // The versions each channel was examined on. Newer releases of the same major are taken as
8
+ // compatible: these CLIs ship minor releases weekly, and gating on the examined minor would turn
9
+ // the harness off at every one of them. A new major (or a Cursor build older than the examined one)
10
+ // is refused, since any flag may change there.
9
11
  const ENGINE_CAPABILITIES = Object.freeze({
10
- claude: { minimum: '2.1.307', generation: '2', executor: true },
11
- codex: { minimum: '0.163.0', generation: '0.163', executor: true },
12
- grok: { minimum: '1.0.62', generation: '1', executor: false },
13
- omp: { minimum: '16.5.0', generation: '16', executor: false },
14
- antigravity: { minimum: '1.4.4', generation: '1', executor: false },
15
- cursor: { minimum: '2025.09.12-4852336', generation: '2025.09.12-4852336', executor: false },
16
- opencode: { minimum: '1.19.2', generation: '1', executor: false },
17
- kimi: { minimum: '2.3.0', generation: '2', executor: false },
18
- copilot: { minimum: '1.0.98', generation: '1', executor: false },
19
- qwen: { minimum: '0.26.0', generation: '0.26', executor: false },
12
+ claude: { minimum: '2.1.307', executor: true },
13
+ codex: { minimum: '0.163.0', executor: true },
14
+ grok: { minimum: '1.0.62', executor: false },
15
+ omp: { minimum: '16.5.0', executor: false },
16
+ antigravity: { minimum: '1.4.4', executor: false },
17
+ cursor: { minimum: '2025.09.12-4852336', executor: false },
18
+ opencode: { minimum: '1.19.2', executor: false },
19
+ kimi: { minimum: '2.3.0', executor: false },
20
+ copilot: { minimum: '1.0.98', executor: false },
21
+ qwen: { minimum: '0.26.0', executor: false },
20
22
  });
21
23
 
22
24
  function supportedVersion(engine, version) {
23
25
  const capability = ENGINE_CAPABILITIES[engine];
24
- if (engine === 'cursor') return version === capability.minimum;
26
+ if (engine === 'cursor') {
27
+ const build = typeof version === 'string' && version.match(/^(\d{4})\.(\d{2})\.(\d{2})-[0-9a-f]+$/);
28
+ return Boolean(build) && build.slice(1).join('') >= capability.minimum.slice(0, 10).replace(/\./g, '');
29
+ }
25
30
  if (typeof version !== 'string' || !/^\d+\.\d+\.\d+$/.test(version)) return false;
26
- if (!version.startsWith(`${capability.generation}.`)) return false;
27
31
  const actual = version.split('.').map(Number);
28
32
  const floor = capability.minimum.split('.').map(Number);
29
- for (let i = 0; i < 3; i++) {
33
+ if (actual[0] !== floor[0]) return false;
34
+ for (let i = 1; i < 3; i++) {
30
35
  if (actual[i] !== floor[i]) return actual[i] > floor[i];
31
36
  }
32
37
  return true;
@@ -268,8 +268,7 @@ function planActivation(releaseRoot, input, env = process.env) {
268
268
  if (off) return finish(plan);
269
269
  plan.release = release;
270
270
  if (!supported) {
271
- const reasons = [reason('engine-version-unsupported', { minimum: ENGINE_CAPABILITIES[context.engine].minimum,
272
- generation: ENGINE_CAPABILITIES[context.engine].generation })];
271
+ const reasons = [reason('engine-version-unsupported', { minimum: ENGINE_CAPABILITIES[context.engine].minimum })];
273
272
  status.principles = channel('unavailable', null, reasons);
274
273
  status.skills = channel('unavailable', null, reasons);
275
274
  return finish(plan);
package/bin/release.js CHANGED
@@ -174,8 +174,8 @@ function metadata(root) {
174
174
  claudePlugin: 'native/claude', codexMarketplace: 'native/codex' },
175
175
  stateCompat: {
176
176
  loopManifest: { read: [1, 1], write: 1 }, addRecord: { read: [1, 1], write: 1 },
177
- // CLI-2 introduces packet v2 and queueBinding together with their implementations.
178
- taskPacket: { inspect: [1, 1], execute: [1, 1], write: 1 },
177
+ taskPacket: { inspect: [1, 2], execute: [1, 2], write: 2 },
178
+ queueBinding: { read: [1, 1], write: 1 },
179
179
  submission: { read: [1, 1], write: 1 }, review: { read: [1, 1], write: 1 },
180
180
  acceptanceResult: { read: [1, 1], write: 1 }, invocationReceipt: { read: [1, 1], write: 1 },
181
181
  legacyFormats: ['completion-receipt-unversioned', 'direct-acceptance-unversioned',
@@ -235,6 +235,19 @@ function buildRelease(root = PACKAGE_ROOT) {
235
235
  digest: manifest.digest.value, fileCount: manifest.files.length };
236
236
  }
237
237
 
238
+ // npm runs prepack in the source checkout and leaves its outputs there; postpack removes them, so a
239
+ // checkout never carries a release.json that would make its own skills read as a release.
240
+ function cleanGenerated(root = PACKAGE_ROOT) {
241
+ root = physicalRoot(root);
242
+ for (const name of ['harness', 'native', 'release.json']) {
243
+ const file = path.join(root, name);
244
+ const stat = fs.lstatSync(file, { throwIfNoEntry: false });
245
+ if (!stat) continue;
246
+ if (stat.isSymbolicLink() || !(stat.isDirectory() || stat.isFile())) fail('release-unsafe-entry', { path: file });
247
+ fs.rmSync(file, { recursive: true });
248
+ }
249
+ }
250
+
238
251
  function checkNpmShims(root, files) {
239
252
  const dir = path.join(root, 'node_modules/.bin');
240
253
  if (!fs.lstatSync(dir, { throwIfNoEntry: false })) return;
@@ -363,6 +376,6 @@ function runJson(action) {
363
376
  }
364
377
  }
365
378
 
366
- module.exports = { buildRelease, verifyRelease, materializeRelease, checkGenerated,
379
+ module.exports = { buildRelease, cleanGenerated, verifyRelease, materializeRelease, checkGenerated,
367
380
  sha256, manifestDigest, physicalRoot, HarnessError, fail,
368
381
  options, releaseCommand, runJson };
@@ -43,6 +43,7 @@
43
43
  "3be2ec1243eb28f779b170e469dfb65555a92dc08b3d927b8973cbd49308125d",
44
44
  "3c58989be2f943896810ef709fb115171420a918dfaadc43ea47940fa8ebc7ed",
45
45
  "3c81c575cc43d70488865b440204cb399091405d9b7f81dbf047a32d3d5ac045",
46
+ "3cb043970432281f3c2c3c7ec813c4721221ca18ff227e94ef204f2ef2178044",
46
47
  "3cd398df22e666fdd2e0f253c5a7464bca62bceefe3737d28a5ef95b75cb3a33",
47
48
  "3e39fbe29d0bd607f4ad940f4ca40f9abf82d468e418d0362fb0fd4fa6812475",
48
49
  "3e5135877e733f1dcff3b747444228f5eece7cb047956c87a3af02a485cfaad9",
@@ -357,6 +358,7 @@
357
358
  "d6433bf0f96dd9dd485c3623885255738654674b3e23cfcc1a2cfd24f3cda96e",
358
359
  "db8d921aabf0d04811b55465a3757c62c4b39631aebaf6eecc994ca241cfbe56",
359
360
  "e25da746dc37b2e82c658a28d9481fd69741151045683eee2119582ddaf731cd",
361
+ "e47a2ce766ae81fa0870e0a8dfbfe737311de69ce620e92217abbe4c532e3c8b",
360
362
  "e929f76c15349f1b9bc1e84e0a3e9e48e916168d79270e0f4a36ea8eab9b5388",
361
363
  "f2243cc4150e225961825f6097b0cf38166c291dcf58127516ceee841f7ed4c8",
362
364
  "fa536198d539a3c85f54dda74d0d78716ac6e6849e733bdadecc8605511eac37",