@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
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join, resolve } from 'path';
|
|
4
|
+
import {
|
|
5
|
+
findWorktreeForFeature,
|
|
6
|
+
shedRootFeatureScope,
|
|
7
|
+
primaryRootUnexplainedDirt,
|
|
8
|
+
teardownWorktree,
|
|
9
|
+
resolvePrimaryRoot,
|
|
10
|
+
isValidFeatureName,
|
|
11
|
+
buildBranchName,
|
|
12
|
+
branchExists,
|
|
13
|
+
} from '../project/worktree.js';
|
|
14
|
+
import { detectDefaultBranch, hasOriginRemote } from '../../lib/git/default-branch.js';
|
|
15
|
+
import { runArchive } from './archive.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `morph-spec finish <feature> --pr|--merge` — the single, symmetric close of a
|
|
19
|
+
* feature's lifecycle, for BOTH the worktree flow and the common flow.
|
|
20
|
+
*
|
|
21
|
+
* Order (see the plan's backbone): validate → ARCHIVE inside the `morph/{f}`
|
|
22
|
+
* branch (committed, so the archived state travels in one integration) →
|
|
23
|
+
* integrate:
|
|
24
|
+
* --merge : merge `--no-ff` into the default branch, LOCAL (no push).
|
|
25
|
+
* --pr : push `morph/{f}` + `gh pr create` (does NOT merge locally).
|
|
26
|
+
* → teardown the worktree (PROVE-or-DECLINE) when there is one.
|
|
27
|
+
*
|
|
28
|
+
* Every refusal is fail-CLOSED, exit 1, and — since the `--pr` remote
|
|
29
|
+
* preconditions run BEFORE the archive — a refused `--pr` leaves no side effect.
|
|
30
|
+
* `finished` is the integration (the actual value); `worktreeRemoved` is the
|
|
31
|
+
* best-effort teardown — two independent signals, as before.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} feature
|
|
34
|
+
* @param {{ pr?: boolean, merge?: boolean, keep?: boolean, force?: boolean,
|
|
35
|
+
* discardIgnored?: boolean, cwd?: string }} [options]
|
|
36
|
+
*/
|
|
37
|
+
export async function finishCommand(feature, options = {}) {
|
|
38
|
+
const cwd = options.cwd || process.cwd();
|
|
39
|
+
const root = resolvePrimaryRoot(cwd);
|
|
40
|
+
const emit = (obj) => console.log(JSON.stringify(obj));
|
|
41
|
+
const rootNorm = resolve(root).replace(/\\/g, '/');
|
|
42
|
+
|
|
43
|
+
// Gate 0: valid kebab name — anti command-injection (the name flows into every
|
|
44
|
+
// git/gh call below) and the same gate the worktree path always applied.
|
|
45
|
+
if (!isValidFeatureName(feature)) {
|
|
46
|
+
emit({ finished: false, error: 'invalid-feature-name', feature });
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Mode: exactly one of --pr / --merge (they are alternative integrations — the
|
|
51
|
+
// finish merges OR opens a PR, never both, never neither).
|
|
52
|
+
const wantPr = !!options.pr;
|
|
53
|
+
const wantMerge = !!options.merge;
|
|
54
|
+
if (wantPr === wantMerge) {
|
|
55
|
+
emit({ finished: false, error: 'mode-required', hint: 'informe exatamente um de --pr ou --merge', feature });
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
const mode = wantPr ? 'pr' : 'merge';
|
|
59
|
+
const expectedBranch = buildBranchName(feature);
|
|
60
|
+
|
|
61
|
+
// ── Resolve the flow ──────────────────────────────────────────────────────
|
|
62
|
+
// findWorktreeForFeature matches ANY worktree on morph/{feature} — including
|
|
63
|
+
// the PRIMARY ROOT itself when the common flow checked the branch out there.
|
|
64
|
+
// A match whose path IS the root is the common flow (no separate worktree to
|
|
65
|
+
// tear down); a match elsewhere is the worktree flow.
|
|
66
|
+
const found = findWorktreeForFeature(feature, root);
|
|
67
|
+
const isWorktreeFlow = !!found && found.path.replace(/\\/g, '/') !== rootNorm;
|
|
68
|
+
|
|
69
|
+
let worktreePath = null;
|
|
70
|
+
let featureCwd = root; // where the feature's own tree (and Gate 3) lives
|
|
71
|
+
|
|
72
|
+
if (isWorktreeFlow) {
|
|
73
|
+
// Validation 0: the worktree must actually be ON the branch we will merge.
|
|
74
|
+
if (found.branch !== found.expectedBranch) {
|
|
75
|
+
emit({
|
|
76
|
+
finished: false,
|
|
77
|
+
error: 'worktree-branch-mismatch',
|
|
78
|
+
feature,
|
|
79
|
+
expectedBranch,
|
|
80
|
+
actualBranch: found.branch,
|
|
81
|
+
path: found.path,
|
|
82
|
+
hint: `o worktree em ${found.path} está em ${found.branch || 'HEAD destacado'}, não em ${expectedBranch}`,
|
|
83
|
+
});
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
worktreePath = found.path;
|
|
87
|
+
featureCwd = worktreePath;
|
|
88
|
+
} else {
|
|
89
|
+
// COMMON flow: the feature branch must exist and be the ROOT's current
|
|
90
|
+
// branch (so the archive commits on it and we can checkout the default to
|
|
91
|
+
// merge). A feature with NO morph/{f} branch was born before this flow —
|
|
92
|
+
// route it explicitly to archive-without-integrate, never a generic refusal.
|
|
93
|
+
if (!branchExists(expectedBranch, root)) {
|
|
94
|
+
emit({
|
|
95
|
+
finished: false,
|
|
96
|
+
error: 'no-feature-branch',
|
|
97
|
+
feature,
|
|
98
|
+
hint: `feature sem branch ${expectedBranch} (criada antes deste fluxo) — feche com \`morph-spec archive ${feature}\` (arquivar sem integrar), ou continue o trabalho numa branch`,
|
|
99
|
+
});
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
const current = currentBranch(root);
|
|
103
|
+
if (current !== expectedBranch) {
|
|
104
|
+
emit({
|
|
105
|
+
finished: false,
|
|
106
|
+
error: 'not-on-feature-branch',
|
|
107
|
+
feature,
|
|
108
|
+
currentBranch: current,
|
|
109
|
+
expectedBranch,
|
|
110
|
+
hint: `faça checkout de ${expectedBranch} antes do finish (\`git checkout ${expectedBranch}\`)`,
|
|
111
|
+
});
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
featureCwd = root;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Validation 1: Gate 3 (review) approved on the feature's own feature.json.
|
|
118
|
+
const fj = readFeatureJson(featureCwd, feature);
|
|
119
|
+
if (!options.force && !(fj?.approvalGates?.review?.approved === true || fj?.approvals?.review?.approved === true)) {
|
|
120
|
+
emit({ finished: false, error: 'review-not-approved', feature });
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
const prTitle = `morph: ${feature}`;
|
|
124
|
+
const prBody = (fj?.description && String(fj.description).trim())
|
|
125
|
+
? String(fj.description).trim()
|
|
126
|
+
: `Feature ${feature} — integrada via morph-spec finish --pr.`;
|
|
127
|
+
|
|
128
|
+
// Validation 2: the tree that will carry the archive commit must be clean
|
|
129
|
+
// (work committed), so the scoped archive commit contains only the move.
|
|
130
|
+
const cleanCwd = isWorktreeFlow ? worktreePath : root;
|
|
131
|
+
const dirty = gitStatusPorcelain(cleanCwd);
|
|
132
|
+
if (dirty === null) {
|
|
133
|
+
emit({ finished: false, error: 'status-failed', feature });
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
if (dirty.trim() !== '') {
|
|
137
|
+
emit({ finished: false, error: isWorktreeFlow ? 'worktree-dirty' : 'working-dir-dirty', hint: 'commit seu trabalho na branch antes do finish', feature });
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Default branch — needed as the merge target and the PR base. Never assume
|
|
142
|
+
// "master"; refuse clearly when it cannot be resolved.
|
|
143
|
+
const defaultBranch = detectDefaultBranch(root);
|
|
144
|
+
if (!defaultBranch) {
|
|
145
|
+
emit({ finished: false, error: 'no-default-branch', hint: 'não foi possível resolver a branch default (origin/HEAD, main ou master)', feature });
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
if (defaultBranch === expectedBranch) {
|
|
149
|
+
emit({ finished: false, error: 'feature-is-default', hint: `a branch da feature (${expectedBranch}) é a própria default — nada a integrar`, feature });
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── PR preconditions run BEFORE the archive, so a refused --pr has NO side
|
|
154
|
+
// effect (nothing archived, nothing committed). ─────────────────────────
|
|
155
|
+
if (mode === 'pr') {
|
|
156
|
+
if (!hasOriginRemote(root)) {
|
|
157
|
+
emit({ finished: false, error: 'no-origin-remote', hint: 'não há remoto `origin` para abrir PR — configure um ou use --merge', feature });
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
if (!ghAuthOk()) {
|
|
161
|
+
emit({ finished: false, error: 'gh-unavailable', hint: '`gh` não está instalado/autenticado — rode `gh auth login` ou use --merge', feature });
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── ARCHIVE (inside morph/{f}) ────────────────────────────────────────────
|
|
167
|
+
const arch = runArchive(feature, { cwd: featureCwd, force: options.force, git: true });
|
|
168
|
+
if (!arch.ok) {
|
|
169
|
+
emit({ finished: false, error: `archive-${arch.error}`, feature });
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
// A move that could not be committed (e.g. missing git identity) would leave
|
|
173
|
+
// the tree dirty and break the checkout/merge below — refuse, recoverable.
|
|
174
|
+
if (arch.archived && !arch.committed) {
|
|
175
|
+
emit({ finished: false, error: 'archive-not-committed', hint: 'o move de archive não pôde ser commitado (identidade git ausente?)', feature });
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── INTEGRATE ─────────────────────────────────────────────────────────────
|
|
180
|
+
if (mode === 'merge') {
|
|
181
|
+
return integrateMerge({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, arch });
|
|
182
|
+
}
|
|
183
|
+
return integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── merge integration (local, no push) ───────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
async function integrateMerge({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, arch }) {
|
|
189
|
+
const mergeMsg = `morph-spec finish: merge ${expectedBranch}`;
|
|
190
|
+
const ucNote = promotedNote(arch);
|
|
191
|
+
|
|
192
|
+
if (isWorktreeFlow) {
|
|
193
|
+
// The root stays on the default; shed this feature's own root-side scaffold,
|
|
194
|
+
// then guard the whole root before landing the merge commit.
|
|
195
|
+
const { shedBackup } = shedRootFeatureScope(root, feature);
|
|
196
|
+
const backupNote = shedBackup ? { shedBackup } : {};
|
|
197
|
+
|
|
198
|
+
const rootClean = primaryRootUnexplainedDirt(root, feature);
|
|
199
|
+
if (!rootClean.ok) {
|
|
200
|
+
emit({ finished: false, error: 'primary-root-status-failed', feature, ...backupNote });
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
if (rootClean.dirt.length) {
|
|
204
|
+
emit({ finished: false, error: 'primary-root-dirty', feature, ...backupNote });
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
execFileSync('git', ['merge', '--no-ff', expectedBranch, '-m', mergeMsg], { cwd: root, stdio: 'pipe' });
|
|
210
|
+
} catch (err) {
|
|
211
|
+
tryGit(['merge', '--abort'], root);
|
|
212
|
+
emit({ finished: false, error: 'merge-conflict', output: gitErr(err), hint: 'resolva manualmente ou re-execute sequencialmente', feature, ...backupNote });
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (options.keep) {
|
|
217
|
+
dropFromRootIndex(root, feature);
|
|
218
|
+
emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false, ...backupNote, ...ucNote });
|
|
219
|
+
process.exit(0);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const teardown = await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: true });
|
|
223
|
+
dropFromRootIndex(root, feature);
|
|
224
|
+
emit({ ...mergeOutput({ feature, branch: expectedBranch, worktreePath, teardown, shedBackup }), ...ucNote });
|
|
225
|
+
process.exit(0);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// COMMON flow: we are on morph/{f} in the root, archive committed. Move to the
|
|
229
|
+
// default and merge the feature branch in.
|
|
230
|
+
try {
|
|
231
|
+
execFileSync('git', ['checkout', defaultBranch], { cwd: root, stdio: 'pipe' });
|
|
232
|
+
} catch (err) {
|
|
233
|
+
emit({ finished: false, error: 'checkout-default-failed', output: gitErr(err), hint: `não foi possível trocar para ${defaultBranch}`, feature });
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
execFileSync('git', ['merge', '--no-ff', expectedBranch, '-m', mergeMsg], { cwd: root, stdio: 'pipe' });
|
|
238
|
+
} catch (err) {
|
|
239
|
+
tryGit(['merge', '--abort'], root);
|
|
240
|
+
emit({
|
|
241
|
+
finished: false,
|
|
242
|
+
error: 'merge-conflict',
|
|
243
|
+
output: gitErr(err),
|
|
244
|
+
// The abort left the working dir on the default WITHOUT the feature — say
|
|
245
|
+
// where the intact work is so the user can recover it.
|
|
246
|
+
hint: `conflito ao mergear ${expectedBranch} em ${defaultBranch}. Seu trabalho está intacto em ${expectedBranch} — rode \`git checkout ${expectedBranch}\` para voltar a ele`,
|
|
247
|
+
feature,
|
|
248
|
+
});
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
tryGit(['branch', '-d', expectedBranch], root);
|
|
252
|
+
// The root index (gitignored) was already updated by the archive (same cwd).
|
|
253
|
+
emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: null, ...ucNote });
|
|
254
|
+
process.exit(0);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── PR integration (push + gh, no local merge) ───────────────────────────────
|
|
258
|
+
|
|
259
|
+
async function integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch }) {
|
|
260
|
+
try {
|
|
261
|
+
execFileSync('git', ['push', '-u', 'origin', expectedBranch], { cwd: root, stdio: 'pipe' });
|
|
262
|
+
} catch (err) {
|
|
263
|
+
emit({ finished: false, error: 'push-failed', output: gitErr(err), hint: 'o archive está commitado localmente — resolva o push e re-execute', feature });
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let prUrl = null;
|
|
268
|
+
try {
|
|
269
|
+
const { bin, prefix } = resolveGh();
|
|
270
|
+
prUrl = execFileSync(bin, [...prefix, 'pr', 'create', '--base', defaultBranch, '--head', expectedBranch, '--title', prTitle, '--body', prBody], { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
271
|
+
} catch (err) {
|
|
272
|
+
emit({ finished: false, error: 'pr-create-failed', output: gitErr(err), hint: 'a branch foi enviada (push OK); abra o PR manualmente com `gh pr create`', feature, pushed: expectedBranch });
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Worktree flow: remove the worktree but KEEP the branch (the open PR needs it).
|
|
277
|
+
// Common flow: nothing to tear down; stay on morph/{f} (the default has no
|
|
278
|
+
// feature yet — honest until the PR merges).
|
|
279
|
+
let teardown = null;
|
|
280
|
+
let shedBackup = null;
|
|
281
|
+
if (isWorktreeFlow && !options.keep) {
|
|
282
|
+
// Shed the transient root-side scaffold BEFORE teardown — exactly as --merge
|
|
283
|
+
// does. The real `create --worktree` leaves the root's .morph/features/{f}
|
|
284
|
+
// UNTRACKED; without this, teardown's clearRootWorktreeRegistration would
|
|
285
|
+
// `git add` + commit that scaffold onto the default branch. Then when the PR
|
|
286
|
+
// later merges (carrying features/{f} → archive/{f}), the default would hold
|
|
287
|
+
// the feature in BOTH features/ and archive/ at once — active and archived.
|
|
288
|
+
// The branch/PR owns the authoritative copy; the root scaffold is transient.
|
|
289
|
+
shedBackup = shedRootFeatureScope(root, feature).shedBackup;
|
|
290
|
+
teardown = await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: false });
|
|
291
|
+
// The shed removed the root's features/{f} scaffold from disk, but runArchive
|
|
292
|
+
// dropped the feature only from the WORKTREE's index (its cwd). Drop it from
|
|
293
|
+
// the ROOT index too — exactly as --merge does — or `doctor`'s lifecycle audit
|
|
294
|
+
// flags a `stale-index` phantom (index lists {f}, no folder on disk).
|
|
295
|
+
dropFromRootIndex(root, feature);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const out = {
|
|
299
|
+
finished: true,
|
|
300
|
+
mode: 'pr',
|
|
301
|
+
feature,
|
|
302
|
+
pushed: expectedBranch,
|
|
303
|
+
base: defaultBranch,
|
|
304
|
+
pr: prUrl,
|
|
305
|
+
...promotedNote(arch),
|
|
306
|
+
...(shedBackup ? { shedBackup } : {}),
|
|
307
|
+
...(teardown
|
|
308
|
+
? { worktreeRemoved: teardown.worktreeRemoved, ...teardownExtras(teardown, worktreePath, expectedBranch, false) }
|
|
309
|
+
: { worktreeRemoved: false }),
|
|
310
|
+
};
|
|
311
|
+
emit(out);
|
|
312
|
+
process.exit(0);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── output helpers ────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Report the use cases the archive promoted into `.morph/specs/usecases/`.
|
|
319
|
+
* Absent when nothing was promoted, so a feature with no use cases keeps the
|
|
320
|
+
* exact output shape it had before this existed.
|
|
321
|
+
*/
|
|
322
|
+
function promotedNote(arch) {
|
|
323
|
+
return arch?.promotedUseCases?.length ? { promotedUseCases: arch.promotedUseCases } : {};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function mergeOutput({ feature, branch, worktreePath, teardown, shedBackup }) {
|
|
327
|
+
return {
|
|
328
|
+
finished: true,
|
|
329
|
+
mode: 'merge',
|
|
330
|
+
feature,
|
|
331
|
+
merged: branch,
|
|
332
|
+
worktreeRemoved: teardown.worktreeRemoved,
|
|
333
|
+
...(shedBackup ? { shedBackup } : {}),
|
|
334
|
+
...teardownExtras(teardown, worktreePath, branch, true),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Shared teardown reporting for both modes (kept identical to the old finish). */
|
|
339
|
+
function teardownExtras(teardown, worktreePath, branch, deleteBranch) {
|
|
340
|
+
const out = {};
|
|
341
|
+
if (teardown.ignoredBackup) out.worktreeIgnoredBackup = teardown.ignoredBackup;
|
|
342
|
+
if (teardown.worktreeRemoved && teardown.discarded.length) out.discardedIgnored = teardown.discarded;
|
|
343
|
+
if (!teardown.worktreeRemoved) {
|
|
344
|
+
out.path = worktreePath;
|
|
345
|
+
out.branch = branch;
|
|
346
|
+
if (teardown.blockers.length) out.reparsePoints = teardown.blockers;
|
|
347
|
+
if (teardown.ignoredBlocking.length) out.ignoredFiles = teardown.ignoredBlocking;
|
|
348
|
+
out.cleanupHint = teardown.declineReason;
|
|
349
|
+
out.relinkHint = `as junctions de infra foram soltas antes desta recusa — para continuar no worktree, rode \`morph-spec worktree link --all --target "${worktreePath}"\``;
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── small git helpers ────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
function currentBranch(cwd) {
|
|
357
|
+
try {
|
|
358
|
+
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, stdio: 'pipe' }).toString().trim();
|
|
359
|
+
} catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function gitStatusPorcelain(cwd) {
|
|
365
|
+
try {
|
|
366
|
+
return execFileSync('git', ['status', '--porcelain'], { cwd, stdio: 'pipe' }).toString();
|
|
367
|
+
} catch {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Resolve the `gh` invocation. Defaults to the `gh` binary on PATH. Overridable
|
|
374
|
+
* via MORPH_GH_BIN: a plain string names a different binary (a corporate wrapper,
|
|
375
|
+
* `hub`, …); a JSON array is a full argv prefix — e.g.
|
|
376
|
+
* `["/path/to/node","/path/to/gh-shim.js"]` — which is how the tests inject a
|
|
377
|
+
* cross-platform stub that execFileSync can actually spawn (Node ≥20 refuses to
|
|
378
|
+
* run `.cmd`/`.bat` shims without `shell:true`, and shell:true would reopen the
|
|
379
|
+
* arg-injection hole the whole file avoids).
|
|
380
|
+
*
|
|
381
|
+
* @returns {{ bin: string, prefix: string[] }}
|
|
382
|
+
*/
|
|
383
|
+
function resolveGh() {
|
|
384
|
+
const raw = process.env.MORPH_GH_BIN;
|
|
385
|
+
if (raw && raw.trim().startsWith('[')) {
|
|
386
|
+
try {
|
|
387
|
+
const arr = JSON.parse(raw);
|
|
388
|
+
if (Array.isArray(arr) && arr.length) return { bin: String(arr[0]), prefix: arr.slice(1).map(String) };
|
|
389
|
+
} catch { /* not JSON — treat as a plain binary name below */ }
|
|
390
|
+
}
|
|
391
|
+
return { bin: raw && raw.trim() ? raw.trim() : 'gh', prefix: [] };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function ghAuthOk() {
|
|
395
|
+
const { bin, prefix } = resolveGh();
|
|
396
|
+
try {
|
|
397
|
+
execFileSync(bin, [...prefix, 'auth', 'status'], { stdio: 'pipe' });
|
|
398
|
+
return true;
|
|
399
|
+
} catch {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function tryGit(args, cwd) {
|
|
405
|
+
try { execFileSync('git', args, { cwd, stdio: 'pipe' }); } catch { /* best-effort */ }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function gitErr(err) {
|
|
409
|
+
return (((err.stdout && err.stdout.toString()) || '') + ((err.stderr && err.stderr.toString()) || '')).slice(0, 2000) || (err.message || '').slice(0, 500);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function readFeatureJson(cwd, feature) {
|
|
413
|
+
try {
|
|
414
|
+
return JSON.parse(readFileSync(join(cwd, '.morph', 'features', feature, 'feature.json'), 'utf8'));
|
|
415
|
+
} catch {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Drop a feature from the thin, gitignored root index (never via saveState). */
|
|
421
|
+
function dropFromRootIndex(root, feature) {
|
|
422
|
+
const rootPath = join(root, '.morph', 'state.json');
|
|
423
|
+
if (!existsSync(rootPath)) return;
|
|
424
|
+
try {
|
|
425
|
+
const idx = JSON.parse(readFileSync(rootPath, 'utf8'));
|
|
426
|
+
if (idx.features && idx.features[feature]) {
|
|
427
|
+
delete idx.features[feature];
|
|
428
|
+
idx.metadata = idx.metadata || {};
|
|
429
|
+
idx.metadata.totalFeatures = Object.keys(idx.features).length;
|
|
430
|
+
writeFileSync(rootPath, JSON.stringify(idx, null, 2), 'utf8');
|
|
431
|
+
}
|
|
432
|
+
} catch { /* index is derivable — non-fatal */ }
|
|
433
|
+
}
|
|
@@ -4,6 +4,7 @@ import { join } from 'path';
|
|
|
4
4
|
import { parseTasksJsonLenient } from '../../lib/tasks/tasks-json-parser.js';
|
|
5
5
|
import { planDispatch } from '../../lib/tasks/dispatch-planner.js';
|
|
6
6
|
import { renderMermaidFlowchart } from '../../lib/tasks/mermaid-renderer.js';
|
|
7
|
+
import { checkUseCases } from '../../lib/specs/usecase-check.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* `morph-spec dag <feature>` — analyze the tasks.json DAG and print the
|
|
@@ -17,7 +18,8 @@ import { renderMermaidFlowchart } from '../../lib/tasks/mermaid-renderer.js';
|
|
|
17
18
|
*/
|
|
18
19
|
export async function dagCommand(featureName, options = {}) {
|
|
19
20
|
try {
|
|
20
|
-
const
|
|
21
|
+
const planDir = join(process.cwd(), '.morph', 'features', featureName, '2-plan');
|
|
22
|
+
const tasksPath = join(planDir, 'tasks.json');
|
|
21
23
|
if (!existsSync(tasksPath)) {
|
|
22
24
|
console.error(chalk.red(`\n❌ tasks.json not found for feature "${featureName}"`));
|
|
23
25
|
console.error(chalk.gray(`Expected at: .morph/features/${featureName}/2-plan/tasks.json\n`));
|
|
@@ -42,13 +44,34 @@ export async function dagCommand(featureName, options = {}) {
|
|
|
42
44
|
return;
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
// Use-case cross-check. Runs AFTER the --mermaid short-circuit on purpose:
|
|
48
|
+
// rendering the DAG is pure visualization and must never be blocked by the
|
|
49
|
+
// state of the spec. A broken `usecase` reference IS fatal here — the file
|
|
50
|
+
// either exists or it does not, so there is no false positive to weigh
|
|
51
|
+
// against a plan that points at a use case nobody can read.
|
|
52
|
+
const ucCheck = checkUseCases(data, join(planDir, 'usecases'));
|
|
53
|
+
|
|
45
54
|
const plan = planDispatch(data);
|
|
46
55
|
|
|
47
56
|
if (options.json) {
|
|
48
|
-
console.log(JSON.stringify(plan, null, 2));
|
|
57
|
+
console.log(JSON.stringify({ ...plan, useCases: ucCheck }, null, 2));
|
|
58
|
+
if (ucCheck.errors.length) process.exit(1);
|
|
49
59
|
return;
|
|
50
60
|
}
|
|
51
61
|
|
|
62
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
63
|
+
|
|
64
|
+
if (ucCheck.errors.length) {
|
|
65
|
+
console.error(chalk.red(`\n❌ Use cases — ${plural(ucCheck.errors.length, 'erro', 'erros')}\n`));
|
|
66
|
+
for (const e of ucCheck.errors) console.error(chalk.red(` ${e}`));
|
|
67
|
+
console.error(chalk.gray('\n Corrija a referência no tasks.json ou restaure o use case.\n'));
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
if (ucCheck.warnings.length) {
|
|
71
|
+
console.error(chalk.yellow(`\n⚠ Use cases — ${plural(ucCheck.warnings.length, 'aviso', 'avisos')}`));
|
|
72
|
+
for (const w of ucCheck.warnings) console.error(chalk.yellow(` ${w}`));
|
|
73
|
+
}
|
|
74
|
+
|
|
52
75
|
console.log(chalk.bold(`\n📊 DAG — ${featureName}`));
|
|
53
76
|
console.log('━'.repeat(60));
|
|
54
77
|
|
|
@@ -36,17 +36,24 @@ import { validateTasksJson } from '../../lib/tasks/tasks-json-parser.js';
|
|
|
36
36
|
*/
|
|
37
37
|
const OUTPUT_SECTIONS = {
|
|
38
38
|
// morph-brainstorming emits: Problema, Escopo, Usuários e fluxos críticos,
|
|
39
|
-
//
|
|
39
|
+
// Stack, Riscos e dependências, Compliance de stack.
|
|
40
|
+
//
|
|
41
|
+
// "Critérios de aceite" is now OPTIONAL, not required: the behaviour layer
|
|
42
|
+
// moved to the use cases (2-plan/usecases/), whose post-conditions must be
|
|
43
|
+
// observable in a test, and morph-brainstorming no longer emits the section.
|
|
44
|
+
// `optional` — not `recommended` — precisely because the skill never emits it
|
|
45
|
+
// anymore: a warning on every new proposal would be systematic noise. Legacy
|
|
46
|
+
// proposals that DO carry the section keep validating exactly as before.
|
|
40
47
|
'proposal.md': {
|
|
41
48
|
required: [
|
|
42
49
|
['Problema', 'Problem'],
|
|
43
50
|
['Escopo', 'Scope'],
|
|
44
|
-
['Critérios de aceite', 'Critério', 'Aceite', 'Acceptance Criteria', 'Acceptance', 'Success Criteria', 'Success Metrics'],
|
|
45
51
|
],
|
|
46
52
|
recommended: [
|
|
47
53
|
['Riscos', 'Risks'],
|
|
48
54
|
],
|
|
49
55
|
optional: [
|
|
56
|
+
['Critérios de aceite', 'Critério', 'Aceite', 'Acceptance Criteria', 'Acceptance', 'Success Criteria', 'Success Metrics'],
|
|
50
57
|
'Stack',
|
|
51
58
|
['Usuários', 'Usuarios', 'Users', 'User Journey', 'User Stories', 'Fluxos'],
|
|
52
59
|
['Dependências', 'Dependencias', 'Dependencies'],
|
|
@@ -15,7 +15,7 @@ import { join } from 'path';
|
|
|
15
15
|
* Schema for output types produced by morph-spec NLH phases.
|
|
16
16
|
*
|
|
17
17
|
* Fields:
|
|
18
|
-
* filename - The file's basename (e.g. 'spec.md')
|
|
18
|
+
* filename - The file's basename (e.g. 'spec.md'). Single-file types only.
|
|
19
19
|
* phaseDir - Subdirectory within .morph/features/{feature}/
|
|
20
20
|
* phase - The morph-spec phase that produces this output
|
|
21
21
|
* protected - Whether the file requires an approval gate before editing
|
|
@@ -25,6 +25,13 @@ import { join } from 'path';
|
|
|
25
25
|
* Lets the implement phase update task progress without
|
|
26
26
|
* unfreezing the plan contract. When present, the protect
|
|
27
27
|
* hook does a semantic diff instead of a blanket block.
|
|
28
|
+
*
|
|
29
|
+
* Multi-file types (`multi: true`) hold N files in a directory instead of one
|
|
30
|
+
* fixed basename. They carry `dirname` + `filenamePattern` + `displayFilename`
|
|
31
|
+
* INSTEAD of `filename`, and every derivation keyed by basename
|
|
32
|
+
* (FILENAME_TO_OUTPUT, PROTECTED_SPEC_FILES, MUTABLE_SPEC_FIELDS) skips them —
|
|
33
|
+
* there is no single basename to key on. `getOutputPath` returns the DIRECTORY
|
|
34
|
+
* for these types.
|
|
28
35
|
*/
|
|
29
36
|
export const OUTPUT_SCHEMA = {
|
|
30
37
|
// 0-proposal/
|
|
@@ -43,6 +50,19 @@ export const OUTPUT_SCHEMA = {
|
|
|
43
50
|
mandate: { filename: 'mandate.md', phaseDir: '2-plan', phase: 'plan', protected: true, approvalGate: 'plan' },
|
|
44
51
|
tasks: { filename: 'tasks.json', phaseDir: '2-plan', phase: 'plan', protected: true, approvalGate: 'plan', mutableFields: ['status', 'outputs', 'notes'] },
|
|
45
52
|
decisions: { filename: 'decisions.md', phaseDir: '2-plan', phase: 'plan', protected: false },
|
|
53
|
+
// N files, one per system use case — the behaviour layer the plan distils
|
|
54
|
+
// BEFORE spec.md/tasks.json. Deliberately NOT protected: editing a use case
|
|
55
|
+
// after Gate 2 is the supported way to change behaviour and re-run implement;
|
|
56
|
+
// the audit trail lives in git.
|
|
57
|
+
usecases: {
|
|
58
|
+
dirname: 'usecases',
|
|
59
|
+
filenamePattern: '^UC-\\d+-[a-z0-9-]+\\.md$',
|
|
60
|
+
displayFilename: 'UC-{nn}-{slug}.md',
|
|
61
|
+
phaseDir: '2-plan',
|
|
62
|
+
phase: 'plan',
|
|
63
|
+
protected: false,
|
|
64
|
+
multi: true,
|
|
65
|
+
},
|
|
46
66
|
|
|
47
67
|
// 3-implement/
|
|
48
68
|
recap: { filename: 'recap.md', phaseDir: '3-implement', phase: 'implement', protected: false },
|
|
@@ -60,9 +80,21 @@ export const OUTPUT_SCHEMA = {
|
|
|
60
80
|
// Builder Functions (Windows-safe via path.join)
|
|
61
81
|
// ============================================================================
|
|
62
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Leaf segment of a schema entry: the basename for single-file types, the
|
|
85
|
+
* directory name for multi-file ones. Keeps every path builder from having to
|
|
86
|
+
* know about `multi` on its own.
|
|
87
|
+
*/
|
|
88
|
+
function leafSegment(entry) {
|
|
89
|
+
return entry.multi ? entry.dirname : entry.filename;
|
|
90
|
+
}
|
|
91
|
+
|
|
63
92
|
/**
|
|
64
93
|
* Get the morph-relative path for a specific output type of a feature.
|
|
65
94
|
*
|
|
95
|
+
* For `multi` types this is the DIRECTORY holding the N files
|
|
96
|
+
* (".morph/features/my-feature/2-plan/usecases"), not a single file.
|
|
97
|
+
*
|
|
66
98
|
* @param {string} featureName
|
|
67
99
|
* @param {string} outputType - camelCase output type key from OUTPUT_SCHEMA
|
|
68
100
|
* @returns {string} e.g. ".morph/features/my-feature/2-plan/spec.md"
|
|
@@ -72,19 +104,38 @@ export function getOutputPath(featureName, outputType) {
|
|
|
72
104
|
if (!entry) {
|
|
73
105
|
throw new Error(`Unknown output type: "${outputType}". Valid types: ${Object.keys(OUTPUT_SCHEMA).join(', ')}`);
|
|
74
106
|
}
|
|
75
|
-
return join('.morph', 'features', featureName, entry.phaseDir, entry
|
|
107
|
+
return join('.morph', 'features', featureName, entry.phaseDir, leafSegment(entry))
|
|
76
108
|
.replace(/\\/g, '/');
|
|
77
109
|
}
|
|
78
110
|
|
|
79
111
|
/**
|
|
80
112
|
* Get the absolute filesystem path for a specific output type.
|
|
113
|
+
* For `multi` types this is the directory — see {@link getOutputPath}.
|
|
81
114
|
*/
|
|
82
115
|
export function getAbsoluteOutputPath(projectRoot, featureName, outputType) {
|
|
83
116
|
const entry = OUTPUT_SCHEMA[outputType];
|
|
84
117
|
if (!entry) {
|
|
85
118
|
throw new Error(`Unknown output type: "${outputType}". Valid types: ${Object.keys(OUTPUT_SCHEMA).join(', ')}`);
|
|
86
119
|
}
|
|
87
|
-
return join(projectRoot, '.morph', 'features', featureName, entry.phaseDir, entry
|
|
120
|
+
return join(projectRoot, '.morph', 'features', featureName, entry.phaseDir, leafSegment(entry));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Human-readable path of a schema entry, relative to the feature directory.
|
|
125
|
+
*
|
|
126
|
+
* SINGLE source for the `morph-outputs` tables: `scripts/generate-refs.js`
|
|
127
|
+
* writes them and `test/framework/refs.test.js` verifies them, and if the two
|
|
128
|
+
* rebuilt the string independently they would drift apart the first time the
|
|
129
|
+
* schema grew a new shape. Multi-file types render the filename PATTERN, so a
|
|
130
|
+
* reader sees it's a folder of N files.
|
|
131
|
+
*
|
|
132
|
+
* @param {Object} entry - an OUTPUT_SCHEMA value
|
|
133
|
+
* @returns {string} e.g. "2-plan/spec.md" or "2-plan/usecases/UC-{nn}-{slug}.md"
|
|
134
|
+
*/
|
|
135
|
+
export function getSchemaDisplayPath(entry) {
|
|
136
|
+
return entry.multi
|
|
137
|
+
? `${entry.phaseDir}/${entry.dirname}/${entry.displayFilename}`
|
|
138
|
+
: `${entry.phaseDir}/${entry.filename}`;
|
|
88
139
|
}
|
|
89
140
|
|
|
90
141
|
/**
|
|
@@ -119,6 +170,9 @@ export function getAbsoluteTaskReportPath(projectRoot, featureName, taskId) {
|
|
|
119
170
|
|
|
120
171
|
/**
|
|
121
172
|
* Get all output paths for a feature, ready for state.json `outputs` block.
|
|
173
|
+
*
|
|
174
|
+
* Multi-file types contribute their DIRECTORY, so `created` for them means
|
|
175
|
+
* "the folder exists" — the state index tracks presence, not file count.
|
|
122
176
|
*/
|
|
123
177
|
export function getAllOutputPaths(featureName) {
|
|
124
178
|
const outputs = {};
|
|
@@ -135,8 +189,14 @@ export function getAllOutputPaths(featureName) {
|
|
|
135
189
|
// Derived Constants
|
|
136
190
|
// ============================================================================
|
|
137
191
|
|
|
192
|
+
/**
|
|
193
|
+
* filename → output type. Multi-file types are absent by construction: they
|
|
194
|
+
* have N basenames, so there is nothing to key on.
|
|
195
|
+
*/
|
|
138
196
|
export const FILENAME_TO_OUTPUT = Object.fromEntries(
|
|
139
|
-
Object.entries(OUTPUT_SCHEMA)
|
|
197
|
+
Object.entries(OUTPUT_SCHEMA)
|
|
198
|
+
.filter(([, entry]) => !entry.multi)
|
|
199
|
+
.map(([key, entry]) => [entry.filename, key])
|
|
140
200
|
);
|
|
141
201
|
|
|
142
202
|
export const OUTPUT_PHASE_MAP = Object.fromEntries(
|
|
@@ -148,7 +208,7 @@ export const OUTPUT_PHASE_MAP = Object.fromEntries(
|
|
|
148
208
|
*/
|
|
149
209
|
export const PROTECTED_SPEC_FILES = Object.fromEntries(
|
|
150
210
|
Object.entries(OUTPUT_SCHEMA)
|
|
151
|
-
.filter(([, entry]) => entry.protected && entry.approvalGate)
|
|
211
|
+
.filter(([, entry]) => entry.protected && entry.approvalGate && !entry.multi)
|
|
152
212
|
.map(([, entry]) => [entry.filename, entry.approvalGate])
|
|
153
213
|
);
|
|
154
214
|
|
|
@@ -160,7 +220,7 @@ export const PROTECTED_SPEC_FILES = Object.fromEntries(
|
|
|
160
220
|
*/
|
|
161
221
|
export const MUTABLE_SPEC_FIELDS = Object.fromEntries(
|
|
162
222
|
Object.entries(OUTPUT_SCHEMA)
|
|
163
|
-
.filter(([, entry]) => entry.protected && entry.approvalGate && entry.mutableFields)
|
|
223
|
+
.filter(([, entry]) => entry.protected && entry.approvalGate && entry.mutableFields && !entry.multi)
|
|
164
224
|
.map(([, entry]) => [entry.filename, entry.mutableFields])
|
|
165
225
|
);
|
|
166
226
|
|