devlyn-cli 4.2.25 → 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.
@@ -0,0 +1,129 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { verifyRelease, physicalRoot, sha256, manifestDigest, fail, options } = require('./release');
4
+
5
+ const MARKER = '.devlyn-harness-projection.json';
6
+ const SOURCE = 'native/codex/plugins/devlyn';
7
+ const marketplaceName = (release) => `devlyn-harness-v${release.version.replace(/\./g, '-')}`;
8
+ const projectionPath = (release, accountHome, paths = path) =>
9
+ paths.join(accountHome, 'plugins', 'cache', marketplaceName(release), 'devlyn', release.version);
10
+ const markerBytes = (release) => Buffer.from(JSON.stringify({ schemaVersion: 1, release, source: SOURCE }, null, 2) + '\n');
11
+
12
+ function conflict(file) { fail('name-collision', { path: file, kind: 'codex-projection' }, 3); }
13
+
14
+ // Account homes may have physical aliases (e.g. /var on macOS). Below that
15
+ // explicitly selected root, never follow a link or adopt a foreign cache entry.
16
+ function cacheParent(accountHome, release, create) {
17
+ let dir = physicalRoot(accountHome);
18
+ for (const part of ['plugins', 'cache', marketplaceName(release), 'devlyn']) {
19
+ dir = path.join(dir, part);
20
+ if (create) {
21
+ try { fs.mkdirSync(dir); }
22
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
23
+ }
24
+ const stat = fs.lstatSync(dir, { throwIfNoEntry: false });
25
+ if (!stat && !create) return null;
26
+ if (!stat?.isDirectory()) conflict(dir);
27
+ }
28
+ return dir;
29
+ }
30
+
31
+ function projectionEntries(release) {
32
+ const manifest = JSON.parse(fs.readFileSync(path.join(release.root, 'release.json'), 'utf8'));
33
+ if (manifest.digest?.value !== release.digest || manifestDigest(manifest) !== release.digest) {
34
+ fail('release-source-changed', { path: release.root }, 3);
35
+ }
36
+ const entries = manifest.files.filter((entry) => entry.path.startsWith(`${SOURCE}/`))
37
+ .map((entry) => ({ ...entry, path: entry.path.slice(SOURCE.length + 1) }));
38
+ if (!entries.some((entry) => entry.path === '.codex-plugin/plugin.json')) conflict(release.root);
39
+ return entries;
40
+ }
41
+
42
+ function verifyProjection(root, release) {
43
+ const entries = projectionEntries(release);
44
+ const expected = new Map(entries.map((entry) => [entry.path, entry]));
45
+ expected.set(MARKER, { sha256: sha256(markerBytes(release)), executable: false });
46
+ const directories = new Set(['']);
47
+ for (const name of expected.keys()) {
48
+ const parts = name.split('/');
49
+ for (let i = 1; i < parts.length; i++) directories.add(parts.slice(0, i).join('/'));
50
+ }
51
+ const seen = new Set();
52
+ function visit(relative) {
53
+ const full = path.join(root, relative);
54
+ const stat = fs.lstatSync(full, { throwIfNoEntry: false });
55
+ if (stat?.isDirectory() && directories.has(relative)) {
56
+ for (const name of fs.readdirSync(full)) visit(relative ? `${relative}/${name}` : name);
57
+ } else {
58
+ const entry = expected.get(relative);
59
+ if (!entry || !stat?.isFile() || stat.nlink !== 1
60
+ || sha256(fs.readFileSync(full)) !== entry.sha256
61
+ || (process.platform !== 'win32' && Boolean(stat.mode & 0o111) !== entry.executable)) conflict(full);
62
+ seen.add(relative);
63
+ }
64
+ }
65
+ visit('');
66
+ if (seen.size !== expected.size) conflict(root);
67
+ return { projectionRoot: root, markerSha256: expected.get(MARKER).sha256 };
68
+ }
69
+
70
+ function inspectProjection(release, accountHome) {
71
+ const parent = cacheParent(accountHome, release, false);
72
+ if (!parent) return null;
73
+ const root = path.join(parent, release.version);
74
+ if (!fs.lstatSync(root, { throwIfNoEntry: false })) return null;
75
+ return verifyProjection(root, release);
76
+ }
77
+
78
+ function syncProjection(releaseRoot, accountHome) {
79
+ const release = verifyRelease(releaseRoot).release;
80
+ const account = physicalRoot(accountHome);
81
+ const relative = path.relative(release.root, account);
82
+ if (!relative || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))) {
83
+ fail('invalid-arguments', { argument: 'account-home must be outside the release' }, 2);
84
+ }
85
+ const parent = cacheParent(account, release, true);
86
+ const target = path.join(parent, release.version);
87
+ const existing = () => ({ release, ...verifyProjection(target, release), created: false });
88
+ if (fs.lstatSync(target, { throwIfNoEntry: false })) return existing();
89
+ // A short exclusive publication lock prevents two CLI publishers from
90
+ // replacing each other's cache. Interrupted locks are explicit conflicts;
91
+ // they are never guessed stale or reaped by age/PID.
92
+ const lock = path.join(parent, `.${release.version}.lock`);
93
+ let lockFd;
94
+ try { lockFd = fs.openSync(lock, 'wx', 0o600); }
95
+ catch (error) { if (error.code === 'EEXIST') conflict(lock); throw error; }
96
+ let temp;
97
+ try {
98
+ if (fs.lstatSync(target, { throwIfNoEntry: false })) return existing();
99
+ temp = fs.mkdtempSync(path.join(parent, `.${release.version}-`));
100
+ for (const entry of projectionEntries(release)) {
101
+ const dest = path.join(temp, entry.path);
102
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
103
+ fs.copyFileSync(path.join(release.root, SOURCE, entry.path), dest, fs.constants.COPYFILE_EXCL);
104
+ fs.chmodSync(dest, entry.executable ? 0o755 : 0o644);
105
+ }
106
+ fs.writeFileSync(path.join(temp, MARKER), markerBytes(release), { flag: 'wx', mode: 0o644 });
107
+ verifyProjection(temp, release);
108
+ if (fs.lstatSync(target, { throwIfNoEntry: false })) return existing();
109
+ fs.renameSync(temp, target);
110
+ return { release, ...verifyProjection(target, release), created: true };
111
+ } finally {
112
+ if (temp) fs.rmSync(temp, { recursive: true, force: true });
113
+ fs.closeSync(lockFd);
114
+ fs.unlinkSync(lock);
115
+ }
116
+ }
117
+
118
+ function machineCommand(args) {
119
+ if (args[0] !== 'sync') fail('invalid-arguments', { argument: 'machine sync' }, 2);
120
+ const opts = options(args.slice(1), {
121
+ 'release-root': { type: 'string' }, engine: { type: 'string' }, 'account-home': { type: 'string' },
122
+ });
123
+ if (opts.engine !== 'codex' || !opts['release-root'] || !opts['account-home']) {
124
+ fail('invalid-arguments', { argument: 'release-root, engine=codex and account-home required' }, 2);
125
+ }
126
+ return syncProjection(opts['release-root'], opts['account-home']);
127
+ }
128
+
129
+ module.exports = { MARKER, marketplaceName, projectionPath, inspectProjection, verifyProjection, syncProjection, machineCommand };
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,21 @@ 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;
1863
+ case 'activation':
1864
+ require('./release').runJson(() => require('./harness-activation').activationCommand(args.slice(1)));
1865
+ break;
1866
+ case 'machine':
1867
+ require('./release').runJson(() => require('./codex-projection').machineCommand(args.slice(1)));
1868
+ break;
1869
+ case 'release':
1870
+ require('./release').runJson(() => require('./release').releaseCommand(args.slice(1)));
1871
+ break;
1872
+ case 'status':
1873
+ require('./release').runJson(() => require('./harness-status').statusCommand(args.slice(1)));
1874
+ break;
1353
1875
  case '--help':
1354
1876
  case '-h':
1355
1877
  showHelp();