@polymorphism-tech/morph-spec 8.4.0 → 8.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/bin/morph-spec.js +13 -10
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +7 -4
- package/docs/QUICKSTART.md +7 -4
- package/framework/CLAUDE.md +11 -8
- package/framework/MORPH.md +24 -4
- package/framework/commands/morph-archive.md +11 -4
- package/framework/evals/rubrics/test-coverage.md +4 -1
- package/framework/hooks/shared/phase-utils.js +1 -0
- package/framework/rules/morph-workflow.md +2 -2
- package/framework/schemas/feature.schema.json +2 -2
- package/framework/schemas/tasks.schema.json +6 -1
- 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 +4 -3
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +3 -3
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +83 -13
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- 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/worktree.js +154 -357
- package/src/commands/state/archive.js +186 -69
- package/src/commands/state/create.js +46 -2
- package/src/commands/state/finish.js +433 -0
- package/src/commands/tasks/dag.js +25 -2
- package/src/commands/validation/validate-feature.js +9 -2
- package/src/core/paths/output-schema.js +66 -6
- package/src/core/state/state-manager.js +2 -2
- package/src/dashboard/scanner/state-scanner.js +1 -1
- package/src/lib/agents/output-collector.js +5 -0
- package/src/lib/generators/handoff-generator.js +6 -4
- package/src/lib/git/default-branch.js +73 -0
- package/src/lib/specs/usecase-check.js +173 -0
- package/src/lib/specs/usecase-promoter.js +120 -0
- package/src/lib/tasks/tasks-json-parser.js +8 -0
- package/src/lib/worktree/env-allocator.js +1 -1
- package/src/utils/file-copier.js +2 -2
|
@@ -1,14 +1,20 @@
|
|
|
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 {
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
|
+
import { promoteUseCases, SPECS_USECASES_PATHSPEC } from '../../lib/specs/usecase-promoter.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* `morph-spec archive <feature>` — atomic, git-aware archive of a completed
|
|
9
|
-
* feature.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* feature. Stamps the feature completed, moves `.morph/features/{name}/` →
|
|
10
|
+
* `.morph/archive/{name}/`, COMMITS the move (scoped to the feature's pathspec),
|
|
11
|
+
* and cleans the thin root index.
|
|
12
|
+
*
|
|
13
|
+
* Two callers:
|
|
14
|
+
* - `archiveCommand` (standalone `morph-spec archive` / `/morph-archive`) —
|
|
15
|
+
* archive-without-integrate, for legacy features and cleanup.
|
|
16
|
+
* - `finishCommand` — runs `runArchive` INSIDE the `morph/{feature}` branch so
|
|
17
|
+
* the committed archived state travels with the merge/PR in one integration.
|
|
12
18
|
*
|
|
13
19
|
* Taxonomy: archived = `status: "completed"` + `archivedAt` (per the project
|
|
14
20
|
* decision — no separate "archived" status value).
|
|
@@ -16,93 +22,204 @@ import { getFeature, getApprovalGate } from '../../core/state/state-manager.js';
|
|
|
16
22
|
|
|
17
23
|
function isGitRepo(cwd) {
|
|
18
24
|
try {
|
|
19
|
-
|
|
25
|
+
execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, stdio: 'pipe' });
|
|
20
26
|
return true;
|
|
21
27
|
} catch {
|
|
22
28
|
return false;
|
|
23
29
|
}
|
|
24
30
|
}
|
|
25
31
|
|
|
26
|
-
|
|
32
|
+
/** Tolerant read of the review gate — mirrors the finish path's shape handling. */
|
|
33
|
+
function readReviewApproved(fjPath) {
|
|
27
34
|
try {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
const fj = JSON.parse(readFileSync(fjPath, 'utf8'));
|
|
36
|
+
const gates = fj?.approvalGates || fj?.approvals || {};
|
|
37
|
+
return gates?.review?.approved === true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
33
42
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Core archive — NO process.exit. Returns a result the caller reports.
|
|
45
|
+
*
|
|
46
|
+
* Reads/writes everything relative to `cwd` (never a hard-coded process.cwd()),
|
|
47
|
+
* so `finishCommand` can archive inside a worktree's own tree.
|
|
48
|
+
*
|
|
49
|
+
* Idempotent: if `.morph/archive/{feature}` already exists, returns
|
|
50
|
+
* `{ ok: true, alreadyArchived: true }` WITHOUT failing — essential so a re-run
|
|
51
|
+
* of `finish` (which archives inside the branch) does not blow up.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} feature
|
|
54
|
+
* @param {{ cwd?: string, force?: boolean, git?: boolean }} [opts]
|
|
55
|
+
* @returns {{ ok: boolean, error?: string, archived?: boolean, alreadyArchived?: boolean,
|
|
56
|
+
* committed?: boolean, staged?: boolean, archivePath?: string, promotedUseCases?: string[] }}
|
|
57
|
+
*/
|
|
58
|
+
export function runArchive(feature, opts = {}) {
|
|
59
|
+
const cwd = opts.cwd || process.cwd();
|
|
60
|
+
const featuresPath = join(cwd, '.morph', 'features', feature);
|
|
61
|
+
const archiveDir = join(cwd, '.morph', 'archive');
|
|
62
|
+
const archivePath = join(archiveDir, feature);
|
|
63
|
+
const featPathspec = join('.morph', 'features', feature);
|
|
64
|
+
const archivePathspec = join('.morph', 'archive', feature);
|
|
41
65
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
66
|
+
// Idempotent short-circuit: already archived here. Checked FIRST so a re-run
|
|
67
|
+
// after a partial finish reports "already done", never "not found".
|
|
68
|
+
//
|
|
69
|
+
// Consequence, deliberate: this short-circuits BEFORE the use-case promotion
|
|
70
|
+
// below, so a promotion that degraded fail-open is NOT retried by re-running
|
|
71
|
+
// `finish`. That is the price of "closing a feature never fails" — an archived
|
|
72
|
+
// feature stays archived, and the only signal is an absent `promotedUseCases`
|
|
73
|
+
// in the output. Recovering means copying the UCs into `.morph/specs/usecases/`
|
|
74
|
+
// by hand; making this retry would mean re-entering the archive path for an
|
|
75
|
+
// already-closed feature, which is a worse trade.
|
|
76
|
+
if (existsSync(archivePath)) {
|
|
77
|
+
return { ok: true, alreadyArchived: true, archived: false, archivePath };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!existsSync(featuresPath)) {
|
|
81
|
+
return { ok: false, error: 'feature-folder-not-found' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Precondition: Gate 3 (review) approved — archiving is for completed work.
|
|
85
|
+
// Read from the feature's own folder (pre-move), tolerant of legacy shapes.
|
|
86
|
+
const fjPathBefore = join(featuresPath, 'feature.json');
|
|
87
|
+
if (!opts.force && !readReviewApproved(fjPathBefore)) {
|
|
88
|
+
return { ok: false, error: 'review-not-approved' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const useGit = opts.git !== false && isGitRepo(cwd);
|
|
92
|
+
|
|
93
|
+
// 1) Move the folder. Prefer `git mv` (keeps history + stages in one step);
|
|
94
|
+
// fall back to a filesystem rename (+ git add) when the folder isn't
|
|
95
|
+
// tracked yet or git mv fails.
|
|
96
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
97
|
+
let staged = false;
|
|
98
|
+
if (useGit) {
|
|
99
|
+
try {
|
|
100
|
+
execFileSync('git', ['mv', featuresPath, archivePath], { cwd, stdio: 'pipe' });
|
|
101
|
+
staged = true;
|
|
102
|
+
} catch {
|
|
103
|
+
renameSync(featuresPath, archivePath);
|
|
104
|
+
try {
|
|
105
|
+
execFileSync('git', ['add', '-A', '--', featPathspec, archivePathspec], { cwd, stdio: 'pipe' });
|
|
106
|
+
staged = true;
|
|
107
|
+
} catch { /* not fatal — the move succeeded */ }
|
|
53
108
|
}
|
|
109
|
+
} else {
|
|
110
|
+
renameSync(featuresPath, archivePath);
|
|
111
|
+
}
|
|
54
112
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
113
|
+
// 2) Stamp the authoritative feature.json (now at the archive path) as
|
|
114
|
+
// completed + archived, and re-stage it so the commit carries the stamp.
|
|
115
|
+
const fjPathAfter = join(archivePath, 'feature.json');
|
|
116
|
+
if (existsSync(fjPathAfter)) {
|
|
117
|
+
try {
|
|
118
|
+
const fj = JSON.parse(readFileSync(fjPathAfter, 'utf8'));
|
|
59
119
|
fj.status = 'completed';
|
|
60
120
|
fj.archivedAt = new Date().toISOString();
|
|
61
121
|
fj.updatedAt = fj.archivedAt;
|
|
62
|
-
writeFileSync(
|
|
122
|
+
writeFileSync(fjPathAfter, JSON.stringify(fj, null, 2), 'utf8');
|
|
123
|
+
if (useGit && staged) {
|
|
124
|
+
try { execFileSync('git', ['add', '--', archivePathspec], { cwd, stdio: 'pipe' }); } catch { /* non-fatal */ }
|
|
125
|
+
}
|
|
126
|
+
} catch { /* non-fatal — the move is what matters */ }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 3) Promote the feature's use cases into the project's permanent archive
|
|
130
|
+
// (.morph/specs/usecases/). Read from the ALREADY-MOVED folder so there is
|
|
131
|
+
// a single source path, and land inside the SAME scoped commit below — the
|
|
132
|
+
// archive travels with the integration, exactly like the archived state
|
|
133
|
+
// (MORPH.md §2.2). Fail-open: any failure degrades to "promoted nothing",
|
|
134
|
+
// never to a refused close.
|
|
135
|
+
let promotedUseCases = [];
|
|
136
|
+
try {
|
|
137
|
+
const { promoted } = promoteUseCases(cwd, archivePath);
|
|
138
|
+
promotedUseCases = promoted;
|
|
139
|
+
if (promoted.length && useGit && staged) {
|
|
140
|
+
// This staging exists for the NEW files: promoted use cases are untracked,
|
|
141
|
+
// and the scoped `commit -- <pathspec>` below only knows paths git already
|
|
142
|
+
// tracks. The replaced-by-ID deletions are tracked, so that same commit
|
|
143
|
+
// would carry them even without this line (verified by mutating it away) —
|
|
144
|
+
// `-A` is belt-and-braces, but dropping the `git add` entirely would
|
|
145
|
+
// silently promote nothing on a first delivery.
|
|
146
|
+
try { execFileSync('git', ['add', '-A', '--', SPECS_USECASES_PATHSPEC], { cwd, stdio: 'pipe' }); } catch { /* non-fatal */ }
|
|
63
147
|
}
|
|
148
|
+
} catch { /* promotion must never block archiving */ }
|
|
64
149
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
150
|
+
// 4) Commit the move — SCOPED to this feature's pathspec, NEVER `commit -a`
|
|
151
|
+
// (same anti-sweep discipline as `finish`'s worktree teardown). This is what
|
|
152
|
+
// lets the archived state travel with the branch on `finish` (archive-before-merge).
|
|
153
|
+
// The acervo pathspec joins only when something was promoted: git refuses a
|
|
154
|
+
// pathspec that matches nothing, and that refusal would leave the tree dirty
|
|
155
|
+
// and break the checkout/merge that follows.
|
|
156
|
+
let committed = false;
|
|
157
|
+
if (useGit && staged) {
|
|
158
|
+
const pathspecs = promotedUseCases.length
|
|
159
|
+
? [featPathspec, archivePathspec, SPECS_USECASES_PATHSPEC]
|
|
160
|
+
: [featPathspec, archivePathspec];
|
|
161
|
+
try {
|
|
162
|
+
execFileSync('git', ['commit', '-m', `morph-spec archive: ${feature}`, '--', ...pathspecs], { cwd, stdio: 'pipe' });
|
|
163
|
+
committed = true;
|
|
164
|
+
} catch { /* nothing to commit / no identity — not fatal, the move + stage stand */ }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 5) Drop the feature from the thin root index. Edit the index file directly
|
|
168
|
+
// rather than calling saveState — saveState would re-create
|
|
169
|
+
// .morph/features/{name}/feature.json and undo the archive.
|
|
170
|
+
const rootPath = join(cwd, '.morph', 'state.json');
|
|
171
|
+
if (existsSync(rootPath)) {
|
|
172
|
+
try {
|
|
173
|
+
const root = JSON.parse(readFileSync(rootPath, 'utf8'));
|
|
174
|
+
if (root.features && root.features[feature]) {
|
|
175
|
+
delete root.features[feature];
|
|
176
|
+
root.metadata = root.metadata || {};
|
|
177
|
+
root.metadata.totalFeatures = Object.keys(root.features).length;
|
|
178
|
+
writeFileSync(rootPath, JSON.stringify(root, null, 2), 'utf8');
|
|
81
179
|
}
|
|
82
|
-
}
|
|
83
|
-
|
|
180
|
+
} catch { /* index is derivable — non-fatal */ }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { ok: true, archived: true, committed, staged, archivePath, promotedUseCases };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Standalone `morph-spec archive <feature>` — thin wrapper over `runArchive`
|
|
188
|
+
* that maps its result to the CLI's messages and exit codes. Preserves the
|
|
189
|
+
* historical behaviour: an already-archived feature is reported as a refusal.
|
|
190
|
+
*/
|
|
191
|
+
export async function archiveCommand(featureName, options = {}) {
|
|
192
|
+
try {
|
|
193
|
+
const result = runArchive(featureName, {
|
|
194
|
+
cwd: process.cwd(),
|
|
195
|
+
force: options.force,
|
|
196
|
+
git: options.git,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (!result.ok) {
|
|
200
|
+
if (result.error === 'feature-folder-not-found') {
|
|
201
|
+
console.error(chalk.red(`\n❌ Feature folder not found: .morph/features/${featureName}\n`));
|
|
202
|
+
} else if (result.error === 'review-not-approved') {
|
|
203
|
+
console.error(chalk.red(`\n❌ Gate 3 (review) is not approved for "${featureName}".`));
|
|
204
|
+
console.log(chalk.gray(`Approve it first (morph-spec approve ${featureName} review) or pass --force.\n`));
|
|
205
|
+
} else {
|
|
206
|
+
console.error(chalk.red(`\n❌ Error archiving feature: ${result.error}\n`));
|
|
207
|
+
}
|
|
208
|
+
process.exit(1);
|
|
84
209
|
}
|
|
85
210
|
|
|
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 */ }
|
|
211
|
+
if (result.alreadyArchived) {
|
|
212
|
+
console.error(chalk.red(`\n❌ Already archived: .morph/archive/${featureName}\n`));
|
|
213
|
+
process.exit(1);
|
|
100
214
|
}
|
|
101
215
|
|
|
102
216
|
console.log(chalk.green(`\n📦 Archived "${featureName}" → .morph/archive/${featureName}/`));
|
|
103
217
|
console.log(chalk.gray(` status: completed, archivedAt stamped in feature.json.`));
|
|
104
|
-
if (
|
|
105
|
-
|
|
218
|
+
if (result.promotedUseCases?.length) {
|
|
219
|
+
console.log(chalk.gray(` ${result.promotedUseCases.length} use case(s) promoted → .morph/specs/usecases/ (status: implemented).`));
|
|
220
|
+
}
|
|
221
|
+
if (result.committed) console.log(chalk.gray(` Move committed on the current branch.\n`));
|
|
222
|
+
else if (result.staged) console.log(chalk.gray(` Move staged in git; commit it when ready.\n`));
|
|
106
223
|
else console.log('');
|
|
107
224
|
} catch (error) {
|
|
108
225
|
console.error(chalk.red(`\n❌ Error archiving feature: ${error.message}\n`));
|
|
@@ -1,10 +1,45 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import { execFileSync } from 'child_process';
|
|
2
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-]*$/;
|
|
@@ -128,6 +163,7 @@ export async function createCommand(featureName, options = {}) {
|
|
|
128
163
|
const wt = getWorkType(feature);
|
|
129
164
|
|
|
130
165
|
let worktreeResult = null;
|
|
166
|
+
let branchResult = null;
|
|
131
167
|
|
|
132
168
|
if (created) {
|
|
133
169
|
// Scaffold the phase folders this work type starts with.
|
|
@@ -158,7 +194,7 @@ export async function createCommand(featureName, options = {}) {
|
|
|
158
194
|
// stamped it onto the root's feature.json, which cpSync just copied in).
|
|
159
195
|
// Strip it from the worktree's branch-local copy so the branch commits a
|
|
160
196
|
// clean feature.json — otherwise it merges back into the root at
|
|
161
|
-
// `
|
|
197
|
+
// `morph-spec finish` and the post-merge cleanup leaves the root dirty.
|
|
162
198
|
// verify.decidePersistence reads the registration from the ROOT's copy,
|
|
163
199
|
// never this branch-local one (see verify.js), so stripping is safe and
|
|
164
200
|
// restores the invariant that comment relies on.
|
|
@@ -183,6 +219,10 @@ export async function createCommand(featureName, options = {}) {
|
|
|
183
219
|
} catch { /* fail-open */ }
|
|
184
220
|
} catch { /* fail-open — the feature already exists in root */ }
|
|
185
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);
|
|
186
226
|
}
|
|
187
227
|
}
|
|
188
228
|
|
|
@@ -194,6 +234,7 @@ export async function createCommand(featureName, options = {}) {
|
|
|
194
234
|
if (options.json) {
|
|
195
235
|
const out = { feature: featureName, created, ...finalFeature };
|
|
196
236
|
if (worktreeResult) out.worktree = worktreeResult;
|
|
237
|
+
if (branchResult) out.branch = branchResult;
|
|
197
238
|
console.log(JSON.stringify(out, null, 2));
|
|
198
239
|
return;
|
|
199
240
|
}
|
|
@@ -216,6 +257,9 @@ export async function createCommand(featureName, options = {}) {
|
|
|
216
257
|
const why = worktreeResult.error || (worktreeResult.alreadyExists ? 'already exists' : 'unknown');
|
|
217
258
|
console.log(chalk.yellow(` ⚠️ worktree setup skipped (${why}) — feature stays in the root worktree\n`));
|
|
218
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`));
|
|
219
263
|
}
|
|
220
264
|
} else {
|
|
221
265
|
console.log(chalk.yellow(`\n⚠️ Feature "${featureName}" already exists — left untouched`));
|