@polymorphism-tech/morph-spec 8.4.0 → 8.6.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 +8 -5
  2. package/bin/morph-spec.js +13 -10
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +7 -4
  5. package/docs/QUICKSTART.md +7 -4
  6. package/framework/CLAUDE.md +11 -8
  7. package/framework/MORPH.md +24 -4
  8. package/framework/commands/morph-archive.md +11 -4
  9. package/framework/evals/rubrics/test-coverage.md +4 -1
  10. package/framework/hooks/shared/phase-utils.js +1 -0
  11. package/framework/rules/morph-workflow.md +2 -2
  12. package/framework/schemas/feature.schema.json +2 -2
  13. package/framework/schemas/tasks.schema.json +6 -1
  14. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  15. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  16. package/framework/skills/core/morph-apply/SKILL.md +8 -5
  17. package/framework/skills/core/morph-brainstorming/SKILL.md +4 -3
  18. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  19. package/framework/skills/core/morph-hotfix/SKILL.md +3 -3
  20. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  21. package/framework/skills/core/morph-plan/SKILL.md +83 -13
  22. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  23. package/framework/skills/core/morph-review/SKILL.md +4 -2
  24. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  25. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  26. package/package.json +1 -1
  27. package/src/commands/project/worktree.js +154 -357
  28. package/src/commands/state/archive.js +186 -69
  29. package/src/commands/state/create.js +46 -2
  30. package/src/commands/state/finish.js +433 -0
  31. package/src/commands/tasks/dag.js +25 -2
  32. package/src/commands/validation/validate-feature.js +9 -2
  33. package/src/core/paths/output-schema.js +66 -6
  34. package/src/core/state/state-manager.js +2 -2
  35. package/src/dashboard/scanner/state-scanner.js +1 -1
  36. package/src/lib/agents/output-collector.js +5 -0
  37. package/src/lib/generators/handoff-generator.js +6 -4
  38. package/src/lib/git/default-branch.js +73 -0
  39. package/src/lib/specs/usecase-check.js +173 -0
  40. package/src/lib/specs/usecase-promoter.js +120 -0
  41. package/src/lib/tasks/tasks-json-parser.js +8 -0
  42. package/src/lib/worktree/env-allocator.js +1 -1
  43. package/src/utils/file-copier.js +2 -2
@@ -1,14 +1,20 @@
1
1
  import chalk from 'chalk';
2
2
  import { existsSync, renameSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
3
3
  import { join } from 'path';
4
- import { execSync } from 'child_process';
5
- import { getFeature, getApprovalGate } from '../../core/state/state-manager.js';
4
+ import { execFileSync } from 'child_process';
5
+ import { promoteUseCases, SPECS_USECASES_PATHSPEC } from '../../lib/specs/usecase-promoter.js';
6
6
 
7
7
  /**
8
8
  * `morph-spec archive <feature>` — atomic, git-aware archive of a completed
9
- * feature. Replaces the manual `/morph-archive` mv: it stamps the feature
10
- * completed, moves `.morph/features/{name}/` → `.morph/archive/{name}/`
11
- * (staging the move in git when possible), and cleans the thin root index.
9
+ * feature. Stamps the feature completed, moves `.morph/features/{name}/` →
10
+ * `.morph/archive/{name}/`, COMMITS the move (scoped to the feature's pathspec),
11
+ * and cleans the thin root index.
12
+ *
13
+ * Two callers:
14
+ * - `archiveCommand` (standalone `morph-spec archive` / `/morph-archive`) —
15
+ * archive-without-integrate, for legacy features and cleanup.
16
+ * - `finishCommand` — runs `runArchive` INSIDE the `morph/{feature}` branch so
17
+ * the committed archived state travels with the merge/PR in one integration.
12
18
  *
13
19
  * Taxonomy: archived = `status: "completed"` + `archivedAt` (per the project
14
20
  * decision — no separate "archived" status value).
@@ -16,93 +22,204 @@ import { getFeature, getApprovalGate } from '../../core/state/state-manager.js';
16
22
 
17
23
  function isGitRepo(cwd) {
18
24
  try {
19
- execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'pipe' });
25
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, stdio: 'pipe' });
20
26
  return true;
21
27
  } catch {
22
28
  return false;
23
29
  }
24
30
  }
25
31
 
26
- export async function archiveCommand(featureName, options = {}) {
32
+ /** Tolerant read of the review gate — mirrors the finish path's shape handling. */
33
+ function readReviewApproved(fjPath) {
27
34
  try {
28
- const feature = getFeature(featureName);
29
- if (!feature) {
30
- console.error(chalk.red(`\n❌ Feature not found: ${featureName}\n`));
31
- process.exit(1);
32
- }
35
+ const fj = JSON.parse(readFileSync(fjPath, 'utf8'));
36
+ const gates = fj?.approvalGates || fj?.approvals || {};
37
+ return gates?.review?.approved === true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
33
42
 
34
- // Precondition: Gate 3 (review) approved — archiving is for completed work.
35
- const reviewGate = getApprovalGate(featureName, 'review');
36
- if (!reviewGate?.approved && !options.force) {
37
- console.error(chalk.red(`\n❌ Gate 3 (review) is not approved for "${featureName}".`));
38
- console.log(chalk.gray(`Approve it first (morph-spec approve ${featureName} review) or pass --force.\n`));
39
- process.exit(1);
40
- }
43
+ /**
44
+ * Core archive — NO process.exit. Returns a result the caller reports.
45
+ *
46
+ * Reads/writes everything relative to `cwd` (never a hard-coded process.cwd()),
47
+ * so `finishCommand` can archive inside a worktree's own tree.
48
+ *
49
+ * Idempotent: if `.morph/archive/{feature}` already exists, returns
50
+ * `{ ok: true, alreadyArchived: true }` WITHOUT failing — essential so a re-run
51
+ * of `finish` (which archives inside the branch) does not blow up.
52
+ *
53
+ * @param {string} feature
54
+ * @param {{ cwd?: string, force?: boolean, git?: boolean }} [opts]
55
+ * @returns {{ ok: boolean, error?: string, archived?: boolean, alreadyArchived?: boolean,
56
+ * committed?: boolean, staged?: boolean, archivePath?: string, promotedUseCases?: string[] }}
57
+ */
58
+ export function runArchive(feature, opts = {}) {
59
+ const cwd = opts.cwd || process.cwd();
60
+ const featuresPath = join(cwd, '.morph', 'features', feature);
61
+ const archiveDir = join(cwd, '.morph', 'archive');
62
+ const archivePath = join(archiveDir, feature);
63
+ const featPathspec = join('.morph', 'features', feature);
64
+ const archivePathspec = join('.morph', 'archive', feature);
41
65
 
42
- const cwd = process.cwd();
43
- const featuresPath = join(cwd, '.morph', 'features', featureName);
44
- if (!existsSync(featuresPath)) {
45
- console.error(chalk.red(`\n❌ Feature folder not found: .morph/features/${featureName}\n`));
46
- process.exit(1);
47
- }
48
- const archiveDir = join(cwd, '.morph', 'archive');
49
- const archivePath = join(archiveDir, featureName);
50
- if (existsSync(archivePath)) {
51
- console.error(chalk.red(`\n❌ Already archived: .morph/archive/${featureName}\n`));
52
- process.exit(1);
66
+ // Idempotent short-circuit: already archived here. Checked FIRST so a re-run
67
+ // after a partial finish reports "already done", never "not found".
68
+ //
69
+ // Consequence, deliberate: this short-circuits BEFORE the use-case promotion
70
+ // below, so a promotion that degraded fail-open is NOT retried by re-running
71
+ // `finish`. That is the price of "closing a feature never fails" — an archived
72
+ // feature stays archived, and the only signal is an absent `promotedUseCases`
73
+ // in the output. Recovering means copying the UCs into `.morph/specs/usecases/`
74
+ // by hand; making this retry would mean re-entering the archive path for an
75
+ // already-closed feature, which is a worse trade.
76
+ if (existsSync(archivePath)) {
77
+ return { ok: true, alreadyArchived: true, archived: false, archivePath };
78
+ }
79
+
80
+ if (!existsSync(featuresPath)) {
81
+ return { ok: false, error: 'feature-folder-not-found' };
82
+ }
83
+
84
+ // Precondition: Gate 3 (review) approved — archiving is for completed work.
85
+ // Read from the feature's own folder (pre-move), tolerant of legacy shapes.
86
+ const fjPathBefore = join(featuresPath, 'feature.json');
87
+ if (!opts.force && !readReviewApproved(fjPathBefore)) {
88
+ return { ok: false, error: 'review-not-approved' };
89
+ }
90
+
91
+ const useGit = opts.git !== false && isGitRepo(cwd);
92
+
93
+ // 1) Move the folder. Prefer `git mv` (keeps history + stages in one step);
94
+ // fall back to a filesystem rename (+ git add) when the folder isn't
95
+ // tracked yet or git mv fails.
96
+ mkdirSync(archiveDir, { recursive: true });
97
+ let staged = false;
98
+ if (useGit) {
99
+ try {
100
+ execFileSync('git', ['mv', featuresPath, archivePath], { cwd, stdio: 'pipe' });
101
+ staged = true;
102
+ } catch {
103
+ renameSync(featuresPath, archivePath);
104
+ try {
105
+ execFileSync('git', ['add', '-A', '--', featPathspec, archivePathspec], { cwd, stdio: 'pipe' });
106
+ staged = true;
107
+ } catch { /* not fatal — the move succeeded */ }
53
108
  }
109
+ } else {
110
+ renameSync(featuresPath, archivePath);
111
+ }
54
112
 
55
- // 1) Stamp the authoritative feature.json (in place) as completed + archived.
56
- const fjPath = join(featuresPath, 'feature.json');
57
- if (existsSync(fjPath)) {
58
- const fj = JSON.parse(readFileSync(fjPath, 'utf8'));
113
+ // 2) Stamp the authoritative feature.json (now at the archive path) as
114
+ // completed + archived, and re-stage it so the commit carries the stamp.
115
+ const fjPathAfter = join(archivePath, 'feature.json');
116
+ if (existsSync(fjPathAfter)) {
117
+ try {
118
+ const fj = JSON.parse(readFileSync(fjPathAfter, 'utf8'));
59
119
  fj.status = 'completed';
60
120
  fj.archivedAt = new Date().toISOString();
61
121
  fj.updatedAt = fj.archivedAt;
62
- writeFileSync(fjPath, JSON.stringify(fj, null, 2), 'utf8');
122
+ writeFileSync(fjPathAfter, JSON.stringify(fj, null, 2), 'utf8');
123
+ if (useGit && staged) {
124
+ try { execFileSync('git', ['add', '--', archivePathspec], { cwd, stdio: 'pipe' }); } catch { /* non-fatal */ }
125
+ }
126
+ } catch { /* non-fatal — the move is what matters */ }
127
+ }
128
+
129
+ // 3) Promote the feature's use cases into the project's permanent archive
130
+ // (.morph/specs/usecases/). Read from the ALREADY-MOVED folder so there is
131
+ // a single source path, and land inside the SAME scoped commit below — the
132
+ // archive travels with the integration, exactly like the archived state
133
+ // (MORPH.md §2.2). Fail-open: any failure degrades to "promoted nothing",
134
+ // never to a refused close.
135
+ let promotedUseCases = [];
136
+ try {
137
+ const { promoted } = promoteUseCases(cwd, archivePath);
138
+ promotedUseCases = promoted;
139
+ if (promoted.length && useGit && staged) {
140
+ // This staging exists for the NEW files: promoted use cases are untracked,
141
+ // and the scoped `commit -- <pathspec>` below only knows paths git already
142
+ // tracks. The replaced-by-ID deletions are tracked, so that same commit
143
+ // would carry them even without this line (verified by mutating it away) —
144
+ // `-A` is belt-and-braces, but dropping the `git add` entirely would
145
+ // silently promote nothing on a first delivery.
146
+ try { execFileSync('git', ['add', '-A', '--', SPECS_USECASES_PATHSPEC], { cwd, stdio: 'pipe' }); } catch { /* non-fatal */ }
63
147
  }
148
+ } catch { /* promotion must never block archiving */ }
64
149
 
65
- // 2) Move the folder. Prefer `git mv` (keeps history + stages in one step);
66
- // fall back to a filesystem rename (+ git add) when the folder isn't
67
- // tracked yet or git mv fails.
68
- mkdirSync(archiveDir, { recursive: true });
69
- const useGit = isGitRepo(cwd) && options.git !== false;
70
- let staged = false;
71
- if (useGit) {
72
- try {
73
- execSync(`git mv "${featuresPath}" "${archivePath}"`, { cwd, stdio: 'pipe' });
74
- staged = true;
75
- } catch {
76
- renameSync(featuresPath, archivePath);
77
- try {
78
- execSync(`git add -A "${featuresPath}" "${archivePath}"`, { cwd, stdio: 'pipe' });
79
- staged = true;
80
- } catch { /* not fatal — the move succeeded */ }
150
+ // 4) Commit the move — SCOPED to this feature's pathspec, NEVER `commit -a`
151
+ // (same anti-sweep discipline as `finish`'s worktree teardown). This is what
152
+ // lets the archived state travel with the branch on `finish` (archive-before-merge).
153
+ // The acervo pathspec joins only when something was promoted: git refuses a
154
+ // pathspec that matches nothing, and that refusal would leave the tree dirty
155
+ // and break the checkout/merge that follows.
156
+ let committed = false;
157
+ if (useGit && staged) {
158
+ const pathspecs = promotedUseCases.length
159
+ ? [featPathspec, archivePathspec, SPECS_USECASES_PATHSPEC]
160
+ : [featPathspec, archivePathspec];
161
+ try {
162
+ execFileSync('git', ['commit', '-m', `morph-spec archive: ${feature}`, '--', ...pathspecs], { cwd, stdio: 'pipe' });
163
+ committed = true;
164
+ } catch { /* nothing to commit / no identity — not fatal, the move + stage stand */ }
165
+ }
166
+
167
+ // 5) Drop the feature from the thin root index. Edit the index file directly
168
+ // rather than calling saveState — saveState would re-create
169
+ // .morph/features/{name}/feature.json and undo the archive.
170
+ const rootPath = join(cwd, '.morph', 'state.json');
171
+ if (existsSync(rootPath)) {
172
+ try {
173
+ const root = JSON.parse(readFileSync(rootPath, 'utf8'));
174
+ if (root.features && root.features[feature]) {
175
+ delete root.features[feature];
176
+ root.metadata = root.metadata || {};
177
+ root.metadata.totalFeatures = Object.keys(root.features).length;
178
+ writeFileSync(rootPath, JSON.stringify(root, null, 2), 'utf8');
81
179
  }
82
- } else {
83
- renameSync(featuresPath, archivePath);
180
+ } catch { /* index is derivable — non-fatal */ }
181
+ }
182
+
183
+ return { ok: true, archived: true, committed, staged, archivePath, promotedUseCases };
184
+ }
185
+
186
+ /**
187
+ * Standalone `morph-spec archive <feature>` — thin wrapper over `runArchive`
188
+ * that maps its result to the CLI's messages and exit codes. Preserves the
189
+ * historical behaviour: an already-archived feature is reported as a refusal.
190
+ */
191
+ export async function archiveCommand(featureName, options = {}) {
192
+ try {
193
+ const result = runArchive(featureName, {
194
+ cwd: process.cwd(),
195
+ force: options.force,
196
+ git: options.git,
197
+ });
198
+
199
+ if (!result.ok) {
200
+ if (result.error === 'feature-folder-not-found') {
201
+ console.error(chalk.red(`\n❌ Feature folder not found: .morph/features/${featureName}\n`));
202
+ } else if (result.error === 'review-not-approved') {
203
+ console.error(chalk.red(`\n❌ Gate 3 (review) is not approved for "${featureName}".`));
204
+ console.log(chalk.gray(`Approve it first (morph-spec approve ${featureName} review) or pass --force.\n`));
205
+ } else {
206
+ console.error(chalk.red(`\n❌ Error archiving feature: ${result.error}\n`));
207
+ }
208
+ process.exit(1);
84
209
  }
85
210
 
86
- // 3) Drop the feature from the thin root index. We edit the index file
87
- // directly rather than calling saveState — saveState would re-create
88
- // .morph/features/{name}/feature.json and undo the archive.
89
- const rootPath = join(cwd, '.morph', 'state.json');
90
- if (existsSync(rootPath)) {
91
- try {
92
- const root = JSON.parse(readFileSync(rootPath, 'utf8'));
93
- if (root.features && root.features[featureName]) {
94
- delete root.features[featureName];
95
- root.metadata = root.metadata || {};
96
- root.metadata.totalFeatures = Object.keys(root.features).length;
97
- writeFileSync(rootPath, JSON.stringify(root, null, 2), 'utf8');
98
- }
99
- } catch { /* index is derivable — non-fatal */ }
211
+ if (result.alreadyArchived) {
212
+ console.error(chalk.red(`\n❌ Already archived: .morph/archive/${featureName}\n`));
213
+ process.exit(1);
100
214
  }
101
215
 
102
216
  console.log(chalk.green(`\n📦 Archived "${featureName}" → .morph/archive/${featureName}/`));
103
217
  console.log(chalk.gray(` status: completed, archivedAt stamped in feature.json.`));
104
- if (staged) console.log(chalk.gray(` Move staged in git.\n`));
105
- else if (useGit) console.log(chalk.gray(` Move complete; stage it manually if needed.\n`));
218
+ if (result.promotedUseCases?.length) {
219
+ console.log(chalk.gray(` ${result.promotedUseCases.length} use case(s) promoted → .morph/specs/usecases/ (status: implemented).`));
220
+ }
221
+ if (result.committed) console.log(chalk.gray(` Move committed on the current branch.\n`));
222
+ else if (result.staged) console.log(chalk.gray(` Move staged in git; commit it when ready.\n`));
106
223
  else console.log('');
107
224
  } catch (error) {
108
225
  console.error(chalk.red(`\n❌ Error archiving feature: ${error.message}\n`));
@@ -1,10 +1,45 @@
1
1
  import chalk from 'chalk';
2
+ import { execFileSync } from 'child_process';
2
3
  import { mkdirSync, cpSync, readFileSync, writeFileSync, existsSync } from 'fs';
3
4
  import { join } from 'path';
4
5
  import { createFeature, setApprovalGate, getFeature } from '../../core/state/state-manager.js';
5
6
  import { WORK_TYPES, DEFAULT_WORK_TYPE, POLICY_APPROVER_PREFIX, isValidWorkType, getWorkType } from '../../core/work-types.js';
6
7
  import { detectWorkType } from '../../lib/work-type-detector.js';
7
- import { runWorktreeSetup, materializeState } from '../project/worktree.js';
8
+ import { runWorktreeSetup, materializeState, isGitRepo, branchExists, buildBranchName } from '../project/worktree.js';
9
+ import { detectDefaultBranch } from '../../lib/git/default-branch.js';
10
+
11
+ /**
12
+ * Common flow (no `--worktree`): still isolate the feature on its own branch
13
+ * `morph/{feature}`, branched from the DEFAULT branch (never HEAD — so it never
14
+ * inherits another feature branch's commits) and checked out in place, so
15
+ * `morph-spec finish` can later merge/PR it back. Symmetric with the worktree
16
+ * flow, which gets its branch from `runWorktreeSetup`.
17
+ *
18
+ * Fail-open: outside a git repo, with no resolvable default, or on any git
19
+ * error, the feature is simply left on the current branch — never a failed birth.
20
+ *
21
+ * @param {string} featureName
22
+ * @returns {{ branched: boolean, branch?: string, from?: string, reason?: string }}
23
+ */
24
+ function branchCommonFlow(featureName) {
25
+ const cwd = process.cwd();
26
+ try {
27
+ if (!isGitRepo(cwd)) return { branched: false, reason: 'not-a-git-repo' };
28
+ const branch = buildBranchName(featureName);
29
+ const defaultBranch = detectDefaultBranch(cwd);
30
+ if (!defaultBranch) return { branched: false, reason: 'no-default-branch' };
31
+ if (!branchExists(branch, cwd)) {
32
+ execFileSync('git', ['branch', branch, defaultBranch], { cwd, stdio: 'pipe' });
33
+ }
34
+ const current = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, stdio: 'pipe' }).toString().trim();
35
+ if (current !== branch) {
36
+ execFileSync('git', ['checkout', branch], { cwd, stdio: 'pipe' });
37
+ }
38
+ return { branched: true, branch, from: defaultBranch };
39
+ } catch {
40
+ return { branched: false, reason: 'git-error' };
41
+ }
42
+ }
8
43
 
9
44
  /** Feature names are kebab-case: lowercase alphanumerics and hyphens. */
10
45
  const FEATURE_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
@@ -128,6 +163,7 @@ export async function createCommand(featureName, options = {}) {
128
163
  const wt = getWorkType(feature);
129
164
 
130
165
  let worktreeResult = null;
166
+ let branchResult = null;
131
167
 
132
168
  if (created) {
133
169
  // Scaffold the phase folders this work type starts with.
@@ -158,7 +194,7 @@ export async function createCommand(featureName, options = {}) {
158
194
  // stamped it onto the root's feature.json, which cpSync just copied in).
159
195
  // Strip it from the worktree's branch-local copy so the branch commits a
160
196
  // clean feature.json — otherwise it merges back into the root at
161
- // `worktree finish` and the post-merge cleanup leaves the root dirty.
197
+ // `morph-spec finish` and the post-merge cleanup leaves the root dirty.
162
198
  // verify.decidePersistence reads the registration from the ROOT's copy,
163
199
  // never this branch-local one (see verify.js), so stripping is safe and
164
200
  // restores the invariant that comment relies on.
@@ -183,6 +219,10 @@ export async function createCommand(featureName, options = {}) {
183
219
  } catch { /* fail-open */ }
184
220
  } catch { /* fail-open — the feature already exists in root */ }
185
221
  }
222
+ } else {
223
+ // Common flow: still isolate the feature on its own branch so `finish`
224
+ // can merge/PR it back. Fail-open (see branchCommonFlow).
225
+ branchResult = branchCommonFlow(featureName);
186
226
  }
187
227
  }
188
228
 
@@ -194,6 +234,7 @@ export async function createCommand(featureName, options = {}) {
194
234
  if (options.json) {
195
235
  const out = { feature: featureName, created, ...finalFeature };
196
236
  if (worktreeResult) out.worktree = worktreeResult;
237
+ if (branchResult) out.branch = branchResult;
197
238
  console.log(JSON.stringify(out, null, 2));
198
239
  return;
199
240
  }
@@ -216,6 +257,9 @@ export async function createCommand(featureName, options = {}) {
216
257
  const why = worktreeResult.error || (worktreeResult.alreadyExists ? 'already exists' : 'unknown');
217
258
  console.log(chalk.yellow(` ⚠️ worktree setup skipped (${why}) — feature stays in the root worktree\n`));
218
259
  }
260
+ } else if (branchResult?.branched) {
261
+ console.log(chalk.green(` branch: ${branchResult.branch} (from ${branchResult.from})`));
262
+ console.log(chalk.gray(` close it with: morph-spec finish ${featureName} --pr | --merge\n`));
219
263
  }
220
264
  } else {
221
265
  console.log(chalk.yellow(`\n⚠️ Feature "${featureName}" already exists — left untouched`));