@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.
- package/README.md +11 -8
- package/bin/morph-spec.js +37 -11
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +12 -7
- package/docs/QUICKSTART.md +19 -10
- package/framework/CLAUDE.md +9 -6
- package/framework/MORPH.md +5 -3
- package/framework/commands/morph-archive.md +11 -4
- package/framework/commands/morph-hotfix.md +1 -1
- package/framework/hooks/claude-code/core/state-sync.js +41 -9
- package/framework/hooks/shared/state-writer.js +14 -3
- package/framework/schemas/feature.schema.json +17 -4
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +8 -5
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +5 -5
- 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 +4 -2
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/doctor.js +28 -0
- package/src/commands/project/worktree.js +154 -357
- package/src/commands/state/advance.js +17 -0
- package/src/commands/state/approve.js +13 -0
- package/src/commands/state/archive.js +148 -70
- package/src/commands/state/create.js +67 -3
- package/src/commands/state/delete.js +143 -0
- package/src/commands/state/finish.js +422 -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 +49 -14
- package/src/dashboard/scanner/state-scanner.js +1 -1
- package/src/lib/generators/handoff-generator.js +2 -2
- package/src/lib/git/default-branch.js +73 -0
- package/src/lib/worktree/env-allocator.js +1 -1
- package/src/utils/file-copier.js +2 -2
|
@@ -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
|
+
}
|
|
@@ -123,7 +123,7 @@ export function atomicWriteJson(targetPath, data) {
|
|
|
123
123
|
* A formatting mismatch is NOT free, though: the guard compares bytes, so a
|
|
124
124
|
* sibling committed with a trailing newline fails it and gets rewritten. The
|
|
125
125
|
* content is equivalent, but git sees ` M` on a file this caller never meant to
|
|
126
|
-
* touch — enough to fail a whole-tree clean check (see
|
|
126
|
+
* touch — enough to fail a whole-tree clean check (see the finish teardown's clearRootWorktreeRegistration).
|
|
127
127
|
* And the guard only skips IDENTICAL content: a concurrent instance's CHANGED
|
|
128
128
|
* write to a sibling would still be clobbered by the sweep.
|
|
129
129
|
*/
|
|
@@ -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
|
|
@@ -343,7 +373,7 @@ export function saveState(state) {
|
|
|
343
373
|
// and shows up as ` M` — dirtying the tree on behalf of a feature the
|
|
344
374
|
// caller never touched. Callers that need to change ONE feature's field
|
|
345
375
|
// should write that feature.json directly instead of round-tripping state
|
|
346
|
-
// through here (see
|
|
376
|
+
// through here (see the finish teardown's clearRootWorktreeRegistration).
|
|
347
377
|
const thinFeatures = {};
|
|
348
378
|
for (const [name, feature] of Object.entries(features)) {
|
|
349
379
|
writeFeatureJson(name, feature);
|
|
@@ -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
|
-
* `
|
|
515
|
-
* `
|
|
516
|
-
*
|
|
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,
|
|
@@ -35,7 +35,7 @@ export function scanState(projectDir) {
|
|
|
35
35
|
// (.worktrees/{wt}/.morph/features/*/feature.json). In the v8 multi-instance
|
|
36
36
|
// model a feature's authoritative feature.json is committed on its
|
|
37
37
|
// `morph/{feature}` branch and only lands in the root .morph/features/ after
|
|
38
|
-
// `
|
|
38
|
+
// `morph-spec finish`. This is read-only visibility for the dashboard — root
|
|
39
39
|
// features (the merged, authoritative state) always take precedence, and
|
|
40
40
|
// worktree-sourced entries are tagged with `_worktree` so the UI can
|
|
41
41
|
// distinguish them.
|
|
@@ -111,8 +111,8 @@ function nextAction(feature, phase, tasks, featureName) {
|
|
|
111
111
|
}
|
|
112
112
|
case 'review':
|
|
113
113
|
return approved('review')
|
|
114
|
-
? `Gate 3 aprovado — feche: \`morph-spec
|
|
115
|
-
: `Produza \`4-review/review-report.md\` (avaliador independente) e aprove o Gate 3; depois \`
|
|
114
|
+
? `Gate 3 aprovado — feche: \`morph-spec finish ${featureName} --pr\` (abre PR) ou \`--merge\` (merge local na default). Arquiva + integra num passo.`
|
|
115
|
+
: `Produza \`4-review/review-report.md\` (avaliador independente) e aprove o Gate 3; depois \`morph-spec finish ${featureName} --pr|--merge\`.`;
|
|
116
116
|
default:
|
|
117
117
|
return `Rode \`morph-spec status ${featureName}\` para orientar o próximo passo.`;
|
|
118
118
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Git helpers shared by `create` (which branches `morph/{f}` off the default)
|
|
5
|
+
* and `finish` (which merges/PRs back into it). Kept tiny and pure so both
|
|
6
|
+
* call sites read the same source of truth for "what is the default branch"
|
|
7
|
+
* and "is there a remote to push to".
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Does a local branch exist? execFile (argv, never a cmd.exe string) so the
|
|
12
|
+
* name reaches git as one token — the same anti-injection discipline the rest
|
|
13
|
+
* of the worktree/finish path uses.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} branch
|
|
16
|
+
* @param {string} cwd
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
function localBranchExists(branch, cwd) {
|
|
20
|
+
try {
|
|
21
|
+
execFileSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { cwd, stdio: 'pipe' });
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the repository's default branch NAME (e.g. `main`), never assuming
|
|
30
|
+
* `master`. Strategy, in order:
|
|
31
|
+
* 1. `refs/remotes/origin/HEAD` symbolic ref — the remote's declared default,
|
|
32
|
+
* the authoritative answer when a remote is configured.
|
|
33
|
+
* 2. A local `main`, then `master` — the fallback for a repo with no remote
|
|
34
|
+
* (or one whose `origin/HEAD` was never set).
|
|
35
|
+
*
|
|
36
|
+
* Returns `null` when none of these resolve (a repo whose default is some other
|
|
37
|
+
* name, with no `origin/HEAD` set) — the caller decides: `create` fails open
|
|
38
|
+
* (no branch), `finish` refuses with a clear message.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} [cwd]
|
|
41
|
+
* @returns {string|null} the default branch name, or null if undeterminable
|
|
42
|
+
*/
|
|
43
|
+
export function detectDefaultBranch(cwd = process.cwd()) {
|
|
44
|
+
try {
|
|
45
|
+
const ref = execFileSync('git', ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { cwd, stdio: 'pipe' })
|
|
46
|
+
.toString()
|
|
47
|
+
.trim();
|
|
48
|
+
// e.g. "refs/remotes/origin/main" → "main"
|
|
49
|
+
const m = ref.match(/^refs\/remotes\/origin\/(.+)$/);
|
|
50
|
+
if (m && m[1]) return m[1];
|
|
51
|
+
} catch { /* no origin/HEAD — fall back to a local default */ }
|
|
52
|
+
|
|
53
|
+
for (const candidate of ['main', 'master']) {
|
|
54
|
+
if (localBranchExists(candidate, cwd)) return candidate;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Is an `origin` remote configured? `--pr` needs one to push the feature branch;
|
|
61
|
+
* `--merge` is purely local and never calls this.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} [cwd]
|
|
64
|
+
* @returns {boolean}
|
|
65
|
+
*/
|
|
66
|
+
export function hasOriginRemote(cwd = process.cwd()) {
|
|
67
|
+
try {
|
|
68
|
+
const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, stdio: 'pipe' }).toString().trim();
|
|
69
|
+
return url.length > 0;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* worktree of the repo shares.
|
|
15
15
|
*
|
|
16
16
|
* Allocation is deterministic (first free index), idempotent per feature, and
|
|
17
|
-
* released only when `
|
|
17
|
+
* released only when `morph-spec finish` actually removed the worktree. Writes
|
|
18
18
|
* are tmp+rename so a crash never leaves a torn registry; a corrupt registry
|
|
19
19
|
* is treated as empty (fail-open — the cost is a re-allocation, never a crash).
|
|
20
20
|
*/
|
package/src/utils/file-copier.js
CHANGED
|
@@ -225,7 +225,7 @@ export async function updateGitignore(projectPath) {
|
|
|
225
225
|
* Windows with `core.autocrlf=true` (the Git-for-Windows default) and no
|
|
226
226
|
* attribute pinning EOL, Git flags EVERY rewritten feature.json as modified
|
|
227
227
|
* even when the content is byte-identical. That silently breaks the whole-root
|
|
228
|
-
* clean-check in `morph-spec
|
|
228
|
+
* clean-check in `morph-spec finish` as soon as a project has more
|
|
229
229
|
* than one feature — precisely the multi-instance / parallel-worktree flow,
|
|
230
230
|
* where one `saveState` touches sibling feature.json files and marks them dirty.
|
|
231
231
|
* This mirrors the .gitattributes this framework repo already carries for its
|
|
@@ -242,7 +242,7 @@ export async function ensureGitattributes(projectPath) {
|
|
|
242
242
|
`${MARKER} — LF-pin morph state so core.autocrlf never churns it`,
|
|
243
243
|
'# (feature.json is committed + rewritten by saveState with LF; without this,',
|
|
244
244
|
'# Windows autocrlf marks every feature.json dirty and breaks the whole-root',
|
|
245
|
-
'# clean-check in `morph-spec
|
|
245
|
+
'# clean-check in `morph-spec finish`).',
|
|
246
246
|
'.morph/** text=auto eol=lf',
|
|
247
247
|
];
|
|
248
248
|
|