@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.
Files changed (43) hide show
  1. package/README.md +11 -8
  2. package/bin/morph-spec.js +37 -11
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +12 -7
  5. package/docs/QUICKSTART.md +19 -10
  6. package/framework/CLAUDE.md +9 -6
  7. package/framework/MORPH.md +5 -3
  8. package/framework/commands/morph-archive.md +11 -4
  9. package/framework/commands/morph-hotfix.md +1 -1
  10. package/framework/hooks/claude-code/core/state-sync.js +41 -9
  11. package/framework/hooks/shared/state-writer.js +14 -3
  12. package/framework/schemas/feature.schema.json +17 -4
  13. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  14. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  15. package/framework/skills/core/morph-apply/SKILL.md +8 -5
  16. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  17. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  18. package/framework/skills/core/morph-hotfix/SKILL.md +5 -5
  19. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  20. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  21. package/framework/skills/core/morph-proposal/SKILL.md +4 -4
  22. package/framework/skills/core/morph-review/SKILL.md +4 -2
  23. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  24. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  25. package/package.json +1 -1
  26. package/src/commands/project/doctor.js +28 -0
  27. package/src/commands/project/worktree.js +154 -357
  28. package/src/commands/state/advance.js +17 -0
  29. package/src/commands/state/approve.js +13 -0
  30. package/src/commands/state/archive.js +148 -70
  31. package/src/commands/state/create.js +67 -3
  32. package/src/commands/state/delete.js +143 -0
  33. package/src/commands/state/finish.js +422 -0
  34. package/src/commands/state/list.js +110 -0
  35. package/src/core/state/feature-identity.js +36 -0
  36. package/src/core/state/gate-order.js +51 -0
  37. package/src/core/state/lifecycle-audit.js +79 -0
  38. package/src/core/state/state-manager.js +49 -14
  39. package/src/dashboard/scanner/state-scanner.js +1 -1
  40. package/src/lib/generators/handoff-generator.js +2 -2
  41. package/src/lib/git/default-branch.js +73 -0
  42. package/src/lib/worktree/env-allocator.js +1 -1
  43. 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 { execSync } from 'child_process';
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. 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.
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
- execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'pipe' });
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
- export async function archiveCommand(featureName, options = {}) {
31
+ /** Tolerant read of the review gate — mirrors the finish path's shape handling. */
32
+ function readReviewApproved(fjPath) {
27
33
  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
- }
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
- // 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
- }
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
- 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);
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
- // 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'));
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(fjPath, JSON.stringify(fj, null, 2), 'utf8');
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
- // 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 */ }
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
- } else {
83
- renameSync(featuresPath, archivePath);
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
- // 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 */ }
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 (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`));
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 { mkdirSync, cpSync, readFileSync, writeFileSync } from 'fs';
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
- // `worktree finish` and the post-merge cleanup leaves the root dirty.
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
+ }