@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
@@ -0,0 +1,422 @@
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
+
191
+ if (isWorktreeFlow) {
192
+ // The root stays on the default; shed this feature's own root-side scaffold,
193
+ // then guard the whole root before landing the merge commit.
194
+ const { shedBackup } = shedRootFeatureScope(root, feature);
195
+ const backupNote = shedBackup ? { shedBackup } : {};
196
+
197
+ const rootClean = primaryRootUnexplainedDirt(root, feature);
198
+ if (!rootClean.ok) {
199
+ emit({ finished: false, error: 'primary-root-status-failed', feature, ...backupNote });
200
+ process.exit(1);
201
+ }
202
+ if (rootClean.dirt.length) {
203
+ emit({ finished: false, error: 'primary-root-dirty', feature, ...backupNote });
204
+ process.exit(1);
205
+ }
206
+
207
+ try {
208
+ execFileSync('git', ['merge', '--no-ff', expectedBranch, '-m', mergeMsg], { cwd: root, stdio: 'pipe' });
209
+ } catch (err) {
210
+ tryGit(['merge', '--abort'], root);
211
+ emit({ finished: false, error: 'merge-conflict', output: gitErr(err), hint: 'resolva manualmente ou re-execute sequencialmente', feature, ...backupNote });
212
+ process.exit(1);
213
+ }
214
+
215
+ if (options.keep) {
216
+ dropFromRootIndex(root, feature);
217
+ emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false, ...backupNote });
218
+ process.exit(0);
219
+ }
220
+
221
+ const teardown = await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: true });
222
+ dropFromRootIndex(root, feature);
223
+ emit(mergeOutput({ feature, branch: expectedBranch, worktreePath, teardown, shedBackup }));
224
+ process.exit(0);
225
+ }
226
+
227
+ // COMMON flow: we are on morph/{f} in the root, archive committed. Move to the
228
+ // default and merge the feature branch in.
229
+ try {
230
+ execFileSync('git', ['checkout', defaultBranch], { cwd: root, stdio: 'pipe' });
231
+ } catch (err) {
232
+ emit({ finished: false, error: 'checkout-default-failed', output: gitErr(err), hint: `não foi possível trocar para ${defaultBranch}`, feature });
233
+ process.exit(1);
234
+ }
235
+ try {
236
+ execFileSync('git', ['merge', '--no-ff', expectedBranch, '-m', mergeMsg], { cwd: root, stdio: 'pipe' });
237
+ } catch (err) {
238
+ tryGit(['merge', '--abort'], root);
239
+ emit({
240
+ finished: false,
241
+ error: 'merge-conflict',
242
+ output: gitErr(err),
243
+ // The abort left the working dir on the default WITHOUT the feature — say
244
+ // where the intact work is so the user can recover it.
245
+ hint: `conflito ao mergear ${expectedBranch} em ${defaultBranch}. Seu trabalho está intacto em ${expectedBranch} — rode \`git checkout ${expectedBranch}\` para voltar a ele`,
246
+ feature,
247
+ });
248
+ process.exit(1);
249
+ }
250
+ tryGit(['branch', '-d', expectedBranch], root);
251
+ // The root index (gitignored) was already updated by the archive (same cwd).
252
+ emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: null });
253
+ process.exit(0);
254
+ }
255
+
256
+ // ── PR integration (push + gh, no local merge) ───────────────────────────────
257
+
258
+ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody }) {
259
+ try {
260
+ execFileSync('git', ['push', '-u', 'origin', expectedBranch], { cwd: root, stdio: 'pipe' });
261
+ } catch (err) {
262
+ emit({ finished: false, error: 'push-failed', output: gitErr(err), hint: 'o archive está commitado localmente — resolva o push e re-execute', feature });
263
+ process.exit(1);
264
+ }
265
+
266
+ let prUrl = null;
267
+ try {
268
+ const { bin, prefix } = resolveGh();
269
+ prUrl = execFileSync(bin, [...prefix, 'pr', 'create', '--base', defaultBranch, '--head', expectedBranch, '--title', prTitle, '--body', prBody], { cwd: root, stdio: 'pipe' }).toString().trim();
270
+ } catch (err) {
271
+ 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 });
272
+ process.exit(1);
273
+ }
274
+
275
+ // Worktree flow: remove the worktree but KEEP the branch (the open PR needs it).
276
+ // Common flow: nothing to tear down; stay on morph/{f} (the default has no
277
+ // feature yet — honest until the PR merges).
278
+ let teardown = null;
279
+ let shedBackup = null;
280
+ if (isWorktreeFlow && !options.keep) {
281
+ // Shed the transient root-side scaffold BEFORE teardown — exactly as --merge
282
+ // does. The real `create --worktree` leaves the root's .morph/features/{f}
283
+ // UNTRACKED; without this, teardown's clearRootWorktreeRegistration would
284
+ // `git add` + commit that scaffold onto the default branch. Then when the PR
285
+ // later merges (carrying features/{f} → archive/{f}), the default would hold
286
+ // the feature in BOTH features/ and archive/ at once — active and archived.
287
+ // The branch/PR owns the authoritative copy; the root scaffold is transient.
288
+ shedBackup = shedRootFeatureScope(root, feature).shedBackup;
289
+ teardown = await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: false });
290
+ // The shed removed the root's features/{f} scaffold from disk, but runArchive
291
+ // dropped the feature only from the WORKTREE's index (its cwd). Drop it from
292
+ // the ROOT index too — exactly as --merge does — or `doctor`'s lifecycle audit
293
+ // flags a `stale-index` phantom (index lists {f}, no folder on disk).
294
+ dropFromRootIndex(root, feature);
295
+ }
296
+
297
+ const out = {
298
+ finished: true,
299
+ mode: 'pr',
300
+ feature,
301
+ pushed: expectedBranch,
302
+ base: defaultBranch,
303
+ pr: prUrl,
304
+ ...(shedBackup ? { shedBackup } : {}),
305
+ ...(teardown
306
+ ? { worktreeRemoved: teardown.worktreeRemoved, ...teardownExtras(teardown, worktreePath, expectedBranch, false) }
307
+ : { worktreeRemoved: false }),
308
+ };
309
+ emit(out);
310
+ process.exit(0);
311
+ }
312
+
313
+ // ── output helpers ────────────────────────────────────────────────────────
314
+
315
+ function mergeOutput({ feature, branch, worktreePath, teardown, shedBackup }) {
316
+ return {
317
+ finished: true,
318
+ mode: 'merge',
319
+ feature,
320
+ merged: branch,
321
+ worktreeRemoved: teardown.worktreeRemoved,
322
+ ...(shedBackup ? { shedBackup } : {}),
323
+ ...teardownExtras(teardown, worktreePath, branch, true),
324
+ };
325
+ }
326
+
327
+ /** Shared teardown reporting for both modes (kept identical to the old finish). */
328
+ function teardownExtras(teardown, worktreePath, branch, deleteBranch) {
329
+ const out = {};
330
+ if (teardown.ignoredBackup) out.worktreeIgnoredBackup = teardown.ignoredBackup;
331
+ if (teardown.worktreeRemoved && teardown.discarded.length) out.discardedIgnored = teardown.discarded;
332
+ if (!teardown.worktreeRemoved) {
333
+ out.path = worktreePath;
334
+ out.branch = branch;
335
+ if (teardown.blockers.length) out.reparsePoints = teardown.blockers;
336
+ if (teardown.ignoredBlocking.length) out.ignoredFiles = teardown.ignoredBlocking;
337
+ out.cleanupHint = teardown.declineReason;
338
+ out.relinkHint = `as junctions de infra foram soltas antes desta recusa — para continuar no worktree, rode \`morph-spec worktree link --all --target "${worktreePath}"\``;
339
+ }
340
+ return out;
341
+ }
342
+
343
+ // ── small git helpers ────────────────────────────────────────────────────────
344
+
345
+ function currentBranch(cwd) {
346
+ try {
347
+ return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, stdio: 'pipe' }).toString().trim();
348
+ } catch {
349
+ return null;
350
+ }
351
+ }
352
+
353
+ function gitStatusPorcelain(cwd) {
354
+ try {
355
+ return execFileSync('git', ['status', '--porcelain'], { cwd, stdio: 'pipe' }).toString();
356
+ } catch {
357
+ return null;
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Resolve the `gh` invocation. Defaults to the `gh` binary on PATH. Overridable
363
+ * via MORPH_GH_BIN: a plain string names a different binary (a corporate wrapper,
364
+ * `hub`, …); a JSON array is a full argv prefix — e.g.
365
+ * `["/path/to/node","/path/to/gh-shim.js"]` — which is how the tests inject a
366
+ * cross-platform stub that execFileSync can actually spawn (Node ≥20 refuses to
367
+ * run `.cmd`/`.bat` shims without `shell:true`, and shell:true would reopen the
368
+ * arg-injection hole the whole file avoids).
369
+ *
370
+ * @returns {{ bin: string, prefix: string[] }}
371
+ */
372
+ function resolveGh() {
373
+ const raw = process.env.MORPH_GH_BIN;
374
+ if (raw && raw.trim().startsWith('[')) {
375
+ try {
376
+ const arr = JSON.parse(raw);
377
+ if (Array.isArray(arr) && arr.length) return { bin: String(arr[0]), prefix: arr.slice(1).map(String) };
378
+ } catch { /* not JSON — treat as a plain binary name below */ }
379
+ }
380
+ return { bin: raw && raw.trim() ? raw.trim() : 'gh', prefix: [] };
381
+ }
382
+
383
+ function ghAuthOk() {
384
+ const { bin, prefix } = resolveGh();
385
+ try {
386
+ execFileSync(bin, [...prefix, 'auth', 'status'], { stdio: 'pipe' });
387
+ return true;
388
+ } catch {
389
+ return false;
390
+ }
391
+ }
392
+
393
+ function tryGit(args, cwd) {
394
+ try { execFileSync('git', args, { cwd, stdio: 'pipe' }); } catch { /* best-effort */ }
395
+ }
396
+
397
+ function gitErr(err) {
398
+ return (((err.stdout && err.stdout.toString()) || '') + ((err.stderr && err.stderr.toString()) || '')).slice(0, 2000) || (err.message || '').slice(0, 500);
399
+ }
400
+
401
+ function readFeatureJson(cwd, feature) {
402
+ try {
403
+ return JSON.parse(readFileSync(join(cwd, '.morph', 'features', feature, 'feature.json'), 'utf8'));
404
+ } catch {
405
+ return null;
406
+ }
407
+ }
408
+
409
+ /** Drop a feature from the thin, gitignored root index (never via saveState). */
410
+ function dropFromRootIndex(root, feature) {
411
+ const rootPath = join(root, '.morph', 'state.json');
412
+ if (!existsSync(rootPath)) return;
413
+ try {
414
+ const idx = JSON.parse(readFileSync(rootPath, 'utf8'));
415
+ if (idx.features && idx.features[feature]) {
416
+ delete idx.features[feature];
417
+ idx.metadata = idx.metadata || {};
418
+ idx.metadata.totalFeatures = Object.keys(idx.features).length;
419
+ writeFileSync(rootPath, JSON.stringify(idx, null, 2), 'utf8');
420
+ }
421
+ } catch { /* index is derivable — non-fatal */ }
422
+ }
@@ -0,0 +1,110 @@
1
+ import chalk from 'chalk';
2
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { derivePhase } from '../../core/state/state-manager.js';
5
+ import { gateOrderViolations } from '../../core/state/gate-order.js';
6
+ import { missingIdentity } from '../../core/state/feature-identity.js';
7
+
8
+ /**
9
+ * `morph-spec list [--all] [--json]` — the feature audit board.
10
+ *
11
+ * One row per feature: slug × workType × status × phase × gates (G1/G2/G3) ×
12
+ * description, plus a flags column for anomalies (missing description, gate
13
+ * order). Complements `delete`: see what's dead before removing it. Replaces
14
+ * the ad-hoc "loop over every feature.json in python" audit.
15
+ *
16
+ * Read-only by construction — reads feature.json straight off disk (never
17
+ * `loadState`, which mutates), so listing can never resurrect or prune state.
18
+ * Always exits 0 (fail-open — an empty board is an answer).
19
+ */
20
+
21
+ const G = ['proposal', 'uiux', 'plan', 'review'];
22
+
23
+ function readFeaturesIn(baseDir, subdir, location) {
24
+ const root = join(baseDir, '.morph', subdir);
25
+ if (!existsSync(root)) return [];
26
+ const rows = [];
27
+ for (const name of readdirSync(root)) {
28
+ const fdir = join(root, name);
29
+ try {
30
+ if (!statSync(fdir).isDirectory()) continue;
31
+ } catch { continue; }
32
+ const fjPath = join(fdir, 'feature.json');
33
+ if (!existsSync(fjPath)) continue;
34
+ let fj;
35
+ try {
36
+ fj = JSON.parse(readFileSync(fjPath, 'utf8'));
37
+ } catch { fj = { status: 'unknown', __corrupt: true }; }
38
+ rows.push(buildRow(name, fj, fdir, location));
39
+ }
40
+ return rows;
41
+ }
42
+
43
+ function buildRow(name, fj, fdir, location) {
44
+ const gates = {};
45
+ for (const g of G) gates[g] = fj.approvalGates?.[g]?.approved === true;
46
+
47
+ const flags = [];
48
+ if (fj.__corrupt) flags.push('corrupt');
49
+ // Identity + gate-order anomalies come from the shared rules
50
+ // (core/state/feature-identity.js, core/state/gate-order.js) — same source
51
+ // doctor reports from.
52
+ for (const field of missingIdentity(fj)) flags.push(`missing-${field}`);
53
+ for (const v of gateOrderViolations(fj.approvalGates)) flags.push(`gate-order:${v.gate}`);
54
+
55
+ return {
56
+ feature: name,
57
+ name: fj.name || null,
58
+ description: fj.description || null,
59
+ workType: fj.workType || 'feature',
60
+ status: fj.status || 'unknown',
61
+ phase: location === 'archived' ? 'archived' : derivePhase(fdir),
62
+ gates,
63
+ flags,
64
+ location,
65
+ createdAt: fj.createdAt || null,
66
+ updatedAt: fj.updatedAt || null,
67
+ };
68
+ }
69
+
70
+ const mark = (on) => (on ? chalk.green('✓') : chalk.gray('·'));
71
+ const truncate = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
72
+
73
+ export function listCommand(options = {}) {
74
+ const cwd = options.cwd || process.cwd();
75
+ const rows = readFeaturesIn(cwd, 'features', 'active');
76
+ if (options.all || options.archived) rows.push(...readFeaturesIn(cwd, 'archive', 'archived'));
77
+ rows.sort((a, b) => a.feature.localeCompare(b.feature));
78
+
79
+ if (options.json) {
80
+ console.log(JSON.stringify(rows));
81
+ process.exit(0);
82
+ }
83
+
84
+ if (!rows.length) {
85
+ console.log(chalk.gray('Nenhuma feature — .morph/features está vazio.'));
86
+ process.exit(0);
87
+ }
88
+
89
+ const col = (s, w) => String(s ?? '-').padEnd(w);
90
+ console.log(chalk.white.bold(
91
+ col('FEATURE', 22) + col('TYPE', 9) + col('STATUS', 13) + col('PHASE', 11)
92
+ + col('G1', 4) + col('G2', 4) + col('G3', 4) + col('', 3) + 'DESCRIPTION',
93
+ ));
94
+ for (const r of rows) {
95
+ const flag = r.flags.length ? chalk.yellow('⚠') : ' ';
96
+ const desc = r.description ? truncate(r.description, 44) : chalk.yellow('(sem descrição)');
97
+ // padEnd counts the invisible color codes; pad the plain marks manually.
98
+ console.log(
99
+ col(r.feature, 22) + col(r.workType, 9) + col(r.status, 13) + col(r.phase, 11)
100
+ + mark(r.gates.proposal) + ' ' + mark(r.gates.plan) + ' ' + mark(r.gates.review) + ' '
101
+ + flag + ' ' + desc,
102
+ );
103
+ }
104
+ const flagged = rows.filter((r) => r.flags.length);
105
+ if (flagged.length) {
106
+ console.log(chalk.gray(`\n⚠ ${flagged.length} com inconsistência — rode 'morph-spec doctor' para detalhes.`));
107
+ }
108
+ console.log(chalk.gray(`\nG1=proposal · G2=plan · G3=review${options.all || options.archived ? ' · inclui arquivadas' : ''}`));
109
+ process.exit(0);
110
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Feature identity — the fields that keep a feature.json from being an
3
+ * anonymous shell (the {status, updatedAt}-only scaffolds nobody could later
4
+ * identify). name/createdAt/workType are auto-stamped at birth (explicit
5
+ * `morph-spec create` or the state-sync hook's implicit registration);
6
+ * `description` is the one field a human must supply.
7
+ *
8
+ * `missingIdentity` powers the audit surfaces (`list`, `doctor`) that flag
9
+ * pre-existing anonymous features LENIENTLY (warn, never invalidate old
10
+ * feature.json). `create` enforces `description` HARD at birth — the two-tier
11
+ * split the schema's `required` array can't express (adding fields there would
12
+ * invalidate every feature.json that predates this).
13
+ *
14
+ * Pure module — no filesystem/CLI side effects.
15
+ */
16
+
17
+ export const IDENTITY_FIELDS = ['name', 'description', 'workType', 'createdAt'];
18
+
19
+ const blank = (v) => v === undefined || v === null || String(v).trim() === '';
20
+
21
+ /**
22
+ * Identity fields absent from a feature.json (empty array = fully identified).
23
+ * `description` leads because its absence is the real anonymity signal.
24
+ * `workType` is intentionally excluded — absent workType defaults to 'feature',
25
+ * so it is never a marker of an anonymous shell.
26
+ *
27
+ * @param {object} feature - feature.json contents
28
+ * @returns {string[]} missing identity field names
29
+ */
30
+ export function missingIdentity(feature) {
31
+ const missing = [];
32
+ if (blank(feature?.description)) missing.push('description');
33
+ if (blank(feature?.createdAt)) missing.push('createdAt');
34
+ if (blank(feature?.name)) missing.push('name');
35
+ return missing;
36
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Gate monotonicity — single source of truth.
3
+ *
4
+ * A gate may only be approved once its predecessors are approved. The strict
5
+ * chain is proposal → plan → review. uiux is optional: it requires proposal but
6
+ * gates nothing downstream, so a feature that skips UI/UX is never blocked.
7
+ *
8
+ * Policy-stamped gates (approvedBy: "policy:{type}", written at birth by
9
+ * `morph-spec create` for a work type's skippedGates) count as approved, so
10
+ * chore/hotfix (proposal+plan pre-stamped) satisfy prereqs naturally.
11
+ *
12
+ * Pure module — no filesystem/CLI side effects. Consumed by
13
+ * `approve`/`advance` (refuse), the state-sync hook (warn), and `list`/`doctor`
14
+ * (report).
15
+ */
16
+
17
+ export const GATE_PREREQ = {
18
+ proposal: [],
19
+ uiux: ['proposal'],
20
+ plan: ['proposal'],
21
+ review: ['plan'],
22
+ };
23
+
24
+ const isApproved = (gates, name) => gates?.[name]?.approved === true;
25
+
26
+ /**
27
+ * Prerequisite gates NOT yet approved that block approving `gate`.
28
+ * @param {string} gate - gate about to be approved
29
+ * @param {object} gates - feature.approvalGates
30
+ * @returns {string[]} unmet prerequisite gate names (empty = clear to approve)
31
+ */
32
+ export function unmetPrereqs(gate, gates) {
33
+ const prereqs = GATE_PREREQ[gate] || [];
34
+ return prereqs.filter((p) => !isApproved(gates, p));
35
+ }
36
+
37
+ /**
38
+ * All monotonicity violations currently present in a feature's gates.
39
+ * @param {object} gates - feature.approvalGates
40
+ * @returns {Array<{gate: string, missing: string[]}>} empty when in order
41
+ */
42
+ export function gateOrderViolations(gates) {
43
+ if (!gates || typeof gates !== 'object') return [];
44
+ const out = [];
45
+ for (const gate of Object.keys(GATE_PREREQ)) {
46
+ if (!isApproved(gates, gate)) continue;
47
+ const missing = GATE_PREREQ[gate].filter((p) => !isApproved(gates, p));
48
+ if (missing.length) out.push({ gate, missing });
49
+ }
50
+ return out;
51
+ }