@polymorphism-tech/morph-spec 8.3.0 → 8.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 +11 -8
- package/bin/morph-spec.js +37 -11
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +12 -7
- package/docs/QUICKSTART.md +19 -10
- package/framework/CLAUDE.md +9 -6
- package/framework/MORPH.md +5 -3
- package/framework/commands/morph-archive.md +11 -4
- package/framework/commands/morph-hotfix.md +1 -1
- package/framework/hooks/claude-code/core/state-sync.js +41 -9
- package/framework/hooks/shared/state-writer.js +14 -3
- package/framework/schemas/feature.schema.json +17 -4
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +8 -5
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +5 -5
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +4 -4
- package/framework/skills/core/morph-review/SKILL.md +4 -2
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/doctor.js +28 -0
- package/src/commands/project/worktree.js +154 -357
- package/src/commands/state/advance.js +17 -0
- package/src/commands/state/approve.js +13 -0
- package/src/commands/state/archive.js +148 -70
- package/src/commands/state/create.js +67 -3
- package/src/commands/state/delete.js +143 -0
- package/src/commands/state/finish.js +422 -0
- package/src/commands/state/list.js +110 -0
- package/src/core/state/feature-identity.js +36 -0
- package/src/core/state/gate-order.js +51 -0
- package/src/core/state/lifecycle-audit.js +79 -0
- package/src/core/state/state-manager.js +49 -14
- package/src/dashboard/scanner/state-scanner.js +1 -1
- package/src/lib/generators/handoff-generator.js +2 -2
- package/src/lib/git/default-branch.js +73 -0
- package/src/lib/worktree/env-allocator.js +1 -1
- package/src/utils/file-copier.js +2 -2
|
@@ -2,6 +2,7 @@ import chalk from 'chalk';
|
|
|
2
2
|
import { existsSync, mkdirSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { getFeature, setApprovalGate, derivePhase } from '../../core/state/state-manager.js';
|
|
5
|
+
import { unmetPrereqs } from '../../core/state/gate-order.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* `morph-spec advance <feature>` — approve the current phase's gate AND scaffold
|
|
@@ -57,6 +58,22 @@ export async function advanceCommand(featureName, options = {}) {
|
|
|
57
58
|
const gate = PHASE_GATE[phase];
|
|
58
59
|
const approvedBy = resolveApprovedBy(options);
|
|
59
60
|
|
|
61
|
+
// Gate monotonicity (mirrors approve.js): advance is a gate write-path too,
|
|
62
|
+
// so it must not stamp gate N while a predecessor is pending. In the
|
|
63
|
+
// sanctioned flow advance is always in-order (it approves G1 before the plan
|
|
64
|
+
// artifact that flips the phase to plan exists), but an anomalous filesystem
|
|
65
|
+
// state — e.g. 2-plan/spec.md present without Gate 1, the lead-termometro
|
|
66
|
+
// shape — would otherwise let advance deepen the violation by stamping G2.
|
|
67
|
+
// Escape via --force for deliberate recovery.
|
|
68
|
+
if (gate) {
|
|
69
|
+
const unmet = unmetPrereqs(gate, feature.approvalGates);
|
|
70
|
+
if (unmet.length && !options.force) {
|
|
71
|
+
console.error(chalk.red(`\n❌ Cannot advance past the ${phase} phase — prerequisite gate(s) not approved: ${unmet.join(', ')}.`));
|
|
72
|
+
console.error(chalk.gray(` Gates must be approved in order (${unmet.join(' → ')} → ${gate}). Approve them first, or pass --force.\n`));
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
// Hotfix backstop (defensive, mirrors approve.js): the phase==='review'
|
|
61
78
|
// early-return above already stops advance from ever reaching a
|
|
62
79
|
// gate==='review' approval, but guard the approval itself too so this
|
|
@@ -3,6 +3,7 @@ import chalk from 'chalk';
|
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { getFeature, setApprovalGate, getApprovalGate, getApprovalHistory, derivePhase } from '../../core/state/state-manager.js';
|
|
6
|
+
import { unmetPrereqs } from '../../core/state/gate-order.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Unapprove action function - Revoke a previously approved gate
|
|
@@ -99,6 +100,18 @@ export async function approveCommand(featureName, gateName, options = {}) {
|
|
|
99
100
|
return;
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
// Gate monotonicity: a gate may only be approved once its predecessors are
|
|
104
|
+
// approved (proposal → plan → review; uiux requires proposal). Prevents the
|
|
105
|
+
// lead-termometro shape — plan.approved with proposal pending. Policy-stamped
|
|
106
|
+
// gates count as approved, so chore/hotfix satisfy prereqs naturally.
|
|
107
|
+
// Escape via --force for deliberate recovery.
|
|
108
|
+
const unmet = unmetPrereqs(gateName, feature.approvalGates);
|
|
109
|
+
if (unmet.length && !options.force) {
|
|
110
|
+
console.error(chalk.red(`\n❌ Cannot approve "${gateName}" — prerequisite gate(s) not approved: ${unmet.join(', ')}.`));
|
|
111
|
+
console.log(chalk.gray(` Gates must be approved in order (${unmet.map((u) => u).join(' → ')} → ${gateName}). Approve them first, or pass --force.\n`));
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
102
115
|
// Resolve who approved BEFORE the hotfix backstop — the backstop gates on
|
|
103
116
|
// the resolved signature, not the raw --mode string (see below).
|
|
104
117
|
//
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { existsSync, renameSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
|
-
import {
|
|
5
|
-
import { getFeature, getApprovalGate } from '../../core/state/state-manager.js';
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* `morph-spec archive <feature>` — atomic, git-aware archive of a completed
|
|
9
|
-
* feature.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* feature. Stamps the feature completed, moves `.morph/features/{name}/` →
|
|
9
|
+
* `.morph/archive/{name}/`, COMMITS the move (scoped to the feature's pathspec),
|
|
10
|
+
* and cleans the thin root index.
|
|
11
|
+
*
|
|
12
|
+
* Two callers:
|
|
13
|
+
* - `archiveCommand` (standalone `morph-spec archive` / `/morph-archive`) —
|
|
14
|
+
* archive-without-integrate, for legacy features and cleanup.
|
|
15
|
+
* - `finishCommand` — runs `runArchive` INSIDE the `morph/{feature}` branch so
|
|
16
|
+
* the committed archived state travels with the merge/PR in one integration.
|
|
12
17
|
*
|
|
13
18
|
* Taxonomy: archived = `status: "completed"` + `archivedAt` (per the project
|
|
14
19
|
* decision — no separate "archived" status value).
|
|
@@ -16,93 +21,166 @@ import { getFeature, getApprovalGate } from '../../core/state/state-manager.js';
|
|
|
16
21
|
|
|
17
22
|
function isGitRepo(cwd) {
|
|
18
23
|
try {
|
|
19
|
-
|
|
24
|
+
execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, stdio: 'pipe' });
|
|
20
25
|
return true;
|
|
21
26
|
} catch {
|
|
22
27
|
return false;
|
|
23
28
|
}
|
|
24
29
|
}
|
|
25
30
|
|
|
26
|
-
|
|
31
|
+
/** Tolerant read of the review gate — mirrors the finish path's shape handling. */
|
|
32
|
+
function readReviewApproved(fjPath) {
|
|
27
33
|
try {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
const fj = JSON.parse(readFileSync(fjPath, 'utf8'));
|
|
35
|
+
const gates = fj?.approvalGates || fj?.approvals || {};
|
|
36
|
+
return gates?.review?.approved === true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
33
41
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Core archive — NO process.exit. Returns a result the caller reports.
|
|
44
|
+
*
|
|
45
|
+
* Reads/writes everything relative to `cwd` (never a hard-coded process.cwd()),
|
|
46
|
+
* so `finishCommand` can archive inside a worktree's own tree.
|
|
47
|
+
*
|
|
48
|
+
* Idempotent: if `.morph/archive/{feature}` already exists, returns
|
|
49
|
+
* `{ ok: true, alreadyArchived: true }` WITHOUT failing — essential so a re-run
|
|
50
|
+
* of `finish` (which archives inside the branch) does not blow up.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} feature
|
|
53
|
+
* @param {{ cwd?: string, force?: boolean, git?: boolean }} [opts]
|
|
54
|
+
* @returns {{ ok: boolean, error?: string, archived?: boolean, alreadyArchived?: boolean,
|
|
55
|
+
* committed?: boolean, staged?: boolean, archivePath?: string }}
|
|
56
|
+
*/
|
|
57
|
+
export function runArchive(feature, opts = {}) {
|
|
58
|
+
const cwd = opts.cwd || process.cwd();
|
|
59
|
+
const featuresPath = join(cwd, '.morph', 'features', feature);
|
|
60
|
+
const archiveDir = join(cwd, '.morph', 'archive');
|
|
61
|
+
const archivePath = join(archiveDir, feature);
|
|
62
|
+
const featPathspec = join('.morph', 'features', feature);
|
|
63
|
+
const archivePathspec = join('.morph', 'archive', feature);
|
|
41
64
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
// Idempotent short-circuit: already archived here. Checked FIRST so a re-run
|
|
66
|
+
// after a partial finish reports "already done", never "not found".
|
|
67
|
+
if (existsSync(archivePath)) {
|
|
68
|
+
return { ok: true, alreadyArchived: true, archived: false, archivePath };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!existsSync(featuresPath)) {
|
|
72
|
+
return { ok: false, error: 'feature-folder-not-found' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Precondition: Gate 3 (review) approved — archiving is for completed work.
|
|
76
|
+
// Read from the feature's own folder (pre-move), tolerant of legacy shapes.
|
|
77
|
+
const fjPathBefore = join(featuresPath, 'feature.json');
|
|
78
|
+
if (!opts.force && !readReviewApproved(fjPathBefore)) {
|
|
79
|
+
return { ok: false, error: 'review-not-approved' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const useGit = opts.git !== false && isGitRepo(cwd);
|
|
83
|
+
|
|
84
|
+
// 1) Move the folder. Prefer `git mv` (keeps history + stages in one step);
|
|
85
|
+
// fall back to a filesystem rename (+ git add) when the folder isn't
|
|
86
|
+
// tracked yet or git mv fails.
|
|
87
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
88
|
+
let staged = false;
|
|
89
|
+
if (useGit) {
|
|
90
|
+
try {
|
|
91
|
+
execFileSync('git', ['mv', featuresPath, archivePath], { cwd, stdio: 'pipe' });
|
|
92
|
+
staged = true;
|
|
93
|
+
} catch {
|
|
94
|
+
renameSync(featuresPath, archivePath);
|
|
95
|
+
try {
|
|
96
|
+
execFileSync('git', ['add', '-A', '--', featPathspec, archivePathspec], { cwd, stdio: 'pipe' });
|
|
97
|
+
staged = true;
|
|
98
|
+
} catch { /* not fatal — the move succeeded */ }
|
|
53
99
|
}
|
|
100
|
+
} else {
|
|
101
|
+
renameSync(featuresPath, archivePath);
|
|
102
|
+
}
|
|
54
103
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
104
|
+
// 2) Stamp the authoritative feature.json (now at the archive path) as
|
|
105
|
+
// completed + archived, and re-stage it so the commit carries the stamp.
|
|
106
|
+
const fjPathAfter = join(archivePath, 'feature.json');
|
|
107
|
+
if (existsSync(fjPathAfter)) {
|
|
108
|
+
try {
|
|
109
|
+
const fj = JSON.parse(readFileSync(fjPathAfter, 'utf8'));
|
|
59
110
|
fj.status = 'completed';
|
|
60
111
|
fj.archivedAt = new Date().toISOString();
|
|
61
112
|
fj.updatedAt = fj.archivedAt;
|
|
62
|
-
writeFileSync(
|
|
63
|
-
|
|
113
|
+
writeFileSync(fjPathAfter, JSON.stringify(fj, null, 2), 'utf8');
|
|
114
|
+
if (useGit && staged) {
|
|
115
|
+
try { execFileSync('git', ['add', '--', archivePathspec], { cwd, stdio: 'pipe' }); } catch { /* non-fatal */ }
|
|
116
|
+
}
|
|
117
|
+
} catch { /* non-fatal — the move is what matters */ }
|
|
118
|
+
}
|
|
64
119
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
120
|
+
// 3) Commit the move — SCOPED to this feature's pathspec, NEVER `commit -a`
|
|
121
|
+
// (same anti-sweep discipline as `finish`'s worktree teardown). This is what
|
|
122
|
+
// lets the archived state travel with the branch on `finish` (archive-before-merge).
|
|
123
|
+
let committed = false;
|
|
124
|
+
if (useGit && staged) {
|
|
125
|
+
try {
|
|
126
|
+
execFileSync('git', ['commit', '-m', `morph-spec archive: ${feature}`, '--', featPathspec, archivePathspec], { cwd, stdio: 'pipe' });
|
|
127
|
+
committed = true;
|
|
128
|
+
} catch { /* nothing to commit / no identity — not fatal, the move + stage stand */ }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 4) Drop the feature from the thin root index. Edit the index file directly
|
|
132
|
+
// rather than calling saveState — saveState would re-create
|
|
133
|
+
// .morph/features/{name}/feature.json and undo the archive.
|
|
134
|
+
const rootPath = join(cwd, '.morph', 'state.json');
|
|
135
|
+
if (existsSync(rootPath)) {
|
|
136
|
+
try {
|
|
137
|
+
const root = JSON.parse(readFileSync(rootPath, 'utf8'));
|
|
138
|
+
if (root.features && root.features[feature]) {
|
|
139
|
+
delete root.features[feature];
|
|
140
|
+
root.metadata = root.metadata || {};
|
|
141
|
+
root.metadata.totalFeatures = Object.keys(root.features).length;
|
|
142
|
+
writeFileSync(rootPath, JSON.stringify(root, null, 2), 'utf8');
|
|
81
143
|
}
|
|
82
|
-
}
|
|
83
|
-
|
|
144
|
+
} catch { /* index is derivable — non-fatal */ }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { ok: true, archived: true, committed, staged, archivePath };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Standalone `morph-spec archive <feature>` — thin wrapper over `runArchive`
|
|
152
|
+
* that maps its result to the CLI's messages and exit codes. Preserves the
|
|
153
|
+
* historical behaviour: an already-archived feature is reported as a refusal.
|
|
154
|
+
*/
|
|
155
|
+
export async function archiveCommand(featureName, options = {}) {
|
|
156
|
+
try {
|
|
157
|
+
const result = runArchive(featureName, {
|
|
158
|
+
cwd: process.cwd(),
|
|
159
|
+
force: options.force,
|
|
160
|
+
git: options.git,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (!result.ok) {
|
|
164
|
+
if (result.error === 'feature-folder-not-found') {
|
|
165
|
+
console.error(chalk.red(`\n❌ Feature folder not found: .morph/features/${featureName}\n`));
|
|
166
|
+
} else if (result.error === 'review-not-approved') {
|
|
167
|
+
console.error(chalk.red(`\n❌ Gate 3 (review) is not approved for "${featureName}".`));
|
|
168
|
+
console.log(chalk.gray(`Approve it first (morph-spec approve ${featureName} review) or pass --force.\n`));
|
|
169
|
+
} else {
|
|
170
|
+
console.error(chalk.red(`\n❌ Error archiving feature: ${result.error}\n`));
|
|
171
|
+
}
|
|
172
|
+
process.exit(1);
|
|
84
173
|
}
|
|
85
174
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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 */ }
|
|
175
|
+
if (result.alreadyArchived) {
|
|
176
|
+
console.error(chalk.red(`\n❌ Already archived: .morph/archive/${featureName}\n`));
|
|
177
|
+
process.exit(1);
|
|
100
178
|
}
|
|
101
179
|
|
|
102
180
|
console.log(chalk.green(`\n📦 Archived "${featureName}" → .morph/archive/${featureName}/`));
|
|
103
181
|
console.log(chalk.gray(` status: completed, archivedAt stamped in feature.json.`));
|
|
104
|
-
if (
|
|
105
|
-
else if (
|
|
182
|
+
if (result.committed) console.log(chalk.gray(` Move committed on the current branch.\n`));
|
|
183
|
+
else if (result.staged) console.log(chalk.gray(` Move staged in git; commit it when ready.\n`));
|
|
106
184
|
else console.log('');
|
|
107
185
|
} catch (error) {
|
|
108
186
|
console.error(chalk.red(`\n❌ Error archiving feature: ${error.message}\n`));
|
|
@@ -1,10 +1,45 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import {
|
|
2
|
+
import { execFileSync } from 'child_process';
|
|
3
|
+
import { mkdirSync, cpSync, readFileSync, writeFileSync, existsSync } from 'fs';
|
|
3
4
|
import { join } from 'path';
|
|
4
5
|
import { createFeature, setApprovalGate, getFeature } from '../../core/state/state-manager.js';
|
|
5
6
|
import { WORK_TYPES, DEFAULT_WORK_TYPE, POLICY_APPROVER_PREFIX, isValidWorkType, getWorkType } from '../../core/work-types.js';
|
|
6
7
|
import { detectWorkType } from '../../lib/work-type-detector.js';
|
|
7
|
-
import { runWorktreeSetup, materializeState } from '../project/worktree.js';
|
|
8
|
+
import { runWorktreeSetup, materializeState, isGitRepo, branchExists, buildBranchName } from '../project/worktree.js';
|
|
9
|
+
import { detectDefaultBranch } from '../../lib/git/default-branch.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Common flow (no `--worktree`): still isolate the feature on its own branch
|
|
13
|
+
* `morph/{feature}`, branched from the DEFAULT branch (never HEAD — so it never
|
|
14
|
+
* inherits another feature branch's commits) and checked out in place, so
|
|
15
|
+
* `morph-spec finish` can later merge/PR it back. Symmetric with the worktree
|
|
16
|
+
* flow, which gets its branch from `runWorktreeSetup`.
|
|
17
|
+
*
|
|
18
|
+
* Fail-open: outside a git repo, with no resolvable default, or on any git
|
|
19
|
+
* error, the feature is simply left on the current branch — never a failed birth.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} featureName
|
|
22
|
+
* @returns {{ branched: boolean, branch?: string, from?: string, reason?: string }}
|
|
23
|
+
*/
|
|
24
|
+
function branchCommonFlow(featureName) {
|
|
25
|
+
const cwd = process.cwd();
|
|
26
|
+
try {
|
|
27
|
+
if (!isGitRepo(cwd)) return { branched: false, reason: 'not-a-git-repo' };
|
|
28
|
+
const branch = buildBranchName(featureName);
|
|
29
|
+
const defaultBranch = detectDefaultBranch(cwd);
|
|
30
|
+
if (!defaultBranch) return { branched: false, reason: 'no-default-branch' };
|
|
31
|
+
if (!branchExists(branch, cwd)) {
|
|
32
|
+
execFileSync('git', ['branch', branch, defaultBranch], { cwd, stdio: 'pipe' });
|
|
33
|
+
}
|
|
34
|
+
const current = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, stdio: 'pipe' }).toString().trim();
|
|
35
|
+
if (current !== branch) {
|
|
36
|
+
execFileSync('git', ['checkout', branch], { cwd, stdio: 'pipe' });
|
|
37
|
+
}
|
|
38
|
+
return { branched: true, branch, from: defaultBranch };
|
|
39
|
+
} catch {
|
|
40
|
+
return { branched: false, reason: 'git-error' };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
8
43
|
|
|
9
44
|
/** Feature names are kebab-case: lowercase alphanumerics and hyphens. */
|
|
10
45
|
const FEATURE_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
@@ -98,16 +133,37 @@ export async function createCommand(featureName, options = {}) {
|
|
|
98
133
|
const now = new Date().toISOString();
|
|
99
134
|
const { workType, workTypeDetection } = resolveWorkType(options, now);
|
|
100
135
|
|
|
136
|
+
// Identity at birth: every feature is stamped name (slug by default) +
|
|
137
|
+
// description, so it is never an anonymous {status,updatedAt} shell.
|
|
138
|
+
// `--request` (which the router already consumes) doubles as the
|
|
139
|
+
// description seed. A NEW feature with no description source is refused —
|
|
140
|
+
// the description is the one identity field a human must supply. An
|
|
141
|
+
// idempotent re-run on an EXISTING feature never demands it.
|
|
142
|
+
const description = options.description ?? options.request ?? null;
|
|
143
|
+
// Existence check via the filesystem, NOT getFeature/loadState — loadState
|
|
144
|
+
// throws when no state exists yet (a feature can be the first morph action
|
|
145
|
+
// in a fresh project), which would break birth-from-nothing.
|
|
146
|
+
const alreadyExists = existsSync(join(process.cwd(), '.morph', 'features', featureName, 'feature.json'));
|
|
147
|
+
if (!alreadyExists && (description === null || String(description).trim() === '')) {
|
|
148
|
+
console.error(chalk.red(`\n❌ A feature needs a description at birth: "${featureName}"`));
|
|
149
|
+
console.error(chalk.gray(' Pass --description "<what/why>" (or --request "<pedido>", which seeds it).'));
|
|
150
|
+
console.error(chalk.gray(' This keeps features identifiable — no more anonymous scaffolds.\n'));
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
|
|
101
154
|
const { feature, created } = await createFeature(featureName, {
|
|
102
155
|
status: options.status,
|
|
103
156
|
workType,
|
|
104
157
|
workTypeDetection,
|
|
158
|
+
name: options.name,
|
|
159
|
+
description: description ?? undefined,
|
|
105
160
|
});
|
|
106
161
|
|
|
107
162
|
const featureDir = join(process.cwd(), '.morph', 'features', featureName);
|
|
108
163
|
const wt = getWorkType(feature);
|
|
109
164
|
|
|
110
165
|
let worktreeResult = null;
|
|
166
|
+
let branchResult = null;
|
|
111
167
|
|
|
112
168
|
if (created) {
|
|
113
169
|
// Scaffold the phase folders this work type starts with.
|
|
@@ -138,7 +194,7 @@ export async function createCommand(featureName, options = {}) {
|
|
|
138
194
|
// stamped it onto the root's feature.json, which cpSync just copied in).
|
|
139
195
|
// Strip it from the worktree's branch-local copy so the branch commits a
|
|
140
196
|
// clean feature.json — otherwise it merges back into the root at
|
|
141
|
-
// `
|
|
197
|
+
// `morph-spec finish` and the post-merge cleanup leaves the root dirty.
|
|
142
198
|
// verify.decidePersistence reads the registration from the ROOT's copy,
|
|
143
199
|
// never this branch-local one (see verify.js), so stripping is safe and
|
|
144
200
|
// restores the invariant that comment relies on.
|
|
@@ -163,6 +219,10 @@ export async function createCommand(featureName, options = {}) {
|
|
|
163
219
|
} catch { /* fail-open */ }
|
|
164
220
|
} catch { /* fail-open — the feature already exists in root */ }
|
|
165
221
|
}
|
|
222
|
+
} else {
|
|
223
|
+
// Common flow: still isolate the feature on its own branch so `finish`
|
|
224
|
+
// can merge/PR it back. Fail-open (see branchCommonFlow).
|
|
225
|
+
branchResult = branchCommonFlow(featureName);
|
|
166
226
|
}
|
|
167
227
|
}
|
|
168
228
|
|
|
@@ -174,6 +234,7 @@ export async function createCommand(featureName, options = {}) {
|
|
|
174
234
|
if (options.json) {
|
|
175
235
|
const out = { feature: featureName, created, ...finalFeature };
|
|
176
236
|
if (worktreeResult) out.worktree = worktreeResult;
|
|
237
|
+
if (branchResult) out.branch = branchResult;
|
|
177
238
|
console.log(JSON.stringify(out, null, 2));
|
|
178
239
|
return;
|
|
179
240
|
}
|
|
@@ -196,6 +257,9 @@ export async function createCommand(featureName, options = {}) {
|
|
|
196
257
|
const why = worktreeResult.error || (worktreeResult.alreadyExists ? 'already exists' : 'unknown');
|
|
197
258
|
console.log(chalk.yellow(` ⚠️ worktree setup skipped (${why}) — feature stays in the root worktree\n`));
|
|
198
259
|
}
|
|
260
|
+
} else if (branchResult?.branched) {
|
|
261
|
+
console.log(chalk.green(` branch: ${branchResult.branch} (from ${branchResult.from})`));
|
|
262
|
+
console.log(chalk.gray(` close it with: morph-spec finish ${featureName} --pr | --merge\n`));
|
|
199
263
|
}
|
|
200
264
|
} else {
|
|
201
265
|
console.log(chalk.yellow(`\n⚠️ Feature "${featureName}" already exists — left untouched`));
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { existsSync, rmSync, renameSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `morph-spec delete <feature>` — remove a feature. Hard by default (folder is
|
|
8
|
+
* gone), soft via `--archive` (moved to .morph/archive with status
|
|
9
|
+
* "abandoned"). The complement of `archive`: archive = completed work you keep;
|
|
10
|
+
* delete = dead scaffolds and abandoned drafts you clear out.
|
|
11
|
+
*
|
|
12
|
+
* The critical guarantee — mirrored from `archive` — is that the deletion also
|
|
13
|
+
* drops the feature from the thin root index (.morph/state.json). Without that,
|
|
14
|
+
* the next `loadState` resurrects the folder from the stale index entry
|
|
15
|
+
* (turning a delete into a zombie). We edit the index directly rather than
|
|
16
|
+
* calling saveState, which would re-materialize .morph/features/{name}.
|
|
17
|
+
*
|
|
18
|
+
* Refuses when any gate is approved (real work, not a dead shell), unless
|
|
19
|
+
* `--force`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function isGitRepo(cwd) {
|
|
23
|
+
try {
|
|
24
|
+
execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'pipe' });
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function approvedGates(feature) {
|
|
32
|
+
const gates = feature?.approvalGates || {};
|
|
33
|
+
return Object.entries(gates)
|
|
34
|
+
.filter(([, g]) => g?.approved === true)
|
|
35
|
+
.map(([name]) => name);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Drop the feature from the thin, derivable root index (like archive does). */
|
|
39
|
+
function dropFromIndex(cwd, featureName) {
|
|
40
|
+
const rootPath = join(cwd, '.morph', 'state.json');
|
|
41
|
+
if (!existsSync(rootPath)) return;
|
|
42
|
+
try {
|
|
43
|
+
const root = JSON.parse(readFileSync(rootPath, 'utf8'));
|
|
44
|
+
if (root.features && root.features[featureName]) {
|
|
45
|
+
delete root.features[featureName];
|
|
46
|
+
root.metadata = root.metadata || {};
|
|
47
|
+
root.metadata.totalFeatures = Object.keys(root.features).length;
|
|
48
|
+
writeFileSync(rootPath, JSON.stringify(root, null, 2), 'utf8');
|
|
49
|
+
}
|
|
50
|
+
} catch { /* index is derivable — non-fatal */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function deleteCommand(featureName, options = {}) {
|
|
54
|
+
try {
|
|
55
|
+
const cwd = options.cwd || process.cwd();
|
|
56
|
+
const featuresPath = join(cwd, '.morph', 'features', featureName);
|
|
57
|
+
const fjPath = join(featuresPath, 'feature.json');
|
|
58
|
+
|
|
59
|
+
if (!existsSync(featuresPath)) {
|
|
60
|
+
console.error(chalk.red(`\n❌ Feature not found: .morph/features/${featureName}\n`));
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Read the authoritative feature.json directly (not loadState — a read that
|
|
65
|
+
// mutates state has no place inside a delete).
|
|
66
|
+
let feature = {};
|
|
67
|
+
if (existsSync(fjPath)) {
|
|
68
|
+
try { feature = JSON.parse(readFileSync(fjPath, 'utf8')); } catch { feature = {}; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Precondition: no approved gate (real work) unless --force.
|
|
72
|
+
const approved = approvedGates(feature);
|
|
73
|
+
if (approved.length && !options.force) {
|
|
74
|
+
console.error(chalk.red(`\n❌ "${featureName}" has approved gate(s): ${approved.join(', ')}.`));
|
|
75
|
+
console.log(chalk.gray(` Deleting approved work is unusual — pass --force to override,`));
|
|
76
|
+
console.log(chalk.gray(` or 'morph-spec archive ${featureName}' if it's actually complete.\n`));
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const useGit = isGitRepo(cwd) && options.git !== false;
|
|
81
|
+
|
|
82
|
+
if (options.archive) {
|
|
83
|
+
// Soft delete: move to .morph/archive with status "abandoned".
|
|
84
|
+
const archiveDir = join(cwd, '.morph', 'archive');
|
|
85
|
+
const archivePath = join(archiveDir, featureName);
|
|
86
|
+
if (existsSync(archivePath)) {
|
|
87
|
+
console.error(chalk.red(`\n❌ Already present in archive: .morph/archive/${featureName}\n`));
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
if (existsSync(fjPath)) {
|
|
91
|
+
feature.status = 'abandoned';
|
|
92
|
+
feature.abandonedAt = new Date().toISOString();
|
|
93
|
+
feature.updatedAt = feature.abandonedAt;
|
|
94
|
+
writeFileSync(fjPath, JSON.stringify(feature, null, 2), 'utf8');
|
|
95
|
+
}
|
|
96
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
97
|
+
let staged = false;
|
|
98
|
+
if (useGit) {
|
|
99
|
+
try {
|
|
100
|
+
execSync(`git mv "${featuresPath}" "${archivePath}"`, { cwd, stdio: 'pipe' });
|
|
101
|
+
staged = true;
|
|
102
|
+
} catch {
|
|
103
|
+
renameSync(featuresPath, archivePath);
|
|
104
|
+
try { execSync(`git add -A "${featuresPath}" "${archivePath}"`, { cwd, stdio: 'pipe' }); staged = true; } catch { /* not fatal */ }
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
renameSync(featuresPath, archivePath);
|
|
108
|
+
}
|
|
109
|
+
dropFromIndex(cwd, featureName);
|
|
110
|
+
|
|
111
|
+
if (options.json) { console.log(JSON.stringify({ deleted: featureName, mode: 'archive', status: 'abandoned', staged })); process.exit(0); }
|
|
112
|
+
console.log(chalk.yellow(`\n🗃 Abandoned "${featureName}" → .morph/archive/${featureName}/`));
|
|
113
|
+
console.log(chalk.gray(` status: abandoned, abandonedAt stamped; dropped from index.`));
|
|
114
|
+
console.log(staged ? chalk.gray(' Move staged in git.\n') : '');
|
|
115
|
+
process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Hard delete: remove the folder (git-aware), then drop from index.
|
|
119
|
+
let staged = false;
|
|
120
|
+
if (useGit) {
|
|
121
|
+
try {
|
|
122
|
+
execSync(`git rm -r -f --quiet "${featuresPath}"`, { cwd, stdio: 'pipe' });
|
|
123
|
+
staged = true;
|
|
124
|
+
} catch {
|
|
125
|
+
// Untracked (never committed) — just remove it from disk.
|
|
126
|
+
rmSync(featuresPath, { recursive: true, force: true });
|
|
127
|
+
try { execSync(`git add -A "${featuresPath}"`, { cwd, stdio: 'pipe' }); staged = true; } catch { /* not fatal */ }
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
rmSync(featuresPath, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
dropFromIndex(cwd, featureName);
|
|
133
|
+
|
|
134
|
+
if (options.json) { console.log(JSON.stringify({ deleted: featureName, mode: 'hard', staged })); process.exit(0); }
|
|
135
|
+
console.log(chalk.green(`\n🗑 Deleted "${featureName}" (.morph/features/${featureName}/ removed, dropped from index).`));
|
|
136
|
+
if (approved.length) console.log(chalk.gray(` (had approved gate(s): ${approved.join(', ')} — removed via --force)`));
|
|
137
|
+
console.log(staged ? chalk.gray(' Removal staged in git.\n') : '');
|
|
138
|
+
process.exit(0);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error(chalk.red(`\n❌ Error deleting feature: ${error.message}\n`));
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
}
|