devlyn-cli 4.2.18 → 4.2.20

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
@@ -113,15 +113,16 @@ Upgrading to 4.1.0: `npx devlyn-cli -y` in a 4.x project keeps its Claude Code i
113
113
  current and adds `AGENTS.md` + `.agents/skills/`. Earlier releases put the Codex, omp, Pi
114
114
  and Grok skills in `~/.codex/skills`, `~/.agents/skills` and `~/.grok/skills`; a project
115
115
  install no longer refreshes them and names each one it finds. Refresh them with `--global`
116
- or delete them; delete `~/.grok/skills`, since Grok now reads `~/.agents/skills`.
116
+ or delete them; global installs and `update` clean Devlyn’s retired `~/.grok/skills` entries automatically.
117
117
  `npx devlyn-cli agents` was removed.
118
118
 
119
119
  <!-- legacy-surface-map:begin — retired command names below are documented as OLD, not current; lint Checks 5e and 10c skip this block -->
120
120
  Upgrading to 4.2.0: `/devlyn-resolve` and the pipeline helpers only it used are retired;
121
121
  no measured replacement ships in their place. Run the installer again where devlyn is
122
122
  installed. It removes the `devlyn-resolve` folders (and pre-4.0.0 `devlyn:resolve` ones)
123
- where it installs, removes the Stop hook it added to `.claude/settings.json` while keeping
124
- your own hooks, and names the other settings it once added for the pipeline
123
+ by their exact retired names, including edited copies and links (link targets stay intact). It
124
+ removes the Stop hook it added to `.claude/settings.json` while keeping your own hooks,
125
+ and names the other settings it once added for the pipeline
125
126
  (`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` and its `.devlyn` and git permissions) without
126
127
  deleting them. `/devlyn-engines` keeps only the executor pin: `pair_judge_priority` and
127
128
  `roles` in `.devlyn/engines.json` stay as written, select nothing, and `clear` removes them.
@@ -147,7 +148,7 @@ devlyn skill by its old name or path (for example Codex `[[skills.config]]` or o
147
148
  `disabledExtensions`) needs the new name. A project that committed the old `devlyn:*`
148
149
  folders checks out on Windows once that re-sync is committed.
149
150
 
150
- Earlier versions of devlyn-cli shipped 16+ slash commands. The iter-0034 Phase 4 cutover (2026-05-04) and the 2026-05-14 follow-up consolidated them, 4.0.0 renamed every devlyn skill to the Agent Skills naming standard (`devlyn-<name>`: lowercase, digits and hyphens, the same as its folder), and after 4.1.0 resolve and design-ui were retired and the queue moved into ideate. Upgrades automatically purge the legacy skill directories.
151
+ Earlier versions of devlyn-cli shipped 16+ slash commands. The iter-0034 Phase 4 cutover (2026-05-04) and the 2026-05-14 follow-up consolidated them, 4.0.0 renamed every devlyn skill to the Agent Skills naming standard (`devlyn-<name>`: lowercase, digits and hyphens, the same as its folder), and after 4.1.0 resolve and design-ui were retired and the queue moved into ideate. Install/update deletes exact retired Devlyn names in its scope; generic files and old instruction text are removed only with evidence matching shipped copies. Other skills, edited generic files, and your instruction text are preserved; instruction changes keep a recovery backup.
151
152
 
152
153
  | Old command | Now use |
153
154
  |---|---|
@@ -0,0 +1,97 @@
1
+ {
2
+ "source": "scripts/update-cleanup-history.js: LF-normalized Git blobs from first-parent config/agents-config/optional-skills history and all tags; excludes optional-addon hashes and third-party web-design-guidelines",
3
+ "files": {
4
+ ".claude/agents/evaluator.md": [
5
+ "146a229b0218938bca2de6b426cda642ae9826dc2dfa8c3687c6fd5de9160cf6",
6
+ "537c4af183aac692ff2abecfd42b4fe0854549b2ecf14fca3bc6a86ae58837fa",
7
+ "b8a48da212e070bab9d4818630cee2cd0de9846ad4e3a34b3a45f0a9a1a913b6",
8
+ "d5615e73af2401ea7418e02fc76d850506e59a64fff00f084ad272e474e138ba"
9
+ ],
10
+ ".claude/commit-conventions.md": [
11
+ "7e75c25d3067dba650781c57f1d94b52090badc2cb034b00759e9c6ed74a5a94"
12
+ ],
13
+ ".claude/templates/prompt-templates.md": [
14
+ "19029bcff7c4e9b579011d93a4f6eecfbe6a260f3cee12072252a6a7402d79e7",
15
+ "3fb7804dbd6e026def99829dda5daccc56a68e93997d8e6f540ee98bb288329e",
16
+ "7318764baa5dc7cd406bc8552848653ad6368982b43faecc6618f697a9ae059f",
17
+ "e583be4175366101bfa0df512b1106f65b18f215b4717eadebbc8129c3d96be5"
18
+ ],
19
+ ".claude/templates/template-feature.spec.md": [
20
+ "16eb59b48f062aa0894b3fe8ba1c8f9e970652fda0bdd0d9e3248acb8e40d3bc"
21
+ ],
22
+ ".claude/templates/template-product-spec.md": [
23
+ "42b5b2c32fd72b1324432da0cf652cf11854e3ac40e020b812ef64d894116d92"
24
+ ],
25
+ ".cursor/rules/evaluator.md": [
26
+ "146a229b0218938bca2de6b426cda642ae9826dc2dfa8c3687c6fd5de9160cf6",
27
+ "537c4af183aac692ff2abecfd42b4fe0854549b2ecf14fca3bc6a86ae58837fa",
28
+ "b8a48da212e070bab9d4818630cee2cd0de9846ad4e3a34b3a45f0a9a1a913b6",
29
+ "d5615e73af2401ea7418e02fc76d850506e59a64fff00f084ad272e474e138ba"
30
+ ],
31
+ ".github/copilot/agents/evaluator.md": [
32
+ "146a229b0218938bca2de6b426cda642ae9826dc2dfa8c3687c6fd5de9160cf6",
33
+ "537c4af183aac692ff2abecfd42b4fe0854549b2ecf14fca3bc6a86ae58837fa",
34
+ "b8a48da212e070bab9d4818630cee2cd0de9846ad4e3a34b3a45f0a9a1a913b6",
35
+ "d5615e73af2401ea7418e02fc76d850506e59a64fff00f084ad272e474e138ba"
36
+ ],
37
+ ".windsurf/rules/evaluator.md": [
38
+ "146a229b0218938bca2de6b426cda642ae9826dc2dfa8c3687c6fd5de9160cf6",
39
+ "537c4af183aac692ff2abecfd42b4fe0854549b2ecf14fca3bc6a86ae58837fa",
40
+ "b8a48da212e070bab9d4818630cee2cd0de9846ad4e3a34b3a45f0a9a1a913b6",
41
+ "d5615e73af2401ea7418e02fc76d850506e59a64fff00f084ad272e474e138ba"
42
+ ]
43
+ },
44
+ "skills": {
45
+ "code-health-standards": [
46
+ "962a82b5ebeafac515f81f5c68494c67dbf31c3e3e453f23c4452d4725e21355",
47
+ "df2838904c938aa0306ed333cc460cfc5c2cf0ffa20bb5ade57067c6e0acf9be"
48
+ ],
49
+ "code-review-standards": [
50
+ "3bc8587a9143487328be2470dec8df17a19cb14f94e6ec916ed58de660d0e1c4",
51
+ "56abe5736f505c53ca9ce14087a9891cc8661be783abaec7b5b924e3f339cc2a",
52
+ "586dde5014f9ac2f9a13d43fa1c8c257a3ae998a3fc1b867b23b3b2e44f77eca",
53
+ "9c906f2b2a88c2d0aaeec9714405945da5177de6ec433f1acf520c6c4385b875"
54
+ ],
55
+ "feature-gap-analysis": [
56
+ "d2328d990305280cf0d08443130cdba63eb5ed8040f5e5b6719c58050c4d3ada"
57
+ ],
58
+ "investigate": [
59
+ "5025c346d1e246d49681650e7b630477d1ef67f4357e5fd7f3e5f02e5a860288"
60
+ ],
61
+ "root-cause-analysis": [
62
+ "0eac104bfcde8bdf14d4c284be6f3f4cb243219a679bf569433c2465df84c6c3",
63
+ "1793dcb0f72621c78cc20c905fe5dd7a0af3115e904bf737a6a983a7b9d2c2b6",
64
+ "aab22d1bddc66c6a798f08cdf7be9b45fbe80ccfc928b068ef5d9e2ad951bdda",
65
+ "e200ddd04196435af3162a9e13595f8eb5ac68b06465026e0155e453b1bd1c93",
66
+ "e66d15a9585c07e819acfd89c1f6d030517130f6d8f8924bda3120307949fb2e"
67
+ ],
68
+ "ui-implementation-standards": [
69
+ "13c709c0e2b01ae94997c271043a685200e969192d2056534bd787c5682874e8",
70
+ "1ff64816454171b3f879e9e5cf3c15e3d9c5f2ffc81b73262155f6bc93a1b054",
71
+ "505ca42b1efd53925d51c570b21df4e05cbc6bc640dd1f7f4e03f15d2d4437c5",
72
+ "e2473e35474e78f2b581e225cb33a1e711bb79519a02e9780fc9606e582b878f"
73
+ ],
74
+ "workflow-routing": [
75
+ "7fec2bb20d808a873a975d64a6223e404bc5b328c800760bb77257ae6b2f467a",
76
+ "8bfdaeff2ce48d5a79b5653de3bbe0d56a61a7fd750ed3ad11149fec5a502ecf"
77
+ ]
78
+ },
79
+ "evaluators": [
80
+ {
81
+ "length": 3081,
82
+ "sha256": "01a566bbecfb7eaef16b53c2000dc58050a3348072019868fbe23cbd6503095d"
83
+ },
84
+ {
85
+ "length": 1559,
86
+ "sha256": "04403ca3008e87adbd0f75f3136a20d6d5262eb7576f8d31cec2d03686dc9898"
87
+ },
88
+ {
89
+ "length": 2980,
90
+ "sha256": "481557b50619e1d47c209b1e1c916919361031f57cf2ee8f27644c2a101cfdb6"
91
+ },
92
+ {
93
+ "length": 2980,
94
+ "sha256": "6c7066a91aa18a62b1257956f0adf4f08603034d8a6eb2fae052a1ea8d0d9a4b"
95
+ }
96
+ ]
97
+ }
package/bin/devlyn.js CHANGED
@@ -12,6 +12,7 @@ const PKG = require('../package.json');
12
12
  const { updateInstructions, InstructionError, holdsDevlynDefaults, importsAgentsMd } = require('./instructions');
13
13
  const { contentHash, fingerprint, gitEnvironment, isIgnoredEntry, trackedPaths } = require('./skill-ownership');
14
14
  const SKILL_HISTORY = require('./skill-history.json');
15
+ const CLEANUP_HISTORY = require('./cleanup-history.json');
15
16
 
16
17
  // The devlyn skill bundle installed into every skill-capable agent's loader
17
18
  // directory. Single source of truth so codex/omp/pi stay in lockstep — adding a
@@ -39,7 +40,13 @@ const RETIRED_PERMISSIONS = ['Write(.devlyn/**)', 'Edit(.devlyn/**)', 'Bash(git
39
40
 
40
41
  // Every spelling a colon name can have on disk: its own and npm's U+F03A extraction alias.
41
42
  function legacySkillPaths(root, name) {
42
- return [...new Set([name, name.replace(/:/g, '\uF03A')])].map((spelling) => path.join(root, spelling));
43
+ const spellings = new Set([name, name.replace(/:/g, '\uF03A')]);
44
+ if (fs.lstatSync(root, { throwIfNoEntry: false })?.isDirectory()) {
45
+ for (const entry of fs.readdirSync(root)) {
46
+ if ([...spellings].some((spelling) => spelling.toLowerCase() === entry.toLowerCase())) spellings.add(entry);
47
+ }
48
+ }
49
+ return [...spellings].map((spelling) => path.join(root, spelling));
43
50
  }
44
51
 
45
52
  // SKILL.md of the pencil skills 0.6.0-0.7.1 installed under today's names, without the
@@ -53,36 +60,8 @@ const PRE_STANDARD_SKILL_MD_SHA256 = new Set([
53
60
  // Skills devlyn-cli installed by default and later deleted or made opt-in, by SHA-256 of their
54
61
  // only file, SKILL.md (LF endings): an opted-in copy or a user's own folder of the same name is
55
62
  // never removed.
56
- const RETIRED_SKILL_MD_SHA256 = {
57
- // 0.2.0-1.15.0; deleted in iter-0034, it still routes agents to retired commands.
58
- 'workflow-routing': new Set([
59
- '7fec2bb20d808a873a975d64a6223e404bc5b328c800760bb77257ae6b2f467a',
60
- '8bfdaeff2ce48d5a79b5653de3bbe0d56a61a7fd750ed3ad11149fec5a502ecf',
61
- ]),
62
- // The standards skills, 0.1.0-3.2.1; 3.3.0 (0221) made them optional addons.
63
- 'code-health-standards': new Set([
64
- 'df2838904c938aa0306ed333cc460cfc5c2cf0ffa20bb5ade57067c6e0acf9be',
65
- '962a82b5ebeafac515f81f5c68494c67dbf31c3e3e453f23c4452d4725e21355',
66
- ]),
67
- 'code-review-standards': new Set([
68
- '9c906f2b2a88c2d0aaeec9714405945da5177de6ec433f1acf520c6c4385b875',
69
- '3bc8587a9143487328be2470dec8df17a19cb14f94e6ec916ed58de660d0e1c4',
70
- '56abe5736f505c53ca9ce14087a9891cc8661be783abaec7b5b924e3f339cc2a',
71
- '586dde5014f9ac2f9a13d43fa1c8c257a3ae998a3fc1b867b23b3b2e44f77eca',
72
- ]),
73
- 'root-cause-analysis': new Set([
74
- 'e200ddd04196435af3162a9e13595f8eb5ac68b06465026e0155e453b1bd1c93',
75
- '1793dcb0f72621c78cc20c905fe5dd7a0af3115e904bf737a6a983a7b9d2c2b6',
76
- 'aab22d1bddc66c6a798f08cdf7be9b45fbe80ccfc928b068ef5d9e2ad951bdda',
77
- '0eac104bfcde8bdf14d4c284be6f3f4cb243219a679bf569433c2465df84c6c3',
78
- 'e66d15a9585c07e819acfd89c1f6d030517130f6d8f8924bda3120307949fb2e',
79
- ]),
80
- 'ui-implementation-standards': new Set([
81
- 'e2473e35474e78f2b581e225cb33a1e711bb79519a02e9780fc9606e582b878f',
82
- '1ff64816454171b3f879e9e5cf3c15e3d9c5f2ffc81b73262155f6bc93a1b054',
83
- '13c709c0e2b01ae94997c271043a685200e969192d2056534bd787c5682874e8',
84
- ]),
85
- };
63
+ const RETIRED_SKILL_MD_SHA256 = Object.fromEntries(
64
+ Object.entries(CLEANUP_HISTORY.skills).map(([name, hashes]) => [name, new Set(hashes)]));
86
65
 
87
66
  // Whether `dir` is a real folder whose only file, besides generated metadata, is a SKILL.md
88
67
  // with an LF-normalized SHA-256 in `hashes`: an unedited copy devlyn-cli shipped, not something
@@ -92,7 +71,7 @@ function isShippedCopy(dir, hashes) {
92
71
  return fs.lstatSync(dir, { throwIfNoEntry: false })?.isDirectory() === true
93
72
  && fs.readdirSync(dir).filter((name) => !isIgnoredEntry(path.join(dir, name))).length === 1
94
73
  && fs.lstatSync(skill, { throwIfNoEntry: false })?.isFile() === true
95
- && hashes.has(contentHash(skill));
74
+ && hashes.has(crypto.createHash('sha256').update(fs.readFileSync(skill).toString('latin1').replace(/\r\n/g, '\n'), 'latin1').digest('hex'));
96
75
  }
97
76
 
98
77
  // Skill roots of each target. In a project, `.agents/skills` serves Codex, omp, Pi and Grok,
@@ -110,12 +89,13 @@ function hasDevlynInstall(root) {
110
89
  const stat = (name) => fs.lstatSync(name, { throwIfNoEntry: false });
111
90
  if (!stat(root)?.isDirectory()) return false;
112
91
  const optional = new Set(OPTIONAL_ADDONS.map((addon) => addon.name));
113
- const names = [...DEVLYN_CORE_SKILLS, ...DEPRECATED_DIRS.map((name) => path.basename(name)),
92
+ const names = [...DEVLYN_CORE_SKILLS,
114
93
  ...Object.keys(RENAMED_SKILLS)].filter((name) => name.startsWith('devlyn')
115
94
  && !optional.has(name.replace(/[:\uF03A]/g, '-')));
116
95
  return !!stat(path.join(root, DEVLYN_INSTALL_LOCK))
117
96
  || names.some((name) => [...legacySkillPaths(root, name.replace(/^devlyn-/, 'devlyn:')),
118
97
  path.join(root, name.replace(/:/g, '-'))].some((full) => stat(full)?.isDirectory()))
98
+ || hasHistoricalRetiredSkill(root)
119
99
  || (!!stat(path.join(root, DEVLYN_INSTALL_MARKER)) && stat(path.join(root, '_shared'))?.isDirectory());
120
100
  } catch (error) {
121
101
  if (error.code === 'ENOTDIR') return false;
@@ -136,15 +116,21 @@ function hasDevlynClaude(global) {
136
116
  const optional = new Set(OPTIONAL_ADDONS.map((addon) => addon.name));
137
117
  const instructions = path.join(path.dirname(claudeDir), 'CLAUDE.md');
138
118
  const claude = fs.lstatSync(instructions, { throwIfNoEntry: false })?.isFile() ? fs.readFileSync(instructions, 'utf8') : '';
139
- return names(path.join(claudeDir, 'skills')).some((name) => name === DEVLYN_INSTALL_MARKER
140
- || (name.startsWith('devlyn') && !optional.has(name.replace(/[:\uF03A]/g, '-'))))
119
+ const root = path.join(claudeDir, 'skills');
120
+ const deprecated = new Set([...deprecatedSkillPaths(root)].map((full) => path.basename(full).toLowerCase()));
121
+ return names(root).some((name) => {
122
+ if (deprecated.has(name.toLowerCase())) return historicalSkill(path.join(root, name));
123
+ return name === DEVLYN_INSTALL_MARKER
124
+ || (name.startsWith('devlyn') && !optional.has(name.replace(/[:\uF03A]/g, '-')));
125
+ })
141
126
  || names(path.join(claudeDir, 'commands')).some((name) => name.startsWith('devlyn.'))
142
127
  || holdsDevlynDefaults('CLAUDE.md', claude) || importsAgentsDefaults(claude);
143
128
  }
144
129
 
145
- // Commands removed in previous versions; the project Claude install deletes them from .claude/
146
- // when they hold the bytes a version shipped (SKILL_HISTORY.commands).
130
+ // Exact Devlyn command names removed by previous versions, in either scope.
147
131
  const DEPRECATED_FILES = [
132
+ 'commands/devlyn.ui.md',
133
+ 'commands/devlyn.team-ui.md',
148
134
  'commands/devlyn.handoff.md', // removed in v0.2.0
149
135
  'commands/devlyn.clean.md', // migrated to skills in v0.6.0
150
136
  'commands/devlyn.design-system.md',
@@ -168,7 +154,7 @@ const DEPRECATED_FILES = [
168
154
  // Skill directories renamed from devlyn-* to devlyn:* in v0.7.x, plus
169
155
  // iter-0034 Phase 4 cutover (2026-05-03): 15 user skills deleted and 3 moved
170
156
  // to optional-skills/. Listed here so post-cutover `npx devlyn-cli` upgrades
171
- // remove verified, unchanged legacy skill dirs from downstream `~/.claude/skills/`
157
+ // remove these exact retired skill names from downstream skill roots
172
158
  // even though the source dirs no longer exist (the installer replaces only
173
159
  // the skills it ships — without this list, deleted-from-source skills persist
174
160
  // in user installs forever).
@@ -367,41 +353,16 @@ function listContents() {
367
353
  log('');
368
354
  }
369
355
 
370
- // Old and retired skills in one skill root; nothing outside it.
371
- function cleanupDeprecated(skillsDir, written) {
372
- let removed = 0;
373
- const deprecated = DEPRECATED_DIRS.flatMap((relPath) =>
374
- legacySkillPaths(skillsDir, path.basename(relPath)).map((fullPath) => [relPath, fullPath]));
375
- for (const [relPath, fullPath] of deprecated) {
376
- if (fs.existsSync(fullPath)) {
377
- fs.rmSync(fullPath, { recursive: true });
378
- written.push(fullPath);
379
- log(` ✕ ${relPath}/ (removed)`, 'dim');
380
- removed++;
381
- }
382
- }
383
- for (const [name, hashes] of Object.entries(RETIRED_SKILL_MD_SHA256)) {
384
- const fullPath = path.join(skillsDir, name);
385
- if (isShippedCopy(fullPath, hashes)) {
386
- fs.rmSync(fullPath, { recursive: true });
387
- written.push(fullPath);
388
- log(` ✕ skills/${name}/ (removed)`, 'dim');
389
- removed++;
390
- }
391
- }
392
- return removed;
393
- }
394
-
395
356
  // Remove every old spelling of the renamed skills in `skillsDir`, after the new core skills
396
357
  // are in place. An optional skill found under an old spelling is installed under its new name
397
358
  // before the old copy goes, so an interrupted migration never loses it. An unedited 0.6.0-0.7.1
398
359
  // copy is refreshed in place. Any other folder under a 4.0 name is the user's own; `-y` leaves it.
399
- function retireRenamedSkills(skillsDir, written, installedSkills) {
360
+ function refreshRenamedSkills(skillsDir, written, installedSkills) {
400
361
  const optional = new Set(OPTIONAL_ADDONS.filter((addon) => addon.type === 'local').map((addon) => addon.name));
401
362
  const refreshed = [];
402
363
  for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
403
364
  const found = legacySkillPaths(skillsDir, oldName).filter((fullPath) => fs.existsSync(fullPath));
404
- if (found.length > 0 && optional.has(newName)) {
365
+ if (found.length > 0 && optional.has(newName) && canRefreshRenamed(skillsDir, newName)) {
405
366
  installOptionalSkillInto(skillsDir, newName, written, installedSkills);
406
367
  refreshed.push(newName);
407
368
  } else if (isShippedCopy(path.join(skillsDir, newName), PRE_STANDARD_SKILL_MD_SHA256)) {
@@ -410,11 +371,6 @@ function retireRenamedSkills(skillsDir, written, installedSkills) {
410
371
  installedSkills.set(path.join(skillsDir, newName), [path.join(skillsDir, newName, 'SKILL.md')]);
411
372
  refreshed.push(newName);
412
373
  }
413
- for (const fullPath of found) {
414
- fs.rmSync(fullPath, { recursive: true, force: true });
415
- written.push(fullPath);
416
- log(` ✕ ${path.basename(fullPath)}/ (renamed to ${newName})`, 'dim');
417
- }
418
374
  }
419
375
  return refreshed;
420
376
  }
@@ -458,44 +414,68 @@ function readInstallManifest(root) {
458
414
  return marker.skills || {};
459
415
  }
460
416
 
417
+ // A retired optional name must not make a foreign copy under its new name an install conflict.
418
+ function canRefreshRenamed(root, name) {
419
+ const dest = path.join(root, name);
420
+ if (!fs.lstatSync(dest, { throwIfNoEntry: false })) return true;
421
+ try { verifySkill(dest, name, readInstallManifest(root)); return true; }
422
+ catch { return false; } // No ownership proof: preserve the new-name entry without installing over it.
423
+ }
424
+
461
425
  // Enumerate every directory this install may replace, migrate or remove, before
462
426
  // touching any target. Optional addons must be selected before this preflight too.
463
427
  function plannedSkills(root, optionalNames = []) {
464
428
  const names = new Set([...DEVLYN_CORE_SKILLS, ...optionalNames]);
465
- for (const relative of DEPRECATED_DIRS) {
466
- for (const full of legacySkillPaths(root, path.basename(relative))) names.add(path.basename(full));
467
- }
468
429
  for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
469
430
  const oldPaths = legacySkillPaths(root, oldName);
470
- oldPaths.forEach((full) => names.add(path.basename(full)));
471
- if (oldPaths.some((full) => fs.lstatSync(full, { throwIfNoEntry: false }))
431
+ if ((oldPaths.some((full) => fs.lstatSync(full, { throwIfNoEntry: false })) && canRefreshRenamed(root, newName))
472
432
  || isShippedCopy(path.join(root, newName), PRE_STANDARD_SKILL_MD_SHA256)) names.add(newName);
473
433
  }
474
- for (const [name, hashes] of Object.entries(RETIRED_SKILL_MD_SHA256)) {
475
- if (isShippedCopy(path.join(root, name), hashes)) names.add(name);
476
- }
477
434
  return names;
478
435
  }
479
436
 
480
- // `commandDir`: the project .claude/ whose DEPRECATED_FILES this install deletes, or null.
481
- function preflightSkills(roots, optionalNames = [], commandDir = null) {
482
- const conflicts = [];
437
+ function verifySkill(dest, name, manifest) {
438
+ const actual = fingerprint(dest);
439
+ const logicalName = name.replace(/[:\uF03A]/g, '-');
440
+ const source = path.join(DEVLYN_CORE_SKILLS.includes(logicalName)
441
+ ? path.join(CONFIG_SOURCE, 'skills') : OPTIONAL_SKILLS_SOURCE, logicalName);
442
+ const current = fs.existsSync(source) && fingerprint(source) === actual;
443
+ const historical = SKILL_HISTORY.skills[logicalName]?.includes(actual);
444
+ const hashes = RETIRED_SKILL_MD_SHA256[name.toLowerCase()] || PRE_STANDARD_SKILL_MD_SHA256;
445
+ const singleFile = isShippedCopy(dest, hashes);
446
+ if (!current && !historical && !singleFile && manifest[name] !== actual) {
447
+ throw new Error(manifest[name] ? 'modified contents (install manifest fingerprint mismatch)'
448
+ : fs.existsSync(source) || SKILL_HISTORY.skills[logicalName]?.length
449
+ ? 'fingerprint mismatch with known shipped copies'
450
+ : 'unknown installer ownership (no fingerprint history for this copy)');
451
+ }
452
+ }
453
+
454
+ // Discovery keeps its historical ownership boundary; cleanup below uses exact retired names.
455
+ function deprecatedSkillPaths(root) {
456
+ return new Set(DEPRECATED_DIRS.flatMap((relative) => {
457
+ const name = path.basename(relative);
458
+ return [...legacySkillPaths(root, name.replace(/^devlyn-/, 'devlyn:')),
459
+ path.join(root, name.replace(/:/g, '-'))];
460
+ }));
461
+ }
462
+
463
+ function historicalSkill(dest) {
483
464
  try {
484
- // Do not follow a commands link into another installation either.
485
- const commands = commandDir && fs.lstatSync(path.join(commandDir, 'commands'), { throwIfNoEntry: false });
486
- if (commands && !commands.isDirectory()) throw new Error('not a real directory');
487
- const tracked = commandDir ? trackedPaths(path.join(commandDir, 'commands')) : [];
488
- for (const relPath of commandDir ? DEPRECATED_FILES : []) {
489
- const fullPath = path.join(commandDir, relPath);
490
- const stat = fs.lstatSync(fullPath, { throwIfNoEntry: false });
491
- if (!stat || (stat.isFile() && SKILL_HISTORY.commands[relPath].includes(contentHash(fullPath)))) continue;
492
- const isTracked = tracked.some((file) => file.toLowerCase() === fullPath.toLowerCase());
493
- conflicts.push(`${fullPath}: ${isTracked ? 'git-tracked; ' : ''}`
494
- + (stat.isFile() ? 'fingerprint mismatch with known shipped copies' : 'link or special file'));
495
- }
496
- } catch (error) {
497
- conflicts.push(`${path.join(commandDir, 'commands')}: ${error.message}`);
465
+ return SKILL_HISTORY.skills[path.basename(dest).toLowerCase().replace(/[:\uF03A]/g, '-')]?.includes(fingerprint(dest)) === true;
466
+ } catch {
467
+ // A link, unreadable or partial tree is not evidence of an install to refresh.
468
+ return false;
498
469
  }
470
+ }
471
+
472
+ function hasHistoricalRetiredSkill(root) {
473
+ return [...deprecatedSkillPaths(root)].some(historicalSkill);
474
+ }
475
+
476
+ // Validate only paths this run installs; removal-only names never refuse an install.
477
+ function preflightSkills(roots, optionalNames = []) {
478
+ const conflicts = [];
499
479
  for (const root of roots) {
500
480
  try {
501
481
  // Do not follow an agent-directory or skill-root link into another installation.
@@ -520,19 +500,7 @@ function preflightSkills(roots, optionalNames = [], commandDir = null) {
520
500
  if (isTracked) throw new Error('missing contents; cannot verify installer ownership');
521
501
  continue;
522
502
  }
523
- const actual = fingerprint(dest);
524
- const logicalName = name.replace(/[:\uF03A]/g, '-');
525
- const source = path.join(DEVLYN_CORE_SKILLS.includes(logicalName)
526
- ? path.join(CONFIG_SOURCE, 'skills') : OPTIONAL_SKILLS_SOURCE, logicalName);
527
- const current = fs.existsSync(source) && fingerprint(source) === actual;
528
- const historical = SKILL_HISTORY.skills[logicalName]?.includes(actual);
529
- const singleFile = isShippedCopy(dest, RETIRED_SKILL_MD_SHA256[name] || PRE_STANDARD_SKILL_MD_SHA256);
530
- if (!current && !historical && !singleFile && manifest[name] !== actual) {
531
- throw new Error(manifest[name] ? 'modified contents (install manifest fingerprint mismatch)'
532
- : fs.existsSync(source) || SKILL_HISTORY.skills[logicalName]?.length
533
- ? 'fingerprint mismatch with known shipped copies'
534
- : 'unknown installer ownership (no fingerprint history for this copy)');
535
- }
503
+ verifySkill(dest, name, manifest);
536
504
  } catch (error) {
537
505
  conflicts.push(`${dest}: ${isTracked ? 'git-tracked; ' : ''}${error.message}`);
538
506
  }
@@ -548,6 +516,7 @@ function preflightSkills(roots, optionalNames = [], commandDir = null) {
548
516
  + '\nFor tracked paths, also remove them from the Git index.'
549
517
  + '\nRerun the same command after resolving every conflict; -y does not bypass this check.');
550
518
  }
519
+ return {};
551
520
  }
552
521
 
553
522
  function assertCompleteSkillInstall(sourceSkillsDir, skillsDir, skillNames) {
@@ -658,16 +627,19 @@ function installLocalSkill(skillName, roots, written = [], installedSkills = new
658
627
  // copy under its name before the 4.0.0 rename goes only once the new one is complete.
659
628
  function installOptionalSkillInto(target, skillName, written, installedSkills) {
660
629
  const dest = path.join(target, skillName);
661
- fs.rmSync(dest, { recursive: true, force: true });
662
- installedSkills.set(dest, copyRecursive(path.join(OPTIONAL_SKILLS_SOURCE, skillName), dest, target));
663
- assertCompleteSkillInstall(OPTIONAL_SKILLS_SOURCE, target, [skillName]);
664
- written.push(dest);
665
- const oldName = Object.keys(RENAMED_SKILLS).find((name) => RENAMED_SKILLS[name] === skillName);
666
- for (const fullPath of oldName ? legacySkillPaths(target, oldName) : []) {
667
- if (!fs.existsSync(fullPath)) continue;
668
- fs.rmSync(fullPath, { recursive: true, force: true });
669
- written.push(fullPath);
630
+ const stage = fs.mkdtempSync(path.join(target, '.devlyn-skill-'));
631
+ const stagedSkill = path.join(stage, skillName);
632
+ try {
633
+ const files = copyRecursive(path.join(OPTIONAL_SKILLS_SOURCE, skillName), stagedSkill, stage);
634
+ assertCompleteSkillInstall(OPTIONAL_SKILLS_SOURCE, stage, [skillName]);
635
+ fs.rmSync(dest, { recursive: true, force: true });
636
+ fs.renameSync(stagedSkill, dest);
637
+ installedSkills.set(dest, files.map((file) => path.join(dest, path.relative(stagedSkill, file))));
638
+ written.push(dest);
639
+ } finally {
640
+ fs.rmSync(stage, { recursive: true, force: true });
670
641
  }
642
+
671
643
  }
672
644
 
673
645
  // A 0.6.0-0.7.1 copy is one SKILL.md, the file that identifies it. A failed swap leaves
@@ -727,10 +699,6 @@ function installCoreSkills(skillsDir, written = [], installedSkills = new Map())
727
699
  fs.mkdirSync(skillsDir, { recursive: true });
728
700
  const previous = readInstallManifest(skillsDir);
729
701
  clearInstallMarker(skillsDir);
730
- const removed = cleanupDeprecated(skillsDir, written);
731
- if (removed > 0) {
732
- log(`\n🧹 Cleaned up ${removed} deprecated file${removed > 1 ? 's' : ''}`, 'yellow');
733
- }
734
702
  for (const skillName of DEVLYN_CORE_SKILLS) {
735
703
  const src = path.join(sourceSkillsDir, skillName);
736
704
  const dest = path.join(skillsDir, skillName);
@@ -741,7 +709,7 @@ function installCoreSkills(skillsDir, written = [], installedSkills = new Map())
741
709
  written.push(dest);
742
710
  }
743
711
  assertCompleteSkillInstall(sourceSkillsDir, skillsDir, DEVLYN_CORE_SKILLS);
744
- const refreshed = retireRenamedSkills(skillsDir, written, installedSkills);
712
+ const refreshed = refreshRenamedSkills(skillsDir, written, installedSkills);
745
713
  writeInstallMarker(skillsDir, [...DEVLYN_CORE_SKILLS, ...refreshed], previous);
746
714
  written.push(path.join(skillsDir, DEVLYN_INSTALL_MARKER));
747
715
  }
@@ -866,6 +834,8 @@ function installClaudeCore(installedSkills = new Map()) {
866
834
  const settingsPath = path.join(targetDir, 'settings.json');
867
835
  const unmergeable = (reason) => new InstructionError(`Cannot merge .claude/settings.json: ${reason}. `
868
836
  + 'CLAUDE.md and .claude/ are unchanged; fix the file and rerun the same install command.');
837
+ const settingsStat = fs.lstatSync(settingsPath, { throwIfNoEntry: false });
838
+ if (settingsStat && !settingsStat.isFile()) throw unmergeable('not a regular file');
869
839
  let settings = {};
870
840
  if (fs.existsSync(settingsPath)) {
871
841
  try {
@@ -883,14 +853,6 @@ function installClaudeCore(installedSkills = new Map()) {
883
853
  throw unmergeable('env must be a JSON object');
884
854
  }
885
855
  const written = installClaudeInstructions() ? ['CLAUDE.md'] : [];
886
- for (const relPath of DEPRECATED_FILES) {
887
- const fullPath = path.join(targetDir, relPath);
888
- if (fs.existsSync(fullPath)) {
889
- fs.unlinkSync(fullPath);
890
- log(` ✕ ${relPath} (deprecated)`, 'dim');
891
- written.push(`.claude/${relPath}`);
892
- }
893
- }
894
856
  installCoreSkills(skillsDir, written, installedSkills);
895
857
  if (updateProjectGitignore('.claude/skills/.devlyn-install.json')) written.push('.gitignore');
896
858
 
@@ -900,28 +862,17 @@ function installClaudeCore(installedSkills = new Map()) {
900
862
  settings.env = {};
901
863
  settingsChanged = true;
902
864
  }
903
- if (!settings.env.ENABLE_PROMPT_CACHING_1H) {
865
+ if (!Object.prototype.hasOwnProperty.call(settings.env, 'ENABLE_PROMPT_CACHING_1H')) {
904
866
  settings.env.ENABLE_PROMPT_CACHING_1H = 'true';
905
867
  settingsChanged = true;
906
868
  }
907
- const bashMaxTimeoutMs = Number.parseInt(settings.env.BASH_MAX_TIMEOUT_MS, 10);
908
- if (!Number.isFinite(bashMaxTimeoutMs) || bashMaxTimeoutMs < 3600000) {
869
+ if (!Object.prototype.hasOwnProperty.call(settings.env, 'BASH_MAX_TIMEOUT_MS')) {
909
870
  settings.env.BASH_MAX_TIMEOUT_MS = '3600000';
910
871
  settingsChanged = true;
911
872
  }
912
- // Remove exactly the retired Stop hook command, and its entry only when nothing else is left in it.
913
- const retiredHook = (hook) => hook?.type === 'command' && hook.command === RETIRED_STOP_HOOK;
914
- const stop = Array.isArray(settings.hooks?.Stop) ? settings.hooks.Stop : [];
915
- for (let i = stop.length - 1; i >= 0; i--) {
916
- const hooks = stop[i]?.hooks;
917
- if (!Array.isArray(hooks) || !hooks.some(retiredHook)) continue;
918
- stop[i].hooks = hooks.filter((hook) => !retiredHook(hook));
919
- if (stop[i].hooks.length === 0 && Object.keys(stop[i]).length === 1) stop.splice(i, 1);
920
- log(' ✕ settings.json Stop hook of the retired pipeline (removed)', 'dim');
921
- settingsChanged = true;
922
- }
873
+ settingsChanged = cleanRetiredSettings(settings) || settingsChanged;
923
874
  if (settingsChanged) {
924
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
875
+ replaceFile(settingsPath, JSON.stringify(settings, null, 2) + '\n');
925
876
  log(' → settings.json', 'dim');
926
877
  written.push('.claude/settings.json');
927
878
  }
@@ -936,9 +887,38 @@ function installClaudeCore(installedSkills = new Map()) {
936
887
  return written;
937
888
  }
938
889
 
890
+ // Replace this directory entry, never a hardlink's other names.
891
+ function replaceFile(file, text) {
892
+ const temp = `${file}.${crypto.randomUUID()}.tmp`;
893
+ try {
894
+ const mode = fs.lstatSync(file, { throwIfNoEntry: false })?.mode & 0o777;
895
+ fs.writeFileSync(temp, text, { flag: 'wx', mode: mode || 0o600 });
896
+ fs.renameSync(temp, file);
897
+ } finally {
898
+ fs.rmSync(temp, { force: true });
899
+ }
900
+ }
901
+
902
+ function cleanRetiredSettings(settings) {
903
+ let changed = false;
904
+ // Remove exactly the retired Stop hook command, and its entry only when nothing else is left in it.
905
+ const retiredHook = (hook) => hook?.type === 'command' && hook.command === RETIRED_STOP_HOOK;
906
+ const stop = Array.isArray(settings.hooks?.Stop) ? settings.hooks.Stop : [];
907
+ for (let i = stop.length - 1; i >= 0; i--) {
908
+ const hooks = stop[i]?.hooks;
909
+ if (!Array.isArray(hooks) || !hooks.some(retiredHook)) continue;
910
+ stop[i].hooks = hooks.filter((hook) => !retiredHook(hook));
911
+ if (stop[i].hooks.length === 0 && Object.keys(stop[i]).length === 1) stop.splice(i, 1);
912
+ log(' ✕ settings.json Stop hook of the retired pipeline (removed)', 'dim');
913
+ changed = true;
914
+ }
915
+ return changed;
916
+ }
917
+
939
918
  // Shared user roots need one writer from ownership checks through the last addon write.
940
919
  // Never reap a foreign lock: PID reuse and slow installs make age/liveness unsafe evidence.
941
- function withUserSkillLocks(roots, action) {
920
+ const heldSkillLocks = new Set();
921
+ function withUserSkillLocks(roots, action, { existingOnly = false } = {}) {
942
922
  const locks = [];
943
923
  const createdDirs = [];
944
924
  try {
@@ -949,18 +929,22 @@ function withUserSkillLocks(roots, action) {
949
929
  const stat = fs.lstatSync(dir, { throwIfNoEntry: false });
950
930
  if (stat && !stat.isDirectory()) throw new Error(`not a real directory: ${dir}`);
951
931
  }
952
- const missing = [];
953
- for (let dir = root; !fs.existsSync(dir); dir = path.dirname(dir)) {
954
- missing.push(dir);
955
- if (path.dirname(dir) === dir) break;
932
+ if (existingOnly && !fs.lstatSync(root, { throwIfNoEntry: false })?.isDirectory()) return;
933
+ if (!existingOnly) {
934
+ const missing = [];
935
+ for (let dir = root; !fs.existsSync(dir); dir = path.dirname(dir)) {
936
+ missing.push(dir);
937
+ if (path.dirname(dir) === dir) break;
938
+ }
939
+ fs.mkdirSync(root, { recursive: true });
940
+ createdDirs.push(...missing);
956
941
  }
957
- fs.mkdirSync(root, { recursive: true });
958
- createdDirs.push(...missing);
959
942
  const token = crypto.randomBytes(16).toString('hex');
960
943
  const fd = fs.openSync(lock, 'wx', 0o600);
961
944
  try {
962
945
  fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, token }) + '\n');
963
946
  locks.push({ lock, token });
947
+ heldSkillLocks.add(root);
964
948
  } catch (error) {
965
949
  // A failed initial write may not leave parseable JSON; verify the open file's identity.
966
950
  const opened = fs.fstatSync(fd);
@@ -988,6 +972,7 @@ function withUserSkillLocks(roots, action) {
988
972
  } finally {
989
973
  const errors = [];
990
974
  for (const { lock, token } of locks.reverse()) {
975
+ heldSkillLocks.delete(path.dirname(lock));
991
976
  try {
992
977
  if (JSON.parse(fs.readFileSync(lock, 'utf8')).token === token) fs.unlinkSync(lock);
993
978
  } catch (error) {
@@ -1007,7 +992,7 @@ function withUserSkillLocks(roots, action) {
1007
992
  }
1008
993
 
1009
994
  // Installs the targets in one scope; returns the skill roots written.
1010
- function install(targets, global, optionalNames = []) {
995
+ function install(targets, global, optionalNames = [], plan, afterInstall = () => {}) {
1011
996
  // In the home folder CLAUDE.md, AGENTS.md (agents read a parent folder's too) and
1012
997
  // .claude/settings.json would apply to every project. Same folder by identity, so a link or
1013
998
  // another spelling of the path is caught too.
@@ -1022,23 +1007,149 @@ function install(targets, global, optionalNames = []) {
1022
1007
  const written = [];
1023
1008
  const installedSkills = new Map();
1024
1009
  if (!global) readProjectGitignore();
1025
- preflightSkills(roots, optionalNames,
1026
- !global && targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
1010
+ plan ??= preflightSkills(roots, optionalNames);
1027
1011
  // AGENTS.md first, whatever order the menu gave: a new CLAUDE.md holds the block only when AGENTS.md does not.
1028
1012
  for (const target of ['agents', 'claude'].filter((name) => targets.includes(name))) {
1029
- if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root));
1013
+ if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root, [], installedSkills));
1030
1014
  else if (target === 'agents') written.push(...installAgentsProject(targets.includes('claude'), installedSkills));
1031
1015
  else written.push(...installClaudeCore(installedSkills));
1032
1016
  }
1033
1017
  for (const name of optionalNames) installLocalSkill(name, roots, written, installedSkills);
1018
+ afterInstall();
1034
1019
  log(`\n✅ devlyn ${PKG.version} installed`, 'green');
1035
1020
  if (global) log('The principles block installs per project: run npx devlyn-cli in the project without --global.');
1036
1021
  noticeGlobalDrift(roots);
1022
+ cleanupScope(global ? os.homedir() : projectDir(), global, written,
1023
+ global ? [] : targets.map((target) => target === 'agents' ? 'AGENTS.md' : 'CLAUDE.md'));
1037
1024
  if (!global) noticeProjectChanges(written, installedSkills);
1038
1025
  return roots;
1039
1026
  });
1040
1027
  }
1041
1028
 
1029
+ // Cleanup visits existing paths in the command's scope; it never creates an install.
1030
+ function cleanupScope(base, global, written, activeInstructions = []) {
1031
+ const warned = new Set();
1032
+ let found = false;
1033
+ const warning = (file, error) => {
1034
+ if (!warned.has(file)) log(`Cannot clean up ${file}: ${error.message}`, 'yellow');
1035
+ warned.add(file);
1036
+ };
1037
+ const attempt = (file, action) => {
1038
+ try { return action(); } catch (error) { warning(file, error); return false; }
1039
+ };
1040
+ const realParents = (file) => {
1041
+ let dir = base;
1042
+ if (!fs.lstatSync(dir, { throwIfNoEntry: false })?.isDirectory()) return false;
1043
+ for (const part of path.relative(base, path.dirname(file)).split(path.sep).filter(Boolean)) {
1044
+ if (part === '..') return false;
1045
+ dir = path.join(dir, part);
1046
+ if (!fs.lstatSync(dir, { throwIfNoEntry: false })?.isDirectory()) return false;
1047
+ }
1048
+ return true;
1049
+ };
1050
+ const remove = (file) => attempt(file, () => {
1051
+ if (!realParents(file) || !fs.lstatSync(file, { throwIfNoEntry: false })) return false;
1052
+ found = true;
1053
+ fs.rmSync(file, { recursive: true }); // lstat/realParents never follows a link; rm removes the link itself.
1054
+ written.push(file);
1055
+ log(` ✕ ${path.relative(base, file)} (removed)`, 'dim');
1056
+ return true;
1057
+ });
1058
+ const hash = (file) => crypto.createHash('sha256')
1059
+ .update(fs.readFileSync(file).toString('latin1').replace(/\r\n/g, '\n'), 'latin1').digest('hex');
1060
+ for (const dir of global ? ['.agents', '.codex', '.claude', '.grok'] : ['.agents', '.claude']) {
1061
+ const root = path.join(base, dir, 'skills');
1062
+ attempt(root, () => {
1063
+ if (!realParents(root) || !fs.lstatSync(root, { throwIfNoEntry: false })?.isDirectory()) return;
1064
+ const retiredRoot = dir === '.grok';
1065
+ const clean = () => {
1066
+ const marker = path.join(root, DEVLYN_INSTALL_MARKER);
1067
+ let manifest;
1068
+ if (fs.lstatSync(marker, { throwIfNoEntry: false })?.isFile()) {
1069
+ try {
1070
+ const data = JSON.parse(fs.readFileSync(marker, 'utf8'));
1071
+ if (data && data.package === PKG.name && data.schemaVersion === 1
1072
+ && (data.skills === undefined || (data.skills && typeof data.skills === 'object' && !Array.isArray(data.skills)))) manifest = data.skills || {};
1073
+ } catch (error) {
1074
+ if (!(error instanceof SyntaxError)) throw error;
1075
+ // A malformed/foreign marker is not ownership evidence and stays in place.
1076
+ }
1077
+ }
1078
+ const exact = new Set([...deprecatedSkillPaths(root),
1079
+ ...Object.keys(RENAMED_SKILLS).flatMap((name) => legacySkillPaths(root, name))]
1080
+ .map((file) => path.basename(file).toLowerCase()));
1081
+ if (retiredRoot) for (const name of [...DEVLYN_CORE_SKILLS, ...Object.values(RENAMED_SKILLS)]
1082
+ .filter((name) => name.startsWith('devlyn'))) exact.add(name);
1083
+ const removed = new Set();
1084
+ for (const name of fs.readdirSync(root)) {
1085
+ const file = path.join(root, name);
1086
+ attempt(file, () => {
1087
+ const listed = manifest && Object.prototype.hasOwnProperty.call(manifest, name);
1088
+ let owned = exact.has(name.toLowerCase()) || (retiredRoot && listed && /^devlyn[-:\uF03A]/i.test(name));
1089
+ if (!owned && RETIRED_SKILL_MD_SHA256[name.toLowerCase()]) owned = isShippedCopy(file, RETIRED_SKILL_MD_SHA256[name.toLowerCase()]);
1090
+ if (!owned && retiredRoot && name.toLowerCase() === '_shared') owned = historicalSkill(file);
1091
+ if (!owned && retiredRoot && listed && fs.lstatSync(file).isDirectory()) {
1092
+ try { owned = fingerprint(file) === manifest[name]; }
1093
+ catch { /* Links or special files cannot prove a generic tree is an unedited copy. */ }
1094
+ }
1095
+ if (owned && remove(file)) removed.add(name);
1096
+ });
1097
+ }
1098
+ if (retiredRoot && manifest) {
1099
+ if (![...warned].some((file) => path.dirname(file) === root)) remove(marker);
1100
+ } else if (manifest && [...removed].some((name) => Object.prototype.hasOwnProperty.call(manifest, name))) {
1101
+ // Keep current markers accurate after cleaning a previously listed retired entry.
1102
+ attempt(marker, () => {
1103
+ const data = JSON.parse(fs.readFileSync(marker, 'utf8'));
1104
+ for (const name of removed) delete data.skills[name];
1105
+ replaceFile(marker, JSON.stringify(data, null, 2) + '\n');
1106
+ written.push(marker);
1107
+ });
1108
+ }
1109
+ };
1110
+ if (heldSkillLocks.has(root)) clean();
1111
+ else withUserSkillLocks([root], clean, { existingOnly: true });
1112
+ });
1113
+ }
1114
+ const commands = path.join(base, '.claude', 'commands');
1115
+ attempt(commands, () => {
1116
+ if (!realParents(commands) || !fs.lstatSync(commands, { throwIfNoEntry: false })?.isDirectory()) return;
1117
+ const names = new Set(DEPRECATED_FILES.map((file) => path.basename(file).toLowerCase()));
1118
+ for (const name of fs.readdirSync(commands)) if (names.has(name.toLowerCase())) remove(path.join(commands, name));
1119
+ });
1120
+ for (const [name, hashes] of global ? [] : Object.entries(CLEANUP_HISTORY.files)) {
1121
+ const file = path.join(base, name);
1122
+ attempt(file, () => {
1123
+ if (realParents(file) && fs.lstatSync(file, { throwIfNoEntry: false })?.isFile() && hashes.includes(hash(file))) remove(file);
1124
+ });
1125
+ }
1126
+ const settingsFile = path.join(base, '.claude', 'settings.json');
1127
+ attempt(settingsFile, () => {
1128
+ if (!realParents(settingsFile) || !fs.lstatSync(settingsFile, { throwIfNoEntry: false })?.isFile()) return;
1129
+ let settings;
1130
+ try { settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8').replace(/^\uFEFF/, '')); }
1131
+ catch (error) { if (error instanceof SyntaxError) return; throw error; }
1132
+ if (settings && typeof settings === 'object' && cleanRetiredSettings(settings)) {
1133
+ found = true;
1134
+ replaceFile(settingsFile, JSON.stringify(settings, null, 2) + '\n');
1135
+ written.push(settingsFile);
1136
+ log(' ✕ .claude/settings.json retired entries (removed)', 'dim');
1137
+ }
1138
+ });
1139
+ const instructions = global ? [] : ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md', '.cursorrules', '.windsurfrules', '.github/copilot-instructions.md'];
1140
+ for (const name of instructions.filter((name) => !activeInstructions.includes(name))) {
1141
+ const file = path.join(base, name);
1142
+ attempt(file, () => {
1143
+ if (!realParents(file) || !fs.lstatSync(file, { throwIfNoEntry: false })?.isFile()) return;
1144
+ const evaluatorOnly = ['CLAUDE.md', 'AGENTS.md'].includes(name);
1145
+ if (!updateInstructions(name, '', false, false, base, evaluatorOnly)) return;
1146
+ found = true;
1147
+ if (updateInstructions(name, '', false, true, base, evaluatorOnly)) written.push(file);
1148
+ });
1149
+ }
1150
+ return found;
1151
+ }
1152
+
1042
1153
  // Advisory only: a successful install stays successful when Git is unavailable or fails.
1043
1154
  function noticeProjectChanges(paths, installedSkills = new Map()) {
1044
1155
  if (!paths.length && !installedSkills.size) return;
@@ -1095,7 +1206,7 @@ function noticeProjectChanges(paths, installedSkills = new Map()) {
1095
1206
  }
1096
1207
  if (!pending.length && !ignored.length) return;
1097
1208
  const quote = (name) => /^[a-zA-Z0-9_./-]+$/.test(name) ? name : `'${name.replace(/'/g, "'\\''")}'`;
1098
- log('Commit these changes for teammates and /devlyn-ideate drains to use them:', 'yellow');
1209
+ log('Optional — commit (and push) these changes if teammates or /devlyn-ideate task worktrees should use them:', 'yellow');
1099
1210
  if (ignored.length) log(`.gitignore hides ${ignored.length} installed files (${rule}).`, 'yellow');
1100
1211
  const commands = [];
1101
1212
  if (pending.length) commands.push(`git add -- ${pending.map(quote).join(' ')}`);
@@ -1107,7 +1218,7 @@ function noticeProjectChanges(paths, installedSkills = new Map()) {
1107
1218
  // A devlyn install in a user root this run does not refresh drifts and can shadow the
1108
1219
  // project's copy; name it without touching it.
1109
1220
  function noticeGlobalDrift(installed) {
1110
- for (const dir of ['.agents', '.codex', '.claude', '.grok']) {
1221
+ for (const dir of ['.agents', '.codex', '.claude']) {
1111
1222
  const root = path.join(os.homedir(), dir, 'skills');
1112
1223
  const marker = path.join(root, DEVLYN_INSTALL_MARKER);
1113
1224
  if (installed.includes(root) || !fs.existsSync(marker)) continue;
@@ -1117,9 +1228,8 @@ function noticeGlobalDrift(installed) {
1117
1228
  } catch (error) {
1118
1229
  version = `(unreadable marker: ${error.message})`;
1119
1230
  }
1120
- if (dir !== '.grok' && version === PKG.version) continue;
1121
- // 4.0's Grok root; Grok now reads ~/.agents/skills, so --global never refreshes it.
1122
- const advice = dir === '.grok' ? 'delete it' : 'refresh it with --global, or delete it';
1231
+ if (version === PKG.version) continue;
1232
+ const advice = 'refresh it with --global, or delete it';
1123
1233
  log(`Global devlyn ${version} in ${root.replace(os.homedir(), '~')} — ${advice}.`, 'yellow');
1124
1234
  }
1125
1235
  }
@@ -1163,15 +1273,16 @@ function update() {
1163
1273
  const projectRoots = targets.flatMap((target) => skillRoots(target, false));
1164
1274
  const roots = [...userRoots, ...projectRoots];
1165
1275
  if (!roots.length) {
1276
+ const written = [];
1277
+ const userCleanup = cleanupScope(os.homedir(), true, written);
1278
+ const projectCleanup = (home?.dev !== here.dev || home?.ino !== here.ino) && cleanupScope(projectDir(), false, written);
1279
+ if (userCleanup || projectCleanup) { noticeProjectChanges(written.filter((file) => !path.isAbsolute(file) || file.startsWith(projectDir() + path.sep))); return; }
1166
1280
  throw new InstructionError(['No devlyn install here to update. Run npx devlyn-cli to install.', ...linkNotices].join('\n'));
1167
1281
  }
1168
1282
 
1169
1283
  return withUserSkillLocks(userRoots, () => {
1170
- if (targets.length) {
1171
- readProjectGitignore();
1172
- preflightSkills(projectRoots, [], targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
1173
- }
1174
- preflightSkills(userRoots);
1284
+ if (targets.length) readProjectGitignore();
1285
+ const plan = preflightSkills(roots);
1175
1286
  // Ownership preflight has already refused unreadable or invalid markers.
1176
1287
  const current = PKG.version.split('.').map(BigInt);
1177
1288
  const newer = roots.flatMap((root) => {
@@ -1194,12 +1305,18 @@ function update() {
1194
1305
  + newer.join('\n') + '\nRun npx devlyn-cli@latest update.');
1195
1306
  }
1196
1307
 
1197
- userRoots.forEach((root) => installCoreSkills(root));
1198
- if (targets.length) install(targets, false);
1308
+ userRoots.forEach((root) => installCoreSkills(root, [], new Map()));
1309
+ if (targets.length) install(targets, false, [], plan, () => cleanupScope(os.homedir(), true, []));
1199
1310
  else {
1311
+ cleanupScope(os.homedir(), true, []);
1200
1312
  log(`\n✅ devlyn ${PKG.version} installed`, 'green');
1201
1313
  noticeGlobalDrift(userRoots);
1202
1314
  }
1315
+ if (!targets.length && (home?.dev !== here.dev || home?.ino !== here.ino)) {
1316
+ const written = [];
1317
+ cleanupScope(projectDir(), false, written);
1318
+ noticeProjectChanges(written);
1319
+ }
1203
1320
  });
1204
1321
  }
1205
1322
 
@@ -3,6 +3,7 @@ const path = require('path');
3
3
  const crypto = require('crypto');
4
4
  const { Lexer } = require('marked');
5
5
  const legacyTemplates = require('./instruction-templates.json');
6
+ const cleanupHistory = require('./cleanup-history.json');
6
7
 
7
8
  const BEGIN = '<!-- devlyn:instructions:begin';
8
9
  const END = '<!-- devlyn:instructions:end -->';
@@ -147,7 +148,7 @@ function importsAgentsMd(text) {
147
148
 
148
149
  function customInstructions(text, name, template, managed = false) {
149
150
  const known = new Set([
150
- ...legacyTemplates.paragraphs[name],
151
+ ...(legacyTemplates.paragraphs[name] || []),
151
152
  ...instructionParagraphs(template).map((p) => p.key),
152
153
  ]);
153
154
  const bom = text.startsWith('\uFEFF') ? '\uFEFF' : '';
@@ -169,6 +170,22 @@ function customInstructions(text, name, template, managed = false) {
169
170
  return bom + paragraphs.filter((_, index) => keep.has(index)).map((p) => p.text).join('');
170
171
  }
171
172
 
173
+ // Only the complete appended bytes are owned, including the separator added by old installers.
174
+ // A heading inside a fenced example is not an installed instruction block.
175
+ function removeEvaluatorBlocks(text) {
176
+ const headings = new Set(instructionParagraphs(text).filter((p) => p.level === 1).map((p) => p.start));
177
+ const ranges = [];
178
+ for (const match of text.matchAll(/\r?\n\r?\n---\r?\n\r?\n(?=# Devlyn Agent Instructions\r?\n)/g)) {
179
+ if (!headings.has(match.index + match[0].length)) continue;
180
+ for (const template of cleanupHistory.evaluators) {
181
+ const end = matchedEnd(text, match.index, template);
182
+ if (end !== null) { ranges.push({ start: match.index, end }); break; }
183
+ }
184
+ }
185
+ for (const { start, end } of ranges.reverse()) text = text.slice(0, start) + text.slice(end);
186
+ return text;
187
+ }
188
+
172
189
  // Devlyn defaults in an instruction file: a managed block, or stock paragraphs of a template
173
190
  // that a release before managed blocks copied in whole (the evidence migration replaces).
174
191
  function holdsDevlynDefaults(name, text) {
@@ -196,22 +213,23 @@ function retainFile(file, bytes) {
196
213
 
197
214
  // `withBlock` false removes the devlyn defaults the file holds, keeping everything else. `write` false writes nothing
198
215
  // and returns whether the file needs an update, a merge included.
199
- function updateInstructions(name, initial = '', withBlock = true, write = true) {
200
- const projectPackage = path.join(process.cwd(), 'package.json');
201
- if (fs.existsSync(path.join(process.cwd(), 'bin', 'devlyn.js')) && fs.existsSync(projectPackage)
216
+ function updateInstructions(name, initial = '', withBlock = true, write = true, directory = process.cwd(), evaluatorOnly = false) {
217
+ const projectPackage = path.join(directory, 'package.json');
218
+ if (fs.existsSync(path.join(directory, 'bin', 'devlyn.js')) && fs.existsSync(projectPackage)
202
219
  && JSON.parse(fs.readFileSync(projectPackage, 'utf8'))?.name === 'devlyn-cli') {
203
220
  console.log(` → Skipping ${name}; shipped instruction template in devlyn-cli source checkout`);
204
221
  return false;
205
222
  }
206
- const templatePath = path.join(__dirname, '..', name);
207
- const template = fs.readFileSync(templatePath, 'utf8');
223
+ const templateName = path.basename(name);
224
+ const templatePath = path.join(__dirname, '..', templateName);
225
+ const template = ['CLAUDE.md', 'AGENTS.md'].includes(templateName) ? fs.readFileSync(templatePath, 'utf8') : '';
208
226
  if (template.includes(BEGIN) || template.includes(END)) {
209
227
  throw new Error(`Packaged instruction template contains managed markers: ${templatePath}`);
210
228
  }
211
- const dest = path.join(process.cwd(), name);
229
+ const dest = path.join(directory, name);
212
230
  const stat = fs.lstatSync(dest, { throwIfNoEntry: false });
213
- const sourceStat = fs.statSync(templatePath);
214
- if (stat?.isFile() && stat.dev === sourceStat.dev && stat.ino === sourceStat.ino) {
231
+ const sourceStat = template ? fs.statSync(templatePath) : null;
232
+ if (stat?.isFile() && stat.dev === sourceStat?.dev && stat.ino === sourceStat?.ino) {
215
233
  console.log(` → Using source ${name}; packaged template left unchanged`);
216
234
  return false;
217
235
  }
@@ -227,15 +245,16 @@ function updateInstructions(name, initial = '', withBlock = true, write = true)
227
245
  } catch (cause) {
228
246
  throw new InstructionError(`${name} must be UTF-8; original preserved. Convert its encoding and rerun installation.`, { cause });
229
247
  }
248
+ current = removeEvaluatorBlocks(current);
230
249
  const eol = current.match(/\r?\n/)?.[0] || '\n';
231
250
  const block = managedBlock(template, eol);
232
251
  const inserted = withBlock ? block : '';
233
- const recovery = path.join(process.cwd(), '.devlyn', 'instructions');
252
+ const recovery = path.join(directory, '.devlyn', 'instructions');
234
253
  const conflict = (reason) => {
235
254
  if (!write) return true;
236
- const incoming = path.join(recovery, `${name}.${digest(block)}.incoming`);
237
- const backup = path.join(recovery, `${name}.${digest(before)}.backup`);
238
- const guide = path.join(recovery, `${name}.${digest(before)}.${digest(block)}.merge.md`);
255
+ const incoming = path.join(recovery, `${encodeURIComponent(name)}.${digest(block)}.incoming`);
256
+ const backup = path.join(recovery, `${encodeURIComponent(name)}.${digest(before)}.backup`);
257
+ const guide = path.join(recovery, `${encodeURIComponent(name)}.${digest(before)}.${digest(block)}.merge.md`);
239
258
  retainFile(backup, before);
240
259
  retainFile(incoming, Buffer.from(block));
241
260
  retainFile(guide, Buffer.from(`# Merge ${name}\n\n`
@@ -252,7 +271,9 @@ function updateInstructions(name, initial = '', withBlock = true, write = true)
252
271
  + `Installation is incomplete; earlier selected agents may already have been updated.`);
253
272
  };
254
273
  let content;
255
- if (current.includes(BEGIN) || current.includes(END)) {
274
+ if (evaluatorOnly) {
275
+ content = current;
276
+ } else if (current.includes(BEGIN) || current.includes(END)) {
256
277
  const starts = [...current.matchAll(/^\uFEFF?<!-- devlyn:instructions:begin sha256=([a-f0-9]{64}) -->\r?\n/gm)];
257
278
  const ends = [...current.matchAll(/^<!-- devlyn:instructions:end -->(?:\r?\n|$)/gm)];
258
279
  if (starts.length !== 1 || ends.length !== 1
@@ -265,29 +286,27 @@ function updateInstructions(name, initial = '', withBlock = true, write = true)
265
286
  if (end.index < bodyStart) return conflict('reversed managed markers');
266
287
  const previous = current.slice(bodyStart, end.index);
267
288
  const custom = digest(normalize(previous)) === start[1] ? ''
268
- : customInstructions(previous, name,
289
+ : customInstructions(previous, templateName,
269
290
  'Project-specific instructions outside this managed block take precedence over these defaults.\n\n' + template, true);
270
291
  const after = current.slice(end.index + end[0].length);
271
292
  content = current.slice(0, start.index + (start[0].startsWith('\uFEFF') ? 1 : 0))
272
293
  + custom + (custom ? eol : '') + inserted + after;
273
- // A block removed from the end takes along the blank lines that set it apart.
274
- if (!inserted && !after) content = content.replace(/(\r?\n)(?:\r?\n)+$/, '$1');
275
294
  } else {
276
295
  const normalized = normalize(template);
277
296
  const bodyStart = normalized.indexOf('## North Star\n');
278
297
  const preamble = normalized.slice(0, bodyStart);
279
298
  const body = normalized.slice(bodyStart);
280
299
  const ranges = legacyRanges(current, [
281
- { length: normalized.length, sha256: digest(normalized),
282
- ...(bodyStart > 0 ? { preamble, body: { length: body.length, sha256: digest(body) } } : {}) },
283
- ...(legacyTemplates[name] || []),
300
+ ...(normalized ? [{ length: normalized.length, sha256: digest(normalized),
301
+ ...(bodyStart > 0 ? { preamble, body: { length: body.length, sha256: digest(body) } } : {}) }] : []),
302
+ ...(legacyTemplates[templateName] || []),
284
303
  ]);
285
304
  if (ranges.length === 1 && !LEGACY.test(current.slice(0, ranges[0].start)
286
305
  + ranges[0].custom + current.slice(ranges[0].end))) {
287
306
  const { start, end, custom } = ranges[0];
288
307
  content = current.slice(0, start) + custom + inserted + current.slice(end);
289
308
  } else {
290
- const custom = customInstructions(current, name, template);
309
+ const custom = customInstructions(current, templateName, template);
291
310
  content = custom + (custom && inserted ? eol + eol : '') + inserted;
292
311
  }
293
312
  }
@@ -295,7 +314,7 @@ function updateInstructions(name, initial = '', withBlock = true, write = true)
295
314
  if (bytes.equals(before)) return false;
296
315
  if (!write) return true;
297
316
  if (exists) {
298
- const backup = path.join(recovery, `${name}.${digest(before)}.backup`);
317
+ const backup = path.join(recovery, `${encodeURIComponent(name)}.${digest(before)}.backup`);
299
318
  retainFile(backup, before);
300
319
  console.log(` → Saved previous ${name}: ${backup}`);
301
320
  }
@@ -306,11 +325,12 @@ function updateInstructions(name, initial = '', withBlock = true, write = true)
306
325
  if (fs.existsSync(dest) !== exists || (exists && !fs.readFileSync(dest).equals(before))) {
307
326
  throw new InstructionError(`${name} changed during installation; preserved it. Rerun installation.`);
308
327
  }
309
- fs.renameSync(temp, dest);
328
+ if (!withBlock && content === '') fs.unlinkSync(dest);
329
+ else fs.renameSync(temp, dest);
310
330
  } finally {
311
331
  if (fs.existsSync(temp)) fs.unlinkSync(temp);
312
332
  }
313
- console.log(!exists ? ` → Created ${name}`
333
+ console.log(!withBlock && content === '' ? ` ✕ ${name} (removed)` : !exists ? ` → Created ${name}`
314
334
  : ` → ${withBlock ? 'Updated Devlyn defaults in' : 'Removed Devlyn defaults from'} ${name}; project-specific instructions preserved`);
315
335
  return true;
316
336
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "source": "skills: config/skills/* and optional-skills/* trees from every first-parent HEAD commit touching those paths and every local tag, derived by scripts/update-skill-history.js using bin/skill-ownership.js normalization (LF, install-path stamps, OS metadata and Python bytecode); deprecated commands: commands/ and optional-commands/*/ files of devlyn-cli 0.0.1-0.5.8 (every published version that shipped them), LF-normalized SHA-256",
2
+ "source": "skills: config/skills/* and optional-skills/* trees from every first-parent HEAD commit touching those paths and every local tag, derived by scripts/update-skill-history.js using bin/skill-ownership.js normalization (LF, install-path stamps, OS metadata and Python bytecode)",
3
3
  "skills": {
4
4
  "_shared": [
5
5
  "015eea12cc07cfb9eb318c30855f6deacb5bf43bd508a511dffd2e48c9d82964",
@@ -619,74 +619,5 @@
619
619
  "711cd2633df2062104f12d50037a07a1012a90ab18c422cb5fdf665fcb9dc987",
620
620
  "d6a98c105a0d1952de90c39ca0734cb70cbdd116e6b4a4d45507d796bd2a9e88"
621
621
  ]
622
- },
623
- "commands": {
624
- "commands/devlyn.handoff.md": [
625
- "91d143c6305e95e4af6992eda0d127e94a394c2e91a2d409b4d41ca03c23f470"
626
- ],
627
- "commands/devlyn.clean.md": [
628
- "918c6ae867c57a4d9748237adae0df8c78a1447c7c028a0af988b59a5fbb0d9a"
629
- ],
630
- "commands/devlyn.design-system.md": [
631
- "f81166b5ba08d9ee1f5cff33c911ea387e5cb50141a9712a96fc4434c60a673f"
632
- ],
633
- "commands/devlyn.design-ui.md": [
634
- "0496a6b2dd8c6bb0474b71ef9a319a6b95bb76972d0f07a01c33f8860fe83dfb",
635
- "4e2aafcc0de6ce7f3022a353aaa9fb4330e76a50a91ab970b9b9a0f192f9f1f2"
636
- ],
637
- "commands/devlyn.discover-product.md": [
638
- "a609401d29cd2bdad7a48c7c8f096b442fe03a17c6b2c3151936c47b4a16e6c6",
639
- "1e66aa35bf300ec6167e1b888f43a92ebd9e60d501397ff0334c9a7809d433f5"
640
- ],
641
- "commands/devlyn.evaluate.md": [
642
- "8f17ccc639d54adab24c49dcf471376bab97511675d00554056a4c1f67a14115"
643
- ],
644
- "commands/devlyn.feature-spec.md": [
645
- "d442ed7ae8d14a255c37f1b120b758f69794be88e2970a8a0c0e51519344b6c7"
646
- ],
647
- "commands/devlyn.implement-ui.md": [
648
- "a8d0813781b2f7a3df5a4751c92755bf7c84c53428cf63cea34c965939b8c478"
649
- ],
650
- "commands/devlyn.product-spec.md": [
651
- "7a92d2df2586b44e2c4d9182eeb3d61f7adc2a13bed4d2cd26abcfe086585895"
652
- ],
653
- "commands/devlyn.recommend-features.md": [
654
- "2b92523017e1da10c299dafb19d84736de5084b5b46693de7fd2d6df51dc56b4"
655
- ],
656
- "commands/devlyn.resolve.md": [
657
- "48c0c3ee553f87f1f0733190effc7220c01c82837a5300ef68c9ffdf30c5d3a1",
658
- "24595766e1a81a1fcacb78261b03c3665bc686bcac8b1805d1c4db379584879f",
659
- "eaad5354cfc20ed378ffd2a2e1ca6bade1310bd2a47ab93e5942e9599c3ba9a8"
660
- ],
661
- "commands/devlyn.review.md": [
662
- "55888e08bf31ead1d359f218277cfa7efc3c6cb0f96fadc665d5a2406ff115d3",
663
- "22f532f587f6d2fbf2662e38d5073b85a030141573f86d92ad40b9d292836fb7",
664
- "7f9ce1db4ffd3cdaf9fd949e4b07de04e0931c91cd846d4ab220ff6114a45b17",
665
- "3a511fc678e599ff4b4c37fd9191399a599e1ba9ea9c40fc4afcbe29cb188d2b"
666
- ],
667
- "commands/devlyn.team-design-ui.md": [
668
- "02485911588e9f52afb5a5082fd7804ee7a41bad44323960a2afe1a16a49d628"
669
- ],
670
- "commands/devlyn.team-resolve.md": [
671
- "8f11fa0c3203c0dd5bf09b5243dcff5968a36275682c54b3a012262fc545859a",
672
- "09fc163d34dfe8906dd8d22501a028bfbbad639499acab01e5e60f06fc520382",
673
- "ccfc5083813ee50d407109616ccbc2509aa7526316cebbf7a271537abdfff3b7",
674
- "72f66be3c4c27a46e76d56d13a0d381880c054a03243188f98da2faf6dc77295",
675
- "54c52c3f27998998ed55cc648d071bbb10444761b819c59ed118b795596a4aee"
676
- ],
677
- "commands/devlyn.team-review.md": [
678
- "58b36cfe2749968d697d486a104a1b139e4312730b0ac2933550634f6ece48db",
679
- "7e5c2d93a23d51af7939dae29c70095dbd8a4a681cc06d4d578f2de29df346a3",
680
- "c8e145955a303141af4082dae174e3b3f1681e4c9698e25ae337eadd31fb883b"
681
- ],
682
- "commands/devlyn.update-docs.md": [
683
- "86945afccf24762a88d26eef3703fbf6c5a90a3343fd3de72a9cb26b6f62d3b5"
684
- ],
685
- "commands/devlyn.pencil-pull.md": [
686
- "dfdd3d19ca676558bfa2bb3ce398181b0a081ab88d1feaf69c54c706a9671631"
687
- ],
688
- "commands/devlyn.pencil-push.md": [
689
- "5f2e8b29609cbb2af72941579823bec598265ebf29cd3a375d6aa47d95b74354"
690
- ]
691
622
  }
692
623
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "4.2.18",
3
+ "version": "4.2.20",
4
4
  "description": "Loop & harness engineering for AI coding agents (Claude Code, Codex, and any AGENTS.md-driven agent) — core engineering principles for CLAUDE.md/AGENTS.md and a loop designer that splits one intent into tasks agents drain hands-free",
5
5
  "homepage": "https://github.com/fysoul17/devlyn-cli#readme",
6
6
  "bin": {