@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
@@ -1,21 +1,38 @@
1
1
  import chalk from 'chalk';
2
2
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
3
- import { join } from 'path';
3
+ import { basename, join } from 'path';
4
4
  import { derivePhase } from '../../core/state/state-manager.js';
5
5
  import { gateOrderViolations } from '../../core/state/gate-order.js';
6
6
  import { missingIdentity } from '../../core/state/feature-identity.js';
7
+ import { derivePhaseFrom } from '../../../framework/hooks/shared/state-reader.js';
8
+ import { isGitRepo } from '../project/worktree.js';
9
+ import { morphBranches, currentBranch, branchView } from '../../lib/worktree/feature-truth.js';
7
10
 
8
11
  /**
9
- * `morph-spec list [--all] [--json]` — the feature audit board.
12
+ * `morph-spec list [--all] [--json] [--no-branch]` — the feature audit board.
10
13
  *
11
14
  * One row per feature: slug × workType × status × phase × gates (G1/G2/G3) ×
12
15
  * description, plus a flags column for anomalies (missing description, gate
13
- * order). Complements `delete`: see what's dead before removing it. Replaces
14
- * the ad-hoc "loop over every feature.json in python" audit.
16
+ * order, dangling worktree pointer). Complements `delete`: see what's dead
17
+ * before removing it. Replaces the ad-hoc "loop over every feature.json in
18
+ * python" audit.
15
19
  *
16
20
  * Read-only by construction — reads feature.json straight off disk (never
17
21
  * `loadState`, which mutates), so listing can never resurrect or prune state.
18
22
  * Always exits 0 (fail-open — an empty board is an answer).
23
+ *
24
+ * WORKTREE TRUTH: `create --worktree` writes a COMPLETE feature.json into the
25
+ * primary root, then the branch-committed copy (`morph/{f}`) becomes the
26
+ * truth and the root's copy never changes again. By default this command
27
+ * reads that branch when it disagrees with the disk — same mechanism fleet
28
+ * already uses (`branchToReadFrom`/`branchView`, moved to feature-truth.js so
29
+ * both consumers share the one place this condition lives). `--no-branch`
30
+ * turns that off for a faster, disk-only read.
31
+ *
32
+ * NOT AN OBJECTIVE: this command only enumerates what exists IN THIS TREE.
33
+ * A feature that lives solely on a branch with no local `.morph/features/`
34
+ * copy in any tree never appears here — aggregating across trees is
35
+ * `morph-spec fleet`'s job, not this one's.
19
36
  */
20
37
 
21
38
  const G = ['proposal', 'uiux', 'plan', 'review'];
@@ -40,7 +57,20 @@ function readFeaturesIn(baseDir, subdir, location) {
40
57
  return rows;
41
58
  }
42
59
 
43
- function buildRow(name, fj, fdir, location) {
60
+ /** The feature's `worktree` pointer, normalized with a computed `exists`. */
61
+ function readWorktreePointer(fj) {
62
+ const w = fj?.worktree;
63
+ if (!w?.path) return null;
64
+ return { path: w.path, branch: w.branch || null, exists: existsSync(w.path) };
65
+ }
66
+
67
+ /**
68
+ * Build a row's flags/gates/identity shape from a `feature.json` object, given
69
+ * an already-resolved phase and provenance. Shared by the disk read
70
+ * (`buildRow`) and the branch read (`resolveBranchTruth`) so the two never
71
+ * drift on what counts as an anomaly.
72
+ */
73
+ function shapeRow(name, fj, location, phase, { corruptDisk = false, worktree = null, source = 'disk', branch = null } = {}) {
44
74
  const gates = {};
45
75
  for (const g of G) gates[g] = fj.approvalGates?.[g]?.approved === true;
46
76
 
@@ -50,13 +80,14 @@ function buildRow(name, fj, fdir, location) {
50
80
  const selfAssessed = fj.approvalGates?.review?.selfAssessed === true;
51
81
 
52
82
  const flags = [];
53
- if (fj.__corrupt) flags.push('corrupt');
83
+ if (corruptDisk) flags.push('corrupt');
54
84
  if (selfAssessed) flags.push('self-assessed-g3');
55
85
  // Identity + gate-order anomalies come from the shared rules
56
86
  // (core/state/feature-identity.js, core/state/gate-order.js) — same source
57
87
  // doctor reports from.
58
88
  for (const field of missingIdentity(fj)) flags.push(`missing-${field}`);
59
89
  for (const v of gateOrderViolations(fj.approvalGates)) flags.push(`gate-order:${v.gate}`);
90
+ if (worktree && !worktree.exists) flags.push('worktree-missing');
60
91
 
61
92
  return {
62
93
  feature: name,
@@ -64,23 +95,106 @@ function buildRow(name, fj, fdir, location) {
64
95
  description: fj.description || null,
65
96
  workType: fj.workType || 'feature',
66
97
  status: fj.status || 'unknown',
67
- phase: location === 'archived' ? 'archived' : derivePhase(fdir),
98
+ phase: location === 'archived' ? 'archived' : phase,
68
99
  gates,
69
100
  selfAssessed,
70
101
  flags,
71
102
  location,
72
103
  createdAt: fj.createdAt || null,
73
104
  updatedAt: fj.updatedAt || null,
105
+ // Provenance — added for worktree-truth awareness. `worktree` is always
106
+ // read from the LOCAL disk copy (root-owned bookkeeping, stripped from
107
+ // branch-committed copies at birth) regardless of where `source` reads
108
+ // status/phase/gates from.
109
+ source,
110
+ branch,
111
+ worktree,
74
112
  };
75
113
  }
76
114
 
115
+ function buildRow(name, fj, fdir, location) {
116
+ const phase = location === 'archived' ? 'archived' : derivePhase(fdir);
117
+ return shapeRow(name, fj, location, phase, {
118
+ corruptDisk: Boolean(fj.__corrupt),
119
+ worktree: readWorktreePointer(fj),
120
+ source: 'disk',
121
+ branch: null,
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Refresh active rows from the branch that owns their truth, when the disk
127
+ * copy in THIS tree is not that owner. Never throws — a git hiccup or a
128
+ * corrupt blob just leaves the disk-read row in place.
129
+ *
130
+ * @param {string} cwd
131
+ * @param {object[]} rows - mutated in place (active rows only)
132
+ */
133
+ function resolveBranchTruth(cwd, rows) {
134
+ if (!isGitRepo(cwd)) return;
135
+ let head;
136
+ let branches;
137
+ try {
138
+ head = currentBranch(cwd);
139
+ branches = morphBranches(cwd);
140
+ } catch { return; }
141
+ if (!branches || branches.size === 0) return;
142
+
143
+ for (let i = 0; i < rows.length; i++) {
144
+ const row = rows[i];
145
+ if (row.location === 'archived') continue;
146
+ if (!branches.has(row.feature)) continue;
147
+ const featureBranch = `morph/${row.feature}`;
148
+ if (head === featureBranch) continue; // cwd IS the checkout — disk is freshest, never go backwards
149
+ const view = branchView(cwd, row.feature, featureBranch);
150
+ if (!view?.fj) continue;
151
+ const phase = derivePhaseFrom(view.fj, (rel) => view.files.has(rel));
152
+ rows[i] = shapeRow(row.feature, view.fj, row.location, phase, {
153
+ corruptDisk: row.flags.includes('corrupt'),
154
+ worktree: row.worktree, // root-owned — never trust the branch copy for this
155
+ source: 'branch',
156
+ branch: featureBranch,
157
+ });
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Flag an active row whose feature ALSO has an archived copy in this tree —
163
+ * the signature left behind when a feature is merged and archived on its
164
+ * branch (or via GitHub) without `morph-spec finish` ever running here:
165
+ * the untracked root skeleton from `create` survives `git pull` (it was
166
+ * never tracked, so pull has nothing to remove) while `.morph/archive/{f}`
167
+ * arrives for real. Flagging beats suppressing the row — a read-only board
168
+ * that resurrects or prunes nothing must not silently hide the anomaly either.
169
+ */
170
+ function flagArchivedElsewhere(cwd, rows) {
171
+ for (const row of rows) {
172
+ if (row.location !== 'active') continue;
173
+ if (existsSync(join(cwd, '.morph', 'archive', row.feature, 'feature.json'))) {
174
+ row.flags.push('archived-elsewhere');
175
+ }
176
+ }
177
+ }
178
+
77
179
  const mark = (on) => (on ? chalk.green('✓') : chalk.gray('·'));
78
180
  const truncate = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
79
181
 
182
+ function whereLabel(row) {
183
+ if (row.location === 'archived') return '-';
184
+ if (row.worktree) return row.worktree.exists ? `worktree ${basename(row.worktree.path)}` : '⚠ worktree ausente';
185
+ if (row.source === 'branch') return `branch ${row.branch}`;
186
+ return 'raiz';
187
+ }
188
+
80
189
  export function listCommand(options = {}) {
81
190
  const cwd = options.cwd || process.cwd();
82
191
  const rows = readFeaturesIn(cwd, 'features', 'active');
83
192
  if (options.all || options.archived) rows.push(...readFeaturesIn(cwd, 'archive', 'archived'));
193
+
194
+ flagArchivedElsewhere(cwd, rows);
195
+ // Commander's `--no-branch` sets options.branch = false; anything else (undefined/true) reads the branch.
196
+ if (options.branch !== false) resolveBranchTruth(cwd, rows);
197
+
84
198
  rows.sort((a, b) => a.feature.localeCompare(b.feature));
85
199
 
86
200
  if (options.json) {
@@ -95,16 +209,16 @@ export function listCommand(options = {}) {
95
209
 
96
210
  const col = (s, w) => String(s ?? '-').padEnd(w);
97
211
  console.log(chalk.white.bold(
98
- col('FEATURE', 22) + col('TYPE', 9) + col('STATUS', 13) + col('PHASE', 11)
212
+ col('FEATURE', 22) + col('TYPE', 9) + col('STATUS', 13) + col('PHASE', 11) + col('WHERE', 22)
99
213
  + col('G1', 4) + col('G2', 4) + col('G3', 4) + col('', 3) + 'DESCRIPTION',
100
214
  ));
101
215
  for (const r of rows) {
102
216
  const flag = r.flags.length ? chalk.yellow('⚠') : ' ';
103
217
  const desc = (r.selfAssessed ? chalk.yellow('[G3 self-assessed] ') : '')
104
- + (r.description ? truncate(r.description, 44) : chalk.yellow('(sem descrição)'));
218
+ + (r.description ? truncate(r.description, 34) : chalk.yellow('(sem descrição)'));
105
219
  // padEnd counts the invisible color codes; pad the plain marks manually.
106
220
  console.log(
107
- col(r.feature, 22) + col(r.workType, 9) + col(r.status, 13) + col(r.phase, 11)
221
+ col(r.feature, 22) + col(r.workType, 9) + col(r.status, 13) + col(r.phase, 11) + col(whereLabel(r), 22)
108
222
  + mark(r.gates.proposal) + ' ' + mark(r.gates.plan) + ' ' + mark(r.gates.review) + ' '
109
223
  + flag + ' ' + desc,
110
224
  );
@@ -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
  /** Fixed --dims order: a,c,q,t (mirrors the morph-eval dimension weights). */
7
8
  const DIM_KEYS = ['architecture', 'contracts', 'codeQuality', 'testCoverage'];
@@ -33,6 +34,14 @@ export async function scoreCommand(featureName, taskId, scoreArg, options = {})
33
34
  process.exit(1);
34
35
  }
35
36
 
37
+ const owner = assertOwnerTree(featureName, process.cwd());
38
+ if (owner.blocked) {
39
+ for (const { tone, text } of ownerTreeRefusalLines(featureName, owner.tree, owner.branch, `morph-spec score ${featureName} ${taskId} ${scoreArg}`)) {
40
+ console.error(chalk[tone](text));
41
+ }
42
+ process.exit(1);
43
+ }
44
+
36
45
  if (feature.taskVerifications?.[taskId]?.status === 'fail') {
37
46
  console.error(chalk.yellow(`⚠️ Último verify de ${taskId} está VERMELHO — score sendo gravado sobre código que não passou no verify determinístico`));
38
47
  }
@@ -126,9 +126,15 @@ function nextAction(feature, phase, tasks, featureName) {
126
126
  *
127
127
  * @param {string} featureName
128
128
  * @param {string} [cwd]
129
+ * @param {{ worktree?: {path:string, branch:string}|null }} [opts] - override
130
+ * the `worktree` bookkeeping instead of reading `feature.worktree`. Needed
131
+ * because that field is ROOT-OWNED and stripped from every branch-local
132
+ * copy at birth (create.js) — a handoff built FROM the worktree's own copy
133
+ * would otherwise always say "no worktree registered", even while sitting
134
+ * inside one. Pass `undefined` (the default) to fall back to `feature.worktree`.
129
135
  * @returns {{ markdown: string, meta: { generatedAt: string, phase: string, tasksDone: number, tasksTotal: number } } | null}
130
136
  */
131
- export function buildHandoff(featureName, cwd = process.cwd()) {
137
+ export function buildHandoff(featureName, cwd = process.cwd(), opts = {}) {
132
138
  const featureDir = join(cwd, '.morph', 'features', featureName);
133
139
  if (!existsSync(join(featureDir, 'feature.json'))) return null;
134
140
 
@@ -150,7 +156,7 @@ export function buildHandoff(featureName, cwd = process.cwd()) {
150
156
  const tasks = taskSummary(featureDir);
151
157
  const env = readWorktreeEnv(cwd);
152
158
  const decisions = decisionTitles(featureDir);
153
- const worktree = feature.worktree || null;
159
+ const worktree = opts.worktree !== undefined ? opts.worktree : (feature.worktree || null);
154
160
 
155
161
  const gates = feature.approvals || feature.approvalGates || {};
156
162
  const gateLine = Object.entries(gates)
@@ -253,10 +259,11 @@ function ensureHandoffExcluded(cwd) {
253
259
  *
254
260
  * @param {string} featureName
255
261
  * @param {string} [cwd]
262
+ * @param {{ worktree?: {path:string, branch:string}|null }} [opts] - see buildHandoff
256
263
  * @returns {string|null}
257
264
  */
258
- export function writeHandoff(featureName, cwd = process.cwd()) {
259
- const result = buildHandoff(featureName, cwd);
265
+ export function writeHandoff(featureName, cwd = process.cwd(), opts = {}) {
266
+ const result = buildHandoff(featureName, cwd, opts);
260
267
  if (!result) return null;
261
268
  const path = join(cwd, '.morph', 'features', featureName, 'handoff.md');
262
269
  try {
@@ -0,0 +1,248 @@
1
+ /**
2
+ * feature-truth.js — where a feature's authoritative `feature.json` actually
3
+ * lives, and how to read it from a place that isn't the feature's own tree.
4
+ *
5
+ * `create --worktree` writes a COMPLETE feature.json into the primary root
6
+ * (status, approvalGates, tasks, gateDecisions) and only then copies it into
7
+ * the worktree. From that instant the branch-committed copy (`morph/{f}`) is
8
+ * the truth and the root's copy never changes again — the root sees the
9
+ * feature exactly as it was at birth, forever, unless someone reads through.
10
+ *
11
+ * `branchToReadFrom`/`branchView` are moved verbatim from fleet-scanner.js —
12
+ * they already solved this (comments B3/B3b there), fleet just never shared
13
+ * them. `resolveFeatureTree` is the missing piece for CONSUMERS OTHER than
14
+ * fleet: "which tree/branch actually owns this feature's state", used by
15
+ * `list`, `handoff`, and the mutator guard (assertOwnerTree).
16
+ *
17
+ * Fail-open throughout: no repo, no branch, no blob, unparseable JSON never
18
+ * throws — callers get null/false and fall back to reading the disk.
19
+ */
20
+
21
+ import { execFileSync } from 'child_process';
22
+ import { existsSync, readFileSync } from 'fs';
23
+ import { join } from 'path';
24
+ import { isGitRepo, resolvePrimaryRoot, findWorktreeForFeature, buildBranchName } from '../../commands/project/worktree.js';
25
+
26
+ /**
27
+ * Which branch holds this feature's truth, when the working tree at `path` does
28
+ * NOT — or `null` when the working tree IS the feature's own checkout.
29
+ *
30
+ * The ONE place this condition lives. `tasks` (B3) and `phase`/`pendingGates`
31
+ * (B3b) are the same defect read through different files, so they must agree on
32
+ * when to look at the branch; two drifting copies of this test would resurrect
33
+ * the bug on one field while the other stayed fixed.
34
+ *
35
+ * A worktree entry is the feature's checkout by construction. A root entry is
36
+ * only the feature's checkout when the root happens to be ON `morph/{f}`.
37
+ *
38
+ * @returns {string|null} branch name to read from, or null for "read the disk"
39
+ */
40
+ export function branchToReadFrom(root, feature, location, branch) {
41
+ if (location === 'worktree' || !root || !feature) return null;
42
+ const featureBranch = branch || `morph/${feature}`;
43
+ try {
44
+ const current = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, stdio: 'pipe' })
45
+ .toString().trim();
46
+ return current === featureBranch ? null : featureBranch;
47
+ } catch {
48
+ return null; // no HEAD to compare against — trust the disk
49
+ }
50
+ }
51
+
52
+ /**
53
+ * The feature's committed state on its own branch: the feature.json blob plus
54
+ * the set of files under its feature dir.
55
+ *
56
+ * `git ls-tree -z` is deliberate: without it git QUOTES paths containing
57
+ * non-ASCII bytes (`"a\303\247/…"`), which would silently fail the prefix strip
58
+ * and leave the artifact set empty — a predicate that is always false looks
59
+ * exactly like "this feature has no artifacts".
60
+ *
61
+ * Fail-open in layers: no blob → null (caller reads the disk); no tree → the
62
+ * gates alone still beat a stale working tree.
63
+ *
64
+ * @returns {{ fj: Object, files: Set<string> }|null}
65
+ */
66
+ export function branchView(root, feature, featureBranch) {
67
+ const prefix = `.morph/features/${feature}/`;
68
+ let fj;
69
+ try {
70
+ fj = JSON.parse(execFileSync('git', ['show', `${featureBranch}:${prefix}feature.json`], {
71
+ cwd: root, stdio: 'pipe',
72
+ }).toString());
73
+ } catch {
74
+ return null;
75
+ }
76
+
77
+ const files = new Set();
78
+ try {
79
+ const out = execFileSync('git', ['ls-tree', '-r', '--name-only', '-z', featureBranch, '--', prefix], {
80
+ cwd: root, stdio: 'pipe',
81
+ }).toString();
82
+ for (const raw of out.split('\0')) {
83
+ const p = raw.trim();
84
+ if (p.startsWith(prefix)) files.add(p.slice(prefix.length));
85
+ }
86
+ } catch { /* no tree — gates alone still beat the stale working tree */ }
87
+
88
+ return { fj, files };
89
+ }
90
+
91
+ /** All local `morph/*` branches, as bare feature names — one spawn, a pre-filter. */
92
+ export function morphBranches(root) {
93
+ try {
94
+ const out = execFileSync('git', ['for-each-ref', '--format=%(refname:short)', 'refs/heads/morph/'], {
95
+ cwd: root, stdio: 'pipe',
96
+ }).toString();
97
+ const names = new Set();
98
+ for (const line of out.split('\n')) {
99
+ const b = line.trim();
100
+ if (b) names.add(b.slice('morph/'.length));
101
+ }
102
+ return names;
103
+ } catch {
104
+ return new Set();
105
+ }
106
+ }
107
+
108
+ /** The tree's current branch, or null (detached HEAD / no repo / no commits yet). */
109
+ export function currentBranch(cwd) {
110
+ try {
111
+ const out = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, stdio: 'pipe' }).toString().trim();
112
+ return out === 'HEAD' ? null : out || null;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /** Read a feature's local feature.json from `dir`, or null if missing/corrupt. */
119
+ function readLocalFeatureJson(dir, feature) {
120
+ const p = join(dir, '.morph', 'features', feature, 'feature.json');
121
+ if (!existsSync(p)) return null;
122
+ try {
123
+ return JSON.parse(readFileSync(p, 'utf8'));
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Resolve which TREE (or, failing that, which BRANCH) actually owns a
131
+ * feature's state — "follow the pointer" for any consumer, not just fleet.
132
+ *
133
+ * Six-step search, each step justified by what it's immune to:
134
+ *
135
+ * 1. cwd holds the feature AND cwd is that feature's registered worktree
136
+ * (git's own record, via findWorktreeForFeature) → running inside the
137
+ * owner never counts as a jump.
138
+ * 2. `findWorktreeForFeature` finds a live worktree elsewhere → the tree the
139
+ * GIT REGISTRY says is the owner, not the feature.json pointer — immune
140
+ * to a dangling/stale `feature.worktree` field.
141
+ * 3. the local feature.json's own `worktree.path` field, if it still exists
142
+ * on disk → covers a worktree living outside the conventional path or a
143
+ * registry `git worktree list` can't see for some reason.
144
+ * 4. cwd holds the feature with no worktree registered anywhere → the common
145
+ * case (no `--worktree`, or the worktree was already torn down).
146
+ * 5. no local copy, but `morph/{feature}` has the file committed → the truth
147
+ * exists, just not materialized into any tree right now.
148
+ * 6. nothing anywhere.
149
+ *
150
+ * @param {string} feature
151
+ * @param {string} [cwd]
152
+ * @returns {{
153
+ * source: 'cwd'|'registry'|'pointer'|'branch-only'|'none',
154
+ * tree: string|null,
155
+ * branch: string|null,
156
+ * worktree: {path:string, branch:string}|null,
157
+ * pointerDangling: boolean,
158
+ * }}
159
+ */
160
+ export function resolveFeatureTree(feature, cwd = process.cwd()) {
161
+ const localFj = readLocalFeatureJson(cwd, feature);
162
+ const pointerPath = localFj?.worktree?.path || null;
163
+ const pointerDangling = Boolean(pointerPath) && !existsSync(pointerPath);
164
+
165
+ const inGit = isGitRepo(cwd);
166
+ const root = inGit ? resolvePrimaryRoot(cwd) : null;
167
+
168
+ // Step 1 + 2: git's own worktree registry is the source of truth for "is
169
+ // there a live worktree for this feature, and where".
170
+ if (inGit && root) {
171
+ const registered = findWorktreeForFeature(feature, root);
172
+ if (registered && existsSync(registered.path)) {
173
+ const branch = registered.branch || buildBranchName(feature);
174
+ if (registered.path.replace(/\\/g, '/') === cwd.replace(/\\/g, '/')) {
175
+ return { source: 'cwd', tree: cwd, branch, worktree: { path: registered.path, branch }, pointerDangling };
176
+ }
177
+ return { source: 'registry', tree: registered.path, branch, worktree: { path: registered.path, branch }, pointerDangling };
178
+ }
179
+ }
180
+
181
+ // Step 3: the local pointer names a worktree the registry didn't surface
182
+ // (unconventional location, or registry unreadable) but that still exists.
183
+ if (pointerPath && !pointerDangling) {
184
+ const branch = localFj?.worktree?.branch || buildBranchName(feature);
185
+ return { source: 'pointer', tree: pointerPath, branch, worktree: { path: pointerPath, branch }, pointerDangling };
186
+ }
187
+
188
+ // Step 4: this tree holds the feature and nothing else claims to own it.
189
+ if (localFj) {
190
+ return { source: 'cwd', tree: cwd, branch: null, worktree: null, pointerDangling };
191
+ }
192
+
193
+ // Step 5: no local copy anywhere in this tree, but the branch has one.
194
+ if (inGit && root) {
195
+ const branch = buildBranchName(feature);
196
+ const view = branchView(root, feature, branch);
197
+ if (view) {
198
+ return { source: 'branch-only', tree: null, branch, worktree: null, pointerDangling: false };
199
+ }
200
+ }
201
+
202
+ // Step 6.
203
+ return { source: 'none', tree: null, branch: null, worktree: null, pointerDangling: false };
204
+ }
205
+
206
+ /**
207
+ * Should a mutator (approve/advance/score/gate-decision/gate-check) refuse to
208
+ * write this feature's `feature.json` from `cwd`?
209
+ *
210
+ * Blocked ONLY when a live worktree owns the feature and it is NOT cwd —
211
+ * `resolveFeatureTree` sources `'registry'`/`'pointer'`. `'cwd'` (cwd IS the
212
+ * owner, or no worktree exists at all) always passes. This is what stops
213
+ * `morph-spec approve <f> plan` run from the root, with the worktree still
214
+ * alive, from silently stamping the gate onto the root's birth-instant
215
+ * skeleton — branch-drift CREATED by a command, not merely left behind by one.
216
+ *
217
+ * @param {string} feature
218
+ * @param {string} [cwd]
219
+ * @returns {{ blocked: boolean, tree: string|null, branch: string|null }}
220
+ */
221
+ export function assertOwnerTree(feature, cwd = process.cwd()) {
222
+ const owner = resolveFeatureTree(feature, cwd);
223
+ const blocked = owner.source === 'registry' || owner.source === 'pointer';
224
+ return { blocked, tree: owner.tree, branch: owner.branch };
225
+ }
226
+
227
+ /**
228
+ * The refusal text every mutator prints on an owner-tree block — one place so
229
+ * `approve`/`advance`/`score`/`gate-decision`/`gate-check` can never drift
230
+ * into five different wordings for the same defect. `commandLine` is the
231
+ * exact command the caller should re-run inside the owner tree.
232
+ *
233
+ * @param {string} feature
234
+ * @param {string} tree
235
+ * @param {string|null} branch
236
+ * @param {string} commandLine
237
+ * @returns {Array<{tone: 'red'|'gray'|'cyan'|'yellow', text: string}>}
238
+ */
239
+ export function ownerTreeRefusalLines(feature, tree, branch, commandLine) {
240
+ return [
241
+ { tone: 'red', text: `\n❌ A feature "${feature}" não pode ser mutada A PARTIR DESTA ÁRVORE.` },
242
+ { tone: 'gray', text: ` O worktree dono está vivo em: ${tree}${branch ? ` (branch ${branch})` : ''}` },
243
+ { tone: 'gray', text: ' Rode o comando LÁ — a mutação é gravada no feature.json daquela árvore:' },
244
+ { tone: 'cyan', text: ` cd "${tree}" && ${commandLine}` },
245
+ { tone: 'yellow', text: '\n NÃO use --force aqui: ele carimbaria a mutação na cópia da raiz, duplicando' },
246
+ { tone: 'yellow', text: ' uma escrita que a outra árvore já tem e desalinhando os dois feature.json.\n' },
247
+ ];
248
+ }
@@ -19,6 +19,7 @@ import { join, resolve } from 'path';
19
19
  import { isGitRepo, resolvePrimaryRoot, parseWorktreeList } from '../../commands/project/worktree.js';
20
20
  import { hasOriginRemote } from '../git/default-branch.js';
21
21
  import { registryPath, resolveFleetConfig } from './env-allocator.js';
22
+ import { branchToReadFrom, branchView } from './feature-truth.js';
22
23
  import {
23
24
  getFeature,
24
25
  derivePhaseForFeature,
@@ -34,71 +35,6 @@ function countFrom(raw) {
34
35
  return { done: data.tasks.filter((t) => t.status === 'done').length, total: data.tasks.length };
35
36
  }
36
37
 
37
- /**
38
- * Which branch holds this feature's truth, when the working tree at `path` does
39
- * NOT — or `null` when the working tree IS the feature's own checkout.
40
- *
41
- * The ONE place this condition lives. `tasks` (B3) and `phase`/`pendingGates`
42
- * (B3b) are the same defect read through different files, so they must agree on
43
- * when to look at the branch; two drifting copies of this test would resurrect
44
- * the bug on one field while the other stayed fixed.
45
- *
46
- * A worktree entry is the feature's checkout by construction. A root entry is
47
- * only the feature's checkout when the root happens to be ON `morph/{f}`.
48
- *
49
- * @returns {string|null} branch name to read from, or null for "read the disk"
50
- */
51
- function branchToReadFrom(root, feature, location, branch) {
52
- if (location === 'worktree' || !root || !feature) return null;
53
- const featureBranch = branch || `morph/${feature}`;
54
- try {
55
- const current = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, stdio: 'pipe' })
56
- .toString().trim();
57
- return current === featureBranch ? null : featureBranch;
58
- } catch {
59
- return null; // no HEAD to compare against — trust the disk
60
- }
61
- }
62
-
63
- /**
64
- * The feature's committed state on its own branch: the feature.json blob plus
65
- * the set of files under its feature dir.
66
- *
67
- * `git ls-tree -z` is deliberate: without it git QUOTES paths containing
68
- * non-ASCII bytes (`"a\303\247/…"`), which would silently fail the prefix strip
69
- * and leave the artifact set empty — a predicate that is always false looks
70
- * exactly like "this feature has no artifacts".
71
- *
72
- * Fail-open in layers: no blob → null (caller reads the disk); no tree → the
73
- * gates alone still beat a stale working tree.
74
- *
75
- * @returns {{ fj: Object, files: Set<string> }|null}
76
- */
77
- function branchView(root, feature, featureBranch) {
78
- const prefix = `.morph/features/${feature}/`;
79
- let fj;
80
- try {
81
- fj = JSON.parse(execFileSync('git', ['show', `${featureBranch}:${prefix}feature.json`], {
82
- cwd: root, stdio: 'pipe',
83
- }).toString());
84
- } catch {
85
- return null;
86
- }
87
-
88
- const files = new Set();
89
- try {
90
- const out = execFileSync('git', ['ls-tree', '-r', '--name-only', '-z', featureBranch, '--', prefix], {
91
- cwd: root, stdio: 'pipe',
92
- }).toString();
93
- for (const raw of out.split('\0')) {
94
- const p = raw.trim();
95
- if (p.startsWith(prefix)) files.add(p.slice(prefix.length));
96
- }
97
- } catch { /* no tree — gates alone still beat the stale working tree */ }
98
-
99
- return { fj, files };
100
- }
101
-
102
38
  /**
103
39
  * Task progress for one feature.
104
40
  *