@polymorphism-tech/morph-spec 8.2.1 → 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.
- package/README.md +73 -102
- package/bin/morph-spec.js +36 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +77 -134
- package/docs/QUICKSTART.md +47 -57
- package/framework/CLAUDE.md +6 -3
- package/framework/MORPH.md +2 -2
- package/framework/agents.json +3 -1
- package/framework/commands/morph-hotfix.md +1 -1
- package/framework/hooks/README.md +3 -0
- package/framework/hooks/claude-code/core/state-sync.js +41 -9
- package/framework/hooks/claude-code/core/telemetry-log.js +102 -0
- package/framework/hooks/shared/state-writer.js +14 -3
- package/framework/hooks/shared/telemetry-logger.js +75 -0
- package/framework/rules/ai-agents-standards.md +1 -0
- package/framework/schemas/feature.schema.json +15 -7
- 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 +1 -1
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- 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 +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +4 -4
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +43 -1
- package/framework/standards/ai-agents/observability-patterns.md +156 -0
- package/framework/standards/ai-agents/production.md +3 -47
- package/framework/templates/REGISTRY.json +0 -93
- package/package.json +1 -1
- package/src/commands/observability/telemetry.js +75 -0
- package/src/commands/project/doctor.js +28 -0
- package/src/commands/state/advance.js +17 -0
- package/src/commands/state/approve.js +13 -0
- package/src/commands/state/create.js +21 -1
- package/src/commands/state/delete.js +143 -0
- package/src/commands/state/list.js +110 -0
- package/src/core/state/feature-identity.js +36 -0
- package/src/core/state/gate-order.js +51 -0
- package/src/core/state/lifecycle-audit.js +79 -0
- package/src/core/state/state-manager.js +47 -140
- package/src/dashboard/app/pages/LiveState.jsx +0 -43
- package/src/lib/detectors/design-system-detector.js +0 -43
- package/src/lib/generators/index.js +0 -1
- package/src/lib/telemetry/telemetry-reader.js +103 -0
- package/src/lib/validators/validation-runner.js +9 -73
- package/src/utils/hooks-installer.js +27 -3
- package/framework/templates/context/CONTEXT-FEATURE.md +0 -269
- package/framework/templates/context/CONTEXT.md +0 -181
- package/src/lib/agents/metrics-aggregator.js +0 -56
- package/src/lib/agents/metrics-calculator.js +0 -125
- package/src/lib/generators/metadata-extractor.js +0 -385
|
@@ -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
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
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 (
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
@@ -472,7 +502,6 @@ async function ensureFeature(featureName) {
|
|
|
472
502
|
status: "draft",
|
|
473
503
|
createdAt: new Date().toISOString(),
|
|
474
504
|
updatedAt: new Date().toISOString(),
|
|
475
|
-
activeAgents: [],
|
|
476
505
|
approvalGates: {
|
|
477
506
|
proposal: { approved: false, timestamp: null, approvedBy: null },
|
|
478
507
|
uiux: { approved: false, timestamp: null, approvedBy: null },
|
|
@@ -486,7 +515,6 @@ async function ensureFeature(featureName) {
|
|
|
486
515
|
pending: 0
|
|
487
516
|
},
|
|
488
517
|
checkpoints: [],
|
|
489
|
-
dispatchLog: [],
|
|
490
518
|
trustConfig: {},
|
|
491
519
|
contextBundles: [],
|
|
492
520
|
fileChanges: []
|
|
@@ -511,15 +539,18 @@ async function ensureFeature(featureName) {
|
|
|
511
539
|
* Works even before any state exists (scaffolds the root index), so a
|
|
512
540
|
* feature can be born as the very first morph action in a project.
|
|
513
541
|
*
|
|
514
|
-
* @param {string} featureName - Feature name (kebab-case)
|
|
515
|
-
* @param {{ status?: string, workType?: string, workTypeDetection?: object }} [options]
|
|
516
|
-
* `
|
|
517
|
-
* `
|
|
518
|
-
*
|
|
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`).
|
|
519
550
|
* @returns {Promise<{ feature: Object, created: boolean }>}
|
|
520
551
|
*/
|
|
521
552
|
export async function createFeature(featureName, options = {}) {
|
|
522
|
-
const { status = 'in_progress', workType, workTypeDetection } = options;
|
|
553
|
+
const { status = 'in_progress', workType, workTypeDetection, name, description } = options;
|
|
523
554
|
const state = loadState(false) || scaffoldState();
|
|
524
555
|
|
|
525
556
|
const existing = state.features[featureName];
|
|
@@ -529,11 +560,12 @@ export async function createFeature(featureName, options = {}) {
|
|
|
529
560
|
|
|
530
561
|
const now = new Date().toISOString();
|
|
531
562
|
state.features[featureName] = {
|
|
563
|
+
name: name || featureName,
|
|
564
|
+
...(description !== undefined && description !== null ? { description } : {}),
|
|
532
565
|
status,
|
|
533
566
|
workType: workType || 'feature',
|
|
534
567
|
createdAt: now,
|
|
535
568
|
updatedAt: now,
|
|
536
|
-
activeAgents: [],
|
|
537
569
|
approvalGates: {
|
|
538
570
|
proposal: { approved: false, timestamp: null, approvedBy: null },
|
|
539
571
|
uiux: { approved: false, timestamp: null, approvedBy: null },
|
|
@@ -549,7 +581,6 @@ export async function createFeature(featureName, options = {}) {
|
|
|
549
581
|
pending: 0
|
|
550
582
|
},
|
|
551
583
|
checkpoints: [],
|
|
552
|
-
dispatchLog: [],
|
|
553
584
|
trustConfig: {},
|
|
554
585
|
contextBundles: [],
|
|
555
586
|
fileChanges: []
|
|
@@ -649,73 +680,6 @@ export async function addCheckpoint(featureName, note) {
|
|
|
649
680
|
return checkpoint;
|
|
650
681
|
}
|
|
651
682
|
|
|
652
|
-
/**
|
|
653
|
-
* Add agent to feature
|
|
654
|
-
* @param {string} featureName - Feature name
|
|
655
|
-
* @param {string} agentId - Agent ID
|
|
656
|
-
* @returns {boolean} True if added, false if already exists
|
|
657
|
-
*/
|
|
658
|
-
export async function addAgent(featureName, agentId) {
|
|
659
|
-
await ensureFeature(featureName);
|
|
660
|
-
const state = loadState(); // Load AFTER ensuring feature exists
|
|
661
|
-
|
|
662
|
-
if (!state.features[featureName].activeAgents.includes(agentId)) {
|
|
663
|
-
state.features[featureName].activeAgents.push(agentId);
|
|
664
|
-
state.features[featureName].updatedAt = new Date().toISOString();
|
|
665
|
-
saveState(state);
|
|
666
|
-
return true;
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
return false;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
/**
|
|
673
|
-
* Remove agent from feature
|
|
674
|
-
* @param {string} featureName - Feature name
|
|
675
|
-
* @param {string} agentId - Agent ID
|
|
676
|
-
* @returns {boolean} True if removed, false if not found
|
|
677
|
-
*/
|
|
678
|
-
export function removeAgent(featureName, agentId) {
|
|
679
|
-
const state = loadState();
|
|
680
|
-
|
|
681
|
-
if (!state.features[featureName]) {
|
|
682
|
-
throw new Error(`Feature '${featureName}' not found.`);
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
const index = state.features[featureName].activeAgents.indexOf(agentId);
|
|
686
|
-
if (index > -1) {
|
|
687
|
-
state.features[featureName].activeAgents.splice(index, 1);
|
|
688
|
-
state.features[featureName].updatedAt = new Date().toISOString();
|
|
689
|
-
saveState(state);
|
|
690
|
-
return true;
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
return false;
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
/**
|
|
697
|
-
* Log a dispatch event to state.json.
|
|
698
|
-
* Appends entry to feature.dispatchLog, capped at 50 entries (oldest trimmed).
|
|
699
|
-
*
|
|
700
|
-
* @param {string} featureName - Feature name
|
|
701
|
-
* @param {{ timestamp: string, phase: string, mode: string, agents: Array<{ id: string, subagentType: string, group: string, tasks: string[] }> }} entry - Dispatch event to log
|
|
702
|
-
*/
|
|
703
|
-
export function logDispatch(featureName, entry) {
|
|
704
|
-
const state = loadState();
|
|
705
|
-
const feature = state.features?.[featureName];
|
|
706
|
-
if (!feature) return;
|
|
707
|
-
|
|
708
|
-
if (!Array.isArray(feature.dispatchLog)) feature.dispatchLog = [];
|
|
709
|
-
feature.dispatchLog.push(entry);
|
|
710
|
-
|
|
711
|
-
// Cap at 50 entries
|
|
712
|
-
if (feature.dispatchLog.length > 50) {
|
|
713
|
-
feature.dispatchLog = feature.dispatchLog.slice(-50);
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
saveState(state);
|
|
717
|
-
}
|
|
718
|
-
|
|
719
683
|
/**
|
|
720
684
|
* Normalize output type from kebab-case to camelCase (BUG-002 fix - Enhanced)
|
|
721
685
|
* Supports both kebab-case and camelCase input formats for all output types
|
|
@@ -1103,60 +1067,3 @@ export function getApprovalHistory(featureName) {
|
|
|
1103
1067
|
// Sort by timestamp
|
|
1104
1068
|
return history.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
|
1105
1069
|
}
|
|
1106
|
-
|
|
1107
|
-
/**
|
|
1108
|
-
* Find which agent was assigned a specific task in the latest dispatch.
|
|
1109
|
-
* Searches only the last dispatchLog entry.
|
|
1110
|
-
*
|
|
1111
|
-
* @param {string} featureName
|
|
1112
|
-
* @param {string} taskId
|
|
1113
|
-
* @returns {string|null} Agent ID or null if not found
|
|
1114
|
-
*/
|
|
1115
|
-
export function resolveAgentForTask(featureName, taskId) {
|
|
1116
|
-
const state = loadState();
|
|
1117
|
-
const feature = state.features?.[featureName];
|
|
1118
|
-
if (!feature) return null;
|
|
1119
|
-
|
|
1120
|
-
const log = feature.dispatchLog;
|
|
1121
|
-
if (!Array.isArray(log) || log.length === 0) return null;
|
|
1122
|
-
|
|
1123
|
-
const latest = log[log.length - 1];
|
|
1124
|
-
for (const agent of latest.agents || []) {
|
|
1125
|
-
if (Array.isArray(agent.tasks) && agent.tasks.includes(taskId)) {
|
|
1126
|
-
return agent.id;
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
return null;
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
/**
|
|
1133
|
-
* Update timing fields on a specific agent in the latest dispatch entry.
|
|
1134
|
-
* Fail-open: returns silently if feature/dispatch/agent not found.
|
|
1135
|
-
*
|
|
1136
|
-
* @param {string} featureName
|
|
1137
|
-
* @param {string} agentId - Agent ID to update
|
|
1138
|
-
* @param {Object} updates - Fields to merge (startedAt, completedAt, tasksCompleted)
|
|
1139
|
-
*/
|
|
1140
|
-
export function updateDispatchAgentTiming(featureName, agentId, updates) {
|
|
1141
|
-
const state = loadState();
|
|
1142
|
-
const feature = state.features?.[featureName];
|
|
1143
|
-
if (!feature) return;
|
|
1144
|
-
|
|
1145
|
-
const log = feature.dispatchLog;
|
|
1146
|
-
if (!Array.isArray(log) || log.length === 0) return;
|
|
1147
|
-
|
|
1148
|
-
const latest = log[log.length - 1];
|
|
1149
|
-
const agent = (latest.agents || []).find(a => a.id === agentId);
|
|
1150
|
-
if (!agent) return;
|
|
1151
|
-
|
|
1152
|
-
Object.assign(agent, updates);
|
|
1153
|
-
|
|
1154
|
-
// If all agents now have completedAt, set entry.completedAt to the latest
|
|
1155
|
-
const allComplete = (latest.agents || []).every(a => a.completedAt);
|
|
1156
|
-
if (allComplete && latest.agents.length > 0) {
|
|
1157
|
-
const timestamps = latest.agents.map(a => new Date(a.completedAt).getTime());
|
|
1158
|
-
latest.completedAt = new Date(Math.max(...timestamps)).toISOString();
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
saveState(state);
|
|
1162
|
-
}
|