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 +3 -3
- package/bin/cleanup-history.json +97 -0
- package/bin/devlyn.js +260 -181
- package/bin/instructions.js +44 -24
- package/bin/skill-history.json +1 -70
- package/package.json +1 -1
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;
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
||
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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 =
|
|
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] ||
|
|
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
|
-
//
|
|
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) =>
|
|
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
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
|
509
|
-
|
|
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
|
-
//
|
|
518
|
-
function preflightSkills(roots, optionalNames = []
|
|
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
|
|
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.
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
written.push(
|
|
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()
|
|
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 =
|
|
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()
|
|
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
|
|
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()
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
984
|
-
|
|
985
|
-
missing
|
|
986
|
-
|
|
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
|
|
1061
|
-
else if (target === 'agents') written.push(...installAgentsProject(targets.includes('claude'), installedSkills
|
|
1062
|
-
else written.push(...installClaudeCore(installedSkills
|
|
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
|
-
|
|
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
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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'
|
|
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 (
|
|
1161
|
-
|
|
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
|
|
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()
|
|
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
|
-
|
|
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
|
}
|
package/bin/instructions.js
CHANGED
|
@@ -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(
|
|
201
|
-
if (fs.existsSync(path.join(
|
|
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
|
|
207
|
-
const
|
|
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(
|
|
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
|
|
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(
|
|
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 (
|
|
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,
|
|
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[
|
|
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,
|
|
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.
|
|
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
|
}
|
package/bin/skill-history.json
CHANGED
|
@@ -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)
|
|
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.
|
|
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": {
|