@polymorphism-tech/morph-spec 7.4.0 → 7.5.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 (49) 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/commands/create-umbraco-lp.md +35 -0
  9. package/framework/commands/migrate-lp-to-umbraco.md +33 -0
  10. package/framework/commands/morph-archive.md +17 -27
  11. package/framework/hooks/claude-code/core/enforce-phase-writes.js +115 -90
  12. package/framework/hooks/claude-code/core/protect-spec-files.js +32 -30
  13. package/framework/hooks/claude-code/core/state-sync.js +16 -48
  14. package/framework/hooks/shared/state-reader.js +74 -8
  15. package/framework/hooks/shared/state-writer.js +124 -0
  16. package/framework/rules/morph-task-tracking.md +69 -49
  17. package/framework/rules/morph-workflow.md +192 -191
  18. package/framework/skills/core/create-umbraco-lp/SKILL.md +252 -0
  19. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +452 -0
  20. package/framework/skills/core/morph-apply/SKILL.md +15 -15
  21. package/framework/skills/core/morph-brainstorming/SKILL.md +5 -5
  22. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  23. package/framework/skills/core/morph-implement/SKILL.md +5 -4
  24. package/framework/skills/core/morph-plan/SKILL.md +6 -6
  25. package/framework/skills/core/morph-proposal/SKILL.md +8 -7
  26. package/framework/skills/core/morph-review/SKILL.md +3 -3
  27. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  28. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  29. package/framework/templates/REGISTRY.json +1 -3
  30. package/framework/templates/context/CONTEXT-FEATURE.md +10 -17
  31. package/framework/templates/context/CONTEXT.md +181 -180
  32. package/framework/templates/docs/onboarding.md +122 -118
  33. package/framework/templates/docs/proposal.md +10 -0
  34. package/framework/templates/docs/spec.md +228 -228
  35. package/framework/templates/meta-prompts/parallel-workers/parallel-worker.md +80 -80
  36. package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +90 -89
  37. package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +2 -1
  38. package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +44 -43
  39. package/framework/templates/meta-prompts/validators/checkpoint-validator.md +107 -107
  40. package/package.json +1 -1
  41. package/src/commands/project/update.js +415 -368
  42. package/src/commands/project/worktree.js +212 -210
  43. package/src/commands/state/advance.js +82 -0
  44. package/src/commands/state/archive.js +111 -0
  45. package/src/commands/validation/validate-feature.js +229 -105
  46. package/src/core/state/state-manager.js +238 -66
  47. package/src/dashboard/scanner/state-scanner.js +35 -8
  48. package/src/lib/generators/context-generator.js +12 -8
  49. package/src/utils/file-copier.js +7 -0
@@ -1,210 +1,212 @@
1
- /**
2
- * MORPH-SPEC Worktree Setup Command
3
- *
4
- * Creates a git worktree at .worktrees/{feature}/ on branch morph/{feature}.
5
- * Used by SessionStart hook to isolate feature work from the main branch.
6
- *
7
- * Usage:
8
- * morph-spec worktree setup <feature>
9
- * morph-spec worktree setup <feature> --fresh
10
- *
11
- * Exit codes:
12
- * 0 — success (created) or fail-open (not a git repo, git error)
13
- * 2 — worktree already exists (signals resume scenario to hook caller)
14
- */
15
-
16
- import { execSync } from 'child_process';
17
- import { existsSync, mkdirSync, symlinkSync, linkSync, copyFileSync } from 'fs';
18
- import { join, resolve, dirname } from 'path';
19
-
20
- // ── Pure helpers (exported for tests) ──────────────────────────────────────
21
-
22
- /**
23
- * Check if a directory is inside a git repository.
24
- * @param {string} cwd
25
- * @returns {boolean}
26
- */
27
- export function isGitRepo(cwd = process.cwd()) {
28
- try {
29
- execSync('git rev-parse --git-dir', { cwd, stdio: 'pipe' });
30
- return true;
31
- } catch {
32
- return false;
33
- }
34
- }
35
-
36
- /**
37
- * Check if a branch exists locally.
38
- * @param {string} branch
39
- * @param {string} cwd
40
- * @returns {boolean}
41
- */
42
- export function branchExists(branch, cwd = process.cwd()) {
43
- try {
44
- execSync(`git show-ref --verify --quiet refs/heads/${branch}`, { cwd, stdio: 'pipe' });
45
- return true;
46
- } catch {
47
- return false;
48
- }
49
- }
50
-
51
- /**
52
- * Check if a worktree path is already registered with git.
53
- * @param {string} worktreePath Relative or absolute path
54
- * @param {string} cwd
55
- * @returns {boolean}
56
- */
57
- export function worktreeExists(worktreePath, cwd = process.cwd()) {
58
- try {
59
- const output = execSync('git worktree list --porcelain', { cwd, stdio: 'pipe' }).toString();
60
- const normalizedPath = worktreePath.replace(/\\/g, '/');
61
- const absPath = normalizedPath.startsWith('/') ? normalizedPath : `${cwd.replace(/\\/g, '/')}/${normalizedPath}`;
62
- return output.replace(/\\/g, '/').includes(absPath);
63
- } catch {
64
- return false;
65
- }
66
- }
67
-
68
- /**
69
- * Build the worktree directory path (always forward slashes).
70
- * @param {string} feature
71
- * @param {string} cwd
72
- * @param {{ fresh?: boolean }} options
73
- * @returns {string}
74
- */
75
- export function buildWorktreePath(feature, cwd = process.cwd(), options = {}) {
76
- const suffix = options.fresh ? `-${getDateSuffix()}` : '';
77
- const normalizedCwd = cwd.replace(/\\/g, '/');
78
- return `${normalizedCwd}/.worktrees/${feature}${suffix}`;
79
- }
80
-
81
- /**
82
- * Build the branch name for a feature worktree.
83
- * @param {string} feature
84
- * @param {{ fresh?: boolean }} options
85
- * @returns {string}
86
- */
87
- export function buildBranchName(feature, options = {}) {
88
- const suffix = options.fresh ? `-${getDateSuffix()}` : '';
89
- return `morph/${feature}${suffix}`;
90
- }
91
-
92
- function getDateSuffix() {
93
- return new Date().toISOString().slice(0, 10).replace(/-/g, '');
94
- }
95
-
96
- // ── Infrastructure linking ───────────────────────────────────────────────────
97
-
98
- /**
99
- * Create symlinks/junctions in the worktree for shared infrastructure files
100
- * that are gitignored (state.json, .claude/, .morph/framework/, node_modules/).
101
- *
102
- * Uses junctions for directories (no admin required on Windows).
103
- * File symlinks fall back to copy if symlink fails.
104
- * Fail-open: any error is silently ignored.
105
- *
106
- * @param {string} worktreePath - Absolute path to the worktree
107
- * @param {string} mainCwd - Absolute path to the main project root
108
- */
109
- export function setupInfraLinks(worktreePath, mainCwd) {
110
- const mainAbs = resolve(mainCwd);
111
- const wtAbs = resolve(worktreePath);
112
-
113
- // Directory junctions — shared across worktrees
114
- const dirLinks = ['node_modules', '.claude'];
115
-
116
- // .morph/framework/ — client projects (in framework repo, framework/ is tracked)
117
- if (existsSync(join(mainAbs, '.morph', 'framework'))) {
118
- dirLinks.push(join('.morph', 'framework'));
119
- }
120
-
121
- for (const rel of dirLinks) {
122
- const target = join(mainAbs, rel);
123
- const linkPath = join(wtAbs, rel);
124
- if (!existsSync(target) || existsSync(linkPath)) continue;
125
- try {
126
- const parent = dirname(linkPath);
127
- if (!existsSync(parent)) mkdirSync(parent, { recursive: true });
128
- symlinkSync(target, linkPath, 'junction');
129
- } catch { /* fail-open */ }
130
- }
131
-
132
- // state.json — shared state via hard link (works on Windows without admin,
133
- // same NTFS volume). Falls back to symlink, then copy.
134
- const stateTarget = join(mainAbs, '.morph', 'state.json');
135
- const stateLink = join(wtAbs, '.morph', 'state.json');
136
- if (existsSync(stateTarget) && !existsSync(stateLink)) {
137
- const morphDir = join(wtAbs, '.morph');
138
- if (!existsSync(morphDir)) {
139
- try { mkdirSync(morphDir, { recursive: true }); } catch { /* */ }
140
- }
141
- try {
142
- linkSync(stateTarget, stateLink); // hard link — no admin, shared data
143
- } catch {
144
- try { copyFileSync(stateTarget, stateLink); } catch { /* fail-open */ }
145
- }
146
- }
147
- }
148
-
149
- // ── Command handler ─────────────────────────────────────────────────────────
150
-
151
- /**
152
- * Main command handler for `morph-spec worktree setup <feature>`
153
- * @param {string} feature
154
- * @param {{ fresh?: boolean, cwd?: string }} options
155
- */
156
- export async function worktreeSetupCommand(feature, options = {}) {
157
- const cwd = options.cwd || process.cwd();
158
-
159
- // Gate 1: Must be a git repo
160
- if (!isGitRepo(cwd)) {
161
- console.log(JSON.stringify({ created: false, error: 'not-a-git-repo', feature }));
162
- process.exit(0); // Fail-open
163
- }
164
-
165
- const branch = buildBranchName(feature, options);
166
- const worktreePath = buildWorktreePath(feature, cwd, options);
167
-
168
- // Gate 2: If worktree already registered and not --fresh, signal resume
169
- if (!options.fresh && worktreeExists(worktreePath, cwd)) {
170
- console.log(JSON.stringify({ created: false, alreadyExists: true, path: worktreePath, branch, feature }));
171
- process.exit(2); // Convention: 2 = already exists
172
- }
173
-
174
- try {
175
- // Create branch if needed (based off current HEAD)
176
- if (!branchExists(branch, cwd)) {
177
- execSync(`git branch ${branch}`, { cwd, stdio: 'pipe' });
178
- }
179
-
180
- // Create worktree directory parent if needed
181
- const parentDir = join(cwd, '.worktrees');
182
- if (!existsSync(parentDir)) {
183
- mkdirSync(parentDir, { recursive: true });
184
- }
185
-
186
- // Add worktree
187
- execSync(`git worktree add "${worktreePath}" ${branch}`, { cwd, stdio: 'pipe' });
188
-
189
- // Set up infrastructure symlinks so the worktree has state.json, .claude/, etc.
190
- setupInfraLinks(worktreePath, cwd);
191
-
192
- // Persist in state (non-blocking — feature may not be in state yet)
193
- try {
194
- const { loadState, saveState } = await import('../../lib/state/state-manager.js');
195
- const state = loadState(cwd);
196
- if (state?.features?.[feature]) {
197
- state.features[feature].worktree = { path: worktreePath, branch, createdAt: new Date().toISOString() };
198
- saveState(state, cwd);
199
- }
200
- } catch {
201
- // Non-blocking
202
- }
203
-
204
- console.log(JSON.stringify({ created: true, path: worktreePath, branch, feature }));
205
- process.exit(0);
206
- } catch (err) {
207
- console.log(JSON.stringify({ created: false, error: (err.message || 'unknown').slice(0, 200), feature }));
208
- process.exit(0); // Fail-open
209
- }
210
- }
1
+ /**
2
+ * MORPH-SPEC Worktree Setup Command
3
+ *
4
+ * Creates a git worktree at .worktrees/{feature}/ on branch morph/{feature}.
5
+ * Used by SessionStart hook to isolate feature work from the main branch.
6
+ *
7
+ * Usage:
8
+ * morph-spec worktree setup <feature>
9
+ * morph-spec worktree setup <feature> --fresh
10
+ *
11
+ * Exit codes:
12
+ * 0 — success (created) or fail-open (not a git repo, git error)
13
+ * 2 — worktree already exists (signals resume scenario to hook caller)
14
+ */
15
+
16
+ import { execSync } from 'child_process';
17
+ import { existsSync, mkdirSync, symlinkSync, linkSync, copyFileSync } from 'fs';
18
+ import { join, resolve, dirname } from 'path';
19
+
20
+ // ── Pure helpers (exported for tests) ──────────────────────────────────────
21
+
22
+ /**
23
+ * Check if a directory is inside a git repository.
24
+ * @param {string} cwd
25
+ * @returns {boolean}
26
+ */
27
+ export function isGitRepo(cwd = process.cwd()) {
28
+ try {
29
+ execSync('git rev-parse --git-dir', { cwd, stdio: 'pipe' });
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Check if a branch exists locally.
38
+ * @param {string} branch
39
+ * @param {string} cwd
40
+ * @returns {boolean}
41
+ */
42
+ export function branchExists(branch, cwd = process.cwd()) {
43
+ try {
44
+ execSync(`git show-ref --verify --quiet refs/heads/${branch}`, { cwd, stdio: 'pipe' });
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Check if a worktree path is already registered with git.
53
+ * @param {string} worktreePath Relative or absolute path
54
+ * @param {string} cwd
55
+ * @returns {boolean}
56
+ */
57
+ export function worktreeExists(worktreePath, cwd = process.cwd()) {
58
+ try {
59
+ const output = execSync('git worktree list --porcelain', { cwd, stdio: 'pipe' }).toString();
60
+ const normalizedPath = worktreePath.replace(/\\/g, '/');
61
+ const absPath = normalizedPath.startsWith('/') ? normalizedPath : `${cwd.replace(/\\/g, '/')}/${normalizedPath}`;
62
+ return output.replace(/\\/g, '/').includes(absPath);
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Build the worktree directory path (always forward slashes).
70
+ * @param {string} feature
71
+ * @param {string} cwd
72
+ * @param {{ fresh?: boolean }} options
73
+ * @returns {string}
74
+ */
75
+ export function buildWorktreePath(feature, cwd = process.cwd(), options = {}) {
76
+ const suffix = options.fresh ? `-${getDateSuffix()}` : '';
77
+ const normalizedCwd = cwd.replace(/\\/g, '/');
78
+ return `${normalizedCwd}/.worktrees/${feature}${suffix}`;
79
+ }
80
+
81
+ /**
82
+ * Build the branch name for a feature worktree.
83
+ * @param {string} feature
84
+ * @param {{ fresh?: boolean }} options
85
+ * @returns {string}
86
+ */
87
+ export function buildBranchName(feature, options = {}) {
88
+ const suffix = options.fresh ? `-${getDateSuffix()}` : '';
89
+ return `morph/${feature}${suffix}`;
90
+ }
91
+
92
+ function getDateSuffix() {
93
+ return new Date().toISOString().slice(0, 10).replace(/-/g, '');
94
+ }
95
+
96
+ // ── Infrastructure linking ───────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Create symlinks/junctions in the worktree for shared infrastructure files
100
+ * that are gitignored (state.json, .claude/, .morph/framework/, node_modules/).
101
+ *
102
+ * Uses junctions for directories (no admin required on Windows).
103
+ * File symlinks fall back to copy if symlink fails.
104
+ * Fail-open: any error is silently ignored.
105
+ *
106
+ * @param {string} worktreePath - Absolute path to the worktree
107
+ * @param {string} mainCwd - Absolute path to the main project root
108
+ */
109
+ export function setupInfraLinks(worktreePath, mainCwd) {
110
+ const mainAbs = resolve(mainCwd);
111
+ const wtAbs = resolve(worktreePath);
112
+
113
+ // Directory junctions — shared across worktrees
114
+ const dirLinks = ['node_modules', '.claude'];
115
+
116
+ // .morph/framework/ — client projects (in framework repo, framework/ is tracked)
117
+ if (existsSync(join(mainAbs, '.morph', 'framework'))) {
118
+ dirLinks.push(join('.morph', 'framework'));
119
+ }
120
+
121
+ for (const rel of dirLinks) {
122
+ const target = join(mainAbs, rel);
123
+ const linkPath = join(wtAbs, rel);
124
+ if (!existsSync(target) || existsSync(linkPath)) continue;
125
+ try {
126
+ const parent = dirname(linkPath);
127
+ if (!existsSync(parent)) mkdirSync(parent, { recursive: true });
128
+ symlinkSync(target, linkPath, 'junction');
129
+ } catch { /* fail-open */ }
130
+ }
131
+
132
+ // state.json — shared state via hard link (works on Windows without admin,
133
+ // same NTFS volume). Falls back to symlink, then copy.
134
+ const stateTarget = join(mainAbs, '.morph', 'state.json');
135
+ const stateLink = join(wtAbs, '.morph', 'state.json');
136
+ if (existsSync(stateTarget) && !existsSync(stateLink)) {
137
+ const morphDir = join(wtAbs, '.morph');
138
+ if (!existsSync(morphDir)) {
139
+ try { mkdirSync(morphDir, { recursive: true }); } catch { /* */ }
140
+ }
141
+ try {
142
+ linkSync(stateTarget, stateLink); // hard link — no admin, shared data
143
+ } catch {
144
+ try { copyFileSync(stateTarget, stateLink); } catch { /* fail-open */ }
145
+ }
146
+ }
147
+ }
148
+
149
+ // ── Command handler ─────────────────────────────────────────────────────────
150
+
151
+ /**
152
+ * Main command handler for `morph-spec worktree setup <feature>`
153
+ * @param {string} feature
154
+ * @param {{ fresh?: boolean, cwd?: string }} options
155
+ */
156
+ export async function worktreeSetupCommand(feature, options = {}) {
157
+ const cwd = options.cwd || process.cwd();
158
+
159
+ // Gate 1: Must be a git repo
160
+ if (!isGitRepo(cwd)) {
161
+ console.log(JSON.stringify({ created: false, error: 'not-a-git-repo', feature }));
162
+ process.exit(0); // Fail-open
163
+ }
164
+
165
+ const branch = buildBranchName(feature, options);
166
+ const worktreePath = buildWorktreePath(feature, cwd, options);
167
+
168
+ // Gate 2: If worktree already registered and not --fresh, signal resume
169
+ if (!options.fresh && worktreeExists(worktreePath, cwd)) {
170
+ console.log(JSON.stringify({ created: false, alreadyExists: true, path: worktreePath, branch, feature }));
171
+ process.exit(2); // Convention: 2 = already exists
172
+ }
173
+
174
+ try {
175
+ // Create branch if needed (based off current HEAD)
176
+ if (!branchExists(branch, cwd)) {
177
+ execSync(`git branch ${branch}`, { cwd, stdio: 'pipe' });
178
+ }
179
+
180
+ // Create worktree directory parent if needed
181
+ const parentDir = join(cwd, '.worktrees');
182
+ if (!existsSync(parentDir)) {
183
+ mkdirSync(parentDir, { recursive: true });
184
+ }
185
+
186
+ // Add worktree
187
+ execSync(`git worktree add "${worktreePath}" ${branch}`, { cwd, stdio: 'pipe' });
188
+
189
+ // Set up infrastructure symlinks so the worktree has state.json, .claude/, etc.
190
+ setupInfraLinks(worktreePath, cwd);
191
+
192
+ // Persist in state (non-blocking — feature may not be in state yet).
193
+ // NOTE: imports from core/ (the lib/ path was a latent bug that silently
194
+ // no-op'd this whole block); loadState/saveState operate on the cwd's state.
195
+ try {
196
+ const { loadState, saveState } = await import('../../core/state/state-manager.js');
197
+ const state = loadState(false);
198
+ if (state?.features?.[feature]) {
199
+ state.features[feature].worktree = { path: worktreePath, branch, createdAt: new Date().toISOString() };
200
+ saveState(state);
201
+ }
202
+ } catch {
203
+ // Non-blocking
204
+ }
205
+
206
+ console.log(JSON.stringify({ created: true, path: worktreePath, branch, feature }));
207
+ process.exit(0);
208
+ } catch (err) {
209
+ console.log(JSON.stringify({ created: false, error: (err.message || 'unknown').slice(0, 200), feature }));
210
+ process.exit(0); // Fail-open
211
+ }
212
+ }
@@ -0,0 +1,82 @@
1
+ import chalk from 'chalk';
2
+ import { existsSync, mkdirSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { getFeature, setApprovalGate, derivePhase } from '../../core/state/state-manager.js';
5
+
6
+ /**
7
+ * `morph-spec advance <feature>` — approve the current phase's gate AND scaffold
8
+ * the next phase's folder in one step. This is the sanctioned, one-command way
9
+ * to move a feature forward: it removes the need to hand-edit state.json (gate)
10
+ * AND the need to `mkdir` the next phase folder by hand. Once the gate is
11
+ * approved, the enforce-phase-writes hook permits writing the next phase's first
12
+ * canonical artifact (which then advances the derived phase).
13
+ */
14
+
15
+ // The gate approved when *completing* a given phase. `implement` has no gate
16
+ // (it ends when all tasks are done), so advancing from implement just scaffolds
17
+ // the review folder.
18
+ const PHASE_GATE = { proposal: 'proposal', uiux: 'uiux', plan: 'plan', review: 'review' };
19
+
20
+ // The next phase folder to scaffold when advancing FROM a given phase. The
21
+ // default forward path skips the optional design phase (proposal → plan); pass
22
+ // --design to take the uiux branch (proposal → 1-design).
23
+ const NEXT_PHASE_DIR = {
24
+ proposal: '2-plan',
25
+ uiux: '2-plan',
26
+ plan: '3-implement',
27
+ implement: '4-review',
28
+ };
29
+
30
+ /** Resolve who approved, honestly (mirrors approve.js — no fake human signatures). */
31
+ function resolveApprovedBy(options) {
32
+ if (options.approver) return options.approver;
33
+ if (options.mode === 'auto') return 'auto';
34
+ if (process.stdin.isTTY) return process.env.USER || process.env.USERNAME || 'user';
35
+ return 'auto';
36
+ }
37
+
38
+ export async function advanceCommand(featureName, options = {}) {
39
+ try {
40
+ const feature = getFeature(featureName);
41
+ if (!feature) {
42
+ console.error(chalk.red(`\n❌ Feature not found: ${featureName}\n`));
43
+ process.exit(1);
44
+ }
45
+
46
+ const featurePath = join(process.cwd(), '.morph', 'features', featureName);
47
+ const phase = derivePhase(featurePath);
48
+
49
+ if (phase === 'review') {
50
+ console.log(chalk.yellow(`\n⚠️ Feature "${featureName}" is already at the final phase (review).`));
51
+ console.log(chalk.gray(`Approve Gate 3 with: morph-spec approve ${featureName} review`));
52
+ console.log(chalk.gray(`Then archive with: morph-spec archive ${featureName}\n`));
53
+ return;
54
+ }
55
+
56
+ // 1) Approve the gate that completes the current phase.
57
+ const gate = PHASE_GATE[phase];
58
+ if (gate) {
59
+ await setApprovalGate(featureName, gate, true, {
60
+ approvedBy: resolveApprovedBy(options),
61
+ approvedAt: new Date().toISOString(),
62
+ notes: options.notes,
63
+ });
64
+ }
65
+
66
+ // 2) Scaffold the next phase folder so the agent can write its first artifact.
67
+ let nextDir = NEXT_PHASE_DIR[phase];
68
+ if (phase === 'proposal' && options.design) nextDir = '1-design';
69
+ if (nextDir) {
70
+ const nextPath = join(featurePath, nextDir);
71
+ if (!existsSync(nextPath)) mkdirSync(nextPath, { recursive: true });
72
+ }
73
+
74
+ console.log(chalk.green(`\n✅ Advanced "${featureName}" past the ${phase} phase.`));
75
+ if (gate) console.log(chalk.gray(` Gate "${gate}" approved (${resolveApprovedBy(options)}).`));
76
+ if (nextDir) console.log(chalk.gray(` Scaffolded ${nextDir}/ — ready for the next phase's first artifact.\n`));
77
+ else console.log('');
78
+ } catch (error) {
79
+ console.error(chalk.red(`\n❌ Error advancing feature: ${error.message}\n`));
80
+ process.exit(1);
81
+ }
82
+ }
@@ -0,0 +1,111 @@
1
+ import chalk from 'chalk';
2
+ import { existsSync, renameSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { execSync } from 'child_process';
5
+ import { getFeature, getApprovalGate } from '../../core/state/state-manager.js';
6
+
7
+ /**
8
+ * `morph-spec archive <feature>` — atomic, git-aware archive of a completed
9
+ * feature. Replaces the manual `/morph-archive` mv: it stamps the feature
10
+ * completed, moves `.morph/features/{name}/` → `.morph/archive/{name}/`
11
+ * (staging the move in git when possible), and cleans the thin root index.
12
+ *
13
+ * Taxonomy: archived = `status: "completed"` + `archivedAt` (per the project
14
+ * decision — no separate "archived" status value).
15
+ */
16
+
17
+ function isGitRepo(cwd) {
18
+ try {
19
+ execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'pipe' });
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export async function archiveCommand(featureName, options = {}) {
27
+ try {
28
+ const feature = getFeature(featureName);
29
+ if (!feature) {
30
+ console.error(chalk.red(`\n❌ Feature not found: ${featureName}\n`));
31
+ process.exit(1);
32
+ }
33
+
34
+ // Precondition: Gate 3 (review) approved — archiving is for completed work.
35
+ const reviewGate = getApprovalGate(featureName, 'review');
36
+ if (!reviewGate?.approved && !options.force) {
37
+ console.error(chalk.red(`\n❌ Gate 3 (review) is not approved for "${featureName}".`));
38
+ console.log(chalk.gray(`Approve it first (morph-spec approve ${featureName} review) or pass --force.\n`));
39
+ process.exit(1);
40
+ }
41
+
42
+ const cwd = process.cwd();
43
+ const featuresPath = join(cwd, '.morph', 'features', featureName);
44
+ if (!existsSync(featuresPath)) {
45
+ console.error(chalk.red(`\n❌ Feature folder not found: .morph/features/${featureName}\n`));
46
+ process.exit(1);
47
+ }
48
+ const archiveDir = join(cwd, '.morph', 'archive');
49
+ const archivePath = join(archiveDir, featureName);
50
+ if (existsSync(archivePath)) {
51
+ console.error(chalk.red(`\n❌ Already archived: .morph/archive/${featureName}\n`));
52
+ process.exit(1);
53
+ }
54
+
55
+ // 1) Stamp the authoritative feature.json (in place) as completed + archived.
56
+ const fjPath = join(featuresPath, 'feature.json');
57
+ if (existsSync(fjPath)) {
58
+ const fj = JSON.parse(readFileSync(fjPath, 'utf8'));
59
+ fj.status = 'completed';
60
+ fj.archivedAt = new Date().toISOString();
61
+ fj.updatedAt = fj.archivedAt;
62
+ writeFileSync(fjPath, JSON.stringify(fj, null, 2), 'utf8');
63
+ }
64
+
65
+ // 2) Move the folder. Prefer `git mv` (keeps history + stages in one step);
66
+ // fall back to a filesystem rename (+ git add) when the folder isn't
67
+ // tracked yet or git mv fails.
68
+ mkdirSync(archiveDir, { recursive: true });
69
+ const useGit = isGitRepo(cwd) && options.git !== false;
70
+ let staged = false;
71
+ if (useGit) {
72
+ try {
73
+ execSync(`git mv "${featuresPath}" "${archivePath}"`, { cwd, stdio: 'pipe' });
74
+ staged = true;
75
+ } catch {
76
+ renameSync(featuresPath, archivePath);
77
+ try {
78
+ execSync(`git add -A "${featuresPath}" "${archivePath}"`, { cwd, stdio: 'pipe' });
79
+ staged = true;
80
+ } catch { /* not fatal — the move succeeded */ }
81
+ }
82
+ } else {
83
+ renameSync(featuresPath, archivePath);
84
+ }
85
+
86
+ // 3) Drop the feature from the thin root index. We edit the index file
87
+ // directly rather than calling saveState — saveState would re-create
88
+ // .morph/features/{name}/feature.json and undo the archive.
89
+ const rootPath = join(cwd, '.morph', 'state.json');
90
+ if (existsSync(rootPath)) {
91
+ try {
92
+ const root = JSON.parse(readFileSync(rootPath, 'utf8'));
93
+ if (root.features && root.features[featureName]) {
94
+ delete root.features[featureName];
95
+ root.metadata = root.metadata || {};
96
+ root.metadata.totalFeatures = Object.keys(root.features).length;
97
+ writeFileSync(rootPath, JSON.stringify(root, null, 2), 'utf8');
98
+ }
99
+ } catch { /* index is derivable — non-fatal */ }
100
+ }
101
+
102
+ console.log(chalk.green(`\n📦 Archived "${featureName}" → .morph/archive/${featureName}/`));
103
+ console.log(chalk.gray(` status: completed, archivedAt stamped in feature.json.`));
104
+ if (staged) console.log(chalk.gray(` Move staged in git.\n`));
105
+ else if (useGit) console.log(chalk.gray(` Move complete; stage it manually if needed.\n`));
106
+ else console.log('');
107
+ } catch (error) {
108
+ console.error(chalk.red(`\n❌ Error archiving feature: ${error.message}\n`));
109
+ process.exit(1);
110
+ }
111
+ }