devlyn-cli 4.2.18 → 4.2.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/bin/cleanup-history.json +97 -0
- package/bin/devlyn.js +285 -168
- package/bin/instructions.js +44 -24
- package/bin/skill-history.json +1 -70
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -113,15 +113,16 @@ Upgrading to 4.1.0: `npx devlyn-cli -y` in a 4.x project keeps its Claude Code i
|
|
|
113
113
|
current and adds `AGENTS.md` + `.agents/skills/`. Earlier releases put the Codex, omp, Pi
|
|
114
114
|
and Grok skills in `~/.codex/skills`, `~/.agents/skills` and `~/.grok/skills`; a project
|
|
115
115
|
install no longer refreshes them and names each one it finds. Refresh them with `--global`
|
|
116
|
-
or delete them;
|
|
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
|
-
|
|
124
|
-
|
|
123
|
+
by their exact retired names, including edited copies and links (link targets stay intact). It
|
|
124
|
+
removes the Stop hook it added to `.claude/settings.json` while keeping your own hooks,
|
|
125
|
+
and names the other settings it once added for the pipeline
|
|
125
126
|
(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` and its `.devlyn` and git permissions) without
|
|
126
127
|
deleting them. `/devlyn-engines` keeps only the executor pin: `pair_judge_priority` and
|
|
127
128
|
`roles` in `.devlyn/engines.json` stay as written, select nothing, and `clear` removes them.
|
|
@@ -147,7 +148,7 @@ devlyn skill by its old name or path (for example Codex `[[skills.config]]` or o
|
|
|
147
148
|
`disabledExtensions`) needs the new name. A project that committed the old `devlyn:*`
|
|
148
149
|
folders checks out on Windows once that re-sync is committed.
|
|
149
150
|
|
|
150
|
-
Earlier versions of devlyn-cli shipped 16+ slash commands. The iter-0034 Phase 4 cutover (2026-05-04) and the 2026-05-14 follow-up consolidated them, 4.0.0 renamed every devlyn skill to the Agent Skills naming standard (`devlyn-<name>`: lowercase, digits and hyphens, the same as its folder), and after 4.1.0 resolve and design-ui were retired and the queue moved into ideate.
|
|
151
|
+
Earlier versions of devlyn-cli shipped 16+ slash commands. The iter-0034 Phase 4 cutover (2026-05-04) and the 2026-05-14 follow-up consolidated them, 4.0.0 renamed every devlyn skill to the Agent Skills naming standard (`devlyn-<name>`: lowercase, digits and hyphens, the same as its folder), and after 4.1.0 resolve and design-ui were retired and the queue moved into ideate. Install/update deletes exact retired Devlyn names in its scope; generic files and old instruction text are removed only with evidence matching shipped copies. Other skills, edited generic files, and your instruction text are preserved; instruction changes keep a recovery backup.
|
|
151
152
|
|
|
152
153
|
| Old command | Now use |
|
|
153
154
|
|---|---|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": "scripts/update-cleanup-history.js: LF-normalized Git blobs from first-parent config/agents-config/optional-skills history and all tags; excludes optional-addon hashes and third-party web-design-guidelines",
|
|
3
|
+
"files": {
|
|
4
|
+
".claude/agents/evaluator.md": [
|
|
5
|
+
"146a229b0218938bca2de6b426cda642ae9826dc2dfa8c3687c6fd5de9160cf6",
|
|
6
|
+
"537c4af183aac692ff2abecfd42b4fe0854549b2ecf14fca3bc6a86ae58837fa",
|
|
7
|
+
"b8a48da212e070bab9d4818630cee2cd0de9846ad4e3a34b3a45f0a9a1a913b6",
|
|
8
|
+
"d5615e73af2401ea7418e02fc76d850506e59a64fff00f084ad272e474e138ba"
|
|
9
|
+
],
|
|
10
|
+
".claude/commit-conventions.md": [
|
|
11
|
+
"7e75c25d3067dba650781c57f1d94b52090badc2cb034b00759e9c6ed74a5a94"
|
|
12
|
+
],
|
|
13
|
+
".claude/templates/prompt-templates.md": [
|
|
14
|
+
"19029bcff7c4e9b579011d93a4f6eecfbe6a260f3cee12072252a6a7402d79e7",
|
|
15
|
+
"3fb7804dbd6e026def99829dda5daccc56a68e93997d8e6f540ee98bb288329e",
|
|
16
|
+
"7318764baa5dc7cd406bc8552848653ad6368982b43faecc6618f697a9ae059f",
|
|
17
|
+
"e583be4175366101bfa0df512b1106f65b18f215b4717eadebbc8129c3d96be5"
|
|
18
|
+
],
|
|
19
|
+
".claude/templates/template-feature.spec.md": [
|
|
20
|
+
"16eb59b48f062aa0894b3fe8ba1c8f9e970652fda0bdd0d9e3248acb8e40d3bc"
|
|
21
|
+
],
|
|
22
|
+
".claude/templates/template-product-spec.md": [
|
|
23
|
+
"42b5b2c32fd72b1324432da0cf652cf11854e3ac40e020b812ef64d894116d92"
|
|
24
|
+
],
|
|
25
|
+
".cursor/rules/evaluator.md": [
|
|
26
|
+
"146a229b0218938bca2de6b426cda642ae9826dc2dfa8c3687c6fd5de9160cf6",
|
|
27
|
+
"537c4af183aac692ff2abecfd42b4fe0854549b2ecf14fca3bc6a86ae58837fa",
|
|
28
|
+
"b8a48da212e070bab9d4818630cee2cd0de9846ad4e3a34b3a45f0a9a1a913b6",
|
|
29
|
+
"d5615e73af2401ea7418e02fc76d850506e59a64fff00f084ad272e474e138ba"
|
|
30
|
+
],
|
|
31
|
+
".github/copilot/agents/evaluator.md": [
|
|
32
|
+
"146a229b0218938bca2de6b426cda642ae9826dc2dfa8c3687c6fd5de9160cf6",
|
|
33
|
+
"537c4af183aac692ff2abecfd42b4fe0854549b2ecf14fca3bc6a86ae58837fa",
|
|
34
|
+
"b8a48da212e070bab9d4818630cee2cd0de9846ad4e3a34b3a45f0a9a1a913b6",
|
|
35
|
+
"d5615e73af2401ea7418e02fc76d850506e59a64fff00f084ad272e474e138ba"
|
|
36
|
+
],
|
|
37
|
+
".windsurf/rules/evaluator.md": [
|
|
38
|
+
"146a229b0218938bca2de6b426cda642ae9826dc2dfa8c3687c6fd5de9160cf6",
|
|
39
|
+
"537c4af183aac692ff2abecfd42b4fe0854549b2ecf14fca3bc6a86ae58837fa",
|
|
40
|
+
"b8a48da212e070bab9d4818630cee2cd0de9846ad4e3a34b3a45f0a9a1a913b6",
|
|
41
|
+
"d5615e73af2401ea7418e02fc76d850506e59a64fff00f084ad272e474e138ba"
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
"skills": {
|
|
45
|
+
"code-health-standards": [
|
|
46
|
+
"962a82b5ebeafac515f81f5c68494c67dbf31c3e3e453f23c4452d4725e21355",
|
|
47
|
+
"df2838904c938aa0306ed333cc460cfc5c2cf0ffa20bb5ade57067c6e0acf9be"
|
|
48
|
+
],
|
|
49
|
+
"code-review-standards": [
|
|
50
|
+
"3bc8587a9143487328be2470dec8df17a19cb14f94e6ec916ed58de660d0e1c4",
|
|
51
|
+
"56abe5736f505c53ca9ce14087a9891cc8661be783abaec7b5b924e3f339cc2a",
|
|
52
|
+
"586dde5014f9ac2f9a13d43fa1c8c257a3ae998a3fc1b867b23b3b2e44f77eca",
|
|
53
|
+
"9c906f2b2a88c2d0aaeec9714405945da5177de6ec433f1acf520c6c4385b875"
|
|
54
|
+
],
|
|
55
|
+
"feature-gap-analysis": [
|
|
56
|
+
"d2328d990305280cf0d08443130cdba63eb5ed8040f5e5b6719c58050c4d3ada"
|
|
57
|
+
],
|
|
58
|
+
"investigate": [
|
|
59
|
+
"5025c346d1e246d49681650e7b630477d1ef67f4357e5fd7f3e5f02e5a860288"
|
|
60
|
+
],
|
|
61
|
+
"root-cause-analysis": [
|
|
62
|
+
"0eac104bfcde8bdf14d4c284be6f3f4cb243219a679bf569433c2465df84c6c3",
|
|
63
|
+
"1793dcb0f72621c78cc20c905fe5dd7a0af3115e904bf737a6a983a7b9d2c2b6",
|
|
64
|
+
"aab22d1bddc66c6a798f08cdf7be9b45fbe80ccfc928b068ef5d9e2ad951bdda",
|
|
65
|
+
"e200ddd04196435af3162a9e13595f8eb5ac68b06465026e0155e453b1bd1c93",
|
|
66
|
+
"e66d15a9585c07e819acfd89c1f6d030517130f6d8f8924bda3120307949fb2e"
|
|
67
|
+
],
|
|
68
|
+
"ui-implementation-standards": [
|
|
69
|
+
"13c709c0e2b01ae94997c271043a685200e969192d2056534bd787c5682874e8",
|
|
70
|
+
"1ff64816454171b3f879e9e5cf3c15e3d9c5f2ffc81b73262155f6bc93a1b054",
|
|
71
|
+
"505ca42b1efd53925d51c570b21df4e05cbc6bc640dd1f7f4e03f15d2d4437c5",
|
|
72
|
+
"e2473e35474e78f2b581e225cb33a1e711bb79519a02e9780fc9606e582b878f"
|
|
73
|
+
],
|
|
74
|
+
"workflow-routing": [
|
|
75
|
+
"7fec2bb20d808a873a975d64a6223e404bc5b328c800760bb77257ae6b2f467a",
|
|
76
|
+
"8bfdaeff2ce48d5a79b5653de3bbe0d56a61a7fd750ed3ad11149fec5a502ecf"
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
"evaluators": [
|
|
80
|
+
{
|
|
81
|
+
"length": 3081,
|
|
82
|
+
"sha256": "01a566bbecfb7eaef16b53c2000dc58050a3348072019868fbe23cbd6503095d"
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"length": 1559,
|
|
86
|
+
"sha256": "04403ca3008e87adbd0f75f3136a20d6d5262eb7576f8d31cec2d03686dc9898"
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"length": 2980,
|
|
90
|
+
"sha256": "481557b50619e1d47c209b1e1c916919361031f57cf2ee8f27644c2a101cfdb6"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"length": 2980,
|
|
94
|
+
"sha256": "6c7066a91aa18a62b1257956f0adf4f08603034d8a6eb2fae052a1ea8d0d9a4b"
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
}
|
package/bin/devlyn.js
CHANGED
|
@@ -12,6 +12,7 @@ const PKG = require('../package.json');
|
|
|
12
12
|
const { updateInstructions, InstructionError, holdsDevlynDefaults, importsAgentsMd } = require('./instructions');
|
|
13
13
|
const { contentHash, fingerprint, gitEnvironment, isIgnoredEntry, trackedPaths } = require('./skill-ownership');
|
|
14
14
|
const SKILL_HISTORY = require('./skill-history.json');
|
|
15
|
+
const CLEANUP_HISTORY = require('./cleanup-history.json');
|
|
15
16
|
|
|
16
17
|
// The devlyn skill bundle installed into every skill-capable agent's loader
|
|
17
18
|
// directory. Single source of truth so codex/omp/pi stay in lockstep — adding a
|
|
@@ -39,7 +40,13 @@ const RETIRED_PERMISSIONS = ['Write(.devlyn/**)', 'Edit(.devlyn/**)', 'Bash(git
|
|
|
39
40
|
|
|
40
41
|
// Every spelling a colon name can have on disk: its own and npm's U+F03A extraction alias.
|
|
41
42
|
function legacySkillPaths(root, name) {
|
|
42
|
-
|
|
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,
|
|
@@ -110,12 +89,13 @@ function hasDevlynInstall(root) {
|
|
|
110
89
|
const stat = (name) => fs.lstatSync(name, { throwIfNoEntry: false });
|
|
111
90
|
if (!stat(root)?.isDirectory()) return false;
|
|
112
91
|
const optional = new Set(OPTIONAL_ADDONS.map((addon) => addon.name));
|
|
113
|
-
const names = [...DEVLYN_CORE_SKILLS,
|
|
92
|
+
const names = [...DEVLYN_CORE_SKILLS,
|
|
114
93
|
...Object.keys(RENAMED_SKILLS)].filter((name) => name.startsWith('devlyn')
|
|
115
94
|
&& !optional.has(name.replace(/[:\uF03A]/g, '-')));
|
|
116
95
|
return !!stat(path.join(root, DEVLYN_INSTALL_LOCK))
|
|
117
96
|
|| names.some((name) => [...legacySkillPaths(root, name.replace(/^devlyn-/, 'devlyn:')),
|
|
118
97
|
path.join(root, name.replace(/:/g, '-'))].some((full) => stat(full)?.isDirectory()))
|
|
98
|
+
|| hasHistoricalRetiredSkill(root)
|
|
119
99
|
|| (!!stat(path.join(root, DEVLYN_INSTALL_MARKER)) && stat(path.join(root, '_shared'))?.isDirectory());
|
|
120
100
|
} catch (error) {
|
|
121
101
|
if (error.code === 'ENOTDIR') return false;
|
|
@@ -136,15 +116,21 @@ function hasDevlynClaude(global) {
|
|
|
136
116
|
const optional = new Set(OPTIONAL_ADDONS.map((addon) => addon.name));
|
|
137
117
|
const instructions = path.join(path.dirname(claudeDir), 'CLAUDE.md');
|
|
138
118
|
const claude = fs.lstatSync(instructions, { throwIfNoEntry: false })?.isFile() ? fs.readFileSync(instructions, 'utf8') : '';
|
|
139
|
-
|
|
140
|
-
|
|
119
|
+
const root = path.join(claudeDir, 'skills');
|
|
120
|
+
const deprecated = new Set([...deprecatedSkillPaths(root)].map((full) => path.basename(full).toLowerCase()));
|
|
121
|
+
return names(root).some((name) => {
|
|
122
|
+
if (deprecated.has(name.toLowerCase())) return historicalSkill(path.join(root, name));
|
|
123
|
+
return name === DEVLYN_INSTALL_MARKER
|
|
124
|
+
|| (name.startsWith('devlyn') && !optional.has(name.replace(/[:\uF03A]/g, '-')));
|
|
125
|
+
})
|
|
141
126
|
|| names(path.join(claudeDir, 'commands')).some((name) => name.startsWith('devlyn.'))
|
|
142
127
|
|| holdsDevlynDefaults('CLAUDE.md', claude) || importsAgentsDefaults(claude);
|
|
143
128
|
}
|
|
144
129
|
|
|
145
|
-
//
|
|
146
|
-
// when they hold the bytes a version shipped (SKILL_HISTORY.commands).
|
|
130
|
+
// Exact Devlyn command names removed by previous versions, in either scope.
|
|
147
131
|
const DEPRECATED_FILES = [
|
|
132
|
+
'commands/devlyn.ui.md',
|
|
133
|
+
'commands/devlyn.team-ui.md',
|
|
148
134
|
'commands/devlyn.handoff.md', // removed in v0.2.0
|
|
149
135
|
'commands/devlyn.clean.md', // migrated to skills in v0.6.0
|
|
150
136
|
'commands/devlyn.design-system.md',
|
|
@@ -168,7 +154,7 @@ const DEPRECATED_FILES = [
|
|
|
168
154
|
// Skill directories renamed from devlyn-* to devlyn:* in v0.7.x, plus
|
|
169
155
|
// iter-0034 Phase 4 cutover (2026-05-03): 15 user skills deleted and 3 moved
|
|
170
156
|
// to optional-skills/. Listed here so post-cutover `npx devlyn-cli` upgrades
|
|
171
|
-
// remove
|
|
157
|
+
// remove these exact retired skill names from downstream skill roots
|
|
172
158
|
// even though the source dirs no longer exist (the installer replaces only
|
|
173
159
|
// the skills it ships — without this list, deleted-from-source skills persist
|
|
174
160
|
// in user installs forever).
|
|
@@ -367,41 +353,16 @@ function listContents() {
|
|
|
367
353
|
log('');
|
|
368
354
|
}
|
|
369
355
|
|
|
370
|
-
// Old and retired skills in one skill root; nothing outside it.
|
|
371
|
-
function cleanupDeprecated(skillsDir, written) {
|
|
372
|
-
let removed = 0;
|
|
373
|
-
const deprecated = DEPRECATED_DIRS.flatMap((relPath) =>
|
|
374
|
-
legacySkillPaths(skillsDir, path.basename(relPath)).map((fullPath) => [relPath, fullPath]));
|
|
375
|
-
for (const [relPath, fullPath] of deprecated) {
|
|
376
|
-
if (fs.existsSync(fullPath)) {
|
|
377
|
-
fs.rmSync(fullPath, { recursive: true });
|
|
378
|
-
written.push(fullPath);
|
|
379
|
-
log(` ✕ ${relPath}/ (removed)`, 'dim');
|
|
380
|
-
removed++;
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
for (const [name, hashes] of Object.entries(RETIRED_SKILL_MD_SHA256)) {
|
|
384
|
-
const fullPath = path.join(skillsDir, name);
|
|
385
|
-
if (isShippedCopy(fullPath, hashes)) {
|
|
386
|
-
fs.rmSync(fullPath, { recursive: true });
|
|
387
|
-
written.push(fullPath);
|
|
388
|
-
log(` ✕ skills/${name}/ (removed)`, 'dim');
|
|
389
|
-
removed++;
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
return removed;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
356
|
// Remove every old spelling of the renamed skills in `skillsDir`, after the new core skills
|
|
396
357
|
// are in place. An optional skill found under an old spelling is installed under its new name
|
|
397
358
|
// before the old copy goes, so an interrupted migration never loses it. An unedited 0.6.0-0.7.1
|
|
398
359
|
// copy is refreshed in place. Any other folder under a 4.0 name is the user's own; `-y` leaves it.
|
|
399
|
-
function
|
|
360
|
+
function refreshRenamedSkills(skillsDir, written, installedSkills) {
|
|
400
361
|
const optional = new Set(OPTIONAL_ADDONS.filter((addon) => addon.type === 'local').map((addon) => addon.name));
|
|
401
362
|
const refreshed = [];
|
|
402
363
|
for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
|
|
403
364
|
const found = legacySkillPaths(skillsDir, oldName).filter((fullPath) => fs.existsSync(fullPath));
|
|
404
|
-
if (found.length > 0 && optional.has(newName)) {
|
|
365
|
+
if (found.length > 0 && optional.has(newName) && canRefreshRenamed(skillsDir, newName)) {
|
|
405
366
|
installOptionalSkillInto(skillsDir, newName, written, installedSkills);
|
|
406
367
|
refreshed.push(newName);
|
|
407
368
|
} else if (isShippedCopy(path.join(skillsDir, newName), PRE_STANDARD_SKILL_MD_SHA256)) {
|
|
@@ -410,11 +371,6 @@ function retireRenamedSkills(skillsDir, written, installedSkills) {
|
|
|
410
371
|
installedSkills.set(path.join(skillsDir, newName), [path.join(skillsDir, newName, 'SKILL.md')]);
|
|
411
372
|
refreshed.push(newName);
|
|
412
373
|
}
|
|
413
|
-
for (const fullPath of found) {
|
|
414
|
-
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
415
|
-
written.push(fullPath);
|
|
416
|
-
log(` ✕ ${path.basename(fullPath)}/ (renamed to ${newName})`, 'dim');
|
|
417
|
-
}
|
|
418
374
|
}
|
|
419
375
|
return refreshed;
|
|
420
376
|
}
|
|
@@ -458,44 +414,68 @@ function readInstallManifest(root) {
|
|
|
458
414
|
return marker.skills || {};
|
|
459
415
|
}
|
|
460
416
|
|
|
417
|
+
// A retired optional name must not make a foreign copy under its new name an install conflict.
|
|
418
|
+
function canRefreshRenamed(root, name) {
|
|
419
|
+
const dest = path.join(root, name);
|
|
420
|
+
if (!fs.lstatSync(dest, { throwIfNoEntry: false })) return true;
|
|
421
|
+
try { verifySkill(dest, name, readInstallManifest(root)); return true; }
|
|
422
|
+
catch { return false; } // No ownership proof: preserve the new-name entry without installing over it.
|
|
423
|
+
}
|
|
424
|
+
|
|
461
425
|
// Enumerate every directory this install may replace, migrate or remove, before
|
|
462
426
|
// touching any target. Optional addons must be selected before this preflight too.
|
|
463
427
|
function plannedSkills(root, optionalNames = []) {
|
|
464
428
|
const names = new Set([...DEVLYN_CORE_SKILLS, ...optionalNames]);
|
|
465
|
-
for (const relative of DEPRECATED_DIRS) {
|
|
466
|
-
for (const full of legacySkillPaths(root, path.basename(relative))) names.add(path.basename(full));
|
|
467
|
-
}
|
|
468
429
|
for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
|
|
469
430
|
const oldPaths = legacySkillPaths(root, oldName);
|
|
470
|
-
oldPaths.
|
|
471
|
-
if (oldPaths.some((full) => fs.lstatSync(full, { throwIfNoEntry: false }))
|
|
431
|
+
if ((oldPaths.some((full) => fs.lstatSync(full, { throwIfNoEntry: false })) && canRefreshRenamed(root, newName))
|
|
472
432
|
|| isShippedCopy(path.join(root, newName), PRE_STANDARD_SKILL_MD_SHA256)) names.add(newName);
|
|
473
433
|
}
|
|
474
|
-
for (const [name, hashes] of Object.entries(RETIRED_SKILL_MD_SHA256)) {
|
|
475
|
-
if (isShippedCopy(path.join(root, name), hashes)) names.add(name);
|
|
476
|
-
}
|
|
477
434
|
return names;
|
|
478
435
|
}
|
|
479
436
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
const
|
|
437
|
+
function verifySkill(dest, name, manifest) {
|
|
438
|
+
const actual = fingerprint(dest);
|
|
439
|
+
const logicalName = name.replace(/[:\uF03A]/g, '-');
|
|
440
|
+
const source = path.join(DEVLYN_CORE_SKILLS.includes(logicalName)
|
|
441
|
+
? path.join(CONFIG_SOURCE, 'skills') : OPTIONAL_SKILLS_SOURCE, logicalName);
|
|
442
|
+
const current = fs.existsSync(source) && fingerprint(source) === actual;
|
|
443
|
+
const historical = SKILL_HISTORY.skills[logicalName]?.includes(actual);
|
|
444
|
+
const hashes = RETIRED_SKILL_MD_SHA256[name.toLowerCase()] || PRE_STANDARD_SKILL_MD_SHA256;
|
|
445
|
+
const singleFile = isShippedCopy(dest, hashes);
|
|
446
|
+
if (!current && !historical && !singleFile && manifest[name] !== actual) {
|
|
447
|
+
throw new Error(manifest[name] ? 'modified contents (install manifest fingerprint mismatch)'
|
|
448
|
+
: fs.existsSync(source) || SKILL_HISTORY.skills[logicalName]?.length
|
|
449
|
+
? 'fingerprint mismatch with known shipped copies'
|
|
450
|
+
: 'unknown installer ownership (no fingerprint history for this copy)');
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Discovery keeps its historical ownership boundary; cleanup below uses exact retired names.
|
|
455
|
+
function deprecatedSkillPaths(root) {
|
|
456
|
+
return new Set(DEPRECATED_DIRS.flatMap((relative) => {
|
|
457
|
+
const name = path.basename(relative);
|
|
458
|
+
return [...legacySkillPaths(root, name.replace(/^devlyn-/, 'devlyn:')),
|
|
459
|
+
path.join(root, name.replace(/:/g, '-'))];
|
|
460
|
+
}));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function historicalSkill(dest) {
|
|
483
464
|
try {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
for (const relPath of commandDir ? DEPRECATED_FILES : []) {
|
|
489
|
-
const fullPath = path.join(commandDir, relPath);
|
|
490
|
-
const stat = fs.lstatSync(fullPath, { throwIfNoEntry: false });
|
|
491
|
-
if (!stat || (stat.isFile() && SKILL_HISTORY.commands[relPath].includes(contentHash(fullPath)))) continue;
|
|
492
|
-
const isTracked = tracked.some((file) => file.toLowerCase() === fullPath.toLowerCase());
|
|
493
|
-
conflicts.push(`${fullPath}: ${isTracked ? 'git-tracked; ' : ''}`
|
|
494
|
-
+ (stat.isFile() ? 'fingerprint mismatch with known shipped copies' : 'link or special file'));
|
|
495
|
-
}
|
|
496
|
-
} catch (error) {
|
|
497
|
-
conflicts.push(`${path.join(commandDir, 'commands')}: ${error.message}`);
|
|
465
|
+
return SKILL_HISTORY.skills[path.basename(dest).toLowerCase().replace(/[:\uF03A]/g, '-')]?.includes(fingerprint(dest)) === true;
|
|
466
|
+
} catch {
|
|
467
|
+
// A link, unreadable or partial tree is not evidence of an install to refresh.
|
|
468
|
+
return false;
|
|
498
469
|
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function hasHistoricalRetiredSkill(root) {
|
|
473
|
+
return [...deprecatedSkillPaths(root)].some(historicalSkill);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Validate only paths this run installs; removal-only names never refuse an install.
|
|
477
|
+
function preflightSkills(roots, optionalNames = []) {
|
|
478
|
+
const conflicts = [];
|
|
499
479
|
for (const root of roots) {
|
|
500
480
|
try {
|
|
501
481
|
// Do not follow an agent-directory or skill-root link into another installation.
|
|
@@ -520,19 +500,7 @@ function preflightSkills(roots, optionalNames = [], commandDir = null) {
|
|
|
520
500
|
if (isTracked) throw new Error('missing contents; cannot verify installer ownership');
|
|
521
501
|
continue;
|
|
522
502
|
}
|
|
523
|
-
|
|
524
|
-
const logicalName = name.replace(/[:\uF03A]/g, '-');
|
|
525
|
-
const source = path.join(DEVLYN_CORE_SKILLS.includes(logicalName)
|
|
526
|
-
? path.join(CONFIG_SOURCE, 'skills') : OPTIONAL_SKILLS_SOURCE, logicalName);
|
|
527
|
-
const current = fs.existsSync(source) && fingerprint(source) === actual;
|
|
528
|
-
const historical = SKILL_HISTORY.skills[logicalName]?.includes(actual);
|
|
529
|
-
const singleFile = isShippedCopy(dest, RETIRED_SKILL_MD_SHA256[name] || PRE_STANDARD_SKILL_MD_SHA256);
|
|
530
|
-
if (!current && !historical && !singleFile && manifest[name] !== actual) {
|
|
531
|
-
throw new Error(manifest[name] ? 'modified contents (install manifest fingerprint mismatch)'
|
|
532
|
-
: fs.existsSync(source) || SKILL_HISTORY.skills[logicalName]?.length
|
|
533
|
-
? 'fingerprint mismatch with known shipped copies'
|
|
534
|
-
: 'unknown installer ownership (no fingerprint history for this copy)');
|
|
535
|
-
}
|
|
503
|
+
verifySkill(dest, name, manifest);
|
|
536
504
|
} catch (error) {
|
|
537
505
|
conflicts.push(`${dest}: ${isTracked ? 'git-tracked; ' : ''}${error.message}`);
|
|
538
506
|
}
|
|
@@ -548,6 +516,7 @@ function preflightSkills(roots, optionalNames = [], commandDir = null) {
|
|
|
548
516
|
+ '\nFor tracked paths, also remove them from the Git index.'
|
|
549
517
|
+ '\nRerun the same command after resolving every conflict; -y does not bypass this check.');
|
|
550
518
|
}
|
|
519
|
+
return {};
|
|
551
520
|
}
|
|
552
521
|
|
|
553
522
|
function assertCompleteSkillInstall(sourceSkillsDir, skillsDir, skillNames) {
|
|
@@ -658,16 +627,19 @@ function installLocalSkill(skillName, roots, written = [], installedSkills = new
|
|
|
658
627
|
// copy under its name before the 4.0.0 rename goes only once the new one is complete.
|
|
659
628
|
function installOptionalSkillInto(target, skillName, written, installedSkills) {
|
|
660
629
|
const dest = path.join(target, skillName);
|
|
661
|
-
fs.
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
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 });
|
|
670
641
|
}
|
|
642
|
+
|
|
671
643
|
}
|
|
672
644
|
|
|
673
645
|
// A 0.6.0-0.7.1 copy is one SKILL.md, the file that identifies it. A failed swap leaves
|
|
@@ -727,10 +699,6 @@ function installCoreSkills(skillsDir, written = [], installedSkills = new Map())
|
|
|
727
699
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
728
700
|
const previous = readInstallManifest(skillsDir);
|
|
729
701
|
clearInstallMarker(skillsDir);
|
|
730
|
-
const removed = cleanupDeprecated(skillsDir, written);
|
|
731
|
-
if (removed > 0) {
|
|
732
|
-
log(`\n🧹 Cleaned up ${removed} deprecated file${removed > 1 ? 's' : ''}`, 'yellow');
|
|
733
|
-
}
|
|
734
702
|
for (const skillName of DEVLYN_CORE_SKILLS) {
|
|
735
703
|
const src = path.join(sourceSkillsDir, skillName);
|
|
736
704
|
const dest = path.join(skillsDir, skillName);
|
|
@@ -741,7 +709,7 @@ function installCoreSkills(skillsDir, written = [], installedSkills = new Map())
|
|
|
741
709
|
written.push(dest);
|
|
742
710
|
}
|
|
743
711
|
assertCompleteSkillInstall(sourceSkillsDir, skillsDir, DEVLYN_CORE_SKILLS);
|
|
744
|
-
const refreshed =
|
|
712
|
+
const refreshed = refreshRenamedSkills(skillsDir, written, installedSkills);
|
|
745
713
|
writeInstallMarker(skillsDir, [...DEVLYN_CORE_SKILLS, ...refreshed], previous);
|
|
746
714
|
written.push(path.join(skillsDir, DEVLYN_INSTALL_MARKER));
|
|
747
715
|
}
|
|
@@ -866,6 +834,8 @@ function installClaudeCore(installedSkills = new Map()) {
|
|
|
866
834
|
const settingsPath = path.join(targetDir, 'settings.json');
|
|
867
835
|
const unmergeable = (reason) => new InstructionError(`Cannot merge .claude/settings.json: ${reason}. `
|
|
868
836
|
+ 'CLAUDE.md and .claude/ are unchanged; fix the file and rerun the same install command.');
|
|
837
|
+
const settingsStat = fs.lstatSync(settingsPath, { throwIfNoEntry: false });
|
|
838
|
+
if (settingsStat && !settingsStat.isFile()) throw unmergeable('not a regular file');
|
|
869
839
|
let settings = {};
|
|
870
840
|
if (fs.existsSync(settingsPath)) {
|
|
871
841
|
try {
|
|
@@ -883,14 +853,6 @@ function installClaudeCore(installedSkills = new Map()) {
|
|
|
883
853
|
throw unmergeable('env must be a JSON object');
|
|
884
854
|
}
|
|
885
855
|
const written = installClaudeInstructions() ? ['CLAUDE.md'] : [];
|
|
886
|
-
for (const relPath of DEPRECATED_FILES) {
|
|
887
|
-
const fullPath = path.join(targetDir, relPath);
|
|
888
|
-
if (fs.existsSync(fullPath)) {
|
|
889
|
-
fs.unlinkSync(fullPath);
|
|
890
|
-
log(` ✕ ${relPath} (deprecated)`, 'dim');
|
|
891
|
-
written.push(`.claude/${relPath}`);
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
856
|
installCoreSkills(skillsDir, written, installedSkills);
|
|
895
857
|
if (updateProjectGitignore('.claude/skills/.devlyn-install.json')) written.push('.gitignore');
|
|
896
858
|
|
|
@@ -900,28 +862,17 @@ function installClaudeCore(installedSkills = new Map()) {
|
|
|
900
862
|
settings.env = {};
|
|
901
863
|
settingsChanged = true;
|
|
902
864
|
}
|
|
903
|
-
if (!settings.env
|
|
865
|
+
if (!Object.prototype.hasOwnProperty.call(settings.env, 'ENABLE_PROMPT_CACHING_1H')) {
|
|
904
866
|
settings.env.ENABLE_PROMPT_CACHING_1H = 'true';
|
|
905
867
|
settingsChanged = true;
|
|
906
868
|
}
|
|
907
|
-
|
|
908
|
-
if (!Number.isFinite(bashMaxTimeoutMs) || bashMaxTimeoutMs < 3600000) {
|
|
869
|
+
if (!Object.prototype.hasOwnProperty.call(settings.env, 'BASH_MAX_TIMEOUT_MS')) {
|
|
909
870
|
settings.env.BASH_MAX_TIMEOUT_MS = '3600000';
|
|
910
871
|
settingsChanged = true;
|
|
911
872
|
}
|
|
912
|
-
|
|
913
|
-
const retiredHook = (hook) => hook?.type === 'command' && hook.command === RETIRED_STOP_HOOK;
|
|
914
|
-
const stop = Array.isArray(settings.hooks?.Stop) ? settings.hooks.Stop : [];
|
|
915
|
-
for (let i = stop.length - 1; i >= 0; i--) {
|
|
916
|
-
const hooks = stop[i]?.hooks;
|
|
917
|
-
if (!Array.isArray(hooks) || !hooks.some(retiredHook)) continue;
|
|
918
|
-
stop[i].hooks = hooks.filter((hook) => !retiredHook(hook));
|
|
919
|
-
if (stop[i].hooks.length === 0 && Object.keys(stop[i]).length === 1) stop.splice(i, 1);
|
|
920
|
-
log(' ✕ settings.json Stop hook of the retired pipeline (removed)', 'dim');
|
|
921
|
-
settingsChanged = true;
|
|
922
|
-
}
|
|
873
|
+
settingsChanged = cleanRetiredSettings(settings) || settingsChanged;
|
|
923
874
|
if (settingsChanged) {
|
|
924
|
-
|
|
875
|
+
replaceFile(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
925
876
|
log(' → settings.json', 'dim');
|
|
926
877
|
written.push('.claude/settings.json');
|
|
927
878
|
}
|
|
@@ -936,9 +887,38 @@ function installClaudeCore(installedSkills = new Map()) {
|
|
|
936
887
|
return written;
|
|
937
888
|
}
|
|
938
889
|
|
|
890
|
+
// Replace this directory entry, never a hardlink's other names.
|
|
891
|
+
function replaceFile(file, text) {
|
|
892
|
+
const temp = `${file}.${crypto.randomUUID()}.tmp`;
|
|
893
|
+
try {
|
|
894
|
+
const mode = fs.lstatSync(file, { throwIfNoEntry: false })?.mode & 0o777;
|
|
895
|
+
fs.writeFileSync(temp, text, { flag: 'wx', mode: mode || 0o600 });
|
|
896
|
+
fs.renameSync(temp, file);
|
|
897
|
+
} finally {
|
|
898
|
+
fs.rmSync(temp, { force: true });
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function cleanRetiredSettings(settings) {
|
|
903
|
+
let changed = false;
|
|
904
|
+
// Remove exactly the retired Stop hook command, and its entry only when nothing else is left in it.
|
|
905
|
+
const retiredHook = (hook) => hook?.type === 'command' && hook.command === RETIRED_STOP_HOOK;
|
|
906
|
+
const stop = Array.isArray(settings.hooks?.Stop) ? settings.hooks.Stop : [];
|
|
907
|
+
for (let i = stop.length - 1; i >= 0; i--) {
|
|
908
|
+
const hooks = stop[i]?.hooks;
|
|
909
|
+
if (!Array.isArray(hooks) || !hooks.some(retiredHook)) continue;
|
|
910
|
+
stop[i].hooks = hooks.filter((hook) => !retiredHook(hook));
|
|
911
|
+
if (stop[i].hooks.length === 0 && Object.keys(stop[i]).length === 1) stop.splice(i, 1);
|
|
912
|
+
log(' ✕ settings.json Stop hook of the retired pipeline (removed)', 'dim');
|
|
913
|
+
changed = true;
|
|
914
|
+
}
|
|
915
|
+
return changed;
|
|
916
|
+
}
|
|
917
|
+
|
|
939
918
|
// Shared user roots need one writer from ownership checks through the last addon write.
|
|
940
919
|
// Never reap a foreign lock: PID reuse and slow installs make age/liveness unsafe evidence.
|
|
941
|
-
|
|
920
|
+
const heldSkillLocks = new Set();
|
|
921
|
+
function withUserSkillLocks(roots, action, { existingOnly = false } = {}) {
|
|
942
922
|
const locks = [];
|
|
943
923
|
const createdDirs = [];
|
|
944
924
|
try {
|
|
@@ -949,18 +929,22 @@ function withUserSkillLocks(roots, action) {
|
|
|
949
929
|
const stat = fs.lstatSync(dir, { throwIfNoEntry: false });
|
|
950
930
|
if (stat && !stat.isDirectory()) throw new Error(`not a real directory: ${dir}`);
|
|
951
931
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
missing
|
|
955
|
-
|
|
932
|
+
if (existingOnly && !fs.lstatSync(root, { throwIfNoEntry: false })?.isDirectory()) return;
|
|
933
|
+
if (!existingOnly) {
|
|
934
|
+
const missing = [];
|
|
935
|
+
for (let dir = root; !fs.existsSync(dir); dir = path.dirname(dir)) {
|
|
936
|
+
missing.push(dir);
|
|
937
|
+
if (path.dirname(dir) === dir) break;
|
|
938
|
+
}
|
|
939
|
+
fs.mkdirSync(root, { recursive: true });
|
|
940
|
+
createdDirs.push(...missing);
|
|
956
941
|
}
|
|
957
|
-
fs.mkdirSync(root, { recursive: true });
|
|
958
|
-
createdDirs.push(...missing);
|
|
959
942
|
const token = crypto.randomBytes(16).toString('hex');
|
|
960
943
|
const fd = fs.openSync(lock, 'wx', 0o600);
|
|
961
944
|
try {
|
|
962
945
|
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, token }) + '\n');
|
|
963
946
|
locks.push({ lock, token });
|
|
947
|
+
heldSkillLocks.add(root);
|
|
964
948
|
} catch (error) {
|
|
965
949
|
// A failed initial write may not leave parseable JSON; verify the open file's identity.
|
|
966
950
|
const opened = fs.fstatSync(fd);
|
|
@@ -988,6 +972,7 @@ function withUserSkillLocks(roots, action) {
|
|
|
988
972
|
} finally {
|
|
989
973
|
const errors = [];
|
|
990
974
|
for (const { lock, token } of locks.reverse()) {
|
|
975
|
+
heldSkillLocks.delete(path.dirname(lock));
|
|
991
976
|
try {
|
|
992
977
|
if (JSON.parse(fs.readFileSync(lock, 'utf8')).token === token) fs.unlinkSync(lock);
|
|
993
978
|
} catch (error) {
|
|
@@ -1007,7 +992,7 @@ function withUserSkillLocks(roots, action) {
|
|
|
1007
992
|
}
|
|
1008
993
|
|
|
1009
994
|
// Installs the targets in one scope; returns the skill roots written.
|
|
1010
|
-
function install(targets, global, optionalNames = []) {
|
|
995
|
+
function install(targets, global, optionalNames = [], plan, afterInstall = () => {}) {
|
|
1011
996
|
// In the home folder CLAUDE.md, AGENTS.md (agents read a parent folder's too) and
|
|
1012
997
|
// .claude/settings.json would apply to every project. Same folder by identity, so a link or
|
|
1013
998
|
// another spelling of the path is caught too.
|
|
@@ -1022,23 +1007,149 @@ function install(targets, global, optionalNames = []) {
|
|
|
1022
1007
|
const written = [];
|
|
1023
1008
|
const installedSkills = new Map();
|
|
1024
1009
|
if (!global) readProjectGitignore();
|
|
1025
|
-
preflightSkills(roots, optionalNames
|
|
1026
|
-
!global && targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
|
|
1010
|
+
plan ??= preflightSkills(roots, optionalNames);
|
|
1027
1011
|
// AGENTS.md first, whatever order the menu gave: a new CLAUDE.md holds the block only when AGENTS.md does not.
|
|
1028
1012
|
for (const target of ['agents', 'claude'].filter((name) => targets.includes(name))) {
|
|
1029
|
-
if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root));
|
|
1013
|
+
if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root, [], installedSkills));
|
|
1030
1014
|
else if (target === 'agents') written.push(...installAgentsProject(targets.includes('claude'), installedSkills));
|
|
1031
1015
|
else written.push(...installClaudeCore(installedSkills));
|
|
1032
1016
|
}
|
|
1033
1017
|
for (const name of optionalNames) installLocalSkill(name, roots, written, installedSkills);
|
|
1018
|
+
afterInstall();
|
|
1034
1019
|
log(`\n✅ devlyn ${PKG.version} installed`, 'green');
|
|
1035
1020
|
if (global) log('The principles block installs per project: run npx devlyn-cli in the project without --global.');
|
|
1036
1021
|
noticeGlobalDrift(roots);
|
|
1022
|
+
cleanupScope(global ? os.homedir() : projectDir(), global, written,
|
|
1023
|
+
global ? [] : targets.map((target) => target === 'agents' ? 'AGENTS.md' : 'CLAUDE.md'));
|
|
1037
1024
|
if (!global) noticeProjectChanges(written, installedSkills);
|
|
1038
1025
|
return roots;
|
|
1039
1026
|
});
|
|
1040
1027
|
}
|
|
1041
1028
|
|
|
1029
|
+
// Cleanup visits existing paths in the command's scope; it never creates an install.
|
|
1030
|
+
function cleanupScope(base, global, written, activeInstructions = []) {
|
|
1031
|
+
const warned = new Set();
|
|
1032
|
+
let found = false;
|
|
1033
|
+
const warning = (file, error) => {
|
|
1034
|
+
if (!warned.has(file)) log(`Cannot clean up ${file}: ${error.message}`, 'yellow');
|
|
1035
|
+
warned.add(file);
|
|
1036
|
+
};
|
|
1037
|
+
const attempt = (file, action) => {
|
|
1038
|
+
try { return action(); } catch (error) { warning(file, error); return false; }
|
|
1039
|
+
};
|
|
1040
|
+
const realParents = (file) => {
|
|
1041
|
+
let dir = base;
|
|
1042
|
+
if (!fs.lstatSync(dir, { throwIfNoEntry: false })?.isDirectory()) return false;
|
|
1043
|
+
for (const part of path.relative(base, path.dirname(file)).split(path.sep).filter(Boolean)) {
|
|
1044
|
+
if (part === '..') return false;
|
|
1045
|
+
dir = path.join(dir, part);
|
|
1046
|
+
if (!fs.lstatSync(dir, { throwIfNoEntry: false })?.isDirectory()) return false;
|
|
1047
|
+
}
|
|
1048
|
+
return true;
|
|
1049
|
+
};
|
|
1050
|
+
const remove = (file) => attempt(file, () => {
|
|
1051
|
+
if (!realParents(file) || !fs.lstatSync(file, { throwIfNoEntry: false })) return false;
|
|
1052
|
+
found = true;
|
|
1053
|
+
fs.rmSync(file, { recursive: true }); // lstat/realParents never follows a link; rm removes the link itself.
|
|
1054
|
+
written.push(file);
|
|
1055
|
+
log(` ✕ ${path.relative(base, file)} (removed)`, 'dim');
|
|
1056
|
+
return true;
|
|
1057
|
+
});
|
|
1058
|
+
const hash = (file) => crypto.createHash('sha256')
|
|
1059
|
+
.update(fs.readFileSync(file).toString('latin1').replace(/\r\n/g, '\n'), 'latin1').digest('hex');
|
|
1060
|
+
for (const dir of global ? ['.agents', '.codex', '.claude', '.grok'] : ['.agents', '.claude']) {
|
|
1061
|
+
const root = path.join(base, dir, 'skills');
|
|
1062
|
+
attempt(root, () => {
|
|
1063
|
+
if (!realParents(root) || !fs.lstatSync(root, { throwIfNoEntry: false })?.isDirectory()) return;
|
|
1064
|
+
const retiredRoot = dir === '.grok';
|
|
1065
|
+
const clean = () => {
|
|
1066
|
+
const marker = path.join(root, DEVLYN_INSTALL_MARKER);
|
|
1067
|
+
let manifest;
|
|
1068
|
+
if (fs.lstatSync(marker, { throwIfNoEntry: false })?.isFile()) {
|
|
1069
|
+
try {
|
|
1070
|
+
const data = JSON.parse(fs.readFileSync(marker, 'utf8'));
|
|
1071
|
+
if (data && data.package === PKG.name && data.schemaVersion === 1
|
|
1072
|
+
&& (data.skills === undefined || (data.skills && typeof data.skills === 'object' && !Array.isArray(data.skills)))) manifest = data.skills || {};
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
1075
|
+
// A malformed/foreign marker is not ownership evidence and stays in place.
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
const exact = new Set([...deprecatedSkillPaths(root),
|
|
1079
|
+
...Object.keys(RENAMED_SKILLS).flatMap((name) => legacySkillPaths(root, name))]
|
|
1080
|
+
.map((file) => path.basename(file).toLowerCase()));
|
|
1081
|
+
if (retiredRoot) for (const name of [...DEVLYN_CORE_SKILLS, ...Object.values(RENAMED_SKILLS)]
|
|
1082
|
+
.filter((name) => name.startsWith('devlyn'))) exact.add(name);
|
|
1083
|
+
const removed = new Set();
|
|
1084
|
+
for (const name of fs.readdirSync(root)) {
|
|
1085
|
+
const file = path.join(root, name);
|
|
1086
|
+
attempt(file, () => {
|
|
1087
|
+
const listed = manifest && Object.prototype.hasOwnProperty.call(manifest, name);
|
|
1088
|
+
let owned = exact.has(name.toLowerCase()) || (retiredRoot && listed && /^devlyn[-:\uF03A]/i.test(name));
|
|
1089
|
+
if (!owned && RETIRED_SKILL_MD_SHA256[name.toLowerCase()]) owned = isShippedCopy(file, RETIRED_SKILL_MD_SHA256[name.toLowerCase()]);
|
|
1090
|
+
if (!owned && retiredRoot && name.toLowerCase() === '_shared') owned = historicalSkill(file);
|
|
1091
|
+
if (!owned && retiredRoot && listed && fs.lstatSync(file).isDirectory()) {
|
|
1092
|
+
try { owned = fingerprint(file) === manifest[name]; }
|
|
1093
|
+
catch { /* Links or special files cannot prove a generic tree is an unedited copy. */ }
|
|
1094
|
+
}
|
|
1095
|
+
if (owned && remove(file)) removed.add(name);
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
if (retiredRoot && manifest) {
|
|
1099
|
+
if (![...warned].some((file) => path.dirname(file) === root)) remove(marker);
|
|
1100
|
+
} else if (manifest && [...removed].some((name) => Object.prototype.hasOwnProperty.call(manifest, name))) {
|
|
1101
|
+
// Keep current markers accurate after cleaning a previously listed retired entry.
|
|
1102
|
+
attempt(marker, () => {
|
|
1103
|
+
const data = JSON.parse(fs.readFileSync(marker, 'utf8'));
|
|
1104
|
+
for (const name of removed) delete data.skills[name];
|
|
1105
|
+
replaceFile(marker, JSON.stringify(data, null, 2) + '\n');
|
|
1106
|
+
written.push(marker);
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
};
|
|
1110
|
+
if (heldSkillLocks.has(root)) clean();
|
|
1111
|
+
else withUserSkillLocks([root], clean, { existingOnly: true });
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
const commands = path.join(base, '.claude', 'commands');
|
|
1115
|
+
attempt(commands, () => {
|
|
1116
|
+
if (!realParents(commands) || !fs.lstatSync(commands, { throwIfNoEntry: false })?.isDirectory()) return;
|
|
1117
|
+
const names = new Set(DEPRECATED_FILES.map((file) => path.basename(file).toLowerCase()));
|
|
1118
|
+
for (const name of fs.readdirSync(commands)) if (names.has(name.toLowerCase())) remove(path.join(commands, name));
|
|
1119
|
+
});
|
|
1120
|
+
for (const [name, hashes] of global ? [] : Object.entries(CLEANUP_HISTORY.files)) {
|
|
1121
|
+
const file = path.join(base, name);
|
|
1122
|
+
attempt(file, () => {
|
|
1123
|
+
if (realParents(file) && fs.lstatSync(file, { throwIfNoEntry: false })?.isFile() && hashes.includes(hash(file))) remove(file);
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
const settingsFile = path.join(base, '.claude', 'settings.json');
|
|
1127
|
+
attempt(settingsFile, () => {
|
|
1128
|
+
if (!realParents(settingsFile) || !fs.lstatSync(settingsFile, { throwIfNoEntry: false })?.isFile()) return;
|
|
1129
|
+
let settings;
|
|
1130
|
+
try { settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8').replace(/^\uFEFF/, '')); }
|
|
1131
|
+
catch (error) { if (error instanceof SyntaxError) return; throw error; }
|
|
1132
|
+
if (settings && typeof settings === 'object' && cleanRetiredSettings(settings)) {
|
|
1133
|
+
found = true;
|
|
1134
|
+
replaceFile(settingsFile, JSON.stringify(settings, null, 2) + '\n');
|
|
1135
|
+
written.push(settingsFile);
|
|
1136
|
+
log(' ✕ .claude/settings.json retired entries (removed)', 'dim');
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
const instructions = global ? [] : ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md', '.cursorrules', '.windsurfrules', '.github/copilot-instructions.md'];
|
|
1140
|
+
for (const name of instructions.filter((name) => !activeInstructions.includes(name))) {
|
|
1141
|
+
const file = path.join(base, name);
|
|
1142
|
+
attempt(file, () => {
|
|
1143
|
+
if (!realParents(file) || !fs.lstatSync(file, { throwIfNoEntry: false })?.isFile()) return;
|
|
1144
|
+
const evaluatorOnly = ['CLAUDE.md', 'AGENTS.md'].includes(name);
|
|
1145
|
+
if (!updateInstructions(name, '', false, false, base, evaluatorOnly)) return;
|
|
1146
|
+
found = true;
|
|
1147
|
+
if (updateInstructions(name, '', false, true, base, evaluatorOnly)) written.push(file);
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
return found;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1042
1153
|
// Advisory only: a successful install stays successful when Git is unavailable or fails.
|
|
1043
1154
|
function noticeProjectChanges(paths, installedSkills = new Map()) {
|
|
1044
1155
|
if (!paths.length && !installedSkills.size) return;
|
|
@@ -1095,7 +1206,7 @@ function noticeProjectChanges(paths, installedSkills = new Map()) {
|
|
|
1095
1206
|
}
|
|
1096
1207
|
if (!pending.length && !ignored.length) return;
|
|
1097
1208
|
const quote = (name) => /^[a-zA-Z0-9_./-]+$/.test(name) ? name : `'${name.replace(/'/g, "'\\''")}'`;
|
|
1098
|
-
log('
|
|
1209
|
+
log('Optional — commit (and push) these changes if teammates or /devlyn-ideate task worktrees should use them:', 'yellow');
|
|
1099
1210
|
if (ignored.length) log(`.gitignore hides ${ignored.length} installed files (${rule}).`, 'yellow');
|
|
1100
1211
|
const commands = [];
|
|
1101
1212
|
if (pending.length) commands.push(`git add -- ${pending.map(quote).join(' ')}`);
|
|
@@ -1107,7 +1218,7 @@ function noticeProjectChanges(paths, installedSkills = new Map()) {
|
|
|
1107
1218
|
// A devlyn install in a user root this run does not refresh drifts and can shadow the
|
|
1108
1219
|
// project's copy; name it without touching it.
|
|
1109
1220
|
function noticeGlobalDrift(installed) {
|
|
1110
|
-
for (const dir of ['.agents', '.codex', '.claude'
|
|
1221
|
+
for (const dir of ['.agents', '.codex', '.claude']) {
|
|
1111
1222
|
const root = path.join(os.homedir(), dir, 'skills');
|
|
1112
1223
|
const marker = path.join(root, DEVLYN_INSTALL_MARKER);
|
|
1113
1224
|
if (installed.includes(root) || !fs.existsSync(marker)) continue;
|
|
@@ -1117,9 +1228,8 @@ function noticeGlobalDrift(installed) {
|
|
|
1117
1228
|
} catch (error) {
|
|
1118
1229
|
version = `(unreadable marker: ${error.message})`;
|
|
1119
1230
|
}
|
|
1120
|
-
if (
|
|
1121
|
-
|
|
1122
|
-
const advice = dir === '.grok' ? 'delete it' : 'refresh it with --global, or delete it';
|
|
1231
|
+
if (version === PKG.version) continue;
|
|
1232
|
+
const advice = 'refresh it with --global, or delete it';
|
|
1123
1233
|
log(`Global devlyn ${version} in ${root.replace(os.homedir(), '~')} — ${advice}.`, 'yellow');
|
|
1124
1234
|
}
|
|
1125
1235
|
}
|
|
@@ -1163,15 +1273,16 @@ function update() {
|
|
|
1163
1273
|
const projectRoots = targets.flatMap((target) => skillRoots(target, false));
|
|
1164
1274
|
const roots = [...userRoots, ...projectRoots];
|
|
1165
1275
|
if (!roots.length) {
|
|
1276
|
+
const written = [];
|
|
1277
|
+
const userCleanup = cleanupScope(os.homedir(), true, written);
|
|
1278
|
+
const projectCleanup = (home?.dev !== here.dev || home?.ino !== here.ino) && cleanupScope(projectDir(), false, written);
|
|
1279
|
+
if (userCleanup || projectCleanup) { noticeProjectChanges(written.filter((file) => !path.isAbsolute(file) || file.startsWith(projectDir() + path.sep))); return; }
|
|
1166
1280
|
throw new InstructionError(['No devlyn install here to update. Run npx devlyn-cli to install.', ...linkNotices].join('\n'));
|
|
1167
1281
|
}
|
|
1168
1282
|
|
|
1169
1283
|
return withUserSkillLocks(userRoots, () => {
|
|
1170
|
-
if (targets.length)
|
|
1171
|
-
|
|
1172
|
-
preflightSkills(projectRoots, [], targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
|
|
1173
|
-
}
|
|
1174
|
-
preflightSkills(userRoots);
|
|
1284
|
+
if (targets.length) readProjectGitignore();
|
|
1285
|
+
const plan = preflightSkills(roots);
|
|
1175
1286
|
// Ownership preflight has already refused unreadable or invalid markers.
|
|
1176
1287
|
const current = PKG.version.split('.').map(BigInt);
|
|
1177
1288
|
const newer = roots.flatMap((root) => {
|
|
@@ -1194,12 +1305,18 @@ function update() {
|
|
|
1194
1305
|
+ newer.join('\n') + '\nRun npx devlyn-cli@latest update.');
|
|
1195
1306
|
}
|
|
1196
1307
|
|
|
1197
|
-
userRoots.forEach((root) => installCoreSkills(root));
|
|
1198
|
-
if (targets.length) install(targets, false);
|
|
1308
|
+
userRoots.forEach((root) => installCoreSkills(root, [], new Map()));
|
|
1309
|
+
if (targets.length) install(targets, false, [], plan, () => cleanupScope(os.homedir(), true, []));
|
|
1199
1310
|
else {
|
|
1311
|
+
cleanupScope(os.homedir(), true, []);
|
|
1200
1312
|
log(`\n✅ devlyn ${PKG.version} installed`, 'green');
|
|
1201
1313
|
noticeGlobalDrift(userRoots);
|
|
1202
1314
|
}
|
|
1315
|
+
if (!targets.length && (home?.dev !== here.dev || home?.ino !== here.ino)) {
|
|
1316
|
+
const written = [];
|
|
1317
|
+
cleanupScope(projectDir(), false, written);
|
|
1318
|
+
noticeProjectChanges(written);
|
|
1319
|
+
}
|
|
1203
1320
|
});
|
|
1204
1321
|
}
|
|
1205
1322
|
|
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": {
|