@polymorphism-tech/morph-spec 8.3.0 → 8.4.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 (34) hide show
  1. package/README.md +5 -5
  2. package/bin/morph-spec.js +24 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +6 -4
  5. package/docs/QUICKSTART.md +13 -7
  6. package/framework/CLAUDE.md +5 -3
  7. package/framework/MORPH.md +2 -2
  8. package/framework/commands/morph-hotfix.md +1 -1
  9. package/framework/hooks/claude-code/core/state-sync.js +41 -9
  10. package/framework/hooks/shared/state-writer.js +14 -3
  11. package/framework/schemas/feature.schema.json +15 -2
  12. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  13. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  14. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  15. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  16. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  17. package/framework/skills/core/morph-hotfix/SKILL.md +3 -3
  18. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  19. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  20. package/framework/skills/core/morph-proposal/SKILL.md +4 -4
  21. package/framework/skills/core/morph-review/SKILL.md +1 -1
  22. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  23. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  24. package/package.json +1 -1
  25. package/src/commands/project/doctor.js +28 -0
  26. package/src/commands/state/advance.js +17 -0
  27. package/src/commands/state/approve.js +13 -0
  28. package/src/commands/state/create.js +21 -1
  29. package/src/commands/state/delete.js +143 -0
  30. package/src/commands/state/list.js +110 -0
  31. package/src/core/state/feature-identity.js +36 -0
  32. package/src/core/state/gate-order.js +51 -0
  33. package/src/core/state/lifecycle-audit.js +79 -0
  34. package/src/core/state/state-manager.js +47 -12
@@ -14,6 +14,7 @@ import {
14
14
  import { resetMorphSettings } from '../../utils/claude-settings-manager.js';
15
15
  import { checkMissingAgents, checkAgentsDrift } from '../../utils/agents-installer.js';
16
16
  import { getProjectTags } from '../../lib/stack-filter.js';
17
+ import { auditFeatureLifecycle } from '../../core/state/lifecycle-audit.js';
17
18
 
18
19
  const isWindows = platform() === 'win32';
19
20
  /**
@@ -896,6 +897,22 @@ export async function doctorCommand(options = {}) {
896
897
  hasWarnings = true;
897
898
  }
898
899
 
900
+ // Feature-lifecycle consistency — the consolidated inconsistency report:
901
+ // gate-order violations (N approved with N-1 pending), identity-less
902
+ // features (no description), orphan folders and stale index entries. Warn
903
+ // only (advisory); the per-feature detail is printed after the summary.
904
+ const lifecycleIssues = auditFeatureLifecycle(targetPath);
905
+ if (lifecycleIssues.length === 0) {
906
+ checks.push({ name: 'feature lifecycle', status: 'ok' });
907
+ } else {
908
+ checks.push({
909
+ name: 'feature lifecycle',
910
+ status: 'warn',
911
+ msg: `${lifecycleIssues.length} inconsistenc${lifecycleIssues.length === 1 ? 'y' : 'ies'} (see below)`,
912
+ });
913
+ hasWarnings = true;
914
+ }
915
+
899
916
  // Check key templates exist (in framework package, not project copy)
900
917
  const frameworkTemplatesRoot = join(import.meta.dirname, '..', '..', '..', 'framework', 'templates');
901
918
  const keyTemplates = [
@@ -944,6 +961,17 @@ export async function doctorCommand(options = {}) {
944
961
  }
945
962
  }
946
963
 
964
+ // Per-feature detail for the lifecycle inconsistencies (item: doctor lists
965
+ // what's already wrong in the repo, e.g. plan approved with proposal pending).
966
+ if (lifecycleIssues.length) {
967
+ logger.blank();
968
+ console.log(chalk.yellow(' Feature lifecycle inconsistencies:'));
969
+ for (const issue of lifecycleIssues) {
970
+ console.log(chalk.gray(` • ${issue.feature} [${issue.type}] — ${issue.detail}`));
971
+ }
972
+ console.log(chalk.gray(" Fix: 'morph-spec approve' (order), '--description' on create, or 'morph-spec delete' for dead scaffolds."));
973
+ }
974
+
947
975
  logger.blank();
948
976
 
949
977
  if (hasErrors) {
@@ -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,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import { mkdirSync, cpSync, readFileSync, writeFileSync } from 'fs';
2
+ import { mkdirSync, cpSync, readFileSync, writeFileSync, existsSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { createFeature, setApprovalGate, getFeature } from '../../core/state/state-manager.js';
5
5
  import { WORK_TYPES, DEFAULT_WORK_TYPE, POLICY_APPROVER_PREFIX, isValidWorkType, getWorkType } from '../../core/work-types.js';
@@ -98,10 +98,30 @@ export async function createCommand(featureName, options = {}) {
98
98
  const now = new Date().toISOString();
99
99
  const { workType, workTypeDetection } = resolveWorkType(options, now);
100
100
 
101
+ // Identity at birth: every feature is stamped name (slug by default) +
102
+ // description, so it is never an anonymous {status,updatedAt} shell.
103
+ // `--request` (which the router already consumes) doubles as the
104
+ // description seed. A NEW feature with no description source is refused —
105
+ // the description is the one identity field a human must supply. An
106
+ // idempotent re-run on an EXISTING feature never demands it.
107
+ const description = options.description ?? options.request ?? null;
108
+ // Existence check via the filesystem, NOT getFeature/loadState — loadState
109
+ // throws when no state exists yet (a feature can be the first morph action
110
+ // in a fresh project), which would break birth-from-nothing.
111
+ const alreadyExists = existsSync(join(process.cwd(), '.morph', 'features', featureName, 'feature.json'));
112
+ if (!alreadyExists && (description === null || String(description).trim() === '')) {
113
+ console.error(chalk.red(`\n❌ A feature needs a description at birth: "${featureName}"`));
114
+ console.error(chalk.gray(' Pass --description "<what/why>" (or --request "<pedido>", which seeds it).'));
115
+ console.error(chalk.gray(' This keeps features identifiable — no more anonymous scaffolds.\n'));
116
+ process.exit(1);
117
+ }
118
+
101
119
  const { feature, created } = await createFeature(featureName, {
102
120
  status: options.status,
103
121
  workType,
104
122
  workTypeDetection,
123
+ name: options.name,
124
+ description: description ?? undefined,
105
125
  });
106
126
 
107
127
  const featureDir = join(process.cwd(), '.morph', 'features', featureName);
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Feature-lifecycle audit — the consolidated inconsistency scanner behind
3
+ * `morph-spec doctor`. Read-only, non-throwing: scans `.morph/features/*` and
4
+ * the thin root index and reports what's already wrong in the repo, without
5
+ * touching state (never calls loadState — a diagnostic must not mutate).
6
+ *
7
+ * Issue types:
8
+ * • gate-order — a gate approved with a predecessor pending (item 4)
9
+ * • identity — a feature missing description/name/createdAt (item 3)
10
+ * • orphan-folder — a feature folder with no feature.json
11
+ * • stale-index — a root-index entry with no feature folder (auto-heals on
12
+ * the next loadState, but surfaced so it's not a mystery)
13
+ * • corrupt — a feature.json that won't parse
14
+ */
15
+
16
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
17
+ import { join } from 'path';
18
+ import { gateOrderViolations } from './gate-order.js';
19
+ import { missingIdentity } from './feature-identity.js';
20
+
21
+ /**
22
+ * @param {string} [baseDir] - project root (defaults to cwd)
23
+ * @returns {Array<{ feature: string, type: string, detail: string }>} issues
24
+ */
25
+ export function auditFeatureLifecycle(baseDir = process.cwd()) {
26
+ const issues = [];
27
+ const featuresDir = join(baseDir, '.morph', 'features');
28
+ const onDisk = new Set();
29
+
30
+ if (existsSync(featuresDir)) {
31
+ for (const name of readdirSync(featuresDir)) {
32
+ const fdir = join(featuresDir, name);
33
+ try {
34
+ if (!statSync(fdir).isDirectory()) continue;
35
+ } catch { continue; }
36
+
37
+ const fjPath = join(fdir, 'feature.json');
38
+ if (!existsSync(fjPath)) {
39
+ issues.push({ feature: name, type: 'orphan-folder', detail: 'folder has no feature.json' });
40
+ continue;
41
+ }
42
+ onDisk.add(name);
43
+
44
+ let fj;
45
+ try {
46
+ fj = JSON.parse(readFileSync(fjPath, 'utf8'));
47
+ } catch {
48
+ issues.push({ feature: name, type: 'corrupt', detail: 'feature.json is not valid JSON' });
49
+ continue;
50
+ }
51
+
52
+ const miss = missingIdentity(fj);
53
+ if (miss.length) {
54
+ issues.push({ feature: name, type: 'identity', detail: `missing ${miss.join(', ')}` });
55
+ }
56
+ for (const v of gateOrderViolations(fj.approvalGates)) {
57
+ issues.push({
58
+ feature: name,
59
+ type: 'gate-order',
60
+ detail: `${v.gate} approved but ${v.missing.join(', ')} pending`,
61
+ });
62
+ }
63
+ }
64
+ }
65
+
66
+ const rootPath = join(baseDir, '.morph', 'state.json');
67
+ if (existsSync(rootPath)) {
68
+ try {
69
+ const root = JSON.parse(readFileSync(rootPath, 'utf8'));
70
+ for (const name of Object.keys(root.features || {})) {
71
+ if (!onDisk.has(name)) {
72
+ issues.push({ feature: name, type: 'stale-index', detail: 'in state.json index but no feature folder' });
73
+ }
74
+ }
75
+ } catch { /* index is derivable — non-fatal */ }
76
+ }
77
+
78
+ return issues;
79
+ }
@@ -261,6 +261,14 @@ export function loadState(throwOnError = true) {
261
261
  if (fromConfig) state.project = { ...(state.project || {}), ...fromConfig };
262
262
  }
263
263
 
264
+ // Snapshot the on-disk version BEFORE the migration blocks below bump it. The
265
+ // reconciliation loop uses it to tell a legacy monolith (a pre-v5 layout that
266
+ // predates the split — materialize) from a current-version index entry whose
267
+ // folder is gone (a deleted feature — prune). A missing root index is treated
268
+ // as "no migration" (null): the loop is empty in that case (features hydrate
269
+ // from disk), so the value only matters when the root index exists.
270
+ const fileVersion = rootExists ? state.version : null;
271
+
264
272
  // A missing root index means we must (re)generate it from the per-feature
265
273
  // files we're about to hydrate — this is the self-heal path after a wipe.
266
274
  let mutated = !rootExists;
@@ -291,14 +299,36 @@ export function loadState(throwOnError = true) {
291
299
  mutated = true;
292
300
  }
293
301
 
294
- // Split-on-load: any feature still living only in the root index (no
295
- // feature.json yet) is written out to its authoritative per-feature file.
296
- // This migrates pre-split monolithic state and is idempotent thereafter.
302
+ // Reconcile index entries whose feature.json is missing. The discriminator is
303
+ // the ON-DISK VERSION (`fileVersion`, snapshotted before the migrations bumped
304
+ // it) — NOT whether any sibling folder survived. Sibling-existence is the wrong
305
+ // signal: deleting EVERY folder at once (the reported incident) would leave no
306
+ // survivor and resurrect them all. Version is unconditional:
307
+ //
308
+ // • A pre-v5 root (v3/v4) or an entry rich beyond the thin {status,updatedAt}
309
+ // shape = a legacy monolithic state.json that predates the split. It is the
310
+ // sole copy of its feature — materialize each to its authoritative
311
+ // feature.json (split-on-load migration; idempotent thereafter).
312
+ //
313
+ // • A current-version (v5) thin entry with NO folder = the feature was
314
+ // DELETED and this gitignored index entry is stale. Prune it; NEVER recreate
315
+ // the folder — that turned an intentional delete into a zombie. This is safe
316
+ // and exhaustive because saveState ALWAYS co-writes feature.json + a thin
317
+ // index entry: a v5 thin entry with no feature.json can ONLY mean the folder
318
+ // was removed. The prune sticks — saveState below derives the index from this
319
+ // same in-memory map, and hydration only re-adds features whose folder exists.
320
+ const isRichEntry = (f) =>
321
+ f && typeof f === 'object' && Object.keys(f).some((k) => k !== 'status' && k !== 'updatedAt');
322
+ const wasVersionMigration = fileVersion !== null && fileVersion !== STATE_VERSION;
297
323
  for (const [name, feature] of Object.entries(state.features)) {
298
- if (!existsSync(getFeatureJsonPath(name))) {
299
- writeFeatureJson(name, feature);
300
- mutated = true;
324
+ if (existsSync(getFeatureJsonPath(name))) continue;
325
+ if (wasVersionMigration || isRichEntry(feature)) {
326
+ writeFeatureJson(name, feature); // legacy monolith — materialize, don't lose it
327
+ } else {
328
+ delete state.features[name]; // v5 thin entry, no folder = deleted — never resurrect
329
+ console.error(`ℹ morph-spec: dropped stale index entry "${name}" (feature folder was deleted).`);
301
330
  }
331
+ mutated = true;
302
332
  }
303
333
 
304
334
  // Hydrate from per-feature files (authoritative). Also picks up features that
@@ -509,15 +539,18 @@ async function ensureFeature(featureName) {
509
539
  * Works even before any state exists (scaffolds the root index), so a
510
540
  * feature can be born as the very first morph action in a project.
511
541
  *
512
- * @param {string} featureName - Feature name (kebab-case)
513
- * @param {{ status?: string, workType?: string, workTypeDetection?: object }} [options]
514
- * `workType` defaults to 'feature' (v8 routing — see src/core/work-types.js).
515
- * `workTypeDetection` is only written when provided (source/confidence/matched
516
- * audit trail from the `--type`/`--request` resolution in `create.js`).
542
+ * @param {string} featureName - Feature name (kebab-case), also the folder id
543
+ * @param {{ status?: string, workType?: string, workTypeDetection?: object, name?: string, description?: string }} [options]
544
+ * `name` defaults to `featureName` (the slug) — always stamped so a feature
545
+ * is never nameless. `description` is written only when provided (the CLI
546
+ * `create` enforces it; internal callers may omit it). `workType` defaults to
547
+ * 'feature' (v8 routing — see src/core/work-types.js). `workTypeDetection` is
548
+ * only written when provided (source/confidence/matched audit trail from the
549
+ * `--type`/`--request` resolution in `create.js`).
517
550
  * @returns {Promise<{ feature: Object, created: boolean }>}
518
551
  */
519
552
  export async function createFeature(featureName, options = {}) {
520
- const { status = 'in_progress', workType, workTypeDetection } = options;
553
+ const { status = 'in_progress', workType, workTypeDetection, name, description } = options;
521
554
  const state = loadState(false) || scaffoldState();
522
555
 
523
556
  const existing = state.features[featureName];
@@ -527,6 +560,8 @@ export async function createFeature(featureName, options = {}) {
527
560
 
528
561
  const now = new Date().toISOString();
529
562
  state.features[featureName] = {
563
+ name: name || featureName,
564
+ ...(description !== undefined && description !== null ? { description } : {}),
530
565
  status,
531
566
  workType: workType || 'feature',
532
567
  createdAt: now,