@polymorphism-tech/morph-spec 8.26.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.
Files changed (41) hide show
  1. package/CLAUDE.md +1 -0
  2. package/README.md +3 -3
  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 +3 -3
  7. package/framework/MORPH.md +8 -8
  8. package/framework/docs/CLI.md +6 -5
  9. package/framework/rules/morph-task-tracking.md +11 -34
  10. package/framework/rules/morph-workflow.md +1 -1
  11. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  12. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  13. package/framework/skills/core/morph-apply/SKILL.md +3 -3
  14. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  15. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  16. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  17. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  18. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  19. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  20. package/framework/skills/core/morph-review/SKILL.md +1 -1
  21. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  22. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  23. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  24. package/framework/templates/docs/onboarding.md +4 -4
  25. package/package.json +1 -1
  26. package/src/commands/project/doctor.js +9 -4
  27. package/src/commands/project/worktree.js +45 -3
  28. package/src/commands/state/advance.js +12 -0
  29. package/src/commands/state/approve.js +20 -0
  30. package/src/commands/state/create.js +4 -1
  31. package/src/commands/state/delete.js +63 -4
  32. package/src/commands/state/finish.js +20 -2
  33. package/src/commands/state/gate-check.js +13 -0
  34. package/src/commands/state/gate-decision.js +9 -0
  35. package/src/commands/state/handoff.js +48 -6
  36. package/src/commands/state/list.js +124 -10
  37. package/src/commands/state/score.js +9 -0
  38. package/src/lib/generators/handoff-generator.js +11 -4
  39. package/src/lib/worktree/feature-truth.js +248 -0
  40. package/src/lib/worktree/fleet-scanner.js +1 -65
  41. package/src/lib/worktree/worktree-audit.js +103 -17
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'url';
5
5
  import { getFeature, setApprovalGate, derivePhase } from '../../core/state/state-manager.js';
6
6
  import { unmetPrereqs } from '../../core/state/gate-order.js';
7
7
  import { OUTPUT_SCHEMA } from '../../core/paths/output-schema.js';
8
+ import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
8
9
 
9
10
  const FRAMEWORK_DIR = fileURLToPath(new URL('../../../framework/', import.meta.url));
10
11
 
@@ -111,6 +112,17 @@ export async function advanceCommand(featureName, options = {}) {
111
112
  process.exit(1);
112
113
  }
113
114
 
115
+ // Owner-tree guard: a live worktree elsewhere owns this feature's state —
116
+ // never stamp a gate onto the root's birth-instant skeleton (branch-drift
117
+ // created by this command, not merely left behind by one).
118
+ const owner = assertOwnerTree(featureName, process.cwd());
119
+ if (owner.blocked) {
120
+ for (const { tone, text } of ownerTreeRefusalLines(featureName, owner.tree, owner.branch, `morph-spec advance ${featureName}`)) {
121
+ console.error(chalk[tone](text));
122
+ }
123
+ process.exit(1);
124
+ }
125
+
114
126
  const featurePath = join(process.cwd(), '.morph', 'features', featureName);
115
127
  const phase = derivePhase(featurePath);
116
128
 
@@ -6,6 +6,7 @@ import { join } from 'path';
6
6
  import { getFeature, setApprovalGate, getApprovalGate, getApprovalHistory, derivePhase } from '../../core/state/state-manager.js';
7
7
  import { unmetPrereqs } from '../../core/state/gate-order.js';
8
8
  import { evaluatorReportState, COMPOSITE_SCORE_TOKEN } from '../../lib/evaluator-report.js';
9
+ import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
9
10
 
10
11
  /**
11
12
  * The literal token `review-report.md` carries to declare which tree it
@@ -86,6 +87,14 @@ export async function unapproveCommand(featureName, gateName, options = {}) {
86
87
  process.exit(1);
87
88
  }
88
89
 
90
+ const owner = assertOwnerTree(featureName, process.cwd());
91
+ if (owner.blocked) {
92
+ for (const { tone, text } of ownerTreeRefusalLines(featureName, owner.tree, owner.branch, `morph-spec unapprove ${featureName} ${gateName}`)) {
93
+ console.error(chalk[tone](text));
94
+ }
95
+ process.exit(1);
96
+ }
97
+
89
98
  const currentStatus = getApprovalGate(featureName, gateName);
90
99
  if (!currentStatus?.approved) {
91
100
  console.log(chalk.yellow(`\n⚠️ Gate "${gateName}" is not currently approved — nothing to revoke\n`));
@@ -155,6 +164,17 @@ export async function approveCommand(featureName, gateName, options = {}) {
155
164
  process.exit(1);
156
165
  }
157
166
 
167
+ // Owner-tree guard: a live worktree elsewhere owns this feature's state —
168
+ // never stamp a gate onto the root's birth-instant skeleton (branch-drift
169
+ // created by this command, not merely left behind by one).
170
+ const owner = assertOwnerTree(featureName, process.cwd());
171
+ if (owner.blocked) {
172
+ for (const { tone, text } of ownerTreeRefusalLines(featureName, owner.tree, owner.branch, `morph-spec approve ${featureName} ${gateName}`)) {
173
+ console.error(chalk[tone](text));
174
+ }
175
+ process.exit(1);
176
+ }
177
+
158
178
  // Check if already approved
159
179
  const currentStatus = getApprovalGate(featureName, gateName);
160
180
  if (currentStatus?.approved) {
@@ -215,7 +215,10 @@ export async function createCommand(featureName, options = {}) {
215
215
  // read. Regenerated here because setup ran before the copy above.
216
216
  try {
217
217
  const { writeHandoff } = await import('../../lib/generators/handoff-generator.js');
218
- writeHandoff(featureName, worktreeResult.path);
218
+ // Same reason as runWorktreeSetup's own call: `worktree` was just
219
+ // stripped from this branch-local copy a few lines up, so the doc
220
+ // must be told explicitly where it actually lives.
221
+ writeHandoff(featureName, worktreeResult.path, { worktree: { path: worktreeResult.path, branch: worktreeResult.branch } });
219
222
  } catch { /* fail-open */ }
220
223
  } catch { /* fail-open — the feature already exists in root */ }
221
224
  }
@@ -2,7 +2,8 @@ import chalk from 'chalk';
2
2
  import { existsSync, rmSync, renameSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
3
3
  import { join, resolve, sep } from 'path';
4
4
  import { execSync } from 'child_process';
5
- import { isValidFeatureName } from '../project/worktree.js';
5
+ import { isValidFeatureName, cwdIsInsideWorktree, teardownWorktree, resolvePrimaryRoot } from '../project/worktree.js';
6
+ import { resolveFeatureTree } from '../../lib/worktree/feature-truth.js';
6
7
 
7
8
  /**
8
9
  * `morph-spec delete <feature>` — remove a feature. Hard by default (folder is
@@ -111,6 +112,57 @@ export async function deleteCommand(featureName, options = {}) {
111
112
  process.exit(1);
112
113
  }
113
114
 
115
+ // A live worktree elsewhere owns this feature's REAL state — deleting the
116
+ // root's registration here would orphan the worktree + branch silently
117
+ // (no `no-feature` audit hit either: featureCommittedOnBranch stays true).
118
+ // Distinct consent from --force on purpose: --force answers "approved gate
119
+ // present, delete anyway" (a STATE risk); a live worktree is a FILESYSTEM
120
+ // risk (orphan directory, junctions into the primary root) — the same
121
+ // reasoning that keeps `worktree remove`'s PROVE-or-DECLINE separate from
122
+ // gate policy.
123
+ const owner = resolveFeatureTree(featureName, cwd);
124
+ const liveElsewhere = (owner.source === 'registry' || owner.source === 'pointer') && owner.tree;
125
+ if (liveElsewhere) {
126
+ if (!options.withWorktree) {
127
+ const payload = { error: 'feature-has-worktree', feature: featureName, worktree: { path: owner.tree, branch: owner.branch } };
128
+ if (options.json) { console.log(JSON.stringify(payload)); process.exit(1); }
129
+ console.error(chalk.red(`\n❌ "${featureName}" tem um worktree vivo em ${owner.tree}.`));
130
+ console.error(chalk.gray(' Feche com `morph-spec finish <feature> --pr|--merge` se o trabalho vale,'));
131
+ console.error(chalk.gray(` ou \`morph-spec worktree remove ${featureName}\` e rode o delete de novo,`));
132
+ console.error(chalk.gray(` ou \`morph-spec delete ${featureName} --with-worktree\` para fazer os dois num passo.\n`));
133
+ process.exit(1);
134
+ }
135
+
136
+ if (cwdIsInsideWorktree(cwd, owner.tree)) {
137
+ const payload = { error: 'cwd-inside-worktree', feature: featureName, path: owner.tree };
138
+ if (options.json) { console.log(JSON.stringify(payload)); process.exit(1); }
139
+ console.error(chalk.red(`\n❌ Rode fora do worktree antes de removê-lo: ${owner.tree}\n`));
140
+ process.exit(1);
141
+ }
142
+
143
+ // PROVE-or-DECLINE first — only delete the feature's own copy if the
144
+ // worktree actually came down. A refusal here must leave EVERYTHING
145
+ // untouched, same contract as `worktree remove`.
146
+ const root = resolvePrimaryRoot(cwd);
147
+ const teardown = await teardownWorktree(owner.tree, {
148
+ root,
149
+ feature: featureName,
150
+ branch: owner.branch,
151
+ options: { discardIgnored: options.discardIgnored === true },
152
+ deleteBranch: options.deleteBranch === true, // kept by default — it is the work
153
+ });
154
+ if (!teardown.worktreeRemoved) {
155
+ const payload = { error: 'worktree-removal-failed', feature: featureName, declineReason: teardown.declineReason };
156
+ if (options.json) { console.log(JSON.stringify(payload)); process.exit(1); }
157
+ console.error(chalk.red(`\n❌ Não foi possível remover o worktree — nada foi apagado.`));
158
+ console.error(chalk.gray(` ${teardown.declineReason || 'motivo desconhecido'}\n`));
159
+ process.exit(1);
160
+ }
161
+ // Fall through: the feature's own .morph/features/{featureName} copy in
162
+ // `cwd` (the primary root, by construction of `liveElsewhere`) is deleted
163
+ // by the normal flow below, exactly as if there had never been a worktree.
164
+ }
165
+
114
166
  const useGit = isGitRepo(cwd) && options.git !== false;
115
167
 
116
168
  if (options.archive) {
@@ -156,10 +208,17 @@ export async function deleteCommand(featureName, options = {}) {
156
208
  execSync(`git rm -r -f --quiet "${featuresPath}"`, { cwd, stdio: 'pipe' });
157
209
  staged = true;
158
210
  } catch {
159
- // Untracked (never committed) — just remove it from disk.
160
- rmSync(featuresPath, { recursive: true, force: true });
161
- try { execSync(`git add -A "${featuresPath}"`, { cwd, stdio: 'pipe' }); staged = true; } catch { /* not fatal */ }
211
+ // Untracked (never committed) — nothing for git rm to act on.
162
212
  }
213
+ // `git rm` only ever removes TRACKED files — a scaffolded phase folder
214
+ // with no artifact in it (0-proposal/ with nothing written yet) has no
215
+ // tracked blob and survives as an empty directory on disk. This bit
216
+ // real after --with-worktree: teardownWorktree's own registration-clear
217
+ // commits the root's feature.json on its way through, so `git rm`
218
+ // SUCCEEDS on that one file while the sibling empty folders it never
219
+ // tracked are left behind. Unconditional sweep closes both paths at once.
220
+ if (existsSync(featuresPath)) rmSync(featuresPath, { recursive: true, force: true });
221
+ try { execSync(`git add -A "${featuresPath}"`, { cwd, stdio: 'pipe' }); staged = true; } catch { /* not fatal */ }
163
222
  } else {
164
223
  rmSync(featuresPath, { recursive: true, force: true });
165
224
  }
@@ -483,7 +483,15 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
483
483
 
484
484
  if (options.keep) {
485
485
  dropFromRootIndex(root, feature);
486
- emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false, ...backupNote, ...ucNote });
486
+ // Worktree preserved on purpose: the root's `worktree` registration stays
487
+ // VALID (it still names a live directory), so nothing to clean up here —
488
+ // only report where it lives and how to close it later.
489
+ emit({
490
+ finished: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false,
491
+ worktree: { path: worktreePath, branch: expectedBranch },
492
+ hint: `worktree preservado em ${worktreePath} — feche depois com \`morph-spec worktree remove ${feature}\`.`,
493
+ ...backupNote, ...ucNote,
494
+ });
487
495
  process.exit(0);
488
496
  }
489
497
 
@@ -574,7 +582,17 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
574
582
  ...(shedBackup ? { shedBackup } : {}),
575
583
  ...(teardown
576
584
  ? { worktreeRemoved: teardown.worktreeRemoved, ...teardownExtras(teardown, worktreePath, expectedBranch, false) }
577
- : { worktreeRemoved: false }),
585
+ : {
586
+ worktreeRemoved: false,
587
+ // isWorktreeFlow && options.keep is the only way `teardown` stays null
588
+ // with a worktree still alive — the common flow has none to report.
589
+ ...(isWorktreeFlow && options.keep
590
+ ? {
591
+ worktree: { path: worktreePath, branch: expectedBranch },
592
+ hint: `worktree preservado em ${worktreePath} — feche depois com \`morph-spec worktree remove ${feature}\`.`,
593
+ }
594
+ : {}),
595
+ }),
578
596
  };
579
597
  emit(out);
580
598
  process.exit(0);
@@ -6,6 +6,7 @@ import { shouldPauseGate, getRiskSignals } from '../../lib/risk-detector.js';
6
6
  import { evaluatorCompositeScore } from '../../lib/evaluator-report.js';
7
7
  import { persistGateDecision, VALID_GATES, readTrustFromConfig } from './gate-decision.js';
8
8
  import { detectDefaultBranch, getHeadSha, getDiffStat } from '../../lib/git/default-branch.js';
9
+ import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
9
10
 
10
11
  /**
11
12
  * `--signals` on `gate-check` is NOT a general override like `gate-decision`'s
@@ -154,6 +155,18 @@ export async function gateCheckCommand(featureName, gateName, options = {}) {
154
155
  process.exit(1);
155
156
  }
156
157
 
158
+ // Owner-tree guard: gate-check reads workType/tasks/scores/git off cwd
159
+ // AND writes the resulting decision through persistGateDecision — both
160
+ // halves are wrong from a tree that isn't this feature's own, so refuse
161
+ // rather than compute a decision from stale reads and then fail to save it.
162
+ const owner = assertOwnerTree(featureName, process.cwd());
163
+ if (owner.blocked) {
164
+ for (const { tone, text } of ownerTreeRefusalLines(featureName, owner.tree, owner.branch, `morph-spec gate-check ${featureName} ${gateName}`)) {
165
+ console.error(chalk[tone](text));
166
+ }
167
+ process.exit(1);
168
+ }
169
+
157
170
  let overrides = {};
158
171
  if (options.signals !== undefined) {
159
172
  let parsed;
@@ -2,6 +2,7 @@ import chalk from 'chalk';
2
2
  import { existsSync, readFileSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { getFeature, updateFeature } from '../../core/state/state-manager.js';
5
+ import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
5
6
 
6
7
  export const VALID_GATES = ['proposal', 'uiux', 'plan', 'review'];
7
8
 
@@ -96,6 +97,14 @@ export async function gateDecisionCommand(featureName, gateName, options = {}) {
96
97
  process.exit(1);
97
98
  }
98
99
 
100
+ const owner = assertOwnerTree(featureName, process.cwd());
101
+ if (owner.blocked) {
102
+ for (const { tone, text } of ownerTreeRefusalLines(featureName, owner.tree, owner.branch, `morph-spec gate-decision ${featureName} ${gateName} --pause ${pause}`)) {
103
+ console.error(chalk[tone](text));
104
+ }
105
+ process.exit(1);
106
+ }
107
+
99
108
  let signals = {};
100
109
  if (options.signals !== undefined) {
101
110
  try {
@@ -9,18 +9,32 @@
9
9
  * in-process by `worktree setup`, `create --worktree`, `advance` and
10
10
  * `approve` — the CLI surface exists for the LLM/user to refresh the
11
11
  * document on demand.
12
+ *
13
+ * WORKTREE-AWARE: `create --worktree` writes a COMPLETE feature.json into the
14
+ * primary root and the branch-committed copy (`morph/{f}`) becomes the truth
15
+ * from then on. Building the handoff from `process.cwd()` unconditionally
16
+ * used to mean: run this from the root while the worktree is alive, and you
17
+ * either got "Feature not found" (root's copy was already sheded) or a
18
+ * document silently built from the stale birth skeleton, written INTO the
19
+ * root (a second, always-defasada copy of a document that's supposed to be
20
+ * regenerated, not duplicated). This resolves the owning tree first
21
+ * (`resolveFeatureTree`) and writes THERE, unless `--here` forces the cwd.
12
22
  */
13
23
 
14
24
  import chalk from 'chalk';
15
25
  import { buildHandoff, writeHandoff } from '../../lib/generators/handoff-generator.js';
26
+ import { resolveFeatureTree } from '../../lib/worktree/feature-truth.js';
16
27
 
17
28
  /**
18
29
  * @param {string} featureName
19
- * @param {{ json?: boolean }} [options]
30
+ * @param {{ json?: boolean, here?: boolean }} [options]
20
31
  */
21
32
  export function handoffCommand(featureName, options = {}) {
22
- const result = buildHandoff(featureName, process.cwd());
23
- if (!result) {
33
+ const cwd = process.cwd();
34
+
35
+ const owner = options.here ? { source: 'cwd', tree: cwd, branch: null, worktree: null } : resolveFeatureTree(featureName, cwd);
36
+
37
+ if (owner.source === 'none') {
24
38
  if (options.json) {
25
39
  console.log(JSON.stringify({ error: 'feature-not-found', feature: featureName }));
26
40
  } else {
@@ -29,10 +43,35 @@ export function handoffCommand(featureName, options = {}) {
29
43
  process.exit(1);
30
44
  }
31
45
 
32
- const path = writeHandoff(featureName, process.cwd());
46
+ if (owner.source === 'branch-only') {
47
+ // The truth exists, committed on morph/{feature}, but is not materialized
48
+ // into ANY tree right now. A handoff points at files on disk — pointing
49
+ // at files that exist nowhere is worse than recusing outright.
50
+ const hint = `morph-spec worktree setup ${featureName} (ou: git checkout ${owner.branch})`;
51
+ if (options.json) {
52
+ console.log(JSON.stringify({ error: 'feature-not-in-tree', feature: featureName, branch: owner.branch, hint }));
53
+ } else {
54
+ console.error(chalk.red(`\n❌ "${featureName}" está commitada em \`${owner.branch}\`, mas não está materializada em árvore nenhuma.`));
55
+ console.error(chalk.gray(` ${hint}\n`));
56
+ }
57
+ process.exit(1);
58
+ }
59
+
60
+ const tree = owner.tree;
61
+ const result = buildHandoff(featureName, tree, { worktree: owner.worktree });
62
+ if (!result) {
63
+ if (options.json) {
64
+ console.log(JSON.stringify({ error: 'feature-not-found', feature: featureName, tree }));
65
+ } else {
66
+ console.error(chalk.red(`\n❌ Feature not found: ${featureName}\n`));
67
+ }
68
+ process.exit(1);
69
+ }
70
+
71
+ const path = writeHandoff(featureName, tree, { worktree: owner.worktree });
33
72
  if (!path) {
34
73
  if (options.json) {
35
- console.log(JSON.stringify({ error: 'write-failed', feature: featureName }));
74
+ console.log(JSON.stringify({ error: 'write-failed', feature: featureName, tree }));
36
75
  } else {
37
76
  console.error(chalk.red(`\n❌ Could not write handoff.md for ${featureName}\n`));
38
77
  }
@@ -40,11 +79,14 @@ export function handoffCommand(featureName, options = {}) {
40
79
  }
41
80
 
42
81
  if (options.json) {
43
- console.log(JSON.stringify({ path, feature: featureName, ...result.meta }));
82
+ console.log(JSON.stringify({ path, feature: featureName, tree, source: owner.source, ...result.meta }));
44
83
  return;
45
84
  }
46
85
 
47
86
  console.log(chalk.green(`\n✅ Handoff gerado: ${path}`));
87
+ if (tree !== cwd) {
88
+ console.log(chalk.yellow(` resolvido para ${tree} — a cópia da raiz é esqueleto de nascimento, não a verdade.`));
89
+ }
48
90
  console.log(chalk.gray(` fase ${result.meta.phase} · tasks ${result.meta.tasksDone}/${result.meta.tasksTotal} · ${result.meta.generatedAt}`));
49
91
  console.log(chalk.gray(' A próxima sessão lê este documento antes de continuar (ponteiros, não cópias).\n'));
50
92
  }
@@ -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 {