devlyn-cli 4.2.19 → 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,14 +113,14 @@ 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
- only when they are unchanged devlyn-cli copies; others are left in place and named. It
123
+ by their exact retired names, including edited copies and links (link targets stay intact). It
124
124
  removes the Stop hook it added to `.claude/settings.json` while keeping your own hooks,
125
125
  and names the other settings it once added for the pipeline
126
126
  (`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` and its `.devlyn` and git permissions) without
@@ -148,7 +148,7 @@ devlyn skill by its old name or path (for example Codex `[[skills.config]]` or o
148
148
  `disabledExtensions`) needs the new name. A project that committed the old `devlyn:*`
149
149
  folders checks out on Windows once that re-sync is committed.
150
150
 
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. Upgrades remove legacy skill directories only when they are unchanged devlyn-cli copies; others are left in place and named.
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.
152
152
 
153
153
  | Old command | Now use |
154
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,
@@ -116,7 +95,7 @@ function hasDevlynInstall(root) {
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()))
119
- || planDeprecatedSkills(root, {}).removals.size > 0
98
+ || hasHistoricalRetiredSkill(root)
120
99
  || (!!stat(path.join(root, DEVLYN_INSTALL_MARKER)) && stat(path.join(root, '_shared'))?.isDirectory());
121
100
  } catch (error) {
122
101
  if (error.code === 'ENOTDIR') return false;
@@ -138,11 +117,9 @@ function hasDevlynClaude(global) {
138
117
  const instructions = path.join(path.dirname(claudeDir), 'CLAUDE.md');
139
118
  const claude = fs.lstatSync(instructions, { throwIfNoEntry: false })?.isFile() ? fs.readFileSync(instructions, 'utf8') : '';
140
119
  const root = path.join(claudeDir, 'skills');
141
- const deprecated = new Map([...deprecatedSkillPaths(root)].map((full) => [path.basename(full).toLowerCase(), full]));
142
- const { removals } = planDeprecatedSkills(root, {});
120
+ const deprecated = new Set([...deprecatedSkillPaths(root)].map((full) => path.basename(full).toLowerCase()));
143
121
  return names(root).some((name) => {
144
- const canonical = deprecated.get(name.toLowerCase());
145
- if (canonical) return removals.has(canonical);
122
+ if (deprecated.has(name.toLowerCase())) return historicalSkill(path.join(root, name));
146
123
  return name === DEVLYN_INSTALL_MARKER
147
124
  || (name.startsWith('devlyn') && !optional.has(name.replace(/[:\uF03A]/g, '-')));
148
125
  })
@@ -150,9 +127,10 @@ function hasDevlynClaude(global) {
150
127
  || holdsDevlynDefaults('CLAUDE.md', claude) || importsAgentsDefaults(claude);
151
128
  }
152
129
 
153
- // Commands removed in previous versions; the project Claude install deletes them from .claude/
154
- // when they hold the bytes a version shipped (SKILL_HISTORY.commands).
130
+ // Exact Devlyn command names removed by previous versions, in either scope.
155
131
  const DEPRECATED_FILES = [
132
+ 'commands/devlyn.ui.md',
133
+ 'commands/devlyn.team-ui.md',
156
134
  'commands/devlyn.handoff.md', // removed in v0.2.0
157
135
  'commands/devlyn.clean.md', // migrated to skills in v0.6.0
158
136
  'commands/devlyn.design-system.md',
@@ -176,7 +154,7 @@ const DEPRECATED_FILES = [
176
154
  // Skill directories renamed from devlyn-* to devlyn:* in v0.7.x, plus
177
155
  // iter-0034 Phase 4 cutover (2026-05-03): 15 user skills deleted and 3 moved
178
156
  // to optional-skills/. Listed here so post-cutover `npx devlyn-cli` upgrades
179
- // remove verified, unchanged legacy skill dirs from downstream `~/.claude/skills/`
157
+ // remove these exact retired skill names from downstream skill roots
180
158
  // even though the source dirs no longer exist (the installer replaces only
181
159
  // the skills it ships — without this list, deleted-from-source skills persist
182
160
  // in user installs forever).
@@ -375,30 +353,16 @@ function listContents() {
375
353
  log('');
376
354
  }
377
355
 
378
- // Old and retired skills in one skill root; nothing outside it.
379
- function cleanupDeprecated(skillsDir, written, removals) {
380
- let removed = 0;
381
- for (const fullPath of removals) {
382
- if (path.dirname(fullPath) === skillsDir) {
383
- fs.rmSync(fullPath, { recursive: true });
384
- written.push(fullPath);
385
- log(` ✕ skills/${path.basename(fullPath)}/ (removed)`, 'dim');
386
- removed++;
387
- }
388
- }
389
- return removed;
390
- }
391
-
392
356
  // Remove every old spelling of the renamed skills in `skillsDir`, after the new core skills
393
357
  // are in place. An optional skill found under an old spelling is installed under its new name
394
358
  // before the old copy goes, so an interrupted migration never loses it. An unedited 0.6.0-0.7.1
395
359
  // copy is refreshed in place. Any other folder under a 4.0 name is the user's own; `-y` leaves it.
396
- function retireRenamedSkills(skillsDir, written, installedSkills) {
360
+ function refreshRenamedSkills(skillsDir, written, installedSkills) {
397
361
  const optional = new Set(OPTIONAL_ADDONS.filter((addon) => addon.type === 'local').map((addon) => addon.name));
398
362
  const refreshed = [];
399
363
  for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
400
364
  const found = legacySkillPaths(skillsDir, oldName).filter((fullPath) => fs.existsSync(fullPath));
401
- if (found.length > 0 && optional.has(newName)) {
365
+ if (found.length > 0 && optional.has(newName) && canRefreshRenamed(skillsDir, newName)) {
402
366
  installOptionalSkillInto(skillsDir, newName, written, installedSkills);
403
367
  refreshed.push(newName);
404
368
  } else if (isShippedCopy(path.join(skillsDir, newName), PRE_STANDARD_SKILL_MD_SHA256)) {
@@ -407,11 +371,6 @@ function retireRenamedSkills(skillsDir, written, installedSkills) {
407
371
  installedSkills.set(path.join(skillsDir, newName), [path.join(skillsDir, newName, 'SKILL.md')]);
408
372
  refreshed.push(newName);
409
373
  }
410
- for (const fullPath of found) {
411
- fs.rmSync(fullPath, { recursive: true, force: true });
412
- written.push(fullPath);
413
- log(` ✕ ${path.basename(fullPath)}/ (renamed to ${newName})`, 'dim');
414
- }
415
374
  }
416
375
  return refreshed;
417
376
  }
@@ -455,27 +414,34 @@ function readInstallManifest(root) {
455
414
  return marker.skills || {};
456
415
  }
457
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
+
458
425
  // Enumerate every directory this install may replace, migrate or remove, before
459
426
  // touching any target. Optional addons must be selected before this preflight too.
460
427
  function plannedSkills(root, optionalNames = []) {
461
428
  const names = new Set([...DEVLYN_CORE_SKILLS, ...optionalNames]);
462
429
  for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
463
430
  const oldPaths = legacySkillPaths(root, oldName);
464
- oldPaths.forEach((full) => names.add(path.basename(full)));
465
- if (oldPaths.some((full) => fs.lstatSync(full, { throwIfNoEntry: false }))
431
+ if ((oldPaths.some((full) => fs.lstatSync(full, { throwIfNoEntry: false })) && canRefreshRenamed(root, newName))
466
432
  || isShippedCopy(path.join(root, newName), PRE_STANDARD_SKILL_MD_SHA256)) names.add(newName);
467
433
  }
468
434
  return names;
469
435
  }
470
436
 
471
- function verifySkill(dest, name, manifest, removalOnly = false) {
437
+ function verifySkill(dest, name, manifest) {
472
438
  const actual = fingerprint(dest);
473
439
  const logicalName = name.replace(/[:\uF03A]/g, '-');
474
440
  const source = path.join(DEVLYN_CORE_SKILLS.includes(logicalName)
475
441
  ? path.join(CONFIG_SOURCE, 'skills') : OPTIONAL_SKILLS_SOURCE, logicalName);
476
- const current = !removalOnly && fs.existsSync(source) && fingerprint(source) === actual;
442
+ const current = fs.existsSync(source) && fingerprint(source) === actual;
477
443
  const historical = SKILL_HISTORY.skills[logicalName]?.includes(actual);
478
- const hashes = RETIRED_SKILL_MD_SHA256[name] || (removalOnly ? new Set() : PRE_STANDARD_SKILL_MD_SHA256);
444
+ const hashes = RETIRED_SKILL_MD_SHA256[name.toLowerCase()] || PRE_STANDARD_SKILL_MD_SHA256;
479
445
  const singleFile = isShippedCopy(dest, hashes);
480
446
  if (!current && !historical && !singleFile && manifest[name] !== actual) {
481
447
  throw new Error(manifest[name] ? 'modified contents (install manifest fingerprint mismatch)'
@@ -485,55 +451,31 @@ function verifySkill(dest, name, manifest, removalOnly = false) {
485
451
  }
486
452
  }
487
453
 
488
- // Removal-only paths never block an install: unverifiable copies stay with their owner.
454
+ // Discovery keeps its historical ownership boundary; cleanup below uses exact retired names.
489
455
  function deprecatedSkillPaths(root) {
490
- return new Set(DEPRECATED_DIRS.flatMap((relative) => legacySkillPaths(root, path.basename(relative))));
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
+ }));
491
461
  }
492
462
 
493
- function planDeprecatedSkills(root, manifest) {
494
- const removals = new Set();
495
- const retained = new Set();
496
- for (const dest of deprecatedSkillPaths(root)) {
497
- try {
498
- if (!fs.lstatSync(dest, { throwIfNoEntry: false })) continue;
499
- verifySkill(dest, path.basename(dest), manifest, true);
500
- removals.add(dest);
501
- } catch (error) {
502
- retained.add(dest);
503
- }
463
+ function historicalSkill(dest) {
464
+ try {
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;
504
469
  }
505
- return { removals, retained };
506
470
  }
507
471
 
508
- function planSkillRemovals(root, manifest) {
509
- const plan = planDeprecatedSkills(root, manifest);
510
- for (const [name, hashes] of Object.entries(RETIRED_SKILL_MD_SHA256)) {
511
- const dest = path.join(root, name);
512
- if (isShippedCopy(dest, hashes)) plan.removals.add(dest);
513
- }
514
- return plan;
472
+ function hasHistoricalRetiredSkill(root) {
473
+ return [...deprecatedSkillPaths(root)].some(historicalSkill);
515
474
  }
516
475
 
517
- // `commandDir`: the project .claude/ whose DEPRECATED_FILES this install deletes, or null.
518
- function preflightSkills(roots, optionalNames = [], commandDir = null) {
476
+ // Validate only paths this run installs; removal-only names never refuse an install.
477
+ function preflightSkills(roots, optionalNames = []) {
519
478
  const conflicts = [];
520
- const plan = { removals: new Set(), retained: new Set() };
521
- try {
522
- // Do not follow a commands link into another installation either.
523
- const commands = commandDir && fs.lstatSync(path.join(commandDir, 'commands'), { throwIfNoEntry: false });
524
- if (commands && !commands.isDirectory()) throw new Error('not a real directory');
525
- const tracked = commandDir ? trackedPaths(path.join(commandDir, 'commands')) : [];
526
- for (const relPath of commandDir ? DEPRECATED_FILES : []) {
527
- const fullPath = path.join(commandDir, relPath);
528
- const stat = fs.lstatSync(fullPath, { throwIfNoEntry: false });
529
- if (!stat || (stat.isFile() && SKILL_HISTORY.commands[relPath].includes(contentHash(fullPath)))) continue;
530
- const isTracked = tracked.some((file) => file.toLowerCase() === fullPath.toLowerCase());
531
- conflicts.push(`${fullPath}: ${isTracked ? 'git-tracked; ' : ''}`
532
- + (stat.isFile() ? 'fingerprint mismatch with known shipped copies' : 'link or special file'));
533
- }
534
- } catch (error) {
535
- conflicts.push(`${path.join(commandDir, 'commands')}: ${error.message}`);
536
- }
537
479
  for (const root of roots) {
538
480
  try {
539
481
  // Do not follow an agent-directory or skill-root link into another installation.
@@ -549,9 +491,6 @@ function preflightSkills(roots, optionalNames = [], commandDir = null) {
549
491
  conflicts.push(`${path.join(root, DEVLYN_INSTALL_MARKER)}: ${error.message}`);
550
492
  continue;
551
493
  }
552
- const deprecated = planSkillRemovals(root, manifest);
553
- for (const dest of deprecated.removals) plan.removals.add(dest);
554
- for (const dest of deprecated.retained) plan.retained.add(dest);
555
494
  for (const name of plannedSkills(root, optionalNames)) {
556
495
  const dest = path.join(root, name);
557
496
  const isTracked = tracked.some((file) => file.toLowerCase() === dest.toLowerCase()
@@ -577,7 +516,7 @@ function preflightSkills(roots, optionalNames = [], commandDir = null) {
577
516
  + '\nFor tracked paths, also remove them from the Git index.'
578
517
  + '\nRerun the same command after resolving every conflict; -y does not bypass this check.');
579
518
  }
580
- return plan;
519
+ return {};
581
520
  }
582
521
 
583
522
  function assertCompleteSkillInstall(sourceSkillsDir, skillsDir, skillNames) {
@@ -688,16 +627,19 @@ function installLocalSkill(skillName, roots, written = [], installedSkills = new
688
627
  // copy under its name before the 4.0.0 rename goes only once the new one is complete.
689
628
  function installOptionalSkillInto(target, skillName, written, installedSkills) {
690
629
  const dest = path.join(target, skillName);
691
- fs.rmSync(dest, { recursive: true, force: true });
692
- installedSkills.set(dest, copyRecursive(path.join(OPTIONAL_SKILLS_SOURCE, skillName), dest, target));
693
- assertCompleteSkillInstall(OPTIONAL_SKILLS_SOURCE, target, [skillName]);
694
- written.push(dest);
695
- const oldName = Object.keys(RENAMED_SKILLS).find((name) => RENAMED_SKILLS[name] === skillName);
696
- for (const fullPath of oldName ? legacySkillPaths(target, oldName) : []) {
697
- if (!fs.existsSync(fullPath)) continue;
698
- fs.rmSync(fullPath, { recursive: true, force: true });
699
- 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 });
700
641
  }
642
+
701
643
  }
702
644
 
703
645
  // A 0.6.0-0.7.1 copy is one SKILL.md, the file that identifies it. A failed swap leaves
@@ -751,17 +693,12 @@ function installAddon(addon) {
751
693
 
752
694
  // The core skills into one skill root, replacing older copies and retired skills, then the
753
695
  // marker that records a complete install.
754
- function installCoreSkills(skillsDir, written = [], installedSkills = new Map(), plan) {
696
+ function installCoreSkills(skillsDir, written = [], installedSkills = new Map()) {
755
697
  const sourceSkillsDir = path.join(CONFIG_SOURCE, 'skills');
756
698
  log(`\n📁 Installing devlyn skills to ${skillsDir.replace(os.homedir(), '~')}`, 'green');
757
699
  fs.mkdirSync(skillsDir, { recursive: true });
758
700
  const previous = readInstallManifest(skillsDir);
759
- plan ??= planSkillRemovals(skillsDir, previous);
760
701
  clearInstallMarker(skillsDir);
761
- const removed = cleanupDeprecated(skillsDir, written, plan.removals);
762
- if (removed > 0) {
763
- log(`\n🧹 Cleaned up ${removed} deprecated file${removed > 1 ? 's' : ''}`, 'yellow');
764
- }
765
702
  for (const skillName of DEVLYN_CORE_SKILLS) {
766
703
  const src = path.join(sourceSkillsDir, skillName);
767
704
  const dest = path.join(skillsDir, skillName);
@@ -772,7 +709,7 @@ function installCoreSkills(skillsDir, written = [], installedSkills = new Map(),
772
709
  written.push(dest);
773
710
  }
774
711
  assertCompleteSkillInstall(sourceSkillsDir, skillsDir, DEVLYN_CORE_SKILLS);
775
- const refreshed = retireRenamedSkills(skillsDir, written, installedSkills);
712
+ const refreshed = refreshRenamedSkills(skillsDir, written, installedSkills);
776
713
  writeInstallMarker(skillsDir, [...DEVLYN_CORE_SKILLS, ...refreshed], previous);
777
714
  written.push(path.join(skillsDir, DEVLYN_INSTALL_MARKER));
778
715
  }
@@ -848,7 +785,7 @@ function agentsMdIsClaudeMd() {
848
785
  return target !== null && fs.existsSync(target) && fs.realpathSync.native(target) === fs.realpathSync.native(claude);
849
786
  }
850
787
 
851
- function installAgentsProject(withClaude, installedSkills = new Map(), plan) {
788
+ function installAgentsProject(withClaude, installedSkills = new Map()) {
852
789
  const written = [];
853
790
  if (!agentsMdIsClaudeMd()) {
854
791
  if (updateInstructions('AGENTS.md')) written.push('AGENTS.md');
@@ -858,7 +795,7 @@ function installAgentsProject(withClaude, installedSkills = new Map(), plan) {
858
795
  throw new InstructionError('AGENTS.md is CLAUDE.md under another name here. Choose CLAUDE.md as well '
859
796
  + '(npx devlyn-cli -y --claude) so the devlyn block is written once, into CLAUDE.md.');
860
797
  }
861
- installCoreSkills(skillRoots('agents', false)[0], written, installedSkills, plan);
798
+ installCoreSkills(skillRoots('agents', false)[0], written, installedSkills);
862
799
  if (updateProjectGitignore('.agents/skills/.devlyn-install.json')) written.push('.gitignore');
863
800
  return written;
864
801
  }
@@ -890,13 +827,15 @@ function installClaudeInstructions() {
890
827
  }
891
828
 
892
829
  // Project CLAUDE.md and .claude/: skills and settings.
893
- function installClaudeCore(installedSkills = new Map(), plan) {
830
+ function installClaudeCore(installedSkills = new Map()) {
894
831
  const skillsDir = skillRoots('claude', false)[0];
895
832
  const targetDir = path.dirname(skillsDir);
896
833
  // Read before any write, so settings this install cannot merge leave the Claude target as it was.
897
834
  const settingsPath = path.join(targetDir, 'settings.json');
898
835
  const unmergeable = (reason) => new InstructionError(`Cannot merge .claude/settings.json: ${reason}. `
899
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');
900
839
  let settings = {};
901
840
  if (fs.existsSync(settingsPath)) {
902
841
  try {
@@ -914,15 +853,7 @@ function installClaudeCore(installedSkills = new Map(), plan) {
914
853
  throw unmergeable('env must be a JSON object');
915
854
  }
916
855
  const written = installClaudeInstructions() ? ['CLAUDE.md'] : [];
917
- for (const relPath of DEPRECATED_FILES) {
918
- const fullPath = path.join(targetDir, relPath);
919
- if (fs.existsSync(fullPath)) {
920
- fs.unlinkSync(fullPath);
921
- log(` ✕ ${relPath} (deprecated)`, 'dim');
922
- written.push(`.claude/${relPath}`);
923
- }
924
- }
925
- installCoreSkills(skillsDir, written, installedSkills, plan);
856
+ installCoreSkills(skillsDir, written, installedSkills);
926
857
  if (updateProjectGitignore('.claude/skills/.devlyn-install.json')) written.push('.gitignore');
927
858
 
928
859
  // One-hour foreground Bash calls and one-hour prompt caching serve a drain host that waits in the foreground.
@@ -931,28 +862,17 @@ function installClaudeCore(installedSkills = new Map(), plan) {
931
862
  settings.env = {};
932
863
  settingsChanged = true;
933
864
  }
934
- if (!settings.env.ENABLE_PROMPT_CACHING_1H) {
865
+ if (!Object.prototype.hasOwnProperty.call(settings.env, 'ENABLE_PROMPT_CACHING_1H')) {
935
866
  settings.env.ENABLE_PROMPT_CACHING_1H = 'true';
936
867
  settingsChanged = true;
937
868
  }
938
- const bashMaxTimeoutMs = Number.parseInt(settings.env.BASH_MAX_TIMEOUT_MS, 10);
939
- if (!Number.isFinite(bashMaxTimeoutMs) || bashMaxTimeoutMs < 3600000) {
869
+ if (!Object.prototype.hasOwnProperty.call(settings.env, 'BASH_MAX_TIMEOUT_MS')) {
940
870
  settings.env.BASH_MAX_TIMEOUT_MS = '3600000';
941
871
  settingsChanged = true;
942
872
  }
943
- // Remove exactly the retired Stop hook command, and its entry only when nothing else is left in it.
944
- const retiredHook = (hook) => hook?.type === 'command' && hook.command === RETIRED_STOP_HOOK;
945
- const stop = Array.isArray(settings.hooks?.Stop) ? settings.hooks.Stop : [];
946
- for (let i = stop.length - 1; i >= 0; i--) {
947
- const hooks = stop[i]?.hooks;
948
- if (!Array.isArray(hooks) || !hooks.some(retiredHook)) continue;
949
- stop[i].hooks = hooks.filter((hook) => !retiredHook(hook));
950
- if (stop[i].hooks.length === 0 && Object.keys(stop[i]).length === 1) stop.splice(i, 1);
951
- log(' ✕ settings.json Stop hook of the retired pipeline (removed)', 'dim');
952
- settingsChanged = true;
953
- }
873
+ settingsChanged = cleanRetiredSettings(settings) || settingsChanged;
954
874
  if (settingsChanged) {
955
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
875
+ replaceFile(settingsPath, JSON.stringify(settings, null, 2) + '\n');
956
876
  log(' → settings.json', 'dim');
957
877
  written.push('.claude/settings.json');
958
878
  }
@@ -967,9 +887,38 @@ function installClaudeCore(installedSkills = new Map(), plan) {
967
887
  return written;
968
888
  }
969
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
+
970
918
  // Shared user roots need one writer from ownership checks through the last addon write.
971
919
  // Never reap a foreign lock: PID reuse and slow installs make age/liveness unsafe evidence.
972
- function withUserSkillLocks(roots, action) {
920
+ const heldSkillLocks = new Set();
921
+ function withUserSkillLocks(roots, action, { existingOnly = false } = {}) {
973
922
  const locks = [];
974
923
  const createdDirs = [];
975
924
  try {
@@ -980,18 +929,22 @@ function withUserSkillLocks(roots, action) {
980
929
  const stat = fs.lstatSync(dir, { throwIfNoEntry: false });
981
930
  if (stat && !stat.isDirectory()) throw new Error(`not a real directory: ${dir}`);
982
931
  }
983
- const missing = [];
984
- for (let dir = root; !fs.existsSync(dir); dir = path.dirname(dir)) {
985
- missing.push(dir);
986
- 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);
987
941
  }
988
- fs.mkdirSync(root, { recursive: true });
989
- createdDirs.push(...missing);
990
942
  const token = crypto.randomBytes(16).toString('hex');
991
943
  const fd = fs.openSync(lock, 'wx', 0o600);
992
944
  try {
993
945
  fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, token }) + '\n');
994
946
  locks.push({ lock, token });
947
+ heldSkillLocks.add(root);
995
948
  } catch (error) {
996
949
  // A failed initial write may not leave parseable JSON; verify the open file's identity.
997
950
  const opened = fs.fstatSync(fd);
@@ -1019,6 +972,7 @@ function withUserSkillLocks(roots, action) {
1019
972
  } finally {
1020
973
  const errors = [];
1021
974
  for (const { lock, token } of locks.reverse()) {
975
+ heldSkillLocks.delete(path.dirname(lock));
1022
976
  try {
1023
977
  if (JSON.parse(fs.readFileSync(lock, 'utf8')).token === token) fs.unlinkSync(lock);
1024
978
  } catch (error) {
@@ -1038,7 +992,7 @@ function withUserSkillLocks(roots, action) {
1038
992
  }
1039
993
 
1040
994
  // Installs the targets in one scope; returns the skill roots written.
1041
- function install(targets, global, optionalNames = [], plan) {
995
+ function install(targets, global, optionalNames = [], plan, afterInstall = () => {}) {
1042
996
  // In the home folder CLAUDE.md, AGENTS.md (agents read a parent folder's too) and
1043
997
  // .claude/settings.json would apply to every project. Same folder by identity, so a link or
1044
998
  // another spelling of the path is caught too.
@@ -1053,30 +1007,147 @@ function install(targets, global, optionalNames = [], plan) {
1053
1007
  const written = [];
1054
1008
  const installedSkills = new Map();
1055
1009
  if (!global) readProjectGitignore();
1056
- plan ??= preflightSkills(roots, optionalNames,
1057
- !global && targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
1010
+ plan ??= preflightSkills(roots, optionalNames);
1058
1011
  // AGENTS.md first, whatever order the menu gave: a new CLAUDE.md holds the block only when AGENTS.md does not.
1059
1012
  for (const target of ['agents', 'claude'].filter((name) => targets.includes(name))) {
1060
- if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root, [], installedSkills, plan));
1061
- else if (target === 'agents') written.push(...installAgentsProject(targets.includes('claude'), installedSkills, plan));
1062
- else written.push(...installClaudeCore(installedSkills, plan));
1013
+ if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root, [], installedSkills));
1014
+ else if (target === 'agents') written.push(...installAgentsProject(targets.includes('claude'), installedSkills));
1015
+ else written.push(...installClaudeCore(installedSkills));
1063
1016
  }
1064
1017
  for (const name of optionalNames) installLocalSkill(name, roots, written, installedSkills);
1018
+ afterInstall();
1065
1019
  log(`\n✅ devlyn ${PKG.version} installed`, 'green');
1066
1020
  if (global) log('The principles block installs per project: run npx devlyn-cli in the project without --global.');
1067
1021
  noticeGlobalDrift(roots);
1068
- noticeRetainedSkills(plan.retained);
1022
+ cleanupScope(global ? os.homedir() : projectDir(), global, written,
1023
+ global ? [] : targets.map((target) => target === 'agents' ? 'AGENTS.md' : 'CLAUDE.md'));
1069
1024
  if (!global) noticeProjectChanges(written, installedSkills);
1070
1025
  return roots;
1071
1026
  });
1072
1027
  }
1073
1028
 
1074
- function noticeRetainedSkills(retained) {
1075
- if (!retained.size) return;
1076
- log('Left in place (not verified as unchanged devlyn-cli copies):\n'
1077
- + [...retained].map((dest) => ` ${dest}`).join('\n')
1078
- + '\nThese retired skills may still be visible to agents and may call removed helpers. '
1079
- + 'Move them out of the skills folder, or delete them, if you no longer use them.', 'yellow');
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;
1080
1151
  }
1081
1152
 
1082
1153
  // Advisory only: a successful install stays successful when Git is unavailable or fails.
@@ -1147,7 +1218,7 @@ function noticeProjectChanges(paths, installedSkills = new Map()) {
1147
1218
  // A devlyn install in a user root this run does not refresh drifts and can shadow the
1148
1219
  // project's copy; name it without touching it.
1149
1220
  function noticeGlobalDrift(installed) {
1150
- for (const dir of ['.agents', '.codex', '.claude', '.grok']) {
1221
+ for (const dir of ['.agents', '.codex', '.claude']) {
1151
1222
  const root = path.join(os.homedir(), dir, 'skills');
1152
1223
  const marker = path.join(root, DEVLYN_INSTALL_MARKER);
1153
1224
  if (installed.includes(root) || !fs.existsSync(marker)) continue;
@@ -1157,9 +1228,8 @@ function noticeGlobalDrift(installed) {
1157
1228
  } catch (error) {
1158
1229
  version = `(unreadable marker: ${error.message})`;
1159
1230
  }
1160
- if (dir !== '.grok' && version === PKG.version) continue;
1161
- // 4.0's Grok root; Grok now reads ~/.agents/skills, so --global never refreshes it.
1162
- 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';
1163
1233
  log(`Global devlyn ${version} in ${root.replace(os.homedir(), '~')} — ${advice}.`, 'yellow');
1164
1234
  }
1165
1235
  }
@@ -1203,12 +1273,16 @@ function update() {
1203
1273
  const projectRoots = targets.flatMap((target) => skillRoots(target, false));
1204
1274
  const roots = [...userRoots, ...projectRoots];
1205
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; }
1206
1280
  throw new InstructionError(['No devlyn install here to update. Run npx devlyn-cli to install.', ...linkNotices].join('\n'));
1207
1281
  }
1208
1282
 
1209
1283
  return withUserSkillLocks(userRoots, () => {
1210
1284
  if (targets.length) readProjectGitignore();
1211
- const plan = preflightSkills(roots, [], targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
1285
+ const plan = preflightSkills(roots);
1212
1286
  // Ownership preflight has already refused unreadable or invalid markers.
1213
1287
  const current = PKG.version.split('.').map(BigInt);
1214
1288
  const newer = roots.flatMap((root) => {
@@ -1231,12 +1305,17 @@ function update() {
1231
1305
  + newer.join('\n') + '\nRun npx devlyn-cli@latest update.');
1232
1306
  }
1233
1307
 
1234
- userRoots.forEach((root) => installCoreSkills(root, [], new Map(), plan));
1235
- if (targets.length) install(targets, false, [], plan);
1308
+ userRoots.forEach((root) => installCoreSkills(root, [], new Map()));
1309
+ if (targets.length) install(targets, false, [], plan, () => cleanupScope(os.homedir(), true, []));
1236
1310
  else {
1311
+ cleanupScope(os.homedir(), true, []);
1237
1312
  log(`\n✅ devlyn ${PKG.version} installed`, 'green');
1238
1313
  noticeGlobalDrift(userRoots);
1239
- noticeRetainedSkills(plan.retained);
1314
+ }
1315
+ if (!targets.length && (home?.dev !== here.dev || home?.ino !== here.ino)) {
1316
+ const written = [];
1317
+ cleanupScope(projectDir(), false, written);
1318
+ noticeProjectChanges(written);
1240
1319
  }
1241
1320
  });
1242
1321
  }
@@ -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.19",
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": {