@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.
- package/CLAUDE.md +1 -0
- package/README.md +2 -2
- package/bin/morph-spec.js +9 -4
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +3 -2
- package/docs/QUICKSTART.md +1 -1
- package/framework/MORPH.md +8 -8
- package/framework/agents.json +14 -3
- package/framework/commands/create-umbraco-lp.md +1 -1
- package/framework/docs/CLI.md +6 -5
- package/framework/mcp/registry.json +59 -8
- package/framework/skills/core/create-umbraco-lp/SKILL.md +13 -2
- package/framework/skills/core/create-umbraco-lp/references/umbraco-lp-patterns.md +598 -0
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +92 -7
- package/framework/skills/core/morph-apply/SKILL.md +1 -1
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- 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 +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/PROVENANCE.json +41 -0
- package/framework/standards/STANDARDS.json +218 -7
- package/framework/standards/frontend/design-system/ai-image-generation.md +43 -0
- package/framework/standards/frontend/design-system/ai-video-generation.md +113 -0
- package/framework/standards/frontend/design-system/premium-finish.md +160 -0
- package/framework/standards/frontend/gsap/gsap-core.md +1 -1
- package/framework/standards/frontend/gsap/gsap-plugins.md +1 -1
- package/framework/standards/frontend/gsap/gsap-scrolltrigger.md +3 -1
- package/framework/standards/frontend/nextjs/motion-patterns.md +3 -1
- package/framework/standards/frontend/scroll-driven/frame-scrub.md +287 -0
- package/framework/standards/frontend/scroll-driven/scroll-components.md +256 -0
- package/framework/standards/frontend/scroll-driven/smooth-scroll.md +148 -0
- package/framework/standards/frontend/seo/meta-schema-audit.md +134 -0
- package/package.json +1 -1
- package/src/commands/project/doctor.js +9 -4
- package/src/commands/project/worktree.js +45 -3
- package/src/commands/state/advance.js +12 -0
- package/src/commands/state/approve.js +20 -0
- package/src/commands/state/create.js +4 -1
- package/src/commands/state/delete.js +63 -4
- package/src/commands/state/finish.js +20 -2
- package/src/commands/state/gate-check.js +13 -0
- package/src/commands/state/gate-decision.js +9 -0
- package/src/commands/state/handoff.js +48 -6
- package/src/commands/state/list.js +124 -10
- package/src/commands/state/score.js +9 -0
- package/src/lib/generators/handoff-generator.js +11 -4
- package/src/lib/worktree/feature-truth.js +248 -0
- package/src/lib/worktree/fleet-scanner.js +1 -65
- 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
|
@@ -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
|
|
568
|
-
const
|
|
569
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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) —
|
|
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
|
-
|
|
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
|
-
: {
|
|
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
|
|
23
|
-
|
|
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
|
-
|
|
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
|
}
|