devlyn-cli 4.1.0 → 4.2.1

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.
Files changed (201) hide show
  1. package/AGENTS.md +10 -94
  2. package/CLAUDE.md +7 -160
  3. package/LICENSE +21 -0
  4. package/README.md +68 -111
  5. package/bin/devlyn.js +265 -259
  6. package/bin/instruction-templates.json +43 -2
  7. package/bin/instructions.js +29 -8
  8. package/bin/skill-history.json +140 -0
  9. package/bin/skill-ownership.js +67 -0
  10. package/config/skills/_shared/adapters/claude.md +8 -85
  11. package/config/skills/_shared/adapters/codex.md +14 -34
  12. package/config/skills/_shared/adapters/omp.md +1 -11
  13. package/config/skills/_shared/codex-monitored.sh +4 -54
  14. package/config/skills/_shared/engine-doctor.sh +18 -58
  15. package/config/skills/_shared/engine-preflight.md +15 -47
  16. package/config/skills/_shared/expected-contract.py +242 -0
  17. package/config/skills/_shared/expected.schema.json +10 -95
  18. package/config/skills/_shared/invocation-receipt.py +26 -757
  19. package/config/skills/_shared/platform-support.py +37 -28
  20. package/config/skills/_shared/role-config.py +100 -421
  21. package/config/skills/_shared/runtime-principles.md +17 -93
  22. package/config/skills/_shared/task-complete.py +490 -285
  23. package/config/skills/_shared/task-completion.md +235 -0
  24. package/config/skills/devlyn-engines/SKILL.md +9 -11
  25. package/config/skills/devlyn-ideate/SKILL.md +57 -107
  26. package/config/skills/devlyn-ideate/references/elicitation.md +18 -99
  27. package/config/skills/devlyn-ideate/references/loop.md +110 -0
  28. package/config/skills/devlyn-ideate/references/package-format.md +128 -0
  29. package/config/skills/devlyn-ideate/scripts/acceptance.py +795 -0
  30. package/config/skills/devlyn-ideate/scripts/queue.py +1780 -0
  31. package/node_modules/@inquirer/ansi/LICENSE +22 -0
  32. package/node_modules/@inquirer/ansi/README.md +121 -0
  33. package/node_modules/@inquirer/ansi/dist/index.d.ts +14 -0
  34. package/node_modules/@inquirer/ansi/dist/index.js +21 -0
  35. package/node_modules/@inquirer/ansi/package.json +79 -0
  36. package/node_modules/@inquirer/checkbox/LICENSE +22 -0
  37. package/node_modules/@inquirer/checkbox/README.md +265 -0
  38. package/node_modules/@inquirer/checkbox/dist/index.d.ts +67 -0
  39. package/node_modules/@inquirer/checkbox/dist/index.js +210 -0
  40. package/node_modules/@inquirer/checkbox/package.json +93 -0
  41. package/node_modules/@inquirer/core/LICENSE +22 -0
  42. package/node_modules/@inquirer/core/README.md +424 -0
  43. package/node_modules/@inquirer/core/dist/index.d.ts +13 -0
  44. package/node_modules/@inquirer/core/dist/index.js +12 -0
  45. package/node_modules/@inquirer/core/dist/lib/Separator.d.ts +10 -0
  46. package/node_modules/@inquirer/core/dist/lib/Separator.js +21 -0
  47. package/node_modules/@inquirer/core/dist/lib/__probe/p1.d.ts +9 -0
  48. package/node_modules/@inquirer/core/dist/lib/__probe/p1.js +4 -0
  49. package/node_modules/@inquirer/core/dist/lib/__probe/p2.d.ts +9 -0
  50. package/node_modules/@inquirer/core/dist/lib/__probe/p2.js +3 -0
  51. package/node_modules/@inquirer/core/dist/lib/__probe/p3.d.ts +9 -0
  52. package/node_modules/@inquirer/core/dist/lib/__probe/p3.js +5 -0
  53. package/node_modules/@inquirer/core/dist/lib/create-prompt.d.ts +11 -0
  54. package/node_modules/@inquirer/core/dist/lib/create-prompt.js +185 -0
  55. package/node_modules/@inquirer/core/dist/lib/errors.d.ts +20 -0
  56. package/node_modules/@inquirer/core/dist/lib/errors.js +21 -0
  57. package/node_modules/@inquirer/core/dist/lib/hook-engine.d.ts +17 -0
  58. package/node_modules/@inquirer/core/dist/lib/hook-engine.js +109 -0
  59. package/node_modules/@inquirer/core/dist/lib/key.d.ts +15 -0
  60. package/node_modules/@inquirer/core/dist/lib/key.js +34 -0
  61. package/node_modules/@inquirer/core/dist/lib/make-theme.d.ts +3 -0
  62. package/node_modules/@inquirer/core/dist/lib/make-theme.js +33 -0
  63. package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.d.ts +15 -0
  64. package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.js +121 -0
  65. package/node_modules/@inquirer/core/dist/lib/screen-manager.d.ts +14 -0
  66. package/node_modules/@inquirer/core/dist/lib/screen-manager.js +84 -0
  67. package/node_modules/@inquirer/core/dist/lib/theme.d.ts +166 -0
  68. package/node_modules/@inquirer/core/dist/lib/theme.js +29 -0
  69. package/node_modules/@inquirer/core/dist/lib/use-effect.d.ts +2 -0
  70. package/node_modules/@inquirer/core/dist/lib/use-effect.js +11 -0
  71. package/node_modules/@inquirer/core/dist/lib/use-keypress.d.ts +3 -0
  72. package/node_modules/@inquirer/core/dist/lib/use-keypress.js +20 -0
  73. package/node_modules/@inquirer/core/dist/lib/use-memo.d.ts +1 -0
  74. package/node_modules/@inquirer/core/dist/lib/use-memo.js +14 -0
  75. package/node_modules/@inquirer/core/dist/lib/use-prefix.d.ts +5 -0
  76. package/node_modules/@inquirer/core/dist/lib/use-prefix.js +35 -0
  77. package/node_modules/@inquirer/core/dist/lib/use-ref.d.ts +6 -0
  78. package/node_modules/@inquirer/core/dist/lib/use-ref.js +4 -0
  79. package/node_modules/@inquirer/core/dist/lib/use-state.d.ts +7 -0
  80. package/node_modules/@inquirer/core/dist/lib/use-state.js +28 -0
  81. package/node_modules/@inquirer/core/dist/lib/utils.d.ts +13 -0
  82. package/node_modules/@inquirer/core/dist/lib/utils.js +25 -0
  83. package/node_modules/@inquirer/core/package.json +98 -0
  84. package/node_modules/@inquirer/figures/LICENSE +22 -0
  85. package/node_modules/@inquirer/figures/dist/index.d.ts +275 -0
  86. package/node_modules/@inquirer/figures/dist/index.js +315 -0
  87. package/node_modules/@inquirer/figures/package.json +81 -0
  88. package/node_modules/@inquirer/select/LICENSE +22 -0
  89. package/node_modules/@inquirer/select/README.md +258 -0
  90. package/node_modules/@inquirer/select/dist/index.d.ts +42 -0
  91. package/node_modules/@inquirer/select/dist/index.js +191 -0
  92. package/node_modules/@inquirer/select/package.json +93 -0
  93. package/node_modules/@inquirer/type/LICENSE +22 -0
  94. package/node_modules/@inquirer/type/dist/index.d.ts +2 -0
  95. package/node_modules/@inquirer/type/dist/index.js +2 -0
  96. package/node_modules/@inquirer/type/dist/inquirer.d.ts +35 -0
  97. package/node_modules/@inquirer/type/dist/inquirer.js +1 -0
  98. package/node_modules/@inquirer/type/dist/utils.d.ts +45 -0
  99. package/node_modules/@inquirer/type/dist/utils.js +16 -0
  100. package/node_modules/@inquirer/type/package.json +88 -0
  101. package/node_modules/cli-width/LICENSE +13 -0
  102. package/node_modules/cli-width/README.md +71 -0
  103. package/node_modules/cli-width/index.d.ts +13 -0
  104. package/node_modules/cli-width/index.js +49 -0
  105. package/node_modules/cli-width/package.json +40 -0
  106. package/node_modules/fast-string-truncated-width/dist/index.d.ts +4 -0
  107. package/node_modules/fast-string-truncated-width/dist/index.js +111 -0
  108. package/node_modules/fast-string-truncated-width/dist/types.d.ts +19 -0
  109. package/node_modules/fast-string-truncated-width/dist/types.js +2 -0
  110. package/node_modules/fast-string-truncated-width/dist/utils.d.ts +4 -0
  111. package/node_modules/fast-string-truncated-width/dist/utils.js +20 -0
  112. package/node_modules/fast-string-truncated-width/license +21 -0
  113. package/node_modules/fast-string-truncated-width/package.json +35 -0
  114. package/node_modules/fast-string-truncated-width/readme.md +59 -0
  115. package/node_modules/fast-string-width/dist/index.d.ts +4 -0
  116. package/node_modules/fast-string-width/dist/index.js +14 -0
  117. package/node_modules/fast-string-width/license +21 -0
  118. package/node_modules/fast-string-width/package.json +34 -0
  119. package/node_modules/fast-string-width/readme.md +42 -0
  120. package/node_modules/fast-wrap-ansi/LICENSE +23 -0
  121. package/node_modules/fast-wrap-ansi/README.md +26 -0
  122. package/node_modules/fast-wrap-ansi/lib/main.d.ts +6 -0
  123. package/node_modules/fast-wrap-ansi/lib/main.js +218 -0
  124. package/node_modules/fast-wrap-ansi/package.json +51 -0
  125. package/node_modules/mute-stream/LICENSE +15 -0
  126. package/node_modules/mute-stream/README.md +68 -0
  127. package/node_modules/mute-stream/lib/index.js +142 -0
  128. package/node_modules/mute-stream/package.json +54 -0
  129. package/node_modules/signal-exit/LICENSE.txt +16 -0
  130. package/node_modules/signal-exit/README.md +74 -0
  131. package/node_modules/signal-exit/dist/cjs/browser.d.ts +12 -0
  132. package/node_modules/signal-exit/dist/cjs/browser.d.ts.map +1 -0
  133. package/node_modules/signal-exit/dist/cjs/browser.js +10 -0
  134. package/node_modules/signal-exit/dist/cjs/browser.js.map +1 -0
  135. package/node_modules/signal-exit/dist/cjs/index.d.ts +48 -0
  136. package/node_modules/signal-exit/dist/cjs/index.d.ts.map +1 -0
  137. package/node_modules/signal-exit/dist/cjs/index.js +279 -0
  138. package/node_modules/signal-exit/dist/cjs/index.js.map +1 -0
  139. package/node_modules/signal-exit/dist/cjs/package.json +3 -0
  140. package/node_modules/signal-exit/dist/cjs/signals.d.ts +29 -0
  141. package/node_modules/signal-exit/dist/cjs/signals.d.ts.map +1 -0
  142. package/node_modules/signal-exit/dist/cjs/signals.js +42 -0
  143. package/node_modules/signal-exit/dist/cjs/signals.js.map +1 -0
  144. package/node_modules/signal-exit/dist/mjs/browser.d.ts +12 -0
  145. package/node_modules/signal-exit/dist/mjs/browser.d.ts.map +1 -0
  146. package/node_modules/signal-exit/dist/mjs/browser.js +4 -0
  147. package/node_modules/signal-exit/dist/mjs/browser.js.map +1 -0
  148. package/node_modules/signal-exit/dist/mjs/index.d.ts +48 -0
  149. package/node_modules/signal-exit/dist/mjs/index.d.ts.map +1 -0
  150. package/node_modules/signal-exit/dist/mjs/index.js +275 -0
  151. package/node_modules/signal-exit/dist/mjs/index.js.map +1 -0
  152. package/node_modules/signal-exit/dist/mjs/package.json +3 -0
  153. package/node_modules/signal-exit/dist/mjs/signals.d.ts +29 -0
  154. package/node_modules/signal-exit/dist/mjs/signals.d.ts.map +1 -0
  155. package/node_modules/signal-exit/dist/mjs/signals.js +39 -0
  156. package/node_modules/signal-exit/dist/mjs/signals.js.map +1 -0
  157. package/node_modules/signal-exit/package.json +106 -0
  158. package/package.json +17 -4
  159. package/config/commit-conventions.md +0 -28
  160. package/config/skills/_shared/adapters/README.md +0 -74
  161. package/config/skills/_shared/adapters/grok.md +0 -100
  162. package/config/skills/_shared/archive_run.py +0 -969
  163. package/config/skills/_shared/codex-config.md +0 -81
  164. package/config/skills/_shared/collect-codex-findings.py +0 -505
  165. package/config/skills/_shared/finish-gate.py +0 -521
  166. package/config/skills/_shared/grok-anchor-guard.py +0 -78
  167. package/config/skills/_shared/judge-output-parser.py +0 -200
  168. package/config/skills/_shared/judge-role-evidence.py +0 -320
  169. package/config/skills/_shared/phase-prompt-render.py +0 -183
  170. package/config/skills/_shared/process-evidence.py +0 -926
  171. package/config/skills/_shared/resolve-bootstrap.py +0 -1410
  172. package/config/skills/_shared/resolve-stop-hook.py +0 -359
  173. package/config/skills/_shared/run-bounded.py +0 -58
  174. package/config/skills/_shared/spec-verify-check.py +0 -5083
  175. package/config/skills/_shared/state-phase-write.py +0 -5989
  176. package/config/skills/_shared/terminal-claim-check.py +0 -777
  177. package/config/skills/_shared/verify-merge-findings.py +0 -3528
  178. package/config/skills/devlyn-design-ui/SKILL.md +0 -650
  179. package/config/skills/devlyn-ideate/references/from-spec-mode.md +0 -61
  180. package/config/skills/devlyn-ideate/references/project-mode.md +0 -80
  181. package/config/skills/devlyn-ideate/references/spec-template.md +0 -121
  182. package/config/skills/devlyn-ideate/references/templates/decision.md +0 -48
  183. package/config/skills/devlyn-ideate/references/templates/roadmap.md +0 -50
  184. package/config/skills/devlyn-ideate/references/templates/vision.md +0 -44
  185. package/config/skills/devlyn-queue/SKILL.md +0 -66
  186. package/config/skills/devlyn-queue/scripts/append.py +0 -94
  187. package/config/skills/devlyn-resolve/SKILL.md +0 -379
  188. package/config/skills/devlyn-resolve/references/free-form-mode.md +0 -71
  189. package/config/skills/devlyn-resolve/references/outer-loop.md +0 -23
  190. package/config/skills/devlyn-resolve/references/phases/build-gate.md +0 -75
  191. package/config/skills/devlyn-resolve/references/phases/cleanup.md +0 -33
  192. package/config/skills/devlyn-resolve/references/phases/implement.md +0 -44
  193. package/config/skills/devlyn-resolve/references/phases/plan.md +0 -39
  194. package/config/skills/devlyn-resolve/references/phases/probe-derive.md +0 -261
  195. package/config/skills/devlyn-resolve/references/phases/surface-close.md +0 -34
  196. package/config/skills/devlyn-resolve/references/phases/verify.md +0 -310
  197. package/config/skills/devlyn-resolve/references/state-schema.md +0 -152
  198. package/config/skills/devlyn-resolve/references/task-completion.md +0 -160
  199. package/config/templates/prompt-templates.md +0 -71
  200. package/config/templates/template-feature.spec.md +0 -255
  201. package/config/templates/template-product-spec.md +0 -680
package/bin/devlyn.js CHANGED
@@ -4,34 +4,37 @@ const crypto = require('crypto');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
- const readline = require('readline');
8
7
  const { execSync } = require('child_process');
9
8
 
10
9
  const CONFIG_SOURCE = path.join(__dirname, '..', 'config');
11
10
  const OPTIONAL_SKILLS_SOURCE = path.join(__dirname, '..', 'optional-skills');
12
11
  const PKG = require('../package.json');
13
- const { updateInstructions, InstructionError, holdsDevlynDefaults } = require('./instructions');
12
+ const { updateInstructions, InstructionError, holdsDevlynDefaults, importsAgentsMd } = require('./instructions');
13
+ const { contentHash, fingerprint, isOsMetadata, trackedPaths } = require('./skill-ownership');
14
+ const SKILL_HISTORY = require('./skill-history.json');
14
15
 
15
16
  // The devlyn skill bundle installed into every skill-capable agent's loader
16
17
  // directory. Single source of truth so codex/omp/pi stay in lockstep — adding a
17
18
  // skill here installs it everywhere.
18
- const DEVLYN_CORE_SKILLS = ['devlyn-resolve', 'devlyn-ideate', 'devlyn-design-ui', 'devlyn-engines', 'devlyn-queue', '_shared'];
19
+ const DEVLYN_CORE_SKILLS = ['devlyn-ideate', 'devlyn-engines', '_shared'];
19
20
  // 4.0.0 renamed the colon-named skills to the Agent Skills standard (`[a-z0-9-]`, name ==
20
21
  // folder): Git for Windows cannot check out a folder with ':' in its name. Installs from 3.x
21
22
  // and earlier keep the old folder under either spelling (':' or npm's U+F03A extraction
22
23
  // alias). Both are removed, and an optional skill that was installed is installed again
23
24
  // under its new name in the same place.
24
25
  const RENAMED_SKILLS = {
25
- 'devlyn:resolve': 'devlyn-resolve',
26
26
  'devlyn:ideate': 'devlyn-ideate',
27
- 'devlyn:design-ui': 'devlyn-design-ui',
28
27
  'devlyn:engines': 'devlyn-engines',
29
- 'devlyn:queue': 'devlyn-queue',
30
28
  'devlyn:pencil-pull': 'devlyn-pencil-pull',
31
29
  'devlyn:pencil-push': 'devlyn-pencil-push',
32
30
  'devlyn:reap': 'devlyn-reap',
33
31
  };
34
32
  const DEVLYN_INSTALL_MARKER = '.devlyn-install.json';
33
+ // Project settings earlier releases added for the retired pipeline.
34
+ const RETIRED_STOP_HOOK = 'python3 "$CLAUDE_PROJECT_DIR/.claude/skills/_shared/resolve-stop-hook.py"';
35
+ const RETIRED_ENV = ['CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS'];
36
+ const RETIRED_PERMISSIONS = ['Write(.devlyn/**)', 'Edit(.devlyn/**)', 'Bash(git add *)', 'Bash(git commit *)',
37
+ 'Bash(git diff *)', 'Bash(git status *)', 'Bash(git log *)'];
35
38
 
36
39
  // Every spelling a colon name can have on disk: its own and npm's U+F03A extraction alias.
37
40
  function legacySkillPaths(root, name) {
@@ -80,13 +83,13 @@ const RETIRED_SKILL_MD_SHA256 = {
80
83
  ]),
81
84
  };
82
85
 
83
- // Whether `dir` is a real folder whose only file (dotfiles such as .DS_Store aside) is a SKILL.md
86
+ // Whether `dir` is a real folder whose only file, besides OS metadata, is a SKILL.md
84
87
  // with an LF-normalized SHA-256 in `hashes`: an unedited copy devlyn-cli shipped, not something
85
88
  // the user added to or wrote.
86
89
  function isShippedCopy(dir, hashes) {
87
90
  const skill = path.join(dir, 'SKILL.md');
88
91
  return fs.lstatSync(dir, { throwIfNoEntry: false })?.isDirectory() === true
89
- && fs.readdirSync(dir).filter((name) => !name.startsWith('.')).length === 1
92
+ && fs.readdirSync(dir).filter((name) => !isOsMetadata(path.join(dir, name))).length === 1
90
93
  && fs.lstatSync(skill, { throwIfNoEntry: false })?.isFile() === true
91
94
  && hashes.has(crypto.createHash('sha256')
92
95
  .update(fs.readFileSync(skill, 'utf8').replace(/\r\n/g, '\n')).digest('hex'));
@@ -102,24 +105,25 @@ function skillRoots(target, global) {
102
105
 
103
106
  // A devlyn Claude install in this scope. Globally only the marker a `--global --claude` run
104
107
  // writes counts; in a project also a devlyn skill (0.6.0 and later), command (0.2-0.5) or
105
- // CLAUDE.md with devlyn defaults, which a team may commit while ignoring .claude/. An optional
106
- // devlyn skill is not one: 4.0.1 put those into .claude/skills without the Claude target. A
107
- // CLAUDE.md link (often to AGENTS.md) is not the Claude target's file: updateInstructions
108
- // refuses links.
108
+ // CLAUDE.md with devlyn defaults, held or imported from AGENTS.md, which a team may commit
109
+ // while ignoring .claude/. An optional devlyn skill is not one: 4.0.1 put those into
110
+ // .claude/skills without the Claude target. A CLAUDE.md link (often to AGENTS.md) is not the
111
+ // Claude target's file: updateInstructions refuses links.
109
112
  function hasDevlynClaude(global) {
110
113
  const claudeDir = path.dirname(skillRoots('claude', global)[0]);
111
114
  if (global) return fs.existsSync(path.join(claudeDir, 'skills', DEVLYN_INSTALL_MARKER));
112
115
  const names = (dir) => (fs.existsSync(dir) ? fs.readdirSync(dir) : []);
113
116
  const optional = new Set(OPTIONAL_ADDONS.map((addon) => addon.name));
114
117
  const instructions = path.join(path.dirname(claudeDir), 'CLAUDE.md');
118
+ const claude = fs.lstatSync(instructions, { throwIfNoEntry: false })?.isFile() ? fs.readFileSync(instructions, 'utf8') : '';
115
119
  return names(path.join(claudeDir, 'skills')).some((name) => name === DEVLYN_INSTALL_MARKER
116
120
  || (name.startsWith('devlyn') && !optional.has(name.replace(/[:\uF03A]/g, '-'))))
117
121
  || names(path.join(claudeDir, 'commands')).some((name) => name.startsWith('devlyn.'))
118
- || (fs.lstatSync(instructions, { throwIfNoEntry: false })?.isFile() === true
119
- && holdsDevlynDefaults('CLAUDE.md', fs.readFileSync(instructions, 'utf8')));
122
+ || holdsDevlynDefaults('CLAUDE.md', claude) || importsAgentsDefaults(claude);
120
123
  }
121
124
 
122
- // Commands removed in previous versions; the project Claude install deletes them from .claude/.
125
+ // Commands removed in previous versions; the project Claude install deletes them from .claude/
126
+ // when they hold the bytes a version shipped (SKILL_HISTORY.commands).
123
127
  const DEPRECATED_FILES = [
124
128
  'commands/devlyn.handoff.md', // removed in v0.2.0
125
129
  'commands/devlyn.clean.md', // migrated to skills in v0.6.0
@@ -144,7 +148,7 @@ const DEPRECATED_FILES = [
144
148
  // Skill directories renamed from devlyn-* to devlyn:* in v0.7.x, plus
145
149
  // iter-0034 Phase 4 cutover (2026-05-03): 15 user skills deleted and 3 moved
146
150
  // to optional-skills/. Listed here so post-cutover `npx devlyn-cli` upgrades
147
- // force-remove stale legacy skill dirs from downstream `~/.claude/skills/`
151
+ // remove verified, unchanged legacy skill dirs from downstream `~/.claude/skills/`
148
152
  // even though the source dirs no longer exist (the installer replaces only
149
153
  // the skills it ships — without this list, deleted-from-source skills persist
150
154
  // in user installs forever).
@@ -183,6 +187,15 @@ const DEPRECATED_DIRS = [
183
187
  // so users who previously opted in get their stale copies purged on upgrade.
184
188
  'skills/devlyn:team-design-ui',
185
189
  'skills/devlyn:design-system',
190
+ // Retired after 4.1.0 (design-ui deleted; the intent queue moved into
191
+ // devlyn-ideate; resolve retired). All were core skills, so their 3.x and
192
+ // 4.x folders go.
193
+ 'skills/devlyn-design-ui',
194
+ 'skills/devlyn:design-ui',
195
+ 'skills/devlyn-queue',
196
+ 'skills/devlyn:queue',
197
+ 'skills/devlyn-resolve',
198
+ 'skills/devlyn:resolve',
186
199
  ];
187
200
 
188
201
  function projectDir() {
@@ -257,10 +270,7 @@ const OPTIONAL_ADDONS = [
257
270
  { name: 'anthropics/skills', desc: 'Official Anthropic skill-creator with eval framework and description optimizer', type: 'external' },
258
271
  { name: 'Leonxlnx/taste-skill', desc: 'Premium frontend design skills — modern layouts, animations, and visual refinement', type: 'external' },
259
272
  // MCP servers (installed via claude mcp add)
260
- // Note: the Codex integration uses the local `codex` CLI binary (not MCP).
261
- // Install the CLI separately per https://platform.openai.com/docs/codex — the
262
- // pair/risk-probe routes fail closed when Codex is required but unavailable.
263
- { name: 'playwright', desc: 'Playwright MCP for browser testing — powers /devlyn-resolve BUILD_GATE browser tier', type: 'mcp', command: 'npx -y @playwright/mcp@latest' },
273
+ { name: 'playwright', desc: 'Playwright MCP for browser testing', type: 'mcp', command: 'npx -y @playwright/mcp@latest' },
264
274
  ];
265
275
 
266
276
  function log(msg, color = 'reset') {
@@ -306,23 +316,8 @@ function listContents() {
306
316
  showLogo();
307
317
  log('─'.repeat(44), 'dim');
308
318
 
309
- const templatesDir = path.join(CONFIG_SOURCE, 'templates');
310
319
  const skillsDir = path.join(CONFIG_SOURCE, 'skills');
311
320
 
312
- // List templates
313
- if (fs.existsSync(templatesDir)) {
314
- const templates = fs.readdirSync(templatesDir).filter((f) => f.endsWith('.md'));
315
- if (templates.length > 0) {
316
- log('\n📄 Templates:', 'blue');
317
- templates.forEach((file) => {
318
- const name = file.replace('.md', '');
319
- const desc = getDescription(path.join(templatesDir, file));
320
- log(` ${COLORS.green}${name}${COLORS.reset}`);
321
- if (desc) log(` ${COLORS.dim}${desc}${COLORS.reset}`);
322
- });
323
- }
324
- }
325
-
326
321
  // List skills
327
322
  if (fs.existsSync(skillsDir)) {
328
323
  const skills = fs.readdirSync(skillsDir).filter((d) => {
@@ -381,15 +376,22 @@ function cleanupDeprecated(skillsDir) {
381
376
  // copy is refreshed in place. Any other folder under a 4.0 name is the user's own; `-y` leaves it.
382
377
  function retireRenamedSkills(skillsDir) {
383
378
  const optional = new Set(OPTIONAL_ADDONS.filter((addon) => addon.type === 'local').map((addon) => addon.name));
379
+ const refreshed = [];
384
380
  for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
385
381
  const found = legacySkillPaths(skillsDir, oldName).filter((fullPath) => fs.existsSync(fullPath));
386
- if (found.length > 0 && optional.has(newName)) installOptionalSkillInto(skillsDir, newName);
387
- else if (isShippedCopy(path.join(skillsDir, newName), PRE_STANDARD_SKILL_MD_SHA256)) refreshPreStandardCopy(skillsDir, newName);
382
+ if (found.length > 0 && optional.has(newName)) {
383
+ installOptionalSkillInto(skillsDir, newName);
384
+ refreshed.push(newName);
385
+ } else if (isShippedCopy(path.join(skillsDir, newName), PRE_STANDARD_SKILL_MD_SHA256)) {
386
+ refreshPreStandardCopy(skillsDir, newName);
387
+ refreshed.push(newName);
388
+ }
388
389
  for (const fullPath of found) {
389
390
  fs.rmSync(fullPath, { recursive: true, force: true });
390
391
  log(` ✕ ${path.basename(fullPath)}/ (renamed to ${newName})`, 'dim');
391
392
  }
392
393
  }
394
+ return refreshed;
393
395
  }
394
396
 
395
397
  function copyRecursive(src, dest, baseDir) {
@@ -417,6 +419,101 @@ function clearInstallMarker(skillsDir) {
417
419
  fs.rmSync(path.join(skillsDir, DEVLYN_INSTALL_MARKER), { force: true });
418
420
  }
419
421
 
422
+ function readInstallManifest(root) {
423
+ const markerPath = path.join(root, DEVLYN_INSTALL_MARKER);
424
+ const stat = fs.lstatSync(markerPath, { throwIfNoEntry: false });
425
+ if (!stat) return {};
426
+ if (!stat.isFile()) throw new Error(`not a regular install marker: ${markerPath}`);
427
+ const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
428
+ return marker?.package === PKG.name && marker.schemaVersion === 1 ? marker.skills || {} : {};
429
+ }
430
+
431
+ // Enumerate every directory this install may replace, migrate or remove, before
432
+ // touching any target. Optional addons must be selected before this preflight too.
433
+ function plannedSkills(root, optionalNames = []) {
434
+ const names = new Set([...DEVLYN_CORE_SKILLS, ...optionalNames]);
435
+ for (const relative of DEPRECATED_DIRS) {
436
+ for (const full of legacySkillPaths(root, path.basename(relative))) names.add(path.basename(full));
437
+ }
438
+ for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
439
+ const oldPaths = legacySkillPaths(root, oldName);
440
+ oldPaths.forEach((full) => names.add(path.basename(full)));
441
+ if (oldPaths.some((full) => fs.lstatSync(full, { throwIfNoEntry: false }))
442
+ || isShippedCopy(path.join(root, newName), PRE_STANDARD_SKILL_MD_SHA256)) names.add(newName);
443
+ }
444
+ for (const [name, hashes] of Object.entries(RETIRED_SKILL_MD_SHA256)) {
445
+ if (isShippedCopy(path.join(root, name), hashes)) names.add(name);
446
+ }
447
+ return names;
448
+ }
449
+
450
+ // `commandDir`: the project .claude/ whose DEPRECATED_FILES this install deletes, or null.
451
+ function preflightSkills(roots, optionalNames = [], commandDir = null) {
452
+ const conflicts = [];
453
+ try {
454
+ // Do not follow a commands link into another installation either.
455
+ const commands = commandDir && fs.lstatSync(path.join(commandDir, 'commands'), { throwIfNoEntry: false });
456
+ if (commands && !commands.isDirectory()) throw new Error('not a real directory');
457
+ const tracked = commandDir ? trackedPaths(path.join(commandDir, 'commands')) : [];
458
+ for (const relPath of commandDir ? DEPRECATED_FILES : []) {
459
+ const fullPath = path.join(commandDir, relPath);
460
+ const stat = fs.lstatSync(fullPath, { throwIfNoEntry: false });
461
+ if (!stat || (stat.isFile() && SKILL_HISTORY.commands[relPath].includes(contentHash(fullPath)))) continue;
462
+ const isTracked = tracked.some((file) => file.toLowerCase() === fullPath.toLowerCase());
463
+ conflicts.push(`${fullPath}: ${isTracked ? 'git-tracked; ' : ''}`
464
+ + (stat.isFile() ? 'fingerprint mismatch with known shipped copies' : 'link or special file'));
465
+ }
466
+ } catch (error) {
467
+ conflicts.push(`${path.join(commandDir, 'commands')}: ${error.message}`);
468
+ }
469
+ for (const root of roots) {
470
+ try {
471
+ // Do not follow an agent-directory or skill-root link into another installation.
472
+ for (const dir of [path.dirname(root), root]) {
473
+ const stat = fs.lstatSync(dir, { throwIfNoEntry: false });
474
+ if (stat && !stat.isDirectory()) throw new Error(`not a real directory: ${dir}`);
475
+ }
476
+ const tracked = trackedPaths(root);
477
+ const manifest = readInstallManifest(root);
478
+ for (const name of plannedSkills(root, optionalNames)) {
479
+ const dest = path.join(root, name);
480
+ const isTracked = tracked.some((file) => file.toLowerCase() === dest.toLowerCase()
481
+ || file.toLowerCase().startsWith(dest.toLowerCase() + path.sep));
482
+ try {
483
+ if (!fs.lstatSync(dest, { throwIfNoEntry: false })) {
484
+ if (isTracked) throw new Error('missing contents; cannot verify installer ownership');
485
+ continue;
486
+ }
487
+ const actual = fingerprint(dest);
488
+ const logicalName = name.replace(/[:\uF03A]/g, '-');
489
+ const source = path.join(DEVLYN_CORE_SKILLS.includes(logicalName)
490
+ ? path.join(CONFIG_SOURCE, 'skills') : OPTIONAL_SKILLS_SOURCE, logicalName);
491
+ const current = fs.existsSync(source) && fingerprint(source) === actual;
492
+ const historical = SKILL_HISTORY.skills[logicalName]?.includes(actual);
493
+ const singleFile = isShippedCopy(dest, RETIRED_SKILL_MD_SHA256[name] || PRE_STANDARD_SKILL_MD_SHA256);
494
+ if (!current && !historical && !singleFile && manifest[name] !== actual) {
495
+ throw new Error(manifest[name] ? 'modified contents (install manifest fingerprint mismatch)'
496
+ : fs.existsSync(source) || SKILL_HISTORY.skills[logicalName]?.length
497
+ ? 'fingerprint mismatch with known shipped copies'
498
+ : 'unknown installer ownership (no fingerprint history for this copy)');
499
+ }
500
+ } catch (error) {
501
+ conflicts.push(`${dest}: ${isTracked ? 'git-tracked; ' : ''}${error.message}`);
502
+ }
503
+ }
504
+ } catch (error) {
505
+ conflicts.push(`${root}: ${error.message}`);
506
+ }
507
+ }
508
+ if (conflicts.length) {
509
+ throw new InstructionError('Installation refused; nothing was changed:\n'
510
+ + conflicts.map((conflict) => ` - ${conflict}`).join('\n')
511
+ + '\nMove these out of the install path, or back them up and remove them, then rerun.'
512
+ + '\nFor tracked paths, also remove them from the Git index.'
513
+ + '\nRerun the same command after resolving every conflict; -y does not bypass this check.');
514
+ }
515
+ }
516
+
420
517
  function assertCompleteSkillInstall(sourceSkillsDir, skillsDir, skillNames) {
421
518
  function complete(src, dest) {
422
519
  if (!fs.existsSync(src) || !fs.existsSync(dest)) return false;
@@ -436,13 +533,14 @@ function assertCompleteSkillInstall(sourceSkillsDir, skillsDir, skillNames) {
436
533
  }
437
534
  }
438
535
 
439
- function writeInstallMarker(skillsDir) {
536
+ function writeInstallMarker(skillsDir, names, previous) {
440
537
  const markerPath = path.join(skillsDir, DEVLYN_INSTALL_MARKER);
441
538
  const tempPath = `${markerPath}.${process.pid}.tmp`;
442
539
  const marker = {
443
540
  schemaVersion: 1,
444
541
  package: PKG.name,
445
542
  version: PKG.version,
543
+ skills: { ...previous, ...Object.fromEntries(names.map((name) => [name, fingerprint(path.join(skillsDir, name))])) },
446
544
  };
447
545
  try {
448
546
  fs.writeFileSync(tempPath, JSON.stringify(marker, null, 2) + '\n', {
@@ -456,141 +554,65 @@ function writeInstallMarker(skillsDir) {
456
554
  }
457
555
  }
458
556
 
459
- function multiSelect(items, preselectedIndices = []) {
460
- return new Promise((resolve) => {
461
- const selected = new Set(preselectedIndices.filter((i) => i >= 0 && i < items.length));
462
- let cursor = 0;
463
- let firstRender = true;
464
-
465
- const render = () => {
466
- // Move cursor up to redraw (skip on first render)
467
- const totalLines = items.length * 2 + 2; // 2 lines per item + header + blank
468
- if (!firstRender) {
469
- process.stdout.write(`\x1b[${totalLines}A\x1b[0J`); // Move up and clear to end of screen
470
- }
471
- firstRender = false;
472
-
473
- console.log(`${COLORS.dim}(↑↓ navigate, space select, enter confirm)${COLORS.reset}\n`);
474
-
475
- items.forEach((item, i) => {
476
- const checkbox = selected.has(i) ? `${COLORS.green}◉${COLORS.reset}` : `${COLORS.dim}○${COLORS.reset}`;
477
- const pointer = i === cursor ? `${COLORS.cyan}❯${COLORS.reset}` : ' ';
478
- const name = i === cursor ? `${COLORS.cyan}${item.name}${COLORS.reset}` : item.name;
479
- const tagLabel = item.type === 'mcp' ? 'mcp' : item.type === 'local' ? 'skill' : 'pack';
480
- const tagColor = item.type === 'mcp' ? COLORS.green : item.type === 'local' ? COLORS.magenta : COLORS.cyan;
481
- const tag = `${tagColor}${tagLabel}${COLORS.reset}`;
482
- console.log(`${pointer} ${checkbox} ${name}${item.type ? ` ${COLORS.dim}[${tag}${COLORS.dim}]${COLORS.reset}` : ''}`);
483
- console.log(` ${COLORS.dim}${item.desc}${COLORS.reset}`);
484
- });
485
- };
486
-
487
- render();
488
-
489
- process.stdin.setRawMode(true);
490
- process.stdin.resume();
491
- process.stdin.setEncoding('utf8');
492
-
493
- const onKeypress = (key) => {
494
- // Ctrl+C
495
- if (key === '\u0003') {
496
- process.stdin.setRawMode(false);
497
- process.stdin.removeListener('data', onKeypress);
498
- process.exit();
499
- }
500
-
501
- // Enter
502
- if (key === '\r' || key === '\n') {
503
- process.stdin.setRawMode(false);
504
- process.stdin.removeListener('data', onKeypress);
505
- process.stdin.pause();
506
- console.log('');
507
- resolve([...selected].map((i) => items[i]));
508
- return;
509
- }
510
-
511
- // Space - toggle selection
512
- if (key === ' ') {
513
- if (selected.has(cursor)) {
514
- selected.delete(cursor);
515
- } else {
516
- selected.add(cursor);
517
- }
518
- render();
519
- return;
520
- }
521
-
522
- // Arrow up or k
523
- if (key === '\x1b[A' || key === 'k') {
524
- cursor = cursor > 0 ? cursor - 1 : items.length - 1;
525
- render();
526
- return;
527
- }
528
-
529
- // Arrow down or j
530
- if (key === '\x1b[B' || key === 'j') {
531
- cursor = cursor < items.length - 1 ? cursor + 1 : 0;
532
- render();
533
- return;
534
- }
535
-
536
- // 'a' - select all
537
- if (key === 'a') {
538
- if (selected.size === items.length) {
539
- selected.clear();
540
- } else {
541
- items.forEach((_, i) => selected.add(i));
542
- }
543
- render();
544
- return;
545
- }
546
- };
547
-
548
- process.stdin.on('data', onKeypress);
557
+ // One Inquirer menu, imported only when shown (Inquirer is ESM). The page gets the terminal
558
+ // rows left after everything else the menu draws, so a redraw never scrolls it into
559
+ // duplicates. Ctrl+C cancels the install.
560
+ async function ask(module, help, config) {
561
+ const { default: prompt } = await import(module);
562
+ const { stdin: input, stdout: output } = process;
563
+ const columns = output.columns || 80;
564
+ // Most rows a text wraps to: an ASCII character takes one cell, any other at most two.
565
+ const rows = (text = '') => Math.ceil([...text].reduce((cells, c) => cells + (c.codePointAt(0) < 0x80 ? 1 : 2), 0) / columns);
566
+ // Message, blank line, the active choice's description, key help, and the line Inquirer
567
+ // adds when the help exactly fills the width.
568
+ const reserved = rows(`? ${config.message}`) + 1 + Math.max(...config.choices.map((choice) => rows(choice.description))) + rows(help) + 1;
569
+ try {
570
+ return await prompt({
571
+ ...config,
572
+ pageSize: Math.max(1, (output.rows || 24) - reserved),
573
+ theme: { keybindings: ['vim'], style: { keysHelpTip: () => help } },
574
+ }, { input, output });
575
+ } catch (error) {
576
+ if (error.name !== 'ExitPromptError') throw error;
577
+ console.log('Installation cancelled.');
578
+ process.exit(0);
579
+ }
580
+ }
581
+
582
+ // Checkbox menu (space toggles, a toggles all); resolves to the chosen items.
583
+ function multiSelect(message, items, preselectedIndices = []) {
584
+ return ask('@inquirer/checkbox', '↑↓ navigate · space select · a all · enter confirm', {
585
+ message,
586
+ choices: items.map((item, index) => ({
587
+ name: item.type ? `${item.name} [${item.type === 'mcp' ? 'mcp' : item.type === 'local' ? 'skill' : 'pack'}]` : item.name,
588
+ short: item.name,
589
+ description: item.desc,
590
+ value: item,
591
+ checked: preselectedIndices.includes(index),
592
+ })),
549
593
  });
550
594
  }
551
595
 
552
- // One of `items` (↑↓ move, Enter confirms); resolves to the chosen index.
553
- function singleSelect(items, initial) {
554
- return new Promise((resolve) => {
555
- let cursor = initial;
556
- let drawn = false;
557
- const render = () => {
558
- if (drawn) process.stdout.write(`\x1b[${items.length + 2}A\x1b[0J`);
559
- drawn = true;
560
- console.log(`${COLORS.dim}(↑↓ navigate, enter confirm)${COLORS.reset}\n`);
561
- items.forEach((item, i) => console.log(i === cursor ? `${COLORS.cyan}❯ ${item}${COLORS.reset}` : ` ${item}`));
562
- };
563
- render();
564
- process.stdin.setRawMode(true);
565
- process.stdin.resume();
566
- process.stdin.setEncoding('utf8');
567
- const onKeypress = (key) => {
568
- if (key === '\u0003') {
569
- process.stdin.setRawMode(false);
570
- process.exit();
571
- }
572
- if (key === '\r' || key === '\n') {
573
- process.stdin.setRawMode(false);
574
- process.stdin.removeListener('data', onKeypress);
575
- process.stdin.pause();
576
- console.log('');
577
- resolve(cursor);
578
- } else if (['\x1b[A', 'k', '\x1b[B', 'j'].includes(key)) {
579
- cursor = (cursor + (key === '\x1b[A' || key === 'k' ? items.length - 1 : 1)) % items.length;
580
- render();
581
- }
582
- };
583
- process.stdin.on('data', onKeypress);
596
+ // One of `items`; resolves to the chosen index.
597
+ function singleSelect(message, items, initial) {
598
+ return ask('@inquirer/select', '↑↓ navigate · enter confirm', {
599
+ message,
600
+ choices: items.map((name, index) => ({ name, value: index })),
601
+ default: initial,
584
602
  });
585
603
  }
586
604
 
605
+ // After install(), whose preflight already cleared every selected addon before any change.
587
606
  function installLocalSkill(skillName, roots) {
588
607
  if (!fs.existsSync(path.join(OPTIONAL_SKILLS_SOURCE, skillName))) {
589
608
  log(` ⚠️ Skill "${skillName}" not found`, 'yellow');
590
609
  return false;
591
610
  }
592
611
  log(`\n🛠️ Installing ${skillName}...`, 'cyan');
593
- for (const root of roots) installOptionalSkillInto(root, skillName);
612
+ for (const root of roots) {
613
+ installOptionalSkillInto(root, skillName);
614
+ writeInstallMarker(root, [skillName], readInstallManifest(root));
615
+ }
594
616
  return true;
595
617
  }
596
618
 
@@ -660,6 +682,7 @@ function installCoreSkills(skillsDir) {
660
682
  const sourceSkillsDir = path.join(CONFIG_SOURCE, 'skills');
661
683
  log(`\n📁 Installing devlyn skills to ${skillsDir.replace(os.homedir(), '~')}`, 'green');
662
684
  fs.mkdirSync(skillsDir, { recursive: true });
685
+ const previous = readInstallManifest(skillsDir);
663
686
  clearInstallMarker(skillsDir);
664
687
  const removed = cleanupDeprecated(skillsDir);
665
688
  if (removed > 0) {
@@ -674,8 +697,8 @@ function installCoreSkills(skillsDir) {
674
697
  copyRecursive(src, dest, skillsDir);
675
698
  }
676
699
  assertCompleteSkillInstall(sourceSkillsDir, skillsDir, DEVLYN_CORE_SKILLS);
677
- retireRenamedSkills(skillsDir);
678
- writeInstallMarker(skillsDir);
700
+ const refreshed = retireRenamedSkills(skillsDir);
701
+ writeInstallMarker(skillsDir, [...DEVLYN_CORE_SKILLS, ...refreshed], previous);
679
702
  }
680
703
 
681
704
  // Keep installer-managed pipeline state and install metadata out of git.
@@ -726,14 +749,56 @@ function installAgentsProject(withClaude) {
726
749
  ignoreInGit(['.devlyn/', '.agents/skills/.devlyn-install.json']);
727
750
  }
728
751
 
729
- // Project CLAUDE.md and .claude/: skills, templates, commit conventions and settings.
752
+ // Whether Claude Code reads the devlyn defaults of AGENTS.md through an `@AGENTS.md` import in this CLAUDE.md text.
753
+ // An AGENTS.md that is CLAUDE.md under another name holds CLAUDE.md's own defaults.
754
+ function importsAgentsDefaults(claude) {
755
+ const agents = path.join(projectDir(), 'AGENTS.md');
756
+ return importsAgentsMd(claude) && !agentsMdIsClaudeMd() && fs.statSync(agents, { throwIfNoEntry: false })?.isFile() === true
757
+ && holdsDevlynDefaults('AGENTS.md', fs.readFileSync(agents, 'utf8'));
758
+ }
759
+
760
+ // Claude Code reads AGENTS.md only where no CLAUDE.md exists, or through a CLAUDE.md that imports it. So a new
761
+ // CLAUDE.md imports an existing AGENTS.md, and a CLAUDE.md importing an AGENTS.md that holds devlyn defaults holds no
762
+ // copy of its own, not even one it got while AGENTS.md lacked the block. Only the AGENTS.md target updates those defaults,
763
+ // together with .agents/skills, so the Claude target stops while they need an update.
764
+ function installClaudeInstructions() {
765
+ const file = path.join(projectDir(), 'CLAUDE.md');
766
+ const stat = fs.lstatSync(file, { throwIfNoEntry: false });
767
+ const initial = !stat && fs.statSync(path.join(projectDir(), 'AGENTS.md'), { throwIfNoEntry: false })?.isFile() ? '@AGENTS.md\n' : '';
768
+ const imported = importsAgentsDefaults(stat?.isFile() ? fs.readFileSync(file, 'utf8') : initial);
769
+ if (imported && updateInstructions('AGENTS.md', '', true, false)) {
770
+ throw new InstructionError('Claude Code reads AGENTS.md here, and its devlyn defaults need an update. Codex, omp, Pi and Grok '
771
+ + 'read them too, with .agents/skills, so choose AGENTS.md as well (npx devlyn-cli -y --claude) to update both together.');
772
+ }
773
+ updateInstructions('CLAUDE.md', initial, !imported);
774
+ if (imported) log(' → CLAUDE.md imports AGENTS.md, which holds the devlyn block', 'dim');
775
+ }
776
+
777
+ // Project CLAUDE.md and .claude/: skills and settings.
730
778
  function installClaudeCore() {
731
- updateInstructions('CLAUDE.md');
732
779
  const skillsDir = skillRoots('claude', false)[0];
733
780
  const targetDir = path.dirname(skillsDir);
734
- for (const entry of fs.readdirSync(CONFIG_SOURCE)) {
735
- if (entry !== 'skills') copyRecursive(path.join(CONFIG_SOURCE, entry), path.join(targetDir, entry), targetDir);
781
+ // Read before any write, so settings this install cannot merge leave the Claude target as it was.
782
+ const settingsPath = path.join(targetDir, 'settings.json');
783
+ const unmergeable = (reason) => new InstructionError(`Cannot merge .claude/settings.json: ${reason}. `
784
+ + 'CLAUDE.md and .claude/ are unchanged; fix the file and rerun the same install command.');
785
+ let settings = {};
786
+ if (fs.existsSync(settingsPath)) {
787
+ try {
788
+ // Claude Code accepts a leading byte order mark.
789
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, ''));
790
+ } catch (error) {
791
+ throw unmergeable(error.message);
792
+ }
793
+ }
794
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
795
+ throw unmergeable('root must be a JSON object');
796
+ }
797
+ if (Object.prototype.hasOwnProperty.call(settings, 'env')
798
+ && (!settings.env || typeof settings.env !== 'object' || Array.isArray(settings.env))) {
799
+ throw unmergeable('env must be a JSON object');
736
800
  }
801
+ installClaudeInstructions();
737
802
  for (const relPath of DEPRECATED_FILES) {
738
803
  const fullPath = path.join(targetDir, relPath);
739
804
  if (fs.existsSync(fullPath)) {
@@ -744,108 +809,51 @@ function installClaudeCore() {
744
809
  installCoreSkills(skillsDir);
745
810
  ignoreInGit(['.devlyn/', '.claude/skills/.devlyn-install.json']);
746
811
 
747
- // Enable agent teams in project settings
748
- const settingsPath = path.join(targetDir, 'settings.json');
749
- let settings = {};
750
- if (fs.existsSync(settingsPath)) {
751
- try {
752
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
753
- } catch (error) {
754
- throw new Error(`Cannot merge .claude/settings.json: ${error.message}`);
755
- }
756
- }
757
- if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
758
- throw new Error('Cannot merge .claude/settings.json: root must be a JSON object');
759
- }
760
- const hasOwnSetting = (key) => Object.prototype.hasOwnProperty.call(settings, key);
812
+ // One-hour foreground Bash calls and one-hour prompt caching serve a drain host that waits in the foreground.
761
813
  let settingsChanged = false;
762
- if (!hasOwnSetting('env')) {
814
+ if (!Object.prototype.hasOwnProperty.call(settings, 'env')) {
763
815
  settings.env = {};
764
816
  settingsChanged = true;
765
817
  }
766
- if (!settings.env || typeof settings.env !== 'object' || Array.isArray(settings.env)) {
767
- throw new Error('Cannot merge .claude/settings.json: env must be a JSON object');
768
- }
769
- // Auto-allow pipeline state directory and common git commands so resolve doesn't prompt
770
- if (!hasOwnSetting('permissions')) {
771
- settings.permissions = {};
772
- settingsChanged = true;
773
- }
774
- if (!settings.permissions || typeof settings.permissions !== 'object' || Array.isArray(settings.permissions)) {
775
- throw new Error('Cannot merge .claude/settings.json: permissions must be a JSON object');
776
- }
777
- if (!Object.prototype.hasOwnProperty.call(settings.permissions, 'allow')) {
778
- settings.permissions.allow = [];
779
- settingsChanged = true;
780
- }
781
- if (!Array.isArray(settings.permissions.allow)) {
782
- throw new Error('Cannot merge .claude/settings.json: permissions.allow must be an array');
783
- }
784
- const pipelinePermissions = [
785
- 'Write(.devlyn/**)',
786
- 'Edit(.devlyn/**)',
787
- 'Bash(git add *)',
788
- 'Bash(git commit *)',
789
- 'Bash(git diff *)',
790
- 'Bash(git status *)',
791
- 'Bash(git log *)',
792
- ];
793
- for (const perm of pipelinePermissions) {
794
- if (!settings.permissions.allow.includes(perm)) {
795
- settings.permissions.allow.push(perm);
796
- settingsChanged = true;
797
- }
798
- }
799
818
  if (!settings.env.ENABLE_PROMPT_CACHING_1H) {
800
819
  settings.env.ENABLE_PROMPT_CACHING_1H = 'true';
801
820
  settingsChanged = true;
802
821
  }
803
- if (!settings.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) {
804
- settings.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
805
- settingsChanged = true;
806
- }
807
822
  const bashMaxTimeoutMs = Number.parseInt(settings.env.BASH_MAX_TIMEOUT_MS, 10);
808
823
  if (!Number.isFinite(bashMaxTimeoutMs) || bashMaxTimeoutMs < 3600000) {
809
824
  settings.env.BASH_MAX_TIMEOUT_MS = '3600000';
810
825
  settingsChanged = true;
811
826
  }
812
- if (!hasOwnSetting('hooks')) {
813
- settings.hooks = {};
814
- settingsChanged = true;
815
- }
816
- if (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks)) {
817
- throw new Error('Cannot merge .claude/settings.json: hooks must be a JSON object');
818
- }
819
- if (!Object.prototype.hasOwnProperty.call(settings.hooks, 'Stop')) {
820
- settings.hooks.Stop = [];
821
- settingsChanged = true;
822
- }
823
- if (!Array.isArray(settings.hooks.Stop)) {
824
- throw new Error('Cannot merge .claude/settings.json: hooks.Stop must be an array');
825
- }
826
- const stopHookCommand = 'python3 "$CLAUDE_PROJECT_DIR/.claude/skills/_shared/resolve-stop-hook.py"';
827
- const stopHookInstalled = settings.hooks.Stop.some((entry) => (
828
- entry && Array.isArray(entry.hooks) && entry.hooks.some((hook) => (
829
- hook && hook.type === 'command' && hook.command === stopHookCommand
830
- ))
831
- ));
832
- if (!stopHookInstalled) {
833
- settings.hooks.Stop.push({
834
- hooks: [{ type: 'command', command: stopHookCommand, timeout: 30 }],
835
- });
827
+ // Remove exactly the retired Stop hook command, and its entry only when nothing else is left in it.
828
+ const retiredHook = (hook) => hook?.type === 'command' && hook.command === RETIRED_STOP_HOOK;
829
+ const stop = Array.isArray(settings.hooks?.Stop) ? settings.hooks.Stop : [];
830
+ for (let i = stop.length - 1; i >= 0; i--) {
831
+ const hooks = stop[i]?.hooks;
832
+ if (!Array.isArray(hooks) || !hooks.some(retiredHook)) continue;
833
+ stop[i].hooks = hooks.filter((hook) => !retiredHook(hook));
834
+ if (stop[i].hooks.length === 0 && Object.keys(stop[i]).length === 1) stop.splice(i, 1);
835
+ log(' ✕ settings.json Stop hook of the retired pipeline (removed)', 'dim');
836
836
  settingsChanged = true;
837
837
  }
838
838
  if (settingsChanged) {
839
839
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
840
- log(' → settings.json (agent teams + one-hour Bash max + 1h prompt caching + pipeline permissions + Stop hook)', 'dim');
840
+ log(' → settings.json', 'dim');
841
+ }
842
+ // Their ownership cannot be established, so the other retired settings stay; name the ones present.
843
+ const retired = [
844
+ ...RETIRED_ENV.filter((key) => Object.prototype.hasOwnProperty.call(settings.env, key)),
845
+ ...RETIRED_PERMISSIONS.filter((rule) => Array.isArray(settings.permissions?.allow) && settings.permissions.allow.includes(rule)),
846
+ ];
847
+ if (retired.length > 0) {
848
+ log(` Retired devlyn settings may remain in .claude/settings.json: ${retired.join(', ')} — delete those you did not add yourself.`, 'yellow');
841
849
  }
842
850
  }
843
851
 
844
852
  // Installs the targets in one scope; returns the skill roots written.
845
- function install(targets, global) {
853
+ function install(targets, global, optionalNames = []) {
846
854
  // In the home folder CLAUDE.md, AGENTS.md (agents read a parent folder's too) and
847
- // .claude/settings.json (permissions, Stop hook) would apply to every project. Same folder by
848
- // identity, so a link or another spelling of the path is caught too.
855
+ // .claude/settings.json would apply to every project. Same folder by identity, so a link or
856
+ // another spelling of the path is caught too.
849
857
  const home = fs.statSync(os.homedir(), { bigint: true, throwIfNoEntry: false });
850
858
  const here = fs.statSync(projectDir(), { bigint: true });
851
859
  if (!global && home?.dev === here.dev && home?.ino === here.ino) {
@@ -853,7 +861,10 @@ function install(targets, global) {
853
861
  + 'would apply to every project. Run from a project folder, or use --global for skills only.');
854
862
  }
855
863
  const roots = targets.flatMap((target) => skillRoots(target, global));
856
- for (const target of targets) {
864
+ preflightSkills(roots, optionalNames,
865
+ !global && targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
866
+ // AGENTS.md first, whatever order the menu gave: a new CLAUDE.md holds the block only when AGENTS.md does not.
867
+ for (const target of ['agents', 'claude'].filter((name) => targets.includes(name))) {
857
868
  if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root));
858
869
  else if (target === 'agents') installAgentsProject(targets.includes('claude'));
859
870
  else installClaudeCore();
@@ -906,13 +917,12 @@ async function init({ yes, claude, global }) {
906
917
  return;
907
918
  }
908
919
 
909
- log('\n🎯 What to install:\n', 'blue');
910
920
  const targetOptions = [
911
921
  { key: 'agents', name: 'AGENTS.md — Codex · omp · Pi · Grok', desc: 'AGENTS.md + .agents/skills' },
912
- { key: 'claude', name: 'CLAUDE.md — Claude Code', desc: 'CLAUDE.md + .claude/ (skills, templates, settings)' },
922
+ { key: 'claude', name: 'CLAUDE.md — Claude Code', desc: 'CLAUDE.md + .claude/ (skills, settings)' },
913
923
  ];
914
924
  const preselected = claude || hasDevlynClaude(false) || (global && hasDevlynClaude(true)) ? [0, 1] : [0];
915
- const targets = (await multiSelect(targetOptions, preselected)).map((option) => option.key);
925
+ const targets = (await multiSelect('What to install', targetOptions, preselected)).map((option) => option.key);
916
926
 
917
927
  if (targets.length === 0) {
918
928
  log('\n💡 Nothing selected — nothing installed.', 'yellow');
@@ -920,14 +930,10 @@ async function init({ yes, claude, global }) {
920
930
  return;
921
931
  }
922
932
 
923
- log('📍 Where:\n', 'blue');
924
- const scope = await singleSelect(['This project', 'Global — every project on this machine'], global ? 1 : 0);
925
- const roots = install(targets, scope === 1);
926
-
933
+ const scope = await singleSelect('Where', ['This project', 'Global — every project on this machine'], global ? 1 : 0);
927
934
  // Ask about optional addons (local skills + external packs; MCP servers belong to Claude Code)
928
- log('\n📚 Optional skills & packs:\n', 'blue');
929
-
930
- const selectedAddons = await multiSelect(OPTIONAL_ADDONS.filter((addon) => addon.type !== 'mcp' || targets.includes('claude')));
935
+ const selectedAddons = await multiSelect('Optional skills & packs', OPTIONAL_ADDONS.filter((addon) => addon.type !== 'mcp' || targets.includes('claude')));
936
+ const roots = install(targets, scope === 1, selectedAddons.filter((addon) => addon.type === 'local').map((addon) => addon.name));
931
937
 
932
938
  if (selectedAddons.length > 0) {
933
939
  for (const addon of selectedAddons) {
@@ -951,7 +957,7 @@ function showHelp() {
951
957
  log(' npx devlyn-cli -y Without prompts: AGENTS.md + .agents/skills, plus CLAUDE.md + .claude/ if this project has them');
952
958
  log(' npx devlyn-cli -y --claude Also install CLAUDE.md + .claude/ for Claude Code');
953
959
  log(' npx devlyn-cli -y --global Skills only, for every project on this machine (~/.agents/skills, ~/.codex/skills; ~/.claude/skills with --claude, or when it already has a devlyn install)');
954
- log(' npx devlyn-cli list List available skills & templates');
960
+ log(' npx devlyn-cli list List available skills');
955
961
  log(' npx devlyn-cli --help Show this help\n');
956
962
  log('Optional skills (select during install):', 'green');
957
963
  OPTIONAL_ADDONS.filter((a) => a.type === 'local').forEach((skill) => {