@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
|
@@ -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
|
|
14
|
-
* the ad-hoc "loop over every feature.json in
|
|
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
|
-
|
|
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 (
|
|
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' :
|
|
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,
|
|
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
|
*
|