@polymorphism-tech/morph-spec 7.4.0 → 7.6.0

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 (66) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +18 -0
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +9 -4
  7. package/framework/MORPH.md +4 -4
  8. package/framework/agents.json +1 -1
  9. package/framework/commands/create-umbraco-lp.md +35 -0
  10. package/framework/commands/migrate-lp-to-umbraco.md +33 -0
  11. package/framework/commands/morph-archive.md +17 -27
  12. package/framework/commands/morph-preflight.md +10 -5
  13. package/framework/hooks/claude-code/core/enforce-phase-writes.js +115 -90
  14. package/framework/hooks/claude-code/core/protect-spec-files.js +32 -30
  15. package/framework/hooks/claude-code/core/state-sync.js +16 -48
  16. package/framework/hooks/shared/state-reader.js +74 -8
  17. package/framework/hooks/shared/state-writer.js +124 -0
  18. package/framework/rules/infrastructure-standards.md +4 -4
  19. package/framework/rules/morph-task-tracking.md +69 -49
  20. package/framework/rules/morph-workflow.md +192 -191
  21. package/framework/skills/core/create-umbraco-lp/SKILL.md +272 -0
  22. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +531 -0
  23. package/framework/skills/core/morph-apply/SKILL.md +15 -15
  24. package/framework/skills/core/morph-brainstorming/SKILL.md +5 -5
  25. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  26. package/framework/skills/core/morph-implement/SKILL.md +5 -4
  27. package/framework/skills/core/morph-plan/SKILL.md +6 -6
  28. package/framework/skills/core/morph-proposal/SKILL.md +8 -7
  29. package/framework/skills/core/morph-review/SKILL.md +3 -3
  30. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  31. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  32. package/framework/standards/STANDARDS.json +80 -8
  33. package/framework/standards/ai-agents/durable-workflows-hitl.md +191 -0
  34. package/framework/standards/ai-agents/rag-custom-pgvector.md +246 -243
  35. package/framework/standards/ai-agents/setup.md +3 -0
  36. package/framework/standards/ai-agents/vector-data-extensions.md +170 -0
  37. package/framework/standards/ai-agents/workflows.md +190 -187
  38. package/framework/standards/infrastructure/docker/{easypanel-deploy.md → coolify-deploy.md} +49 -20
  39. package/framework/templates/README.md +7 -7
  40. package/framework/templates/REGISTRY.json +15 -17
  41. package/framework/templates/context/CONTEXT-FEATURE.md +10 -17
  42. package/framework/templates/context/CONTEXT.md +181 -180
  43. package/framework/templates/docs/onboarding.md +122 -118
  44. package/framework/templates/docs/proposal.md +12 -2
  45. package/framework/templates/docs/spec.md +228 -228
  46. package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +1 -1
  47. package/framework/templates/infrastructure/docker/coolify.template.json +49 -0
  48. package/framework/templates/infrastructure/github/README.md +6 -6
  49. package/framework/templates/infrastructure/github/workflows/deploy-coolify.yml.hbs +33 -0
  50. package/framework/templates/meta-prompts/parallel-workers/parallel-worker.md +80 -80
  51. package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +90 -89
  52. package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +2 -1
  53. package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +44 -43
  54. package/framework/templates/meta-prompts/validators/checkpoint-validator.md +107 -107
  55. package/package.json +1 -1
  56. package/src/commands/project/update.js +415 -368
  57. package/src/commands/project/worktree.js +212 -210
  58. package/src/commands/state/advance.js +82 -0
  59. package/src/commands/state/archive.js +111 -0
  60. package/src/commands/validation/validate-feature.js +229 -105
  61. package/src/core/state/state-manager.js +238 -66
  62. package/src/dashboard/scanner/state-scanner.js +35 -8
  63. package/src/lib/generators/context-generator.js +12 -8
  64. package/src/utils/file-copier.js +7 -0
  65. package/framework/templates/infrastructure/docker/easypanel.template.json +0 -46
  66. package/framework/templates/infrastructure/github/workflows/deploy-easypanel.yml.hbs +0 -31
@@ -1,368 +1,415 @@
1
- import { join, dirname } from 'path';
2
- import { fileURLToPath } from 'url';
3
- import { spawnSync } from 'child_process';
4
- import { existsSync } from 'fs';
5
-
6
- const __dirname = dirname(fileURLToPath(import.meta.url));
7
- import fs from 'fs-extra';
8
- import ora from 'ora';
9
- import chalk from 'chalk';
10
- import { logger } from '../../utils/logger.js';
11
- import {
12
- getContentDir,
13
- copyDirectory,
14
- copyFile,
15
- pathExists,
16
- ensureDir,
17
- readJson,
18
- writeJson,
19
- updateGitignore
20
- } from '../../utils/file-copier.js';
21
- import {
22
- checkCLIOutdated,
23
- checkProjectOutdated,
24
- saveProjectMorphVersion,
25
- getUpdateInstructions,
26
- detectInstallMethod
27
- } from '../../utils/version-checker.js';
28
- import { installClaudeHooks, installGlobalStatusline } from '../../utils/claude-settings-manager.js';
29
- import { installSkills } from '../../utils/skills-installer.js';
30
- import { installAgents, installDomainAgents } from '../../utils/agents-installer.js';
31
- import { injectMorphImport } from '../../utils/claude-md-injector.js';
32
- import { getProjectTags } from '../../lib/stack-filter.js';
33
-
34
- /**
35
- * Backup user's config.json before cleaning
36
- * @param {string} morphPath - Path to .morph directory
37
- * @returns {Promise<object|null>} Backup of config.json or null
38
- */
39
- async function backupUserConfig(morphPath) {
40
- try {
41
- const configPath = join(morphPath, 'config', 'config.json');
42
- if (await pathExists(configPath)) {
43
- const config = await readJson(configPath);
44
- return config;
45
- }
46
- } catch (error) {
47
- // If backup fails, return null and continue
48
- }
49
- return null;
50
- }
51
-
52
- /**
53
- * Clean framework directories but preserve .morph/project/
54
- * @param {string} morphPath - Path to .morph directory
55
- * @param {string} targetPath - Project root path
56
- */
57
- async function cleanFrameworkDirs(morphPath, targetPath) {
58
- const claudeDir = join(targetPath, '.claude');
59
- const dirsToClean = [
60
- join(morphPath, 'framework', 'templates'),
61
- join(morphPath, 'framework', 'standards'),
62
- join(morphPath, 'framework', 'hooks'),
63
- join(morphPath, 'config'),
64
- join(claudeDir, 'commands'),
65
- join(claudeDir, 'rules'),
66
- join(claudeDir, 'skills'),
67
- join(claudeDir, 'agents'),
68
- ];
69
-
70
- for (const dir of dirsToClean) {
71
- try {
72
- if (await pathExists(dir)) {
73
- await fs.remove(dir);
74
- }
75
- } catch (error) {
76
- // Continue even if cleanup fails for one directory
77
- }
78
- }
79
- }
80
-
81
- /**
82
- * Restore user's config.json after reinstallation
83
- * @param {string} morphPath - Path to .morph directory
84
- * @param {string} targetPath - Project root path
85
- * @param {object|null} configBackup - Backup of config.json
86
- * @param {string} currentVersion - Current CLI version
87
- */
88
- async function restoreUserConfig(morphPath, targetPath, configBackup, currentVersion) {
89
- const configDir = join(morphPath, 'config');
90
- await ensureDir(configDir);
91
- const configPath = join(configDir, 'config.json');
92
-
93
- if (configBackup) {
94
- // Preserve user data but update framework version
95
- configBackup.frameworkVersion = currentVersion;
96
- await writeJson(configPath, configBackup);
97
- } else {
98
- // Create basic config if none existed
99
- const dirName = targetPath.split(/[\\/]/).pop();
100
- const defaultConfig = {
101
- framework: 'global',
102
- frameworkVersion: currentVersion,
103
- project: {
104
- name: dirName,
105
- architecture: 'unknown'
106
- }
107
- };
108
- await writeJson(configPath, defaultConfig);
109
- }
110
- }
111
-
112
- export async function updateCommand(options) {
113
- const targetPath = process.cwd();
114
- const contentDir = getContentDir();
115
- const morphPath = join(targetPath, '.morph');
116
-
117
- logger.header('MORPH-SPEC Update');
118
-
119
- // Check if MORPH is initialized
120
- let needsInitialization = false;
121
- if (!(await pathExists(morphPath))) {
122
- logger.warn('MORPH not fully initialized. Creating basic structure...');
123
- needsInitialization = true;
124
-
125
- // Create basic .morph structure
126
- await ensureDir(join(morphPath, 'context'));
127
- await ensureDir(join(morphPath, 'features'));
128
- await ensureDir(join(morphPath, 'framework'));
129
- await ensureDir(join(morphPath, 'config'));
130
- logger.dim(' ✓ Created .morph directory structure');
131
- }
132
-
133
- // Check CLI version
134
- const spinner = ora('Checking versions...').start();
135
- const cliCheck = await checkCLIOutdated();
136
- const projectCheck = await checkProjectOutdated(targetPath);
137
-
138
- spinner.stop();
139
-
140
- // Display version info
141
- logger.blank();
142
- logger.info('Version Status:');
143
- logger.dim(` CLI version: ${cliCheck.current}`);
144
-
145
- if (projectCheck.current) {
146
- logger.dim(` Project MORPH version: ${projectCheck.current}`);
147
- } else {
148
- logger.dim(' Project MORPH version: not found (legacy installation)');
149
- }
150
-
151
- if (cliCheck.latest) {
152
- const latestColor = cliCheck.isOutdated ? chalk.yellow : chalk.green;
153
- logger.dim(` Latest available: ${latestColor(cliCheck.latest)}`);
154
- }
155
- logger.blank();
156
-
157
- // If CLI is outdated, stop and instruct user
158
- if (cliCheck.isOutdated && cliCheck.latest) {
159
- logger.warn(`Your CLI is outdated (${cliCheck.current} → ${cliCheck.latest})`);
160
- logger.blank();
161
- logger.info('Please update the CLI first:');
162
- logger.blank();
163
-
164
- const method = detectInstallMethod();
165
- const instructions = getUpdateInstructions(method);
166
-
167
- instructions.forEach(line => {
168
- if (line === '') {
169
- logger.blank();
170
- } else if (line.startsWith('Or ')) {
171
- logger.dim(line);
172
- } else {
173
- logger.box([line]);
174
- }
175
- });
176
-
177
- logger.blank();
178
- logger.dim('Then run "morph-spec update" again.');
179
- process.exit(0);
180
- }
181
-
182
- // Proceed with update
183
- const updateSpinner = ora('Updating MORPH-SPEC...').start();
184
-
185
- try {
186
- // Backup user config before cleaning
187
- updateSpinner.text = 'Backing up user configuration...';
188
- const configBackup = await backupUserConfig(morphPath);
189
- if (configBackup) {
190
- logger.dim(' ✓ User config backed up');
191
- }
192
-
193
- // Clean framework directories (preserve .morph/project/)
194
- updateSpinner.text = 'Cleaning old framework files...';
195
- await cleanFrameworkDirs(morphPath, targetPath);
196
- logger.dim(' ✓ Old framework files removed');
197
-
198
- const updateTemplates = !options.standards || options.templates;
199
- const updateStandards = !options.templates || options.standards;
200
-
201
- // Update templates
202
- if (updateTemplates) {
203
- updateSpinner.text = 'Updating templates...';
204
- const templatesSrc = join(contentDir, 'framework', 'templates');
205
- const templatesDest = join(morphPath, 'framework', 'templates');
206
- await copyDirectory(templatesSrc, templatesDest);
207
- }
208
-
209
- // Update standards
210
- if (updateStandards) {
211
- updateSpinner.text = 'Updating standards...';
212
- const standardsSrc = join(contentDir, 'framework', 'standards');
213
- const standardsDest = join(morphPath, 'framework', 'standards');
214
- await copyDirectory(standardsSrc, standardsDest);
215
- }
216
-
217
- // Update hooks (runtime hook scripts under .morph/framework/hooks/)
218
- updateSpinner.text = 'Updating hooks...';
219
- const hooksSrc = join(__dirname, '..', '..', '..', 'framework', 'hooks');
220
- const hooksDest = join(morphPath, 'framework', 'hooks');
221
- if (await pathExists(hooksSrc)) {
222
- await copyDirectory(hooksSrc, hooksDest);
223
- }
224
-
225
- // Update agents.json (sourced from framework/ — canonical single source of truth)
226
- updateSpinner.text = 'Updating agents configuration...';
227
- const agentsSrc = join(__dirname, '..', '..', '..', 'framework', 'agents.json');
228
- const agentsDest = join(morphPath, 'framework', 'agents.json');
229
- if (await pathExists(agentsSrc)) {
230
- await copyFile(agentsSrc, agentsDest);
231
- }
232
-
233
- // Regenerate derived files (phase-utils.js, skill output tables) from output-schema.js
234
- // Only runs when the generator script is present (framework dev installs via npm link)
235
- updateSpinner.text = 'Syncing generated refs...';
236
- try {
237
- const generateScript = join(__dirname, '..', '..', '..', 'scripts', 'generate-refs.js');
238
- if (existsSync(generateScript)) {
239
- const result = spawnSync(process.execPath, [generateScript], { encoding: 'utf8' });
240
- if (result.status === 0) {
241
- logger.dim(' ✓ Generated refs synced (phase-utils.js, skill outputs)');
242
- }
243
- }
244
- } catch {
245
- // Non-fatal: generator unavailable or files already in sync
246
- }
247
-
248
- // Update .claude commands and skills
249
- // Source: framework/ (canonical for all stacks)
250
- updateSpinner.text = 'Setting up Claude Code integration...';
251
- const frameworkDir = join(__dirname, '..', '..', '..', 'framework');
252
- const claudeDest = join(targetPath, '.claude');
253
-
254
- let commandsCopied = false;
255
-
256
- // Copy commands directory (slash commands): framework/commands/ → .claude/commands/
257
- const commandsSrc = join(frameworkDir, 'commands');
258
- const commandsDest = join(claudeDest, 'commands');
259
- if (await pathExists(commandsSrc)) {
260
- await copyDirectory(commandsSrc, commandsDest);
261
- commandsCopied = true;
262
- } else {
263
- logger.warn(' ⚠ framework/commands/ source missing — commands not updated');
264
- }
265
-
266
- // Compute project tags for stack-aware installation of skills, agents, and rules
267
- let projectTags = [];
268
- try {
269
- const cfg = await readJson(join(morphPath, 'config', 'config.json'));
270
- projectTags = getProjectTags(cfg);
271
- } catch { /* ignore — config may not exist yet */ }
272
-
273
- // Sync morph skills to .claude/skills/ for native Claude Code discovery
274
- updateSpinner.text = 'Syncing morph skills to .claude/skills/...';
275
- await installSkills(targetPath, { projectTags });
276
-
277
- // Sync native subagents to .claude/agents/
278
- updateSpinner.text = 'Syncing agents to .claude/agents/...';
279
- await installAgents(targetPath, frameworkDir, { projectTags });
280
- await installDomainAgents(targetPath, frameworkDir, { projectTags });
281
-
282
- // Sync path-scoped rules to .claude/rules/
283
- updateSpinner.text = 'Syncing rules to .claude/rules/...';
284
- const rulesSrc = join(frameworkDir, 'rules');
285
- const rulesDest = join(claudeDest, 'rules');
286
- if (await pathExists(rulesSrc)) {
287
- await copyDirectory(rulesSrc, rulesDest);
288
- }
289
-
290
- // Sync runtime CLAUDE.md to .claude/CLAUDE.md
291
- updateSpinner.text = 'Syncing .claude/CLAUDE.md...';
292
- const runtimeSrc = join(frameworkDir, 'CLAUDE.md');
293
- const runtimeDest = join(claudeDest, 'CLAUDE.md');
294
- if (await pathExists(runtimeSrc)) {
295
- await copyFile(runtimeSrc, runtimeDest);
296
- }
297
-
298
- // Sync statusline globally to ~/.claude/
299
- updateSpinner.text = 'Syncing statusline to ~/.claude/...';
300
- const HOOKS_SRC = join(__dirname, '..', '..', '..', 'framework', 'hooks', 'claude-code');
301
- try {
302
- await installGlobalStatusline(HOOKS_SRC);
303
- } catch {
304
- // Non-critical: global dir may not be writable in all environments
305
- logger.dim(' ⚠ Could not install statusline globally (non-critical)');
306
- }
307
-
308
- // Update Claude Code hooks in .claude/settings.local.json
309
- updateSpinner.text = 'Updating Claude Code hooks...';
310
- const hooksResult = await installClaudeHooks(targetPath);
311
-
312
- // Ensure root CLAUDE.md has morph-spec @import (preserves user content)
313
- updateSpinner.text = 'Checking CLAUDE.md import...';
314
- const claudeMdDest = join(targetPath, 'CLAUDE.md');
315
- await injectMorphImport(claudeMdDest);
316
-
317
- // Restore user config after framework reinstallation
318
- updateSpinner.text = 'Restoring user configuration...';
319
- await restoreUserConfig(morphPath, targetPath, configBackup, cliCheck.current);
320
- logger.dim(' ✓ User config restored with updated framework version');
321
-
322
- // Update .gitignore with current morph rules
323
- updateSpinner.text = 'Updating .gitignore...';
324
- await updateGitignore(targetPath);
325
-
326
- // Update .morphversion
327
- updateSpinner.text = 'Saving version info...';
328
- await saveProjectMorphVersion(targetPath, cliCheck.current);
329
-
330
- updateSpinner.succeed('MORPH-SPEC updated successfully!');
331
- logger.blank();
332
-
333
- // Show version change
334
- if (projectCheck.current && projectCheck.current !== cliCheck.current) {
335
- logger.success(`Updated: ${projectCheck.current} → ${cliCheck.current}`);
336
- } else if (!projectCheck.current) {
337
- logger.success(`Updated to v${cliCheck.current}`);
338
- } else {
339
- logger.success(`Already up to date (v${cliCheck.current})`);
340
- }
341
-
342
- logger.blank();
343
- logger.info('Updated files:');
344
- if (updateTemplates) logger.dim(' ✓ .morph/framework/templates/');
345
- if (updateStandards) logger.dim(' ✓ .morph/framework/standards/');
346
- logger.dim(' ✓ .morph/framework/hooks/');
347
- logger.dim(' ✓ .morph/framework/agents.json');
348
- if (commandsCopied) logger.dim(' ✓ .claude/commands/');
349
-
350
- logger.dim(` ✓ .claude/settings.local.json (${hooksResult.installed} hooks installed)`);
351
- logger.dim(' ✓ .claude/skills/ (skills installed as directories)');
352
- logger.dim(' ✓ .claude/agents/ (native subagents refreshed)');
353
- logger.dim(' ✓ .claude/rules/ (path-scoped rules synced)');
354
- logger.dim(' ✓ .claude/CLAUDE.md (runtime quick reference)');
355
- logger.dim(' ✓ ~/.claude/statusline.sh (global statusline synced)');
356
- logger.dim(' ✓ CLAUDE.md');
357
- logger.dim(' ✓ .gitignore (morph rules updated)');
358
- logger.blank();
359
- logger.info('Your config.json was preserved.');
360
- logger.dim('Review the updated files for any new features.');
361
- logger.blank();
362
-
363
- } catch (error) {
364
- updateSpinner.fail('Update failed');
365
- logger.error(error.message);
366
- process.exit(1);
367
- }
368
- }
1
+ import { join, dirname } from 'path';
2
+ import { fileURLToPath } from 'url';
3
+ import { spawnSync } from 'child_process';
4
+ import { existsSync } from 'fs';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ import fs from 'fs-extra';
8
+ import ora from 'ora';
9
+ import chalk from 'chalk';
10
+ import { logger } from '../../utils/logger.js';
11
+ import {
12
+ getContentDir,
13
+ copyDirectory,
14
+ copyFile,
15
+ pathExists,
16
+ ensureDir,
17
+ readJson,
18
+ writeJson,
19
+ updateGitignore
20
+ } from '../../utils/file-copier.js';
21
+ import {
22
+ checkCLIOutdated,
23
+ checkProjectOutdated,
24
+ saveProjectMorphVersion,
25
+ getUpdateInstructions,
26
+ detectInstallMethod
27
+ } from '../../utils/version-checker.js';
28
+ import { installClaudeHooks, installGlobalStatusline } from '../../utils/claude-settings-manager.js';
29
+ import { installSkills } from '../../utils/skills-installer.js';
30
+ import { installAgents, installDomainAgents } from '../../utils/agents-installer.js';
31
+ import { injectMorphImport } from '../../utils/claude-md-injector.js';
32
+ import { getProjectTags } from '../../lib/stack-filter.js';
33
+
34
+ /**
35
+ * Backup user's config.json before cleaning
36
+ * @param {string} morphPath - Path to .morph directory
37
+ * @returns {Promise<object|null>} Backup of config.json or null
38
+ */
39
+ async function backupUserConfig(morphPath) {
40
+ try {
41
+ const configPath = join(morphPath, 'config', 'config.json');
42
+ if (await pathExists(configPath)) {
43
+ const config = await readJson(configPath);
44
+ return config;
45
+ }
46
+ } catch (error) {
47
+ // If backup fails, return null and continue
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Clean framework directories but preserve .morph/project/
54
+ * @param {string} morphPath - Path to .morph directory
55
+ * @param {string} targetPath - Project root path
56
+ */
57
+ async function cleanFrameworkDirs(morphPath, targetPath) {
58
+ const claudeDir = join(targetPath, '.claude');
59
+ const dirsToClean = [
60
+ join(morphPath, 'framework', 'templates'),
61
+ join(morphPath, 'framework', 'standards'),
62
+ join(morphPath, 'framework', 'hooks'),
63
+ join(morphPath, 'config'),
64
+ join(claudeDir, 'commands'),
65
+ join(claudeDir, 'rules'),
66
+ join(claudeDir, 'skills'),
67
+ join(claudeDir, 'agents'),
68
+ ];
69
+
70
+ for (const dir of dirsToClean) {
71
+ try {
72
+ if (await pathExists(dir)) {
73
+ await fs.remove(dir);
74
+ }
75
+ } catch (error) {
76
+ // Continue even if cleanup fails for one directory
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Restore user's config.json after reinstallation
83
+ * @param {string} morphPath - Path to .morph directory
84
+ * @param {string} targetPath - Project root path
85
+ * @param {object|null} configBackup - Backup of config.json
86
+ * @param {string} currentVersion - Current CLI version
87
+ */
88
+ async function restoreUserConfig(morphPath, targetPath, configBackup, currentVersion) {
89
+ const configDir = join(morphPath, 'config');
90
+ await ensureDir(configDir);
91
+ const configPath = join(configDir, 'config.json');
92
+
93
+ if (configBackup) {
94
+ // Preserve user data but update framework version
95
+ configBackup.frameworkVersion = currentVersion;
96
+ await writeJson(configPath, configBackup);
97
+ } else {
98
+ // Create basic config if none existed
99
+ const dirName = targetPath.split(/[\\/]/).pop();
100
+ const defaultConfig = {
101
+ framework: 'global',
102
+ frameworkVersion: currentVersion,
103
+ project: {
104
+ name: dirName,
105
+ architecture: 'unknown'
106
+ }
107
+ };
108
+ await writeJson(configPath, defaultConfig);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Untrack an already-committed `.morph/state.json` from git.
114
+ *
115
+ * Before v5's per-feature model, `.morph/state.json` was committed. Adding it to
116
+ * `.gitignore` does NOT stop git from tracking a file already in the index — so on
117
+ * exactly the repos that had the P1 sweep problem, `git commit -a` would keep
118
+ * staging state.json. This runs `git rm --cached` once so the next commit drops it.
119
+ * The working-tree file is left in place (it's rebuilt from feature.json on load).
120
+ *
121
+ * Mildly destructive (mutates the git index), but no data is lost: state.json is a
122
+ * derivable index. Fail-open — a non-git project or any git error is a silent no-op.
123
+ *
124
+ * @param {string} targetPath - Project root
125
+ * @returns {boolean} true if state.json was untracked, false otherwise
126
+ */
127
+ export function untrackCommittedStateJson(targetPath) {
128
+ try {
129
+ const isRepo = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
130
+ cwd: targetPath, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore']
131
+ });
132
+ if (isRepo.status !== 0) return false;
133
+
134
+ // Is .morph/state.json currently tracked?
135
+ const tracked = spawnSync('git', ['ls-files', '--error-unmatch', '.morph/state.json'], {
136
+ cwd: targetPath, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore']
137
+ });
138
+ if (tracked.status !== 0) return false; // not tracked — nothing to migrate
139
+
140
+ const rm = spawnSync('git', ['rm', '--cached', '--quiet', '.morph/state.json'], {
141
+ cwd: targetPath, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore']
142
+ });
143
+ return rm.status === 0;
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ export async function updateCommand(options) {
150
+ const targetPath = process.cwd();
151
+ const contentDir = getContentDir();
152
+ const morphPath = join(targetPath, '.morph');
153
+
154
+ logger.header('MORPH-SPEC Update');
155
+
156
+ // Check if MORPH is initialized
157
+ let needsInitialization = false;
158
+ if (!(await pathExists(morphPath))) {
159
+ logger.warn('MORPH not fully initialized. Creating basic structure...');
160
+ needsInitialization = true;
161
+
162
+ // Create basic .morph structure
163
+ await ensureDir(join(morphPath, 'context'));
164
+ await ensureDir(join(morphPath, 'features'));
165
+ await ensureDir(join(morphPath, 'framework'));
166
+ await ensureDir(join(morphPath, 'config'));
167
+ logger.dim(' ✓ Created .morph directory structure');
168
+ }
169
+
170
+ // Check CLI version
171
+ const spinner = ora('Checking versions...').start();
172
+ const cliCheck = await checkCLIOutdated();
173
+ const projectCheck = await checkProjectOutdated(targetPath);
174
+
175
+ spinner.stop();
176
+
177
+ // Display version info
178
+ logger.blank();
179
+ logger.info('Version Status:');
180
+ logger.dim(` CLI version: ${cliCheck.current}`);
181
+
182
+ if (projectCheck.current) {
183
+ logger.dim(` Project MORPH version: ${projectCheck.current}`);
184
+ } else {
185
+ logger.dim(' Project MORPH version: not found (legacy installation)');
186
+ }
187
+
188
+ if (cliCheck.latest) {
189
+ const latestColor = cliCheck.isOutdated ? chalk.yellow : chalk.green;
190
+ logger.dim(` Latest available: ${latestColor(cliCheck.latest)}`);
191
+ }
192
+ logger.blank();
193
+
194
+ // If CLI is outdated, stop and instruct user
195
+ if (cliCheck.isOutdated && cliCheck.latest) {
196
+ logger.warn(`Your CLI is outdated (${cliCheck.current} → ${cliCheck.latest})`);
197
+ logger.blank();
198
+ logger.info('Please update the CLI first:');
199
+ logger.blank();
200
+
201
+ const method = detectInstallMethod();
202
+ const instructions = getUpdateInstructions(method);
203
+
204
+ instructions.forEach(line => {
205
+ if (line === '') {
206
+ logger.blank();
207
+ } else if (line.startsWith('Or ')) {
208
+ logger.dim(line);
209
+ } else {
210
+ logger.box([line]);
211
+ }
212
+ });
213
+
214
+ logger.blank();
215
+ logger.dim('Then run "morph-spec update" again.');
216
+ process.exit(0);
217
+ }
218
+
219
+ // Proceed with update
220
+ const updateSpinner = ora('Updating MORPH-SPEC...').start();
221
+
222
+ try {
223
+ // Backup user config before cleaning
224
+ updateSpinner.text = 'Backing up user configuration...';
225
+ const configBackup = await backupUserConfig(morphPath);
226
+ if (configBackup) {
227
+ logger.dim(' ✓ User config backed up');
228
+ }
229
+
230
+ // Clean framework directories (preserve .morph/project/)
231
+ updateSpinner.text = 'Cleaning old framework files...';
232
+ await cleanFrameworkDirs(morphPath, targetPath);
233
+ logger.dim(' ✓ Old framework files removed');
234
+
235
+ const updateTemplates = !options.standards || options.templates;
236
+ const updateStandards = !options.templates || options.standards;
237
+
238
+ // Update templates
239
+ if (updateTemplates) {
240
+ updateSpinner.text = 'Updating templates...';
241
+ const templatesSrc = join(contentDir, 'framework', 'templates');
242
+ const templatesDest = join(morphPath, 'framework', 'templates');
243
+ await copyDirectory(templatesSrc, templatesDest);
244
+ }
245
+
246
+ // Update standards
247
+ if (updateStandards) {
248
+ updateSpinner.text = 'Updating standards...';
249
+ const standardsSrc = join(contentDir, 'framework', 'standards');
250
+ const standardsDest = join(morphPath, 'framework', 'standards');
251
+ await copyDirectory(standardsSrc, standardsDest);
252
+ }
253
+
254
+ // Update hooks (runtime hook scripts under .morph/framework/hooks/)
255
+ updateSpinner.text = 'Updating hooks...';
256
+ const hooksSrc = join(__dirname, '..', '..', '..', 'framework', 'hooks');
257
+ const hooksDest = join(morphPath, 'framework', 'hooks');
258
+ if (await pathExists(hooksSrc)) {
259
+ await copyDirectory(hooksSrc, hooksDest);
260
+ }
261
+
262
+ // Update agents.json (sourced from framework/ — canonical single source of truth)
263
+ updateSpinner.text = 'Updating agents configuration...';
264
+ const agentsSrc = join(__dirname, '..', '..', '..', 'framework', 'agents.json');
265
+ const agentsDest = join(morphPath, 'framework', 'agents.json');
266
+ if (await pathExists(agentsSrc)) {
267
+ await copyFile(agentsSrc, agentsDest);
268
+ }
269
+
270
+ // Regenerate derived files (phase-utils.js, skill output tables) from output-schema.js
271
+ // Only runs when the generator script is present (framework dev installs via npm link)
272
+ updateSpinner.text = 'Syncing generated refs...';
273
+ try {
274
+ const generateScript = join(__dirname, '..', '..', '..', 'scripts', 'generate-refs.js');
275
+ if (existsSync(generateScript)) {
276
+ const result = spawnSync(process.execPath, [generateScript], { encoding: 'utf8' });
277
+ if (result.status === 0) {
278
+ logger.dim(' ✓ Generated refs synced (phase-utils.js, skill outputs)');
279
+ }
280
+ }
281
+ } catch {
282
+ // Non-fatal: generator unavailable or files already in sync
283
+ }
284
+
285
+ // Update .claude commands and skills
286
+ // Source: framework/ (canonical for all stacks)
287
+ updateSpinner.text = 'Setting up Claude Code integration...';
288
+ const frameworkDir = join(__dirname, '..', '..', '..', 'framework');
289
+ const claudeDest = join(targetPath, '.claude');
290
+
291
+ let commandsCopied = false;
292
+
293
+ // Copy commands directory (slash commands): framework/commands/ → .claude/commands/
294
+ const commandsSrc = join(frameworkDir, 'commands');
295
+ const commandsDest = join(claudeDest, 'commands');
296
+ if (await pathExists(commandsSrc)) {
297
+ await copyDirectory(commandsSrc, commandsDest);
298
+ commandsCopied = true;
299
+ } else {
300
+ logger.warn(' ⚠ framework/commands/ source missing — commands not updated');
301
+ }
302
+
303
+ // Compute project tags for stack-aware installation of skills, agents, and rules
304
+ let projectTags = [];
305
+ try {
306
+ const cfg = await readJson(join(morphPath, 'config', 'config.json'));
307
+ projectTags = getProjectTags(cfg);
308
+ } catch { /* ignore — config may not exist yet */ }
309
+
310
+ // Sync morph skills to .claude/skills/ for native Claude Code discovery
311
+ updateSpinner.text = 'Syncing morph skills to .claude/skills/...';
312
+ await installSkills(targetPath, { projectTags });
313
+
314
+ // Sync native subagents to .claude/agents/
315
+ updateSpinner.text = 'Syncing agents to .claude/agents/...';
316
+ await installAgents(targetPath, frameworkDir, { projectTags });
317
+ await installDomainAgents(targetPath, frameworkDir, { projectTags });
318
+
319
+ // Sync path-scoped rules to .claude/rules/
320
+ updateSpinner.text = 'Syncing rules to .claude/rules/...';
321
+ const rulesSrc = join(frameworkDir, 'rules');
322
+ const rulesDest = join(claudeDest, 'rules');
323
+ if (await pathExists(rulesSrc)) {
324
+ await copyDirectory(rulesSrc, rulesDest);
325
+ }
326
+
327
+ // Sync runtime CLAUDE.md to .claude/CLAUDE.md
328
+ updateSpinner.text = 'Syncing .claude/CLAUDE.md...';
329
+ const runtimeSrc = join(frameworkDir, 'CLAUDE.md');
330
+ const runtimeDest = join(claudeDest, 'CLAUDE.md');
331
+ if (await pathExists(runtimeSrc)) {
332
+ await copyFile(runtimeSrc, runtimeDest);
333
+ }
334
+
335
+ // Sync statusline globally to ~/.claude/
336
+ updateSpinner.text = 'Syncing statusline to ~/.claude/...';
337
+ const HOOKS_SRC = join(__dirname, '..', '..', '..', 'framework', 'hooks', 'claude-code');
338
+ try {
339
+ await installGlobalStatusline(HOOKS_SRC);
340
+ } catch {
341
+ // Non-critical: global dir may not be writable in all environments
342
+ logger.dim(' ⚠ Could not install statusline globally (non-critical)');
343
+ }
344
+
345
+ // Update Claude Code hooks in .claude/settings.local.json
346
+ updateSpinner.text = 'Updating Claude Code hooks...';
347
+ const hooksResult = await installClaudeHooks(targetPath);
348
+
349
+ // Ensure root CLAUDE.md has morph-spec @import (preserves user content)
350
+ updateSpinner.text = 'Checking CLAUDE.md import...';
351
+ const claudeMdDest = join(targetPath, 'CLAUDE.md');
352
+ await injectMorphImport(claudeMdDest);
353
+
354
+ // Restore user config after framework reinstallation
355
+ updateSpinner.text = 'Restoring user configuration...';
356
+ await restoreUserConfig(morphPath, targetPath, configBackup, cliCheck.current);
357
+ logger.dim(' ✓ User config restored with updated framework version');
358
+
359
+ // Update .gitignore with current morph rules
360
+ updateSpinner.text = 'Updating .gitignore...';
361
+ await updateGitignore(targetPath);
362
+
363
+ // Migration: untrack a pre-v5 committed state.json so it stops being swept
364
+ // into feature commits. Gitignore alone can't do this for an already-tracked file.
365
+ const untracked = untrackCommittedStateJson(targetPath);
366
+
367
+ // Update .morphversion
368
+ updateSpinner.text = 'Saving version info...';
369
+ await saveProjectMorphVersion(targetPath, cliCheck.current);
370
+
371
+ updateSpinner.succeed('MORPH-SPEC updated successfully!');
372
+ logger.blank();
373
+
374
+ // Show version change
375
+ if (projectCheck.current && projectCheck.current !== cliCheck.current) {
376
+ logger.success(`Updated: ${projectCheck.current} → ${cliCheck.current}`);
377
+ } else if (!projectCheck.current) {
378
+ logger.success(`Updated to v${cliCheck.current}`);
379
+ } else {
380
+ logger.success(`Already up to date (v${cliCheck.current})`);
381
+ }
382
+
383
+ logger.blank();
384
+ logger.info('Updated files:');
385
+ if (updateTemplates) logger.dim(' ✓ .morph/framework/templates/');
386
+ if (updateStandards) logger.dim(' ✓ .morph/framework/standards/');
387
+ logger.dim(' ✓ .morph/framework/hooks/');
388
+ logger.dim(' ✓ .morph/framework/agents.json');
389
+ if (commandsCopied) logger.dim(' ✓ .claude/commands/');
390
+
391
+ logger.dim(` ✓ .claude/settings.local.json (${hooksResult.installed} hooks installed)`);
392
+ logger.dim(' ✓ .claude/skills/ (skills installed as directories)');
393
+ logger.dim(' ✓ .claude/agents/ (native subagents refreshed)');
394
+ logger.dim(' ✓ .claude/rules/ (path-scoped rules synced)');
395
+ logger.dim(' ✓ .claude/CLAUDE.md (runtime quick reference)');
396
+ logger.dim(' ✓ ~/.claude/statusline.sh (global statusline synced)');
397
+ logger.dim(' ✓ CLAUDE.md');
398
+ logger.dim(' ✓ .gitignore (morph rules updated)');
399
+ if (untracked) {
400
+ logger.dim(' ✓ .morph/state.json untracked (git rm --cached) — commit to finalize');
401
+ logger.warn('Migration: .morph/state.json was removed from git tracking. The');
402
+ logger.dim(' working file stays (rebuilt from feature.json); commit the deletion');
403
+ logger.dim(' so it stops being swept into per-feature commits.');
404
+ }
405
+ logger.blank();
406
+ logger.info('Your config.json was preserved.');
407
+ logger.dim('Review the updated files for any new features.');
408
+ logger.blank();
409
+
410
+ } catch (error) {
411
+ updateSpinner.fail('Update failed');
412
+ logger.error(error.message);
413
+ process.exit(1);
414
+ }
415
+ }