@polymorphism-tech/morph-spec 8.27.0 → 8.28.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/docs/CLI.md +6 -5
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- 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/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
|
@@ -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
|
}
|
|
@@ -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 {
|