@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.
- package/README.md +2 -2
- package/bin/morph-spec.js +18 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +9 -4
- package/framework/MORPH.md +4 -4
- package/framework/commands/create-umbraco-lp.md +35 -0
- package/framework/commands/migrate-lp-to-umbraco.md +33 -0
- package/framework/commands/morph-archive.md +17 -27
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +115 -90
- package/framework/hooks/claude-code/core/protect-spec-files.js +32 -30
- package/framework/hooks/claude-code/core/state-sync.js +16 -48
- package/framework/hooks/shared/state-reader.js +74 -8
- package/framework/hooks/shared/state-writer.js +124 -0
- package/framework/rules/morph-task-tracking.md +69 -49
- package/framework/rules/morph-workflow.md +192 -191
- package/framework/skills/core/create-umbraco-lp/SKILL.md +252 -0
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +452 -0
- package/framework/skills/core/morph-apply/SKILL.md +15 -15
- package/framework/skills/core/morph-brainstorming/SKILL.md +5 -5
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +5 -4
- package/framework/skills/core/morph-plan/SKILL.md +6 -6
- package/framework/skills/core/morph-proposal/SKILL.md +8 -7
- package/framework/skills/core/morph-review/SKILL.md +3 -3
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/templates/REGISTRY.json +1 -3
- package/framework/templates/context/CONTEXT-FEATURE.md +10 -17
- package/framework/templates/context/CONTEXT.md +181 -180
- package/framework/templates/docs/onboarding.md +122 -118
- package/framework/templates/docs/proposal.md +10 -0
- package/framework/templates/docs/spec.md +228 -228
- package/framework/templates/meta-prompts/parallel-workers/parallel-worker.md +80 -80
- package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +90 -89
- package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +2 -1
- package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +44 -43
- package/framework/templates/meta-prompts/validators/checkpoint-validator.md +107 -107
- package/package.json +1 -1
- package/src/commands/project/update.js +415 -368
- package/src/commands/project/worktree.js +212 -210
- package/src/commands/state/advance.js +82 -0
- package/src/commands/state/archive.js +111 -0
- package/src/commands/validation/validate-feature.js +229 -105
- package/src/core/state/state-manager.js +238 -66
- package/src/dashboard/scanner/state-scanner.js +35 -8
- package/src/lib/generators/context-generator.js +12 -8
- 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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
+
}
|