@polymorphism-tech/morph-spec 8.33.0 → 8.34.2

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 (176) hide show
  1. package/CLAUDE.md +3 -0
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +25 -1
  4. package/bin/validate.js +379 -379
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +2 -2
  9. package/framework/MORPH.md +4 -5
  10. package/framework/agents.json +1 -0
  11. package/framework/docs/CLI.md +5 -2
  12. package/framework/hooks/README.md +11 -1
  13. package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +5 -8
  14. package/framework/hooks/claude-code/core/block-raw-worktree-add.js +79 -0
  15. package/framework/hooks/claude-code/core/chain.js +27 -7
  16. package/framework/hooks/claude-code/core/worktree-context.js +83 -14
  17. package/framework/hooks/claude-code/core/worktree-create.js +206 -0
  18. package/framework/hooks/claude-code/core/worktree-env-inject.js +87 -0
  19. package/framework/hooks/claude-code/core/worktree-remove.js +81 -0
  20. package/framework/hooks/claude-code/statusline.sh +11 -11
  21. package/framework/hooks/git/commit-msg/conventional-commits.sh +33 -33
  22. package/framework/hooks/git/pre-commit/agents.sh +25 -25
  23. package/framework/hooks/git/pre-commit/orchestrator.sh +64 -64
  24. package/framework/hooks/git/pre-commit/specs.sh +50 -50
  25. package/framework/hooks/git/pre-push/run-tests.sh +44 -44
  26. package/framework/hooks/shared/command-tokens.js +23 -0
  27. package/framework/hooks/shared/e2e-check.js +0 -0
  28. package/framework/hooks/shared/git-paths.js +25 -0
  29. package/framework/hooks/shared/hook-response.js +17 -0
  30. package/framework/hooks/shared/morph-cli.js +134 -0
  31. package/framework/hooks/shared/stdin-reader.js +26 -26
  32. package/framework/rules/infrastructure-standards.md +3 -1
  33. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  34. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  35. package/framework/skills/core/morph-apply/SKILL.md +3 -3
  36. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  37. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  38. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  39. package/framework/skills/core/morph-implement/SKILL.md +3 -1
  40. package/framework/skills/core/morph-plan/SKILL.md +6 -3
  41. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  42. package/framework/skills/core/morph-review/SKILL.md +1 -1
  43. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  44. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  45. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  46. package/framework/standards/STANDARDS.json +30 -0
  47. package/framework/standards/ai-agents/agent-session.md +160 -160
  48. package/framework/standards/ai-agents/batching.md +193 -193
  49. package/framework/standards/ai-agents/context-providers.md +188 -188
  50. package/framework/standards/ai-agents/mcp-server.md +250 -250
  51. package/framework/standards/ai-agents/mcp-tools.md +197 -197
  52. package/framework/standards/ai-agents/middleware-patterns.md +207 -207
  53. package/framework/standards/ai-agents/modalities-image-gen.md +173 -173
  54. package/framework/standards/ai-agents/modalities-speech-to-text.md +171 -171
  55. package/framework/standards/ai-agents/modalities-text-to-speech.md +181 -181
  56. package/framework/standards/ai-agents/modalities-vision.md +201 -201
  57. package/framework/standards/ai-agents/multi-agent-patterns.md +155 -155
  58. package/framework/standards/ai-agents/production.md +231 -231
  59. package/framework/standards/ai-agents/prompts-in-db.md +214 -214
  60. package/framework/standards/ai-agents/providers/model-registry.md +158 -158
  61. package/framework/standards/ai-agents/service-tier-flex.md +135 -135
  62. package/framework/standards/ai-agents/structured-output.md +249 -249
  63. package/framework/standards/ai-agents/sweet-spot.md +109 -109
  64. package/framework/standards/infrastructure/docker/local-compose-isolation.md +88 -0
  65. package/framework/templates/IDE-SNIPPETS.md +262 -262
  66. package/framework/templates/REGISTRY.json +1 -1
  67. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  68. package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +39 -39
  69. package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +68 -68
  70. package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +117 -117
  71. package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
  72. package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +61 -61
  73. package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +107 -107
  74. package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +58 -58
  75. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +281 -281
  76. package/framework/templates/code/dotnet/database/migration.cs +83 -83
  77. package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +21 -21
  78. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  79. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  80. package/framework/templates/code/dotnet/feature/validator.cs.hbs +15 -15
  81. package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
  82. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  83. package/framework/templates/code/typescript/contracts.ts +167 -167
  84. package/framework/templates/docs/clarifications.md +253 -253
  85. package/framework/templates/docs/schema-analysis.md +119 -119
  86. package/framework/templates/docs/ui-components.md +52 -52
  87. package/framework/templates/docs/ui-design-system.md +76 -76
  88. package/framework/templates/docs/ui-flows.md +167 -167
  89. package/framework/templates/docs/ui-mockups.md +98 -98
  90. package/framework/templates/feature/decisions.md +122 -122
  91. package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +43 -43
  92. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  93. package/framework/templates/frontend/nextjs/env.mjs.hbs +32 -32
  94. package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
  95. package/framework/templates/frontend/nextjs/page.tsx.hbs +22 -22
  96. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  97. package/framework/templates/frontend/nextjs/use-feature.ts.hbs +54 -54
  98. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  99. package/framework/templates/infrastructure/docker/docker-compose.template.yml +24 -14
  100. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  101. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  102. package/framework/templates/infrastructure/github/README.md +56 -56
  103. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  104. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  105. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  106. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  107. package/framework/templates/integrations/asaas-client.cs +387 -387
  108. package/framework/templates/integrations/asaas-webhook.cs +351 -351
  109. package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
  110. package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
  111. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +95 -95
  112. package/framework/templates/project/validate.js +120 -120
  113. package/framework/templates/state.template.json +17 -17
  114. package/framework/templates/ui/design-system.css +226 -226
  115. package/package.json +1 -1
  116. package/src/commands/dashboard/dashboard.js +53 -53
  117. package/src/commands/e2e/e2e.js +77 -39
  118. package/src/commands/index.js +13 -13
  119. package/src/commands/project/checkpoint.js +209 -209
  120. package/src/commands/project/doctor.js +8 -6
  121. package/src/commands/project/env.js +95 -0
  122. package/src/commands/project/worktree.js +266 -52
  123. package/src/commands/state/delete.js +8 -0
  124. package/src/commands/state/finish.js +29 -34
  125. package/src/core/state/phase-state-machine.js +197 -197
  126. package/src/core/templates/template-renderer.js +477 -477
  127. package/src/dashboard/app/components/DetailPanel.jsx +11 -11
  128. package/src/dashboard/app/components/FlowGraph.jsx +17 -17
  129. package/src/dashboard/app/components/SearchFilter.jsx +22 -22
  130. package/src/dashboard/app/hooks/useDetailPanel.js +15 -15
  131. package/src/dashboard/app/main.jsx +13 -13
  132. package/src/dashboard/app/pages/AgentMap.jsx +370 -370
  133. package/src/dashboard/app/pages/HookInspector.jsx +430 -418
  134. package/src/dashboard/app/pages/PhasePipeline.jsx +408 -408
  135. package/src/dashboard/app/pages/RulesMap.jsx +273 -273
  136. package/src/dashboard/app/pages/SkillsLibrary.jsx +366 -366
  137. package/src/dashboard/app/pages/Standards.jsx +332 -332
  138. package/src/dashboard/app/styles/index.css +47 -47
  139. package/src/dashboard/index.html +12 -12
  140. package/src/dashboard/package.json +24 -24
  141. package/src/dashboard/scanner/hooks-scanner.js +5 -1
  142. package/src/dashboard/scanner/phases-scanner.js +15 -15
  143. package/src/dashboard/scanner/rubrics-scanner.js +12 -12
  144. package/src/dashboard/scanner/rules-scanner.js +37 -37
  145. package/src/dashboard/scanner/skills-scanner.js +43 -43
  146. package/src/dashboard/scanner/squads-scanner.js +13 -13
  147. package/src/dashboard/vite-plugin-morph-data.js +17 -17
  148. package/src/dashboard/vite.config.js +24 -24
  149. package/src/lib/detectors/design-system-detector.js +188 -188
  150. package/src/lib/detectors/index.js +217 -217
  151. package/src/lib/e2e/compose-env.js +6 -11
  152. package/src/lib/e2e/stack-teardown.js +115 -0
  153. package/src/lib/generators/architecture-scanner.js +2 -1
  154. package/src/lib/generators/design-system-generator.js +508 -508
  155. package/src/lib/generators/settings-generator.js +209 -209
  156. package/src/lib/graph/graphifyignore.js +2 -1
  157. package/src/lib/hooks/hook-executor.js +167 -167
  158. package/src/lib/installers/mcp-installer.js +719 -719
  159. package/src/lib/tasks/task-classifier.js +30 -30
  160. package/src/lib/tasks/task-parser.js +209 -209
  161. package/src/lib/tasks/test-runner.js +21 -4
  162. package/src/lib/validators/checkpoint-persistence.js +39 -39
  163. package/src/lib/validators/css/css-validator.js +340 -340
  164. package/src/lib/validators/shared/emit-validator-dispatch.js +14 -14
  165. package/src/lib/validators/shared/issue-counter.js +14 -14
  166. package/src/lib/validators/verify-runner.js +7 -0
  167. package/src/lib/worktree/dirs.js +32 -0
  168. package/src/lib/worktree/env-allocator.js +45 -11
  169. package/src/lib/worktree/worktree-audit.js +40 -2
  170. package/src/utils/agent-id-mapper.js +11 -11
  171. package/src/utils/claude-settings-manager.js +300 -300
  172. package/src/utils/file-copier.js +18 -13
  173. package/src/utils/hooks-installer.js +46 -3
  174. package/src/utils/skills-installer.js +187 -187
  175. package/framework/templates/.idea/morph-templates.xml +0 -92
  176. package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
@@ -27,8 +27,15 @@ import { execSync } from 'child_process';
27
27
  * (`copyDirectory` defaults to `overwrite: true`), so the bump does not gate
28
28
  * delivery — it makes `morph-spec update` report the change honestly instead
29
29
  * of "already current".
30
+ *
31
+ * 3.8.0 (worktree governance) registers Claude Code's WorktreeCreate /
32
+ * WorktreeRemove events — the native `claude -w` / EnterWorktree / Agent
33
+ * isolation worktree is replaced by `morph-spec worktree setup|provision`
34
+ * (junctions, port block, primary-root landing) and its removal routed
35
+ * through the safe `worktree remove --if-clean` — and appends
36
+ * block-raw-worktree-add to the PreToolUse:Bash chain.
30
37
  */
31
- const HOOKS_VERSION = '3.7.0';
38
+ const HOOKS_VERSION = '3.8.0';
32
39
 
33
40
  /** Marker for old dispatch.js (v1) */
34
41
  const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
@@ -137,14 +144,50 @@ const MORPH_HOOKS = [
137
144
  // block-destructive-worktree-removal (dynamic: scans the target for reparse
138
145
  // points), so the cheap check short-circuits the filesystem walk. The second
139
146
  // one is the only defense against the raw-removal vector that does not depend
140
- // on someone having read MORPH.md. ===
147
+ // on someone having read MORPH.md. block-raw-worktree-add is its way-IN
148
+ // counterpart (a raw `git worktree add` builds a tree with no junctions, no
149
+ // ports, no identity) — static, so it costs nothing after the first two.
150
+ // worktree-env-inject is LAST: it rewrites launcher commands (updatedInput)
151
+ // with the worktree's port block, and a command a previous link blocked must
152
+ // never be rewritten. Block-first-wins guarantees it never sees one.
141
153
  {
142
154
  event: 'PreToolUse',
143
155
  matcher: 'Bash',
144
156
  hooks: [{
145
157
  type: 'command',
146
158
  command: 'node framework/hooks/claude-code/core/chain.js',
147
- args: ['pre-bash-denylist', 'block-destructive-worktree-removal']
159
+ args: ['pre-bash-denylist', 'block-destructive-worktree-removal', 'block-raw-worktree-add', 'worktree-env-inject']
160
+ }]
161
+ },
162
+
163
+ // === WorktreeCreate — REPLACES Claude Code's native `git worktree add` for
164
+ // `claude -w`, the EnterWorktree tool and `Agent isolation:"worktree"`
165
+ // (measured: all three fire it). The hook prints the created path as its
166
+ // last stdout line; a non-zero exit fails the creation. No matcher exists
167
+ // for this event. 120s: it spawns the morph-spec CLI (setup or provision),
168
+ // which runs git, links infra, allocates ports and materializes state. ===
169
+ {
170
+ event: 'WorktreeCreate',
171
+ matcher: null,
172
+ hooks: [{
173
+ type: 'command',
174
+ command: 'node framework/hooks/claude-code/core/worktree-create.js',
175
+ timeout: 120000
176
+ }]
177
+ },
178
+
179
+ // === WorktreeRemove — routes Claude Code's own cleanup through
180
+ // `morph-spec worktree remove --target --if-clean` (junctions dropped BEFORE
181
+ // git touches the tree; a dirty tree is left alone). Output ignored, always
182
+ // exit 0. Secondary net: in headless runs the event was observed not to
183
+ // fire — block-destructive-worktree-removal remains the primary defense. ===
184
+ {
185
+ event: 'WorktreeRemove',
186
+ matcher: null,
187
+ hooks: [{
188
+ type: 'command',
189
+ command: 'node framework/hooks/claude-code/core/worktree-remove.js',
190
+ timeout: 60000
148
191
  }]
149
192
  },
150
193
 
@@ -1,187 +1,187 @@
1
- /**
2
- * Skills Installer
3
- *
4
- * Copies framework skills to .claude/skills/ during morph-spec init.
5
- * Installs only the `core/` skill set — the canonical NLH skills referenced by
6
- * MORPH.md (morph-brainstorming, morph-stack-scan, morph-plan, morph-implement,
7
- * morph-eval, morph-review, morph-standards). The 3-tier system was collapsed
8
- * into a single source of truth in framework/skills/core/.
9
- *
10
- * Source skills are directory-based: framework/skills/core/{skill-name}/SKILL.md
11
- * (plus optional scripts/, references/, assets/ subdirectories).
12
- *
13
- * Directory-based skills have their entire directory copied so Claude Code can
14
- * execute scripts and load references on demand.
15
- */
16
-
17
- import { mkdirSync, copyFileSync, existsSync, readdirSync, statSync, readFileSync, rmSync } from 'fs';
18
- import { join } from 'path';
19
- import { fileURLToPath } from 'url';
20
- import { parseStacks, shouldInstall } from '../lib/stack-filter.js';
21
-
22
- const __dirname = fileURLToPath(new URL('.', import.meta.url));
23
- const FRAMEWORK_SKILLS_DIR = join(__dirname, '..', '..', 'framework', 'skills');
24
-
25
- /**
26
- * Skill levels to install in .claude/skills/.
27
- * Only `core/` survives — single source of truth aligned with MORPH.md.
28
- */
29
- const SKILL_LEVELS_TO_INSTALL = ['core'];
30
-
31
- /**
32
- * Check if a skill should be installed based on its stacks: frontmatter.
33
- * @param {string} skillContent - Raw SKILL.md content
34
- * @param {string[]} projectTags - Stack tags from config
35
- * @returns {boolean}
36
- */
37
- export function shouldInstallSkill(skillContent, projectTags) {
38
- return shouldInstall(parseStacks(skillContent), projectTags);
39
- }
40
-
41
- /**
42
- * Recursively copy a directory tree from src to dest.
43
- * Creates dest if it doesn't exist. Skips README.md files.
44
- *
45
- * @param {string} src - Source directory path
46
- * @param {string} dest - Destination directory path
47
- */
48
- function copyDirectory(src, dest) {
49
- mkdirSync(dest, { recursive: true });
50
- for (const entry of readdirSync(src)) {
51
- if (entry === 'README.md') continue;
52
- const srcPath = join(src, entry);
53
- const destPath = join(dest, entry);
54
- if (statSync(srcPath).isDirectory()) {
55
- copyDirectory(srcPath, destPath);
56
- } else {
57
- copyFileSync(srcPath, destPath);
58
- }
59
- }
60
- }
61
-
62
- /**
63
- * Walk srcDir and install skills into destDir.
64
- *
65
- * Handles two source formats:
66
- * 1. Directory-based skill: srcDir/{skill-name}/SKILL.md
67
- * → copies entire directory (SKILL.md + scripts/ + references/ + assets/)
68
- * → destDir/{skill-name}/
69
- *
70
- * 2. Flat .md skill (legacy): srcDir/{skill-name}.md
71
- * → destDir/{skill-name}/SKILL.md
72
- *
73
- * The `core/` level directory is traversed recursively. A directory is
74
- * recognized as a skill directory when it contains a SKILL.md file.
75
- *
76
- * @param {string} srcDir - Source directory to walk
77
- * @param {string} destDir - Destination base directory (.claude/skills/)
78
- * @param {string[]} projectTags - Stack tags from config
79
- * @param {Set<string>} installedNow - Accumulator: skill names this call
80
- * actually (re)installed. NOT derived by re-scanning destDir afterwards —
81
- * a disk scan can't distinguish "installed this run" from "leftover from a
82
- * previous run with different projectTags", which is exactly the stale
83
- * entry a stack change needs cleaned up.
84
- */
85
- function installSkillsFromDir(srcDir, destDir, projectTags, installedNow) {
86
- const entries = readdirSync(srcDir);
87
- for (const entry of entries) {
88
- const srcPath = join(srcDir, entry);
89
- const stat = statSync(srcPath);
90
-
91
- if (stat.isDirectory()) {
92
- const skillMdPath = join(srcPath, 'SKILL.md');
93
- if (existsSync(skillMdPath)) {
94
- // Directory-based skill — check stack filter before copying
95
- const content = readFileSync(skillMdPath, 'utf-8');
96
- if (!shouldInstallSkill(content, projectTags)) continue;
97
- const skillDestDir = join(destDir, entry);
98
- copyDirectory(srcPath, skillDestDir);
99
- installedNow.add(entry);
100
- } else {
101
- // Level/category directory — recurse
102
- installSkillsFromDir(srcPath, destDir, projectTags, installedNow);
103
- }
104
- } else if (entry.endsWith('.md') && entry !== 'README.md') {
105
- // Legacy flat .md skill — check stack filter before copying
106
- const content = readFileSync(srcPath, 'utf-8');
107
- if (!shouldInstallSkill(content, projectTags)) continue;
108
- const skillName = entry.slice(0, -3);
109
- const skillDir = join(destDir, skillName);
110
- mkdirSync(skillDir, { recursive: true });
111
- copyFileSync(srcPath, join(skillDir, 'SKILL.md'));
112
- installedNow.add(skillName);
113
- }
114
- }
115
- }
116
-
117
- function collectSourceSkillNames() {
118
- const names = new Set();
119
- for (const level of SKILL_LEVELS_TO_INSTALL) {
120
- const levelDir = join(FRAMEWORK_SKILLS_DIR, level);
121
- if (!existsSync(levelDir)) continue;
122
- collectNamesFromDir(levelDir, names);
123
- }
124
- return names;
125
- }
126
-
127
- function collectNamesFromDir(dir, names) {
128
- for (const entry of readdirSync(dir)) {
129
- const fullPath = join(dir, entry);
130
- if (!statSync(fullPath).isDirectory()) continue;
131
- if (existsSync(join(fullPath, 'SKILL.md'))) {
132
- names.add(entry);
133
- } else {
134
- collectNamesFromDir(fullPath, names);
135
- }
136
- }
137
- }
138
-
139
- /**
140
- * Install morph framework skills to .claude/skills/ in the target project.
141
- * Skills are copied into subdirectories as SKILL.md files so Claude Code can
142
- * discover them natively via /<skill-name> syntax (e.g., /phase-design,
143
- * /nextjs-expert). Directory-based skills also get their scripts/,
144
- * references/, and assets/ subdirectories copied for on-demand loading.
145
- *
146
- * Stack filtering: if projectTags is provided, only skills whose `stacks:`
147
- * frontmatter matches the project tags are installed. Skills without a
148
- * `stacks:` field are always installed (universal).
149
- *
150
- * @param {string} projectDir - Target project root directory
151
- * @param {Object} [options]
152
- * @param {string[]} [options.projectTags=[]] - Stack tags from config
153
- */
154
- export async function installSkills(projectDir, { projectTags = [] } = {}) {
155
- const claudeSkillsDir = join(projectDir, '.claude', 'skills');
156
- mkdirSync(claudeSkillsDir, { recursive: true });
157
-
158
- // Skill names this call actually (re)installs, given projectTags. Tracked
159
- // as we go (NOT by re-scanning claudeSkillsDir afterwards) — a disk scan
160
- // can't tell "installed this run" apart from "leftover from a previous
161
- // run with different projectTags", which is exactly the stale entry a
162
- // stack change (or a re-run that narrows the stack) needs cleaned up.
163
- const installedNow = new Set();
164
- for (const level of SKILL_LEVELS_TO_INSTALL) {
165
- const levelDir = join(FRAMEWORK_SKILLS_DIR, level);
166
- if (!existsSync(levelDir)) continue;
167
- installSkillsFromDir(levelDir, claudeSkillsDir, projectTags, installedNow);
168
- }
169
-
170
- // Clean-slate: remove any skill dir that is a KNOWN framework skill (still
171
- // present in framework/skills/core/) but did NOT get (re)installed this
172
- // run — i.e. it was filtered out by a `stacks:` mismatch (project.stack
173
- // changed, or a re-run narrowed the tags). Previously scoped to `morph-`
174
- // prefixed names only, which silently exempted every non-`morph-` skill
175
- // (the Umbraco pair) from ever being cleaned up: install once with
176
- // `umbraco` in the stack, drop the stack later, and they'd survive every
177
- // reinstall/update forever. Names outside allSourceSkills (user-added,
178
- // non-framework skills) are never touched.
179
- const allSourceSkills = collectSourceSkillNames();
180
- for (const entry of readdirSync(claudeSkillsDir)) {
181
- if (!installedNow.has(entry) && allSourceSkills.has(entry)) {
182
- rmSync(join(claudeSkillsDir, entry), { recursive: true, force: true });
183
- }
184
- }
185
-
186
- return { installed: installedNow.size };
187
- }
1
+ /**
2
+ * Skills Installer
3
+ *
4
+ * Copies framework skills to .claude/skills/ during morph-spec init.
5
+ * Installs only the `core/` skill set — the canonical NLH skills referenced by
6
+ * MORPH.md (morph-brainstorming, morph-stack-scan, morph-plan, morph-implement,
7
+ * morph-eval, morph-review, morph-standards). The 3-tier system was collapsed
8
+ * into a single source of truth in framework/skills/core/.
9
+ *
10
+ * Source skills are directory-based: framework/skills/core/{skill-name}/SKILL.md
11
+ * (plus optional scripts/, references/, assets/ subdirectories).
12
+ *
13
+ * Directory-based skills have their entire directory copied so Claude Code can
14
+ * execute scripts and load references on demand.
15
+ */
16
+
17
+ import { mkdirSync, copyFileSync, existsSync, readdirSync, statSync, readFileSync, rmSync } from 'fs';
18
+ import { join } from 'path';
19
+ import { fileURLToPath } from 'url';
20
+ import { parseStacks, shouldInstall } from '../lib/stack-filter.js';
21
+
22
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
23
+ const FRAMEWORK_SKILLS_DIR = join(__dirname, '..', '..', 'framework', 'skills');
24
+
25
+ /**
26
+ * Skill levels to install in .claude/skills/.
27
+ * Only `core/` survives — single source of truth aligned with MORPH.md.
28
+ */
29
+ const SKILL_LEVELS_TO_INSTALL = ['core'];
30
+
31
+ /**
32
+ * Check if a skill should be installed based on its stacks: frontmatter.
33
+ * @param {string} skillContent - Raw SKILL.md content
34
+ * @param {string[]} projectTags - Stack tags from config
35
+ * @returns {boolean}
36
+ */
37
+ export function shouldInstallSkill(skillContent, projectTags) {
38
+ return shouldInstall(parseStacks(skillContent), projectTags);
39
+ }
40
+
41
+ /**
42
+ * Recursively copy a directory tree from src to dest.
43
+ * Creates dest if it doesn't exist. Skips README.md files.
44
+ *
45
+ * @param {string} src - Source directory path
46
+ * @param {string} dest - Destination directory path
47
+ */
48
+ function copyDirectory(src, dest) {
49
+ mkdirSync(dest, { recursive: true });
50
+ for (const entry of readdirSync(src)) {
51
+ if (entry === 'README.md') continue;
52
+ const srcPath = join(src, entry);
53
+ const destPath = join(dest, entry);
54
+ if (statSync(srcPath).isDirectory()) {
55
+ copyDirectory(srcPath, destPath);
56
+ } else {
57
+ copyFileSync(srcPath, destPath);
58
+ }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Walk srcDir and install skills into destDir.
64
+ *
65
+ * Handles two source formats:
66
+ * 1. Directory-based skill: srcDir/{skill-name}/SKILL.md
67
+ * → copies entire directory (SKILL.md + scripts/ + references/ + assets/)
68
+ * → destDir/{skill-name}/
69
+ *
70
+ * 2. Flat .md skill (legacy): srcDir/{skill-name}.md
71
+ * → destDir/{skill-name}/SKILL.md
72
+ *
73
+ * The `core/` level directory is traversed recursively. A directory is
74
+ * recognized as a skill directory when it contains a SKILL.md file.
75
+ *
76
+ * @param {string} srcDir - Source directory to walk
77
+ * @param {string} destDir - Destination base directory (.claude/skills/)
78
+ * @param {string[]} projectTags - Stack tags from config
79
+ * @param {Set<string>} installedNow - Accumulator: skill names this call
80
+ * actually (re)installed. NOT derived by re-scanning destDir afterwards —
81
+ * a disk scan can't distinguish "installed this run" from "leftover from a
82
+ * previous run with different projectTags", which is exactly the stale
83
+ * entry a stack change needs cleaned up.
84
+ */
85
+ function installSkillsFromDir(srcDir, destDir, projectTags, installedNow) {
86
+ const entries = readdirSync(srcDir);
87
+ for (const entry of entries) {
88
+ const srcPath = join(srcDir, entry);
89
+ const stat = statSync(srcPath);
90
+
91
+ if (stat.isDirectory()) {
92
+ const skillMdPath = join(srcPath, 'SKILL.md');
93
+ if (existsSync(skillMdPath)) {
94
+ // Directory-based skill — check stack filter before copying
95
+ const content = readFileSync(skillMdPath, 'utf-8');
96
+ if (!shouldInstallSkill(content, projectTags)) continue;
97
+ const skillDestDir = join(destDir, entry);
98
+ copyDirectory(srcPath, skillDestDir);
99
+ installedNow.add(entry);
100
+ } else {
101
+ // Level/category directory — recurse
102
+ installSkillsFromDir(srcPath, destDir, projectTags, installedNow);
103
+ }
104
+ } else if (entry.endsWith('.md') && entry !== 'README.md') {
105
+ // Legacy flat .md skill — check stack filter before copying
106
+ const content = readFileSync(srcPath, 'utf-8');
107
+ if (!shouldInstallSkill(content, projectTags)) continue;
108
+ const skillName = entry.slice(0, -3);
109
+ const skillDir = join(destDir, skillName);
110
+ mkdirSync(skillDir, { recursive: true });
111
+ copyFileSync(srcPath, join(skillDir, 'SKILL.md'));
112
+ installedNow.add(skillName);
113
+ }
114
+ }
115
+ }
116
+
117
+ function collectSourceSkillNames() {
118
+ const names = new Set();
119
+ for (const level of SKILL_LEVELS_TO_INSTALL) {
120
+ const levelDir = join(FRAMEWORK_SKILLS_DIR, level);
121
+ if (!existsSync(levelDir)) continue;
122
+ collectNamesFromDir(levelDir, names);
123
+ }
124
+ return names;
125
+ }
126
+
127
+ function collectNamesFromDir(dir, names) {
128
+ for (const entry of readdirSync(dir)) {
129
+ const fullPath = join(dir, entry);
130
+ if (!statSync(fullPath).isDirectory()) continue;
131
+ if (existsSync(join(fullPath, 'SKILL.md'))) {
132
+ names.add(entry);
133
+ } else {
134
+ collectNamesFromDir(fullPath, names);
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Install morph framework skills to .claude/skills/ in the target project.
141
+ * Skills are copied into subdirectories as SKILL.md files so Claude Code can
142
+ * discover them natively via /<skill-name> syntax (e.g., /phase-design,
143
+ * /nextjs-expert). Directory-based skills also get their scripts/,
144
+ * references/, and assets/ subdirectories copied for on-demand loading.
145
+ *
146
+ * Stack filtering: if projectTags is provided, only skills whose `stacks:`
147
+ * frontmatter matches the project tags are installed. Skills without a
148
+ * `stacks:` field are always installed (universal).
149
+ *
150
+ * @param {string} projectDir - Target project root directory
151
+ * @param {Object} [options]
152
+ * @param {string[]} [options.projectTags=[]] - Stack tags from config
153
+ */
154
+ export async function installSkills(projectDir, { projectTags = [] } = {}) {
155
+ const claudeSkillsDir = join(projectDir, '.claude', 'skills');
156
+ mkdirSync(claudeSkillsDir, { recursive: true });
157
+
158
+ // Skill names this call actually (re)installs, given projectTags. Tracked
159
+ // as we go (NOT by re-scanning claudeSkillsDir afterwards) — a disk scan
160
+ // can't tell "installed this run" apart from "leftover from a previous
161
+ // run with different projectTags", which is exactly the stale entry a
162
+ // stack change (or a re-run that narrows the stack) needs cleaned up.
163
+ const installedNow = new Set();
164
+ for (const level of SKILL_LEVELS_TO_INSTALL) {
165
+ const levelDir = join(FRAMEWORK_SKILLS_DIR, level);
166
+ if (!existsSync(levelDir)) continue;
167
+ installSkillsFromDir(levelDir, claudeSkillsDir, projectTags, installedNow);
168
+ }
169
+
170
+ // Clean-slate: remove any skill dir that is a KNOWN framework skill (still
171
+ // present in framework/skills/core/) but did NOT get (re)installed this
172
+ // run — i.e. it was filtered out by a `stacks:` mismatch (project.stack
173
+ // changed, or a re-run narrowed the tags). Previously scoped to `morph-`
174
+ // prefixed names only, which silently exempted every non-`morph-` skill
175
+ // (the Umbraco pair) from ever being cleaned up: install once with
176
+ // `umbraco` in the stack, drop the stack later, and they'd survive every
177
+ // reinstall/update forever. Names outside allSourceSkills (user-added,
178
+ // non-framework skills) are never touched.
179
+ const allSourceSkills = collectSourceSkillNames();
180
+ for (const entry of readdirSync(claudeSkillsDir)) {
181
+ if (!installedNow.has(entry) && allSourceSkills.has(entry)) {
182
+ rmSync(join(claudeSkillsDir, entry), { recursive: true, force: true });
183
+ }
184
+ }
185
+
186
+ return { installed: installedNow.size };
187
+ }
@@ -1,92 +0,0 @@
1
- <templateSet group="MORPH Templates">
2
- <template name="morph-pascal" value="{{pascalCase $FEATURE_NAME$}}" description="Convert to PascalCase (UserAuth)" toReformat="false" toShortenFQNames="true">
3
- <variable name="FEATURE_NAME" expression="" defaultValue="&quot;FEATURE_NAME&quot;" alwaysStopAt="true" />
4
- <context>
5
- <option name="OTHER" value="true" />
6
- </context>
7
- </template>
8
-
9
- <template name="morph-camel" value="{{camelCase $FEATURE_NAME$}}" description="Convert to camelCase (userAuth)" toReformat="false" toShortenFQNames="true">
10
- <variable name="FEATURE_NAME" expression="" defaultValue="&quot;FEATURE_NAME&quot;" alwaysStopAt="true" />
11
- <context>
12
- <option name="OTHER" value="true" />
13
- </context>
14
- </template>
15
-
16
- <template name="morph-snake" value="{{snakeCase $FEATURE_NAME$}}" description="Convert to snake_case (user_auth)" toReformat="false" toShortenFQNames="true">
17
- <variable name="FEATURE_NAME" expression="" defaultValue="&quot;FEATURE_NAME&quot;" alwaysStopAt="true" />
18
- <context>
19
- <option name="OTHER" value="true" />
20
- </context>
21
- </template>
22
-
23
- <template name="morph-title" value="{{titleCase $FEATURE_NAME$}}" description="Convert to Title Case (User Auth)" toReformat="false" toShortenFQNames="true">
24
- <variable name="FEATURE_NAME" expression="" defaultValue="&quot;FEATURE_NAME&quot;" alwaysStopAt="true" />
25
- <context>
26
- <option name="OTHER" value="true" />
27
- </context>
28
- </template>
29
-
30
- <template name="morph-kebab" value="{{kebabCase $FEATURE_NAME$}}" description="Convert to kebab-case (user-auth)" toReformat="false" toShortenFQNames="true">
31
- <variable name="FEATURE_NAME" expression="" defaultValue="&quot;FEATURE_NAME&quot;" alwaysStopAt="true" />
32
- <context>
33
- <option name="OTHER" value="true" />
34
- </context>
35
- </template>
36
-
37
- <template name="morph-if" value="{{#if $CONDITION$}}&#10; $END$&#10;{{/if}}" description="Handlebars if block" toReformat="false" toShortenFQNames="true">
38
- <variable name="CONDITION" expression="" defaultValue="&quot;condition&quot;" alwaysStopAt="true" />
39
- <context>
40
- <option name="OTHER" value="true" />
41
- </context>
42
- </template>
43
-
44
- <template name="morph-if-else" value="{{#if $CONDITION$}}&#10; $TRUE_BRANCH$&#10;{{else}}&#10; $FALSE_BRANCH$&#10;{{/if}}" description="Handlebars if-else block" toReformat="false" toShortenFQNames="true">
45
- <variable name="CONDITION" expression="" defaultValue="&quot;condition&quot;" alwaysStopAt="true" />
46
- <variable name="TRUE_BRANCH" expression="" defaultValue="&quot;&quot;" alwaysStopAt="true" />
47
- <variable name="FALSE_BRANCH" expression="" defaultValue="&quot;&quot;" alwaysStopAt="true" />
48
- <context>
49
- <option name="OTHER" value="true" />
50
- </context>
51
- </template>
52
-
53
- <template name="morph-each" value="{{#each $ITEMS$}}&#10; {{this.$PROPERTY$}}&#10;{{/each}}" description="Handlebars each loop" toReformat="false" toShortenFQNames="true">
54
- <variable name="ITEMS" expression="" defaultValue="&quot;items&quot;" alwaysStopAt="true" />
55
- <variable name="PROPERTY" expression="" defaultValue="&quot;name&quot;" alwaysStopAt="true" />
56
- <context>
57
- <option name="OTHER" value="true" />
58
- </context>
59
- </template>
60
-
61
- <template name="morph-service" value="namespace {{NAMESPACE}}.Application.Features.{{pascalCase FEATURE_NAME}}.Services;&#10;&#10;public class {{pascalCase FEATURE_NAME}}Service(&#10; I{{pascalCase FEATURE_NAME}}Repository repository,&#10; ILogger&lt;{{pascalCase FEATURE_NAME}}Service&gt; logger) : I{{pascalCase FEATURE_NAME}}Service&#10;{&#10; public async Task&lt;{{pascalCase FEATURE_NAME}}Dto?&gt; GetByIdAsync(int id, CancellationToken cancellationToken = default)&#10; {&#10; $END$&#10; }&#10;}" description="MORPH .NET Service class template" toReformat="false" toShortenFQNames="true">
62
- <context>
63
- <option name="C_SHARP" value="true" />
64
- </context>
65
- </template>
66
-
67
- <template name="morph-repository" value="public class {{pascalCase FEATURE_NAME}}Repository(AppDbContext context) : I{{pascalCase FEATURE_NAME}}Repository&#10;{&#10; private readonly DbSet&lt;{{pascalCase FEATURE_NAME}}&gt; _dbSet = context.Set&lt;{{pascalCase FEATURE_NAME}}&gt;();&#10;&#10; public async Task&lt;{{pascalCase FEATURE_NAME}}?&gt; GetByIdAsync(int id, CancellationToken cancellationToken = default)&#10; {&#10; $END$&#10; }&#10;}" description="MORPH .NET Repository class template" toReformat="false" toShortenFQNames="true">
68
- <context>
69
- <option name="C_SHARP" value="true" />
70
- </context>
71
- </template>
72
-
73
- <template name="morph-component" value="@page &quot;/{{kebabCase FEATURE_NAME}}&quot;&#10;@inject I{{pascalCase FEATURE_NAME}}Service {{pascalCase FEATURE_NAME}}Service&#10;@inject ILogger&lt;{{pascalCase FEATURE_NAME}}Page&gt; Logger&#10;&#10;&lt;PageTitle&gt;{{pascalCase FEATURE_NAME}}&lt;/PageTitle&gt;&#10;&#10;&lt;div class=&quot;container-fluid&quot;&gt;&#10; $END$&#10;&lt;/div&gt;&#10;&#10;@code {&#10;&#10;}" description="MORPH Blazor component template" toReformat="false" toShortenFQNames="true">
74
- <context>
75
- <option name="HTML" value="true" />
76
- </context>
77
- </template>
78
-
79
- <template name="morph-migration" value="public partial class Add{{pascalCase FEATURE_NAME}} : Migration&#10;{&#10; protected override void Up(MigrationBuilder migrationBuilder)&#10; {&#10; migrationBuilder.CreateTable(&#10; name: &quot;{{pascalCase FEATURE_NAME}}s&quot;,&#10; columns: table =&gt; new&#10; {&#10; Id = table.Column&lt;int&gt;(type: &quot;int&quot;, nullable: false)&#10; .Annotation(&quot;SqlServer:Identity&quot;, &quot;1, 1&quot;),&#10; $END$&#10; },&#10; constraints: table =&gt;&#10; {&#10; table.PrimaryKey(&quot;PK_{{pascalCase FEATURE_NAME}}s&quot;, x =&gt; x.Id);&#10; });&#10; }&#10;&#10; protected override void Down(MigrationBuilder migrationBuilder)&#10; {&#10; migrationBuilder.DropTable(name: &quot;{{pascalCase FEATURE_NAME}}s&quot;);&#10; }&#10;}" description="MORPH EF Core migration template" toReformat="false" toShortenFQNames="true">
80
- <context>
81
- <option name="C_SHARP" value="true" />
82
- </context>
83
- </template>
84
-
85
- <template name="morph-ts-contract" value="export interface {{pascalCase FEATURE_NAME}} {&#10; id: number;&#10; $FIELD1$&#10;}&#10;&#10;export interface Create{{pascalCase FEATURE_NAME}}Request {&#10; $FIELD2$&#10;}&#10;&#10;export interface Update{{pascalCase FEATURE_NAME}}Request {&#10; $END$&#10;}" description="MORPH TypeScript contract template" toReformat="false" toShortenFQNames="true">
86
- <variable name="FIELD1" expression="" defaultValue="&quot;&quot;" alwaysStopAt="true" />
87
- <variable name="FIELD2" expression="" defaultValue="&quot;&quot;" alwaysStopAt="true" />
88
- <context>
89
- <option name="TypeScript" value="true" />
90
- </context>
91
- </template>
92
- </templateSet>