@polymorphism-tech/morph-spec 8.27.0 → 8.29.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 (55) hide show
  1. package/CLAUDE.md +1 -0
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +9 -4
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +3 -2
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/MORPH.md +8 -8
  8. package/framework/agents.json +14 -3
  9. package/framework/commands/create-umbraco-lp.md +1 -1
  10. package/framework/docs/CLI.md +6 -5
  11. package/framework/mcp/registry.json +59 -8
  12. package/framework/skills/core/create-umbraco-lp/SKILL.md +13 -2
  13. package/framework/skills/core/create-umbraco-lp/references/umbraco-lp-patterns.md +598 -0
  14. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +92 -7
  15. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  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 +1 -1
  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 +1 -1
  22. package/framework/skills/core/morph-review/SKILL.md +1 -1
  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/framework/skills/core/morph-uiux/SKILL.md +1 -1
  26. package/framework/standards/PROVENANCE.json +41 -0
  27. package/framework/standards/STANDARDS.json +218 -7
  28. package/framework/standards/frontend/design-system/ai-image-generation.md +43 -0
  29. package/framework/standards/frontend/design-system/ai-video-generation.md +113 -0
  30. package/framework/standards/frontend/design-system/premium-finish.md +160 -0
  31. package/framework/standards/frontend/gsap/gsap-core.md +1 -1
  32. package/framework/standards/frontend/gsap/gsap-plugins.md +1 -1
  33. package/framework/standards/frontend/gsap/gsap-scrolltrigger.md +3 -1
  34. package/framework/standards/frontend/nextjs/motion-patterns.md +3 -1
  35. package/framework/standards/frontend/scroll-driven/frame-scrub.md +287 -0
  36. package/framework/standards/frontend/scroll-driven/scroll-components.md +256 -0
  37. package/framework/standards/frontend/scroll-driven/smooth-scroll.md +148 -0
  38. package/framework/standards/frontend/seo/meta-schema-audit.md +134 -0
  39. package/package.json +1 -1
  40. package/src/commands/project/doctor.js +9 -4
  41. package/src/commands/project/worktree.js +45 -3
  42. package/src/commands/state/advance.js +12 -0
  43. package/src/commands/state/approve.js +20 -0
  44. package/src/commands/state/create.js +4 -1
  45. package/src/commands/state/delete.js +63 -4
  46. package/src/commands/state/finish.js +20 -2
  47. package/src/commands/state/gate-check.js +13 -0
  48. package/src/commands/state/gate-decision.js +9 -0
  49. package/src/commands/state/handoff.js +48 -6
  50. package/src/commands/state/list.js +124 -10
  51. package/src/commands/state/score.js +9 -0
  52. package/src/lib/generators/handoff-generator.js +11 -4
  53. package/src/lib/worktree/feature-truth.js +248 -0
  54. package/src/lib/worktree/fleet-scanner.js +1 -65
  55. package/src/lib/worktree/worktree-audit.js +103 -17
@@ -0,0 +1,134 @@
1
+ # SEO, Schema and Launch Audit
2
+
3
+ > **Scope:** frontend-seo
4
+ > **Layer:** 3
5
+ > **Keywords:** seo, meta tags, open graph, schema.org, json-ld, rich results, lighthouse, accessibility, go-live audit
6
+ > **Load When:** any public-facing page is being built or is about to go live
7
+
8
+ **Verified against:** Schema.org vocabulary + Lighthouse via Chrome DevTools MCP, shipped to production. Last-verified: 2026-08-21.
9
+
10
+ ---
11
+
12
+ A page that cannot be found and does not share well is half delivered. This layer is cheap (almost all of it lives in `<head>`) and pays for itself. Covers the essential meta set, Open Graph, JSON-LD by business type, the accessibility items that also affect ranking, and the audit to run before going live.
13
+
14
+ ---
15
+
16
+ ## Section 1: Essential Meta
17
+
18
+ ```html
19
+ <title>{Brand} · {short value proposition in {city}}</title>
20
+ <meta name="description" content="{1-2 sentences: what it does, where, and a differentiator or proof}" />
21
+ <link rel="canonical" href="https://{domain}/" />
22
+ <meta name="theme-color" content="{brand background color}" />
23
+ <link rel="icon" type="image/png" href="/img/{favicon}.png" />
24
+
25
+ <!-- font preconnect: measurable performance win -->
26
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
27
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
28
+ ```
29
+
30
+ - **`title`**: brand plus proposition plus place, when it is a local business. Do not stuff keywords.
31
+ - **`description`**: what it sells, to whom, where, plus a proof hook (rating, years, differentiator).
32
+ - **`canonical`**: always present, to avoid duplicate-URL dilution.
33
+ - **`lang`**: set the real locale on `<html>` (for example `lang="pt-BR"`).
34
+
35
+ ---
36
+
37
+ ## Section 2: Open Graph and Twitter Cards
38
+
39
+ Without these, a link pasted into WhatsApp, Instagram or LinkedIn renders as a bare, ugly card. With them, it renders as an image card.
40
+
41
+ ```html
42
+ <meta property="og:type" content="website" />
43
+ <meta property="og:locale" content="pt_BR" />
44
+ <meta property="og:site_name" content="{Brand}" />
45
+ <meta property="og:title" content="{share title, can be more emotional than the SEO title}" />
46
+ <meta property="og:description" content="{one sentence}" />
47
+ <meta property="og:url" content="https://{domain}/" />
48
+ <meta property="og:image" content="https://{domain}/img/{share-image}.jpg" />
49
+ <meta name="twitter:card" content="summary_large_image" />
50
+ ```
51
+
52
+ `og:image` should be 1200x630 and must use an absolute URL. Use the strongest image on the page.
53
+
54
+ ---
55
+
56
+ ## Section 3: Schema.org (JSON-LD) by Business Type
57
+
58
+ Structured data is what earns rich results (stars, address, opening hours). Pick `@type` from the business:
59
+
60
+ | Business | `@type` |
61
+ |---|---|
62
+ | Retail, fashion, tailoring | `ClothingStore` or `Store` |
63
+ | Local service (clinic, studio, workshop) | `LocalBusiness` or a specific subtype |
64
+ | Property developer, real estate | `RealEstateAgent`, or `Residence` / `Apartment` |
65
+ | Restaurant, food | `Restaurant` |
66
+ | SaaS, software | `SoftwareApplication` or `Organization` |
67
+ | Independent professional | `ProfessionalService` |
68
+
69
+ ```html
70
+ <script type="application/ld+json">
71
+ {
72
+ "@context": "https://schema.org",
73
+ "@type": "ClothingStore",
74
+ "name": "{Brand}",
75
+ "image": "https://{domain}/img/{hero}.jpg",
76
+ "description": "{description}",
77
+ "address": {
78
+ "@type": "PostalAddress",
79
+ "streetAddress": "{street, number}", "addressLocality": "{city}",
80
+ "addressRegion": "{state}", "postalCode": "{zip}", "addressCountry": "BR"
81
+ },
82
+ "telephone": "+55-XX-XXXXX-XXXX",
83
+ "url": "https://{domain}/",
84
+ "aggregateRating": { "@type": "AggregateRating", "ratingValue": "4.9", "reviewCount": "74" },
85
+ "openingHours": "Mo-Fr 09:00-18:00"
86
+ }
87
+ </script>
88
+ ```
89
+
90
+ **Include only true fields.** `aggregateRating` goes in only with a real number from a real source (for example the client's Google rating and review count). Never invent a rating. With no real data, omit the block entirely. Fabricated structured data is both a trust problem and a manual-action risk.
91
+
92
+ ---
93
+
94
+ ## Section 4: Accessibility (Also An SEO Signal)
95
+
96
+ - `lang` set correctly on `<html>`.
97
+ - Descriptive `alt` on every content image, including video posters.
98
+ - Sufficient contrast for text over media. Check against the brightest frames, not the average. See `frontend/design-system/premium-finish.md`.
99
+ - Logical focus order, visible `:focus-visible`.
100
+ - Overlays carry `role`, `aria-modal`, and close on Escape.
101
+ - `prefers-reduced-motion` honored: scroll-driven effects do not mount.
102
+ - Coherent heading hierarchy: exactly one `<h1>`, then `<h2>` / `<h3>`.
103
+
104
+ ---
105
+
106
+ ## Section 5: Pre-Launch Audit
107
+
108
+ "Premium" is confirmed by measurement, not by vibe. Run this before the go-live gate and fix what hurts:
109
+
110
+ | Check | How | Pass condition |
111
+ |---|---|---|
112
+ | Lighthouse | Chrome DevTools MCP `lighthouse_audit` | Performance, SEO, Best Practices, Accessibility reviewed and explained |
113
+ | Console | Desktop and mobile viewport | Zero errors |
114
+ | Share card | Paste the URL into an OG validator or a test chat | Image card renders with the right title |
115
+ | Technical SEO | `seo-analyzer` pass over meta, headings, links | No missing or duplicated essentials |
116
+ | Cross-device | Desktop, tablet, mobile; Safari or iOS included | Layout holds, motion fallbacks fire |
117
+
118
+ **On Performance specifically:** a heavy frame sequence (see `frontend/scroll-driven/frame-scrub.md`) will cost Performance points. That is an accepted, deliberate trade. What must be confirmed is that the cost sits in the scrub and not in unoptimized images, dead JS, or missing compression. State the trade explicitly rather than letting a low score pass unexamined.
119
+
120
+ Record the audited viewports and the Lighthouse result alongside the go-live decision.
121
+
122
+ ---
123
+
124
+ ## Checklist
125
+
126
+ - [ ] Title, description, canonical, theme-color, favicon in place
127
+ - [ ] `lang` correct on `<html>`
128
+ - [ ] Font preconnects present
129
+ - [ ] Full Open Graph set with an absolute 1200x630 `og:image`
130
+ - [ ] JSON-LD present, `@type` matched to the business, every field true
131
+ - [ ] `aggregateRating` backed by real data, or omitted
132
+ - [ ] Alt text, focus order, heading hierarchy, reduced-motion verified
133
+ - [ ] Lighthouse run; any Performance cost traced to a deliberate trade
134
+ - [ ] Console clean, share card verified, cross-device pass recorded
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "8.27.0",
3
+ "version": "8.29.0",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -564,10 +564,15 @@ export async function doctorWorktreesCommand(targetPath, options = {}) {
564
564
  const { auditWorktrees, pruneWorktrees } = await import('../../lib/worktree/worktree-audit.js');
565
565
 
566
566
  if (options.prune) {
567
- const before = auditWorktrees(targetPath).filter((i) => i.type === 'registered-missing').length;
568
- const result = pruneWorktrees(targetPath);
569
- if (result.pruned) logger.success(`git worktree prune: ${before} registro(s) órfão(s) removido(s).`);
567
+ const beforeIssues = auditWorktrees(targetPath);
568
+ const beforeMissing = beforeIssues.filter((i) => i.type === 'registered-missing').length;
569
+ const beforeDangling = beforeIssues.filter((i) => i.type === 'dangling-registration').length;
570
+ const result = await pruneWorktrees(targetPath);
571
+ if (result.pruned) logger.success(`git worktree prune: ${beforeMissing} registro(s) órfão(s) removido(s).`);
570
572
  else logger.warn(`prune falhou: ${result.error}`);
573
+ if (result.clearedRegistrations.length) {
574
+ logger.success(`registro de worktree limpo em ${result.clearedRegistrations.length}/${beforeDangling} feature(s): ${result.clearedRegistrations.map((r) => r.feature).join(', ')}`);
575
+ }
571
576
  }
572
577
 
573
578
  const issues = auditWorktrees(targetPath);
@@ -589,7 +594,7 @@ export async function doctorWorktreesCommand(targetPath, options = {}) {
589
594
  console.log(` ${i.detail}`);
590
595
  console.log(` → ${i.fix}`);
591
596
  }
592
- if (issues.some((i) => i.type === 'registered-missing') && !options.prune) {
597
+ if (issues.some((i) => i.type === 'registered-missing' || i.type === 'dangling-registration') && !options.prune) {
593
598
  logger.dim('\n Rode `morph-spec doctor --worktrees --prune` para limpar os registros órfãos.');
594
599
  }
595
600
  return false;
@@ -443,7 +443,13 @@ export async function runWorktreeSetup(feature, options = {}) {
443
443
  const normalizedExisting = existingPath.replace(/\\/g, '/');
444
444
  const normalizedNew = worktreePath.replace(/\\/g, '/');
445
445
  if (normalizedExisting !== normalizedNew && existsSync(existingPath)) {
446
- return { ok: false, exitCode: 3, created: false, error: 'worktree-exists-elsewhere', existingPath, feature };
446
+ return {
447
+ ok: false, exitCode: 3, created: false, error: 'worktree-exists-elsewhere', existingPath, feature,
448
+ hint: `A different live worktree is already registered for "${feature}". `
449
+ + `If it's stale (removed by hand, or by \`git worktree prune\` directly, outside \`morph-spec finish\`/\`worktree remove\`), `
450
+ + `run \`morph-spec doctor --worktrees --prune\` to clear the registration, or `
451
+ + `\`morph-spec worktree remove --target "${existingPath}"\` if the directory is still there but unwanted.`,
452
+ };
447
453
  }
448
454
  }
449
455
  }
@@ -519,7 +525,10 @@ export async function runWorktreeSetup(feature, options = {}) {
519
525
  try {
520
526
  if (existsSync(join(worktreePath, '.morph', 'features', feature, 'feature.json'))) {
521
527
  const { writeHandoff } = await import('../../lib/generators/handoff-generator.js');
522
- writeHandoff(feature, worktreePath);
528
+ // `worktree` is ROOT-OWNED bookkeeping, absent from this branch-local
529
+ // copy by contract — pass it explicitly so the doc built FROM INSIDE
530
+ // the worktree still says "Local: worktree", not "raiz primária".
531
+ writeHandoff(feature, worktreePath, { worktree: { path: worktreePath, branch } });
523
532
  }
524
533
  } catch { /* fail-open */ }
525
534
 
@@ -1018,7 +1027,7 @@ export function primaryRootUnexplainedDirt(root, feature, opts = {}) {
1018
1027
  * @param {string} root
1019
1028
  * @param {string} feature
1020
1029
  */
1021
- async function clearRootWorktreeRegistration(root, feature) {
1030
+ export async function clearRootWorktreeRegistration(root, feature) {
1022
1031
  const featPathspec = `.morph/features/${feature}`;
1023
1032
  try {
1024
1033
  const rootFjPath = join(root, '.morph', 'features', feature, 'feature.json');
@@ -1044,6 +1053,39 @@ async function clearRootWorktreeRegistration(root, feature) {
1044
1053
  } catch { /* fail-open — a dirty root is a nuisance, not a reason to fail */ }
1045
1054
  }
1046
1055
 
1056
+ /**
1057
+ * Clear a feature's `worktree` registration ONLY when it's dangling — the
1058
+ * path it names no longer exists on disk (the directory was removed outside
1059
+ * `morph-spec finish`/`worktree remove`: by hand, by `git worktree prune`, or
1060
+ * by any tool that doesn't know about this pointer).
1061
+ *
1062
+ * Deliberately narrower than `clearRootWorktreeRegistration`: a registration
1063
+ * naming a worktree that's still ALIVE is not a bug to repair — it's the
1064
+ * anti-dupe guard's only signal (`runWorktreeSetup` Guard 3) and `verify`'s
1065
+ * only signal for which cwd may persist (`decidePersistence`). Clearing a
1066
+ * live registration would trade a true pointer for a false negative on both.
1067
+ *
1068
+ * Reuses `clearRootWorktreeRegistration` for the actual mutation (same
1069
+ * atomic-write + scoped-commit contract) once the dangling predicate holds.
1070
+ *
1071
+ * @param {string} root
1072
+ * @param {string} feature
1073
+ * @returns {Promise<{ cleared: boolean }>}
1074
+ */
1075
+ export async function clearDanglingWorktreeRegistration(root, feature) {
1076
+ try {
1077
+ const rootFjPath = join(root, '.morph', 'features', feature, 'feature.json');
1078
+ if (!existsSync(rootFjPath)) return { cleared: false };
1079
+ const fj = JSON.parse(readFileSync(rootFjPath, 'utf8'));
1080
+ const path = fj?.worktree?.path;
1081
+ if (!path || existsSync(path)) return { cleared: false };
1082
+ await clearRootWorktreeRegistration(root, feature);
1083
+ return { cleared: true };
1084
+ } catch {
1085
+ return { cleared: false }; // fail-open — malformed feature.json must never throw
1086
+ }
1087
+ }
1088
+
1047
1089
  /**
1048
1090
  * Why `git worktree remove` failed, from its stderr.
1049
1091
  *
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'url';
5
5
  import { getFeature, setApprovalGate, derivePhase } from '../../core/state/state-manager.js';
6
6
  import { unmetPrereqs } from '../../core/state/gate-order.js';
7
7
  import { OUTPUT_SCHEMA } from '../../core/paths/output-schema.js';
8
+ import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
8
9
 
9
10
  const FRAMEWORK_DIR = fileURLToPath(new URL('../../../framework/', import.meta.url));
10
11
 
@@ -111,6 +112,17 @@ export async function advanceCommand(featureName, options = {}) {
111
112
  process.exit(1);
112
113
  }
113
114
 
115
+ // Owner-tree guard: a live worktree elsewhere owns this feature's state —
116
+ // never stamp a gate onto the root's birth-instant skeleton (branch-drift
117
+ // created by this command, not merely left behind by one).
118
+ const owner = assertOwnerTree(featureName, process.cwd());
119
+ if (owner.blocked) {
120
+ for (const { tone, text } of ownerTreeRefusalLines(featureName, owner.tree, owner.branch, `morph-spec advance ${featureName}`)) {
121
+ console.error(chalk[tone](text));
122
+ }
123
+ process.exit(1);
124
+ }
125
+
114
126
  const featurePath = join(process.cwd(), '.morph', 'features', featureName);
115
127
  const phase = derivePhase(featurePath);
116
128
 
@@ -6,6 +6,7 @@ import { join } from 'path';
6
6
  import { getFeature, setApprovalGate, getApprovalGate, getApprovalHistory, derivePhase } from '../../core/state/state-manager.js';
7
7
  import { unmetPrereqs } from '../../core/state/gate-order.js';
8
8
  import { evaluatorReportState, COMPOSITE_SCORE_TOKEN } from '../../lib/evaluator-report.js';
9
+ import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
9
10
 
10
11
  /**
11
12
  * The literal token `review-report.md` carries to declare which tree it
@@ -86,6 +87,14 @@ export async function unapproveCommand(featureName, gateName, options = {}) {
86
87
  process.exit(1);
87
88
  }
88
89
 
90
+ const owner = assertOwnerTree(featureName, process.cwd());
91
+ if (owner.blocked) {
92
+ for (const { tone, text } of ownerTreeRefusalLines(featureName, owner.tree, owner.branch, `morph-spec unapprove ${featureName} ${gateName}`)) {
93
+ console.error(chalk[tone](text));
94
+ }
95
+ process.exit(1);
96
+ }
97
+
89
98
  const currentStatus = getApprovalGate(featureName, gateName);
90
99
  if (!currentStatus?.approved) {
91
100
  console.log(chalk.yellow(`\n⚠️ Gate "${gateName}" is not currently approved — nothing to revoke\n`));
@@ -155,6 +164,17 @@ export async function approveCommand(featureName, gateName, options = {}) {
155
164
  process.exit(1);
156
165
  }
157
166
 
167
+ // Owner-tree guard: a live worktree elsewhere owns this feature's state —
168
+ // never stamp a gate onto the root's birth-instant skeleton (branch-drift
169
+ // created by this command, not merely left behind by one).
170
+ const owner = assertOwnerTree(featureName, process.cwd());
171
+ if (owner.blocked) {
172
+ for (const { tone, text } of ownerTreeRefusalLines(featureName, owner.tree, owner.branch, `morph-spec approve ${featureName} ${gateName}`)) {
173
+ console.error(chalk[tone](text));
174
+ }
175
+ process.exit(1);
176
+ }
177
+
158
178
  // Check if already approved
159
179
  const currentStatus = getApprovalGate(featureName, gateName);
160
180
  if (currentStatus?.approved) {
@@ -215,7 +215,10 @@ export async function createCommand(featureName, options = {}) {
215
215
  // read. Regenerated here because setup ran before the copy above.
216
216
  try {
217
217
  const { writeHandoff } = await import('../../lib/generators/handoff-generator.js');
218
- writeHandoff(featureName, worktreeResult.path);
218
+ // Same reason as runWorktreeSetup's own call: `worktree` was just
219
+ // stripped from this branch-local copy a few lines up, so the doc
220
+ // must be told explicitly where it actually lives.
221
+ writeHandoff(featureName, worktreeResult.path, { worktree: { path: worktreeResult.path, branch: worktreeResult.branch } });
219
222
  } catch { /* fail-open */ }
220
223
  } catch { /* fail-open — the feature already exists in root */ }
221
224
  }
@@ -2,7 +2,8 @@ import chalk from 'chalk';
2
2
  import { existsSync, rmSync, renameSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
3
3
  import { join, resolve, sep } from 'path';
4
4
  import { execSync } from 'child_process';
5
- import { isValidFeatureName } from '../project/worktree.js';
5
+ import { isValidFeatureName, cwdIsInsideWorktree, teardownWorktree, resolvePrimaryRoot } from '../project/worktree.js';
6
+ import { resolveFeatureTree } from '../../lib/worktree/feature-truth.js';
6
7
 
7
8
  /**
8
9
  * `morph-spec delete <feature>` — remove a feature. Hard by default (folder is
@@ -111,6 +112,57 @@ export async function deleteCommand(featureName, options = {}) {
111
112
  process.exit(1);
112
113
  }
113
114
 
115
+ // A live worktree elsewhere owns this feature's REAL state — deleting the
116
+ // root's registration here would orphan the worktree + branch silently
117
+ // (no `no-feature` audit hit either: featureCommittedOnBranch stays true).
118
+ // Distinct consent from --force on purpose: --force answers "approved gate
119
+ // present, delete anyway" (a STATE risk); a live worktree is a FILESYSTEM
120
+ // risk (orphan directory, junctions into the primary root) — the same
121
+ // reasoning that keeps `worktree remove`'s PROVE-or-DECLINE separate from
122
+ // gate policy.
123
+ const owner = resolveFeatureTree(featureName, cwd);
124
+ const liveElsewhere = (owner.source === 'registry' || owner.source === 'pointer') && owner.tree;
125
+ if (liveElsewhere) {
126
+ if (!options.withWorktree) {
127
+ const payload = { error: 'feature-has-worktree', feature: featureName, worktree: { path: owner.tree, branch: owner.branch } };
128
+ if (options.json) { console.log(JSON.stringify(payload)); process.exit(1); }
129
+ console.error(chalk.red(`\n❌ "${featureName}" tem um worktree vivo em ${owner.tree}.`));
130
+ console.error(chalk.gray(' Feche com `morph-spec finish <feature> --pr|--merge` se o trabalho vale,'));
131
+ console.error(chalk.gray(` ou \`morph-spec worktree remove ${featureName}\` e rode o delete de novo,`));
132
+ console.error(chalk.gray(` ou \`morph-spec delete ${featureName} --with-worktree\` para fazer os dois num passo.\n`));
133
+ process.exit(1);
134
+ }
135
+
136
+ if (cwdIsInsideWorktree(cwd, owner.tree)) {
137
+ const payload = { error: 'cwd-inside-worktree', feature: featureName, path: owner.tree };
138
+ if (options.json) { console.log(JSON.stringify(payload)); process.exit(1); }
139
+ console.error(chalk.red(`\n❌ Rode fora do worktree antes de removê-lo: ${owner.tree}\n`));
140
+ process.exit(1);
141
+ }
142
+
143
+ // PROVE-or-DECLINE first — only delete the feature's own copy if the
144
+ // worktree actually came down. A refusal here must leave EVERYTHING
145
+ // untouched, same contract as `worktree remove`.
146
+ const root = resolvePrimaryRoot(cwd);
147
+ const teardown = await teardownWorktree(owner.tree, {
148
+ root,
149
+ feature: featureName,
150
+ branch: owner.branch,
151
+ options: { discardIgnored: options.discardIgnored === true },
152
+ deleteBranch: options.deleteBranch === true, // kept by default — it is the work
153
+ });
154
+ if (!teardown.worktreeRemoved) {
155
+ const payload = { error: 'worktree-removal-failed', feature: featureName, declineReason: teardown.declineReason };
156
+ if (options.json) { console.log(JSON.stringify(payload)); process.exit(1); }
157
+ console.error(chalk.red(`\n❌ Não foi possível remover o worktree — nada foi apagado.`));
158
+ console.error(chalk.gray(` ${teardown.declineReason || 'motivo desconhecido'}\n`));
159
+ process.exit(1);
160
+ }
161
+ // Fall through: the feature's own .morph/features/{featureName} copy in
162
+ // `cwd` (the primary root, by construction of `liveElsewhere`) is deleted
163
+ // by the normal flow below, exactly as if there had never been a worktree.
164
+ }
165
+
114
166
  const useGit = isGitRepo(cwd) && options.git !== false;
115
167
 
116
168
  if (options.archive) {
@@ -156,10 +208,17 @@ export async function deleteCommand(featureName, options = {}) {
156
208
  execSync(`git rm -r -f --quiet "${featuresPath}"`, { cwd, stdio: 'pipe' });
157
209
  staged = true;
158
210
  } catch {
159
- // Untracked (never committed) — just remove it from disk.
160
- rmSync(featuresPath, { recursive: true, force: true });
161
- try { execSync(`git add -A "${featuresPath}"`, { cwd, stdio: 'pipe' }); staged = true; } catch { /* not fatal */ }
211
+ // Untracked (never committed) — nothing for git rm to act on.
162
212
  }
213
+ // `git rm` only ever removes TRACKED files — a scaffolded phase folder
214
+ // with no artifact in it (0-proposal/ with nothing written yet) has no
215
+ // tracked blob and survives as an empty directory on disk. This bit
216
+ // real after --with-worktree: teardownWorktree's own registration-clear
217
+ // commits the root's feature.json on its way through, so `git rm`
218
+ // SUCCEEDS on that one file while the sibling empty folders it never
219
+ // tracked are left behind. Unconditional sweep closes both paths at once.
220
+ if (existsSync(featuresPath)) rmSync(featuresPath, { recursive: true, force: true });
221
+ try { execSync(`git add -A "${featuresPath}"`, { cwd, stdio: 'pipe' }); staged = true; } catch { /* not fatal */ }
163
222
  } else {
164
223
  rmSync(featuresPath, { recursive: true, force: true });
165
224
  }
@@ -483,7 +483,15 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
483
483
 
484
484
  if (options.keep) {
485
485
  dropFromRootIndex(root, feature);
486
- emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false, ...backupNote, ...ucNote });
486
+ // Worktree preserved on purpose: the root's `worktree` registration stays
487
+ // VALID (it still names a live directory), so nothing to clean up here —
488
+ // only report where it lives and how to close it later.
489
+ emit({
490
+ finished: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false,
491
+ worktree: { path: worktreePath, branch: expectedBranch },
492
+ hint: `worktree preservado em ${worktreePath} — feche depois com \`morph-spec worktree remove ${feature}\`.`,
493
+ ...backupNote, ...ucNote,
494
+ });
487
495
  process.exit(0);
488
496
  }
489
497
 
@@ -574,7 +582,17 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
574
582
  ...(shedBackup ? { shedBackup } : {}),
575
583
  ...(teardown
576
584
  ? { worktreeRemoved: teardown.worktreeRemoved, ...teardownExtras(teardown, worktreePath, expectedBranch, false) }
577
- : { worktreeRemoved: false }),
585
+ : {
586
+ worktreeRemoved: false,
587
+ // isWorktreeFlow && options.keep is the only way `teardown` stays null
588
+ // with a worktree still alive — the common flow has none to report.
589
+ ...(isWorktreeFlow && options.keep
590
+ ? {
591
+ worktree: { path: worktreePath, branch: expectedBranch },
592
+ hint: `worktree preservado em ${worktreePath} — feche depois com \`morph-spec worktree remove ${feature}\`.`,
593
+ }
594
+ : {}),
595
+ }),
578
596
  };
579
597
  emit(out);
580
598
  process.exit(0);
@@ -6,6 +6,7 @@ import { shouldPauseGate, getRiskSignals } from '../../lib/risk-detector.js';
6
6
  import { evaluatorCompositeScore } from '../../lib/evaluator-report.js';
7
7
  import { persistGateDecision, VALID_GATES, readTrustFromConfig } from './gate-decision.js';
8
8
  import { detectDefaultBranch, getHeadSha, getDiffStat } from '../../lib/git/default-branch.js';
9
+ import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
9
10
 
10
11
  /**
11
12
  * `--signals` on `gate-check` is NOT a general override like `gate-decision`'s
@@ -154,6 +155,18 @@ export async function gateCheckCommand(featureName, gateName, options = {}) {
154
155
  process.exit(1);
155
156
  }
156
157
 
158
+ // Owner-tree guard: gate-check reads workType/tasks/scores/git off cwd
159
+ // AND writes the resulting decision through persistGateDecision — both
160
+ // halves are wrong from a tree that isn't this feature's own, so refuse
161
+ // rather than compute a decision from stale reads and then fail to save it.
162
+ const owner = assertOwnerTree(featureName, process.cwd());
163
+ if (owner.blocked) {
164
+ for (const { tone, text } of ownerTreeRefusalLines(featureName, owner.tree, owner.branch, `morph-spec gate-check ${featureName} ${gateName}`)) {
165
+ console.error(chalk[tone](text));
166
+ }
167
+ process.exit(1);
168
+ }
169
+
157
170
  let overrides = {};
158
171
  if (options.signals !== undefined) {
159
172
  let parsed;
@@ -2,6 +2,7 @@ import chalk from 'chalk';
2
2
  import { existsSync, readFileSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { getFeature, updateFeature } from '../../core/state/state-manager.js';
5
+ import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
5
6
 
6
7
  export const VALID_GATES = ['proposal', 'uiux', 'plan', 'review'];
7
8
 
@@ -96,6 +97,14 @@ export async function gateDecisionCommand(featureName, gateName, options = {}) {
96
97
  process.exit(1);
97
98
  }
98
99
 
100
+ const owner = assertOwnerTree(featureName, process.cwd());
101
+ if (owner.blocked) {
102
+ for (const { tone, text } of ownerTreeRefusalLines(featureName, owner.tree, owner.branch, `morph-spec gate-decision ${featureName} ${gateName} --pause ${pause}`)) {
103
+ console.error(chalk[tone](text));
104
+ }
105
+ process.exit(1);
106
+ }
107
+
99
108
  let signals = {};
100
109
  if (options.signals !== undefined) {
101
110
  try {
@@ -9,18 +9,32 @@
9
9
  * in-process by `worktree setup`, `create --worktree`, `advance` and
10
10
  * `approve` — the CLI surface exists for the LLM/user to refresh the
11
11
  * document on demand.
12
+ *
13
+ * WORKTREE-AWARE: `create --worktree` writes a COMPLETE feature.json into the
14
+ * primary root and the branch-committed copy (`morph/{f}`) becomes the truth
15
+ * from then on. Building the handoff from `process.cwd()` unconditionally
16
+ * used to mean: run this from the root while the worktree is alive, and you
17
+ * either got "Feature not found" (root's copy was already sheded) or a
18
+ * document silently built from the stale birth skeleton, written INTO the
19
+ * root (a second, always-defasada copy of a document that's supposed to be
20
+ * regenerated, not duplicated). This resolves the owning tree first
21
+ * (`resolveFeatureTree`) and writes THERE, unless `--here` forces the cwd.
12
22
  */
13
23
 
14
24
  import chalk from 'chalk';
15
25
  import { buildHandoff, writeHandoff } from '../../lib/generators/handoff-generator.js';
26
+ import { resolveFeatureTree } from '../../lib/worktree/feature-truth.js';
16
27
 
17
28
  /**
18
29
  * @param {string} featureName
19
- * @param {{ json?: boolean }} [options]
30
+ * @param {{ json?: boolean, here?: boolean }} [options]
20
31
  */
21
32
  export function handoffCommand(featureName, options = {}) {
22
- const result = buildHandoff(featureName, process.cwd());
23
- if (!result) {
33
+ const cwd = process.cwd();
34
+
35
+ const owner = options.here ? { source: 'cwd', tree: cwd, branch: null, worktree: null } : resolveFeatureTree(featureName, cwd);
36
+
37
+ if (owner.source === 'none') {
24
38
  if (options.json) {
25
39
  console.log(JSON.stringify({ error: 'feature-not-found', feature: featureName }));
26
40
  } else {
@@ -29,10 +43,35 @@ export function handoffCommand(featureName, options = {}) {
29
43
  process.exit(1);
30
44
  }
31
45
 
32
- const path = writeHandoff(featureName, process.cwd());
46
+ if (owner.source === 'branch-only') {
47
+ // The truth exists, committed on morph/{feature}, but is not materialized
48
+ // into ANY tree right now. A handoff points at files on disk — pointing
49
+ // at files that exist nowhere is worse than recusing outright.
50
+ const hint = `morph-spec worktree setup ${featureName} (ou: git checkout ${owner.branch})`;
51
+ if (options.json) {
52
+ console.log(JSON.stringify({ error: 'feature-not-in-tree', feature: featureName, branch: owner.branch, hint }));
53
+ } else {
54
+ console.error(chalk.red(`\n❌ "${featureName}" está commitada em \`${owner.branch}\`, mas não está materializada em árvore nenhuma.`));
55
+ console.error(chalk.gray(` ${hint}\n`));
56
+ }
57
+ process.exit(1);
58
+ }
59
+
60
+ const tree = owner.tree;
61
+ const result = buildHandoff(featureName, tree, { worktree: owner.worktree });
62
+ if (!result) {
63
+ if (options.json) {
64
+ console.log(JSON.stringify({ error: 'feature-not-found', feature: featureName, tree }));
65
+ } else {
66
+ console.error(chalk.red(`\n❌ Feature not found: ${featureName}\n`));
67
+ }
68
+ process.exit(1);
69
+ }
70
+
71
+ const path = writeHandoff(featureName, tree, { worktree: owner.worktree });
33
72
  if (!path) {
34
73
  if (options.json) {
35
- console.log(JSON.stringify({ error: 'write-failed', feature: featureName }));
74
+ console.log(JSON.stringify({ error: 'write-failed', feature: featureName, tree }));
36
75
  } else {
37
76
  console.error(chalk.red(`\n❌ Could not write handoff.md for ${featureName}\n`));
38
77
  }
@@ -40,11 +79,14 @@ export function handoffCommand(featureName, options = {}) {
40
79
  }
41
80
 
42
81
  if (options.json) {
43
- console.log(JSON.stringify({ path, feature: featureName, ...result.meta }));
82
+ console.log(JSON.stringify({ path, feature: featureName, tree, source: owner.source, ...result.meta }));
44
83
  return;
45
84
  }
46
85
 
47
86
  console.log(chalk.green(`\n✅ Handoff gerado: ${path}`));
87
+ if (tree !== cwd) {
88
+ console.log(chalk.yellow(` resolvido para ${tree} — a cópia da raiz é esqueleto de nascimento, não a verdade.`));
89
+ }
48
90
  console.log(chalk.gray(` fase ${result.meta.phase} · tasks ${result.meta.tasksDone}/${result.meta.tasksTotal} · ${result.meta.generatedAt}`));
49
91
  console.log(chalk.gray(' A próxima sessão lê este documento antes de continuar (ponteiros, não cópias).\n'));
50
92
  }