@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,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 worktreeFinishCommand).
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
- // 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
@@ -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 worktreeFinishCommand's registration cleanup).
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
- * `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,
@@ -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
- // `worktree finish`. This is read-only visibility for the dashboard — root
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 worktree finish ${featureName}\` e depois \`morph-spec archive ${featureName}\` na raiz.`
115
- : `Produza \`4-review/review-report.md\` (avaliador independente) e aprove o Gate 3; depois \`worktree finish\` + \`archive\`.`;
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 `worktree finish` actually removed the worktree. Writes
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
  */
@@ -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 worktree finish` as soon as a project has more
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 worktree finish`).',
245
+ '# clean-check in `morph-spec finish`).',
246
246
  '.morph/** text=auto eol=lf',
247
247
  ];
248
248