@polymorphism-tech/morph-spec 8.6.0 → 8.8.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 (47) hide show
  1. package/README.md +4 -4
  2. package/bin/morph-spec.js +23 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +2 -2
  5. package/docs/QUICKSTART.md +2 -2
  6. package/framework/CLAUDE.md +6 -3
  7. package/framework/MORPH.md +28 -3
  8. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  9. package/framework/hooks/claude-code/core/telemetry-log.js +15 -0
  10. package/framework/hooks/claude-code/core/worktree-context.js +41 -5
  11. package/framework/hooks/claude-code/statusline.py +545 -521
  12. package/framework/hooks/shared/claim-registry.js +175 -0
  13. package/framework/hooks/shared/state-reader.js +68 -25
  14. package/framework/rules/morph-task-tracking.md +14 -8
  15. package/framework/rules/morph-workflow.md +34 -1
  16. package/framework/schemas/feature.schema.json +2 -1
  17. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  18. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  19. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  20. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  21. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  22. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  23. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  24. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  25. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  26. package/framework/skills/core/morph-review/SKILL.md +7 -1
  27. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  28. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  29. package/package.json +1 -1
  30. package/src/commands/project/claim.js +91 -0
  31. package/src/commands/project/doctor.js +65 -0
  32. package/src/commands/project/fleet.js +37 -2
  33. package/src/commands/project/worktree.js +70 -7
  34. package/src/commands/state/advance.js +60 -1
  35. package/src/commands/state/approve.js +125 -2
  36. package/src/commands/state/create.js +3 -3
  37. package/src/commands/state/finish.js +88 -3
  38. package/src/commands/state/list.js +9 -1
  39. package/src/commands/state/report-check.js +202 -0
  40. package/src/core/state/lifecycle-audit.js +57 -0
  41. package/src/dashboard/scanner/state-scanner.js +11 -3
  42. package/src/lib/generators/architecture-scanner.js +1 -1
  43. package/src/lib/tasks/tasks-json-parser.js +8 -0
  44. package/src/lib/worktree/fleet-scanner.js +224 -15
  45. package/src/lib/worktree/worktree-audit.js +181 -0
  46. package/src/utils/file-copier.js +5 -1
  47. package/src/utils/hooks-installer.js +7 -1
@@ -428,7 +428,67 @@ async function doctorMcpCommand(targetPath) {
428
428
  logger.blank();
429
429
  }
430
430
 
431
+ /**
432
+ * `morph-spec doctor --worktrees [--prune]` — reconcile git's worktree registry
433
+ * with the directories on disk and the features morph knows (C2).
434
+ *
435
+ * Orphan worktrees used to accumulate invisibly: `fleet` only reports worktrees
436
+ * on a `morph/*` branch, so anything else never showed up anywhere.
437
+ *
438
+ * `--prune` runs the one safe repair (`git worktree prune`: admin records for
439
+ * directories that are already gone). Deleting a directory that still exists is
440
+ * reported with the exact command, never performed — same PROVE-or-DECLINE
441
+ * discipline as the finish teardown.
442
+ */
443
+ export async function doctorWorktreesCommand(targetPath, options = {}) {
444
+ const { auditWorktrees, pruneWorktrees } = await import('../../lib/worktree/worktree-audit.js');
445
+
446
+ if (options.prune) {
447
+ const before = auditWorktrees(targetPath).filter((i) => i.type === 'registered-missing').length;
448
+ const result = pruneWorktrees(targetPath);
449
+ if (result.pruned) logger.success(`git worktree prune: ${before} registro(s) órfão(s) removido(s).`);
450
+ else logger.warn(`prune falhou: ${result.error}`);
451
+ }
452
+
453
+ const issues = auditWorktrees(targetPath);
454
+
455
+ if (options.json) {
456
+ console.log(JSON.stringify({ issues }, null, 2));
457
+ return issues.length === 0;
458
+ }
459
+
460
+ logger.header('MORPH-SPEC Worktrees');
461
+ if (issues.length === 0) {
462
+ logger.success('Worktrees reconciliados — git, disco e features batem.');
463
+ return true;
464
+ }
465
+
466
+ logger.warn(`${issues.length} inconsistência(s):`);
467
+ for (const i of issues) {
468
+ console.log(` ⚠ ${i.type}`);
469
+ console.log(` ${i.detail}`);
470
+ console.log(` → ${i.fix}`);
471
+ }
472
+ if (issues.some((i) => i.type === 'registered-missing') && !options.prune) {
473
+ logger.dim('\n Rode `morph-spec doctor --worktrees --prune` para limpar os registros órfãos.');
474
+ }
475
+ return false;
476
+ }
477
+
431
478
  export async function doctorCommand(options = {}) {
479
+ // `--json` and `--prune` only mean something under `--worktrees`. Falling
480
+ // through to the human health check would hand a script a banner and a table
481
+ // where it asked for JSON — a caller has no way to notice a flag that was
482
+ // dropped in silence, so refuse instead of pretending.
483
+ const scoped = ['json', 'prune'].filter((f) => options[f]);
484
+ if (scoped.length && !options.worktrees) {
485
+ const flags = scoped.map((f) => `--${f}`).join(' e ');
486
+ logger.error(`${flags} só se aplica${scoped.length > 1 ? 'm' : ''} a \`morph-spec doctor --worktrees\`.`);
487
+ logger.dim(` Rode: morph-spec doctor --worktrees ${scoped.map((f) => `--${f}`).join(' ')}`);
488
+ process.exitCode = 1;
489
+ return false;
490
+ }
491
+
432
492
  // Reset mode: remove morph-managed settings entries
433
493
  if (options.reset) {
434
494
  const targetPath = process.cwd();
@@ -465,6 +525,11 @@ export async function doctorCommand(options = {}) {
465
525
  return doctorMcpCommand(process.cwd());
466
526
  }
467
527
 
528
+ // Worktree reconciliation mode (C2)
529
+ if (options.worktrees) {
530
+ return doctorWorktreesCommand(process.cwd(), options);
531
+ }
532
+
468
533
  const targetPath = process.cwd();
469
534
 
470
535
  logger.header('MORPH-SPEC Health Check');
@@ -12,6 +12,7 @@
12
12
 
13
13
  import chalk from 'chalk';
14
14
  import { scanFleet } from '../../lib/worktree/fleet-scanner.js';
15
+ import { claimAge } from '../../../framework/hooks/shared/claim-registry.js';
15
16
 
16
17
  /**
17
18
  * @param {{ json?: boolean, cwd?: string }} [options]
@@ -30,9 +31,33 @@ export function fleetCommand(options = {}) {
30
31
  }
31
32
 
32
33
  const col = (s, w) => String(s ?? '-').padEnd(w);
34
+
35
+ /**
36
+ * The REMOTO cell. `-` means "unknown" (no origin, or the probe failed) —
37
+ * deliberately the same glyph as "nothing there", because both mean "do not
38
+ * trust the local view alone". `local` is the confident negative.
39
+ */
40
+ const remoteCell = (r) => {
41
+ if (!r) return '-';
42
+ if (r.pr) return `PR #${r.pr.number}${r.pr.state && r.pr.state !== 'OPEN' ? ` (${r.pr.state.toLowerCase()})` : ''}`;
43
+ return r.pushed ? 'pushed' : 'local';
44
+ };
45
+
46
+ /**
47
+ * The CLAIM cell — who is on this feature right now (B1). A stale claim is
48
+ * shown as stale, not hidden: "nobody here" and "somebody died here" are
49
+ * different facts for the operator.
50
+ */
51
+ const claimCell = (c) => {
52
+ if (!c) return '-';
53
+ const age = claimAge(c);
54
+ return c.live ? chalk.yellow(`sessão (${age})`) : chalk.gray(`morta (${age})`);
55
+ };
56
+
33
57
  console.log(chalk.white.bold(
34
58
  col('FEATURE', 20) + col('TYPE', 9) + col('PHASE', 11) + col('TASKS', 8)
35
- + col('GATES PEND.', 20) + col('WHERE', 24) + col('PORTS', 12) + col('HANDOFF', 9) + 'ATIVIDADE',
59
+ + col('GATES PEND.', 20) + col('WHERE', 24) + col('REMOTO', 16) + col('CLAIM', 16)
60
+ + col('PORTS', 12) + col('HANDOFF', 9) + 'ATIVIDADE',
36
61
  ));
37
62
  for (const e of fleet.entries) {
38
63
  const tasks = e.tasks ? `${e.tasks.done}/${e.tasks.total}` : '-';
@@ -44,11 +69,21 @@ export function fleetCommand(options = {}) {
44
69
  e.lastCommit ? `commit ${e.lastCommit}` : null,
45
70
  Number.isInteger(e.dirty) && e.dirty > 0 ? chalk.yellow(`${e.dirty} dirty`) : null,
46
71
  ].filter(Boolean).join(' · ') || '-';
72
+ // padEnd counts invisible color codes, so pad the claim cell on its plain
73
+ // length and append the padding by hand.
74
+ const claimPlain = e.claim ? `${e.claim.live ? 'sessão' : 'morta'} (${claimAge(e.claim)})` : '-';
47
75
  console.log(
48
76
  col(e.feature, 20) + col(e.workType || '-', 9) + col(e.phase, 11) + col(tasks, 8)
49
- + col(gates, 20) + col(where, 24) + col(ports, 12) + col(handoff, 9) + activity,
77
+ + col(gates, 20) + col(where, 24) + col(remoteCell(e.remote), 16)
78
+ + claimCell(e.claim) + ' '.repeat(Math.max(1, 16 - claimPlain.length))
79
+ + col(ports, 12) + col(handoff, 9) + activity,
50
80
  );
51
81
  }
82
+ const claimed = fleet.entries.filter((e) => e.claim?.live);
83
+ if (claimed.length) {
84
+ console.log(chalk.yellow(`\n⚠ ${claimed.length} feature(s) com sessão ativa — confirme antes de finish/archive/delete.`));
85
+ console.log(chalk.gray(" Claim órfão de sessão morta: `morph-spec claim <f> --release`"));
86
+ }
52
87
  console.log(chalk.gray(`\nRaiz primária: ${fleet.root}`));
53
88
  process.exit(0);
54
89
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * MORPH-SPEC Worktree Command Suite
3
3
  *
4
- * Isolates feature work into per-feature git worktrees at .worktrees/{feature}/
4
+ * Isolates feature work into per-feature git worktrees at worktrees/{feature}/
5
5
  * on branch morph/{feature}, so each feature can run its own Claude Code
6
6
  * instance (v8 multi-instance, parallel-feature-development flow).
7
7
  *
@@ -34,6 +34,31 @@ import { execSync, execFileSync } from 'child_process';
34
34
  import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, appendFileSync, lstatSync, unlinkSync, rmdirSync, readdirSync, cpSync } from 'fs';
35
35
  import { join, resolve, dirname, relative } from 'path';
36
36
  import chalk from 'chalk';
37
+ import { detectDefaultBranch } from '../../lib/git/default-branch.js';
38
+
39
+ /**
40
+ * The directory new worktrees are created under, relative to the primary root.
41
+ *
42
+ * NO leading dot, deliberately (C1). Jest escapes the `.` when it interpolates
43
+ * an absolute `rootDir` into the `testMatch` glob, so `<root>/.worktrees/...`
44
+ * stops matching and an entire frontend suite reports **0 tests without an
45
+ * error** — the suite "passes" having tested nothing. The dot bought nothing:
46
+ * the directory is gitignored either way.
47
+ */
48
+ export const WORKTREES_DIR = 'worktrees';
49
+
50
+ /**
51
+ * The pre-C1 directory name. New worktrees never land here, but every reader
52
+ * that resolves worktrees BY CONVENTION must still accept it, so a machine
53
+ * that already had `.worktrees/{f}` keeps working with no manual migration.
54
+ *
55
+ * Readers that go through `git worktree list` (fleet, finish, worktree list)
56
+ * were never affected — git records absolute paths, not this convention.
57
+ */
58
+ export const LEGACY_WORKTREES_DIR = '.worktrees';
59
+
60
+ /** Both names, new first — the read order for convention-based lookups. */
61
+ export const WORKTREE_DIR_NAMES = [WORKTREES_DIR, LEGACY_WORKTREES_DIR];
37
62
 
38
63
  // ── Pure helpers (exported for tests) ──────────────────────────────────────
39
64
 
@@ -47,7 +72,7 @@ import chalk from 'chalk';
47
72
  *
48
73
  * Mirrors the validation `morph-spec create` applies, and every worktree
49
74
  * subcommand enforces it for the same reason plus a sharper one: a feature name
50
- * flows into `.worktrees/{name}`, the branch `morph/{name}`, and from there into
75
+ * flows into `worktrees/{name}`, the branch `morph/{name}`, and from there into
51
76
  * git invocations. Characters like `& | ; > ^` are legal in a git refname but
52
77
  * are cmd.exe metacharacters — on Windows an unvalidated name turns any
53
78
  * string-interpolated git call into command injection, and makes a `finish`
@@ -121,7 +146,7 @@ export function worktreeExists(worktreePath, cwd = process.cwd()) {
121
146
  export function buildWorktreePath(feature, cwd = process.cwd(), options = {}) {
122
147
  const suffix = options.fresh ? `-${getDateSuffix()}` : '';
123
148
  const normalizedCwd = cwd.replace(/\\/g, '/');
124
- return `${normalizedCwd}/.worktrees/${feature}${suffix}`;
149
+ return `${normalizedCwd}/${WORKTREES_DIR}/${feature}${suffix}`;
125
150
  }
126
151
 
127
152
  /**
@@ -421,14 +446,29 @@ export async function runWorktreeSetup(feature, options = {}) {
421
446
  }
422
447
  } catch { /* fail-open — malformed feature.json must never block setup */ }
423
448
 
449
+ let branchedFrom = null;
424
450
  try {
425
- // Create branch if needed (based off current HEAD)
451
+ // Create branch if needed — off the DEFAULT branch, never the current HEAD.
452
+ // Branching from HEAD meant a root parked on some other `morph/{other}`
453
+ // gave the new feature that feature's commits, and every PR opened from
454
+ // this worktree carried the sibling's diff (A1). Symmetric with the common
455
+ // flow in create.js → branchCommonFlow.
456
+ //
457
+ // Fail-open, same as create: with no resolvable default (a repo whose
458
+ // default is neither `main`/`master` nor declared via origin/HEAD) the
459
+ // branch is still created — off HEAD — and `branchedFrom: null` reports it.
426
460
  if (!branchExists(branch, cwd)) {
427
- execSync(`git branch ${branch}`, { cwd, stdio: 'pipe' });
461
+ const defaultBranch = detectDefaultBranch(cwd);
462
+ if (defaultBranch) {
463
+ execFileSync('git', ['branch', branch, defaultBranch], { cwd, stdio: 'pipe' });
464
+ branchedFrom = defaultBranch;
465
+ } else {
466
+ execFileSync('git', ['branch', branch], { cwd, stdio: 'pipe' });
467
+ }
428
468
  }
429
469
 
430
470
  // Create worktree directory parent if needed
431
- const parentDir = join(cwd, '.worktrees');
471
+ const parentDir = join(cwd, WORKTREES_DIR);
432
472
  if (!existsSync(parentDir)) {
433
473
  mkdirSync(parentDir, { recursive: true });
434
474
  }
@@ -500,6 +540,7 @@ export async function runWorktreeSetup(feature, options = {}) {
500
540
  created: true,
501
541
  path: worktreePath,
502
542
  branch,
543
+ branchedFrom,
503
544
  feature,
504
545
  materialized: materialized.materialized,
505
546
  ...(envBlock ? { env: { base: envBlock.base, range: envBlock.range } } : {}),
@@ -1065,7 +1106,13 @@ async function clearRootWorktreeRegistration(root, feature) {
1065
1106
  export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true }) {
1066
1107
  // Drop the infra junctions FIRST, without following them — this is what makes
1067
1108
  // a removal survivable (see unlinkWorktreeInfraLinks). Runs unconditionally.
1068
- unlinkWorktreeInfraLinks(worktreePath, root);
1109
+ //
1110
+ // The ORDER IS LOAD-BEARING and must not be flipped: `git worktree remove`
1111
+ // (and its --force fallback) follows reparse points OUT of the worktree and
1112
+ // deletes the primary root's real `.claude/` and `.morph/framework/`.
1113
+ // Whatever this unlink costs on a declined path is repaired by the rollback
1114
+ // below — the reverse ordering has no repair, it destroys the installation.
1115
+ const unlinked = unlinkWorktreeInfraLinks(worktreePath, root);
1069
1116
 
1070
1117
  const scan = findReparsePoints(worktreePath);
1071
1118
  const ignored = classifyIgnoredLeftovers(worktreePath, options);
@@ -1114,6 +1161,21 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1114
1161
  await clearRootWorktreeRegistration(root, feature);
1115
1162
  }
1116
1163
 
1164
+ // Rollback (A4): the removal was refused or failed, so the worktree is still
1165
+ // there — but without the junctions this function dropped up front, which is
1166
+ // strictly worse than never having tried (the observed complaint: "um estado
1167
+ // pior que não ter tentado"). Put them back so the worktree stays usable and
1168
+ // the user can just re-run finish from the primary root. Fail-open: a failed
1169
+ // re-link reports `relinked: false` and the caller falls back to telling the
1170
+ // user to run `morph-spec worktree link --all`.
1171
+ let relinked = false;
1172
+ if (!worktreeRemoved && unlinked.length && existsSync(worktreePath)) {
1173
+ try {
1174
+ setupInfraLinks(worktreePath, root);
1175
+ relinked = true;
1176
+ } catch { relinked = false; }
1177
+ }
1178
+
1117
1179
  const declineReason = worktreeRemoved
1118
1180
  ? null
1119
1181
  : ignored.blocking.length
@@ -1132,6 +1194,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1132
1194
  ignoredBlocking: ignored.blocking,
1133
1195
  shieldable: ignored.shieldable,
1134
1196
  declineReason,
1197
+ relinked,
1135
1198
  };
1136
1199
  }
1137
1200
 
@@ -1,8 +1,12 @@
1
1
  import chalk from 'chalk';
2
- import { existsSync, mkdirSync } from 'fs';
2
+ import { existsSync, mkdirSync, readFileSync } from 'fs';
3
3
  import { join } from 'path';
4
+ import { fileURLToPath } from 'url';
4
5
  import { getFeature, setApprovalGate, derivePhase } from '../../core/state/state-manager.js';
5
6
  import { unmetPrereqs } from '../../core/state/gate-order.js';
7
+ import { OUTPUT_SCHEMA } from '../../core/paths/output-schema.js';
8
+
9
+ const FRAMEWORK_DIR = fileURLToPath(new URL('../../../framework/', import.meta.url));
6
10
 
7
11
  /**
8
12
  * `morph-spec advance <feature>` — approve the current phase's gate AND scaffold
@@ -28,6 +32,41 @@ const NEXT_PHASE_DIR = {
28
32
  implement: '4-review',
29
33
  };
30
34
 
35
+ /**
36
+ * The canonical artifacts the phase being CLOSED owes, per
37
+ * `framework/phases.json → phases[phase].requiredOutputs`, resolved to real
38
+ * filenames through OUTPUT_SCHEMA.
39
+ *
40
+ * Data-driven on purpose: adding an artifact to a phase must not need a code
41
+ * change here, and the list must never drift from the phase definition.
42
+ * Fail-open — an unreadable phases.json/OUTPUT_SCHEMA yields an empty list, so
43
+ * a broken install degrades to today's behaviour instead of blocking every
44
+ * advance.
45
+ *
46
+ * @param {string} phase
47
+ * @param {string} featurePath
48
+ * @returns {string[]} relative paths of the MISSING required artifacts
49
+ */
50
+ function missingPhaseArtifacts(phase, featurePath) {
51
+ try {
52
+ const phasesPath = join(FRAMEWORK_DIR, 'phases.json');
53
+ const phases = JSON.parse(readFileSync(phasesPath, 'utf8')).phases || {};
54
+ const required = phases[phase]?.requiredOutputs || [];
55
+ const missing = [];
56
+ for (const outputType of required) {
57
+ // requiredOutputs already carries OUTPUT_SCHEMA's camelCase keys — an
58
+ // unknown key is skipped rather than guessed at.
59
+ const entry = OUTPUT_SCHEMA[outputType];
60
+ if (!entry?.filename || !entry?.phaseDir) continue;
61
+ const rel = `${entry.phaseDir}/${entry.filename}`;
62
+ if (!existsSync(join(featurePath, entry.phaseDir, entry.filename))) missing.push(rel);
63
+ }
64
+ return missing;
65
+ } catch {
66
+ return [];
67
+ }
68
+ }
69
+
31
70
  /** Resolve who approved, honestly (mirrors approve.js — no fake human signatures). */
32
71
  function resolveApprovedBy(options) {
33
72
  if (options.approver) return options.approver;
@@ -54,6 +93,23 @@ export async function advanceCommand(featureName, options = {}) {
54
93
  return;
55
94
  }
56
95
 
96
+ // Phase-exit artifact check (D3): a phase transition must not step over the
97
+ // artifact the phase it is CLOSING owes. Observed: a feature reached review
98
+ // with NO 3-implement/recap.md — the file was written later, by hand, so
99
+ // the Gate 3 review had nothing to review. `implement` has no gate, which
100
+ // is precisely why nothing stopped it.
101
+ //
102
+ // `--force` bypasses (same deliberate-recovery role it already plays for
103
+ // gate order) but still names what was skipped — a silent bypass would be
104
+ // the defect again.
105
+ const missingArtifacts = missingPhaseArtifacts(phase, featurePath);
106
+ if (missingArtifacts.length && !options.force) {
107
+ console.error(chalk.red(`\n❌ A fase "${phase}" não pode ser encerrada — artefato(s) obrigatório(s) ausente(s):`));
108
+ for (const rel of missingArtifacts) console.error(chalk.gray(` • ${rel}`));
109
+ console.error(chalk.gray('\n Produza o artefato e rode de novo, ou passe --force para avançar assim mesmo.\n'));
110
+ process.exit(1);
111
+ }
112
+
57
113
  // 1) Approve the gate that completes the current phase.
58
114
  const gate = PHASE_GATE[phase];
59
115
  const approvedBy = resolveApprovedBy(options);
@@ -112,6 +168,9 @@ export async function advanceCommand(featureName, options = {}) {
112
168
  } catch { /* fail-open */ }
113
169
 
114
170
  console.log(chalk.green(`\n✅ Advanced "${featureName}" past the ${phase} phase.`));
171
+ if (missingArtifacts.length) {
172
+ console.log(chalk.yellow(` ⚠️ --force: avançou SEM ${missingArtifacts.join(', ')}.`));
173
+ }
115
174
  if (gate) console.log(chalk.gray(` Gate "${gate}" approved (${approvedBy}).`));
116
175
  if (nextDir) console.log(chalk.gray(` Scaffolded ${nextDir}/ — ready for the next phase's first artifact.\n`));
117
176
  else console.log('');
@@ -1,10 +1,73 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { existsSync } from 'fs';
3
+ import { execFileSync } from 'child_process';
4
+ import { existsSync, readFileSync } from 'fs';
4
5
  import { join } from 'path';
5
6
  import { getFeature, setApprovalGate, getApprovalGate, getApprovalHistory, derivePhase } from '../../core/state/state-manager.js';
6
7
  import { unmetPrereqs } from '../../core/state/gate-order.js';
7
8
 
9
+ /**
10
+ * The literal token `review-report.md` carries to declare which tree it
11
+ * reviewed. Kept as one exported constant so the CLI parser, the warning text
12
+ * and the `morph-review` skill can never drift apart (the A5 lesson).
13
+ */
14
+ export const REVIEWED_SHA_TOKEN = '**Reviewed-SHA:**';
15
+
16
+ const REVIEWED_SHA_RE = /\*\*Reviewed-SHA:\*\*\s*([0-9a-f]{7,40})/i;
17
+
18
+ /**
19
+ * Is the feature's `review-report.md` still describing the current tree?
20
+ *
21
+ * @param {string} featureName
22
+ * @returns {{ status: 'ok'|'stale'|'unstamped'|'skip', reviewed?: string, head?: string, ahead?: number }}
23
+ * `skip` covers every case where the question cannot be answered honestly:
24
+ * no report, no git repo, an unresolvable SHA. Never a false refusal.
25
+ */
26
+ function reviewStaleness(featureName) {
27
+ const cwd = process.cwd();
28
+ const reportPath = join(cwd, '.morph', 'features', featureName, '4-review', 'review-report.md');
29
+ if (!existsSync(reportPath)) return { status: 'skip' };
30
+
31
+ let head;
32
+ try {
33
+ head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd, stdio: 'pipe' }).toString().trim();
34
+ } catch {
35
+ return { status: 'skip' }; // not a git repo (or no commits) — nothing to compare
36
+ }
37
+
38
+ let body = '';
39
+ try {
40
+ body = readFileSync(reportPath, 'utf8');
41
+ } catch {
42
+ return { status: 'skip' };
43
+ }
44
+
45
+ const stamped = body.match(REVIEWED_SHA_RE)?.[1];
46
+ if (!stamped) return { status: 'unstamped', head };
47
+
48
+ let reviewed;
49
+ try {
50
+ reviewed = execFileSync('git', ['rev-parse', '--verify', `${stamped}^{commit}`], { cwd, stdio: 'pipe' })
51
+ .toString().trim();
52
+ } catch {
53
+ // A stamp git cannot resolve (rewritten history, a typo) is not evidence
54
+ // of staleness — say "unstamped" rather than block on a guess.
55
+ return { status: 'unstamped', head };
56
+ }
57
+ if (reviewed === head) return { status: 'ok', reviewed, head };
58
+
59
+ let ahead = 0;
60
+ try {
61
+ ahead = execFileSync('git', ['rev-list', '--count', `${reviewed}..${head}`], { cwd, stdio: 'pipe' })
62
+ .toString().trim();
63
+ ahead = Number(ahead) || 0;
64
+ } catch { ahead = 0; }
65
+
66
+ // HEAD not descending from the reviewed commit (a rebase, a different branch)
67
+ // is still "not the tree that was reviewed".
68
+ return { status: 'stale', reviewed, head, ahead };
69
+ }
70
+
8
71
  /**
9
72
  * Unapprove action function - Revoke a previously approved gate
10
73
  */
@@ -155,12 +218,72 @@ export async function approveCommand(featureName, gateName, options = {}) {
155
218
  process.exit(1);
156
219
  }
157
220
 
221
+ // Gate 3 evaluator backstop: the composite score of a feature comes from
222
+ // the INDEPENDENT evaluator (persona `evaluator`, fresh context) dispatched
223
+ // by morph-review — never from self-assessment by whoever implemented it.
224
+ // Nothing enforced that. When the dispatch was impossible (observed: a
225
+ // session instruction forbade sub-agents), the flow still produced a
226
+ // review-report.md carrying a composite score and a verdict, with a caveat
227
+ // in prose nobody is obliged to read. A gate that yields a grade without
228
+ // its evaluator is not a gate.
229
+ //
230
+ // The escape hatch is EXPLICIT and RECORDED (`--self-assessed`), never
231
+ // silent — the silence is the defect. `--force` deliberately does NOT
232
+ // bypass this: it is the out-of-order recovery flag, a different concern.
233
+ // Gate 3 staleness backstop (D1): a review reviews a TREE, and the tree
234
+ // moves. Observed: review-report.md written at b30ac6e, then T3 landed and
235
+ // inverted the default of a business rule; the report stayed in the repo
236
+ // claiming 976/976 tests (they were 982) and never mentioning T3. Nothing
237
+ // detected that the review artifact had fallen behind the code it reviews.
238
+ //
239
+ // The report stamps the SHA it reviewed; approval compares it with HEAD.
240
+ // A stamp that is BEHIND HEAD refuses. A MISSING stamp only warns — reports
241
+ // written before this existed must not brick a legacy feature — but the
242
+ // warning names the exact token to add, so it teaches instead of nagging.
243
+ if (gateName === 'review') {
244
+ const stale = reviewStaleness(featureName);
245
+ if (stale.status === 'stale') {
246
+ console.error(chalk.red('\n❌ O `4-review/review-report.md` está desatualizado em relação ao código.'));
247
+ console.error(chalk.gray(` Revisado em: ${stale.reviewed.slice(0, 8)}`));
248
+ console.error(chalk.gray(` HEAD atual: ${stale.head.slice(0, 8)}${stale.ahead ? ` (${stale.ahead} commit(s) depois da revisão)` : ''}`));
249
+ console.error(chalk.gray(' Um review revisa uma ÁRVORE, e a árvore andou. Re-rode a `morph-review`'));
250
+ console.error(chalk.gray(' (incluindo o avaliador independente) e atualize o `**Reviewed-SHA:**`.\n'));
251
+ process.exit(1);
252
+ }
253
+ if (stale.status === 'unstamped') {
254
+ console.log(chalk.yellow('\n⚠️ O `review-report.md` não carrega `**Reviewed-SHA:**` — impossível saber se ele revisa o código atual.'));
255
+ console.log(chalk.gray(` Adicione a linha \`**Reviewed-SHA:** ${stale.head}\` ao relatório para que o Gate 3 possa verificar.`));
256
+ }
257
+ }
258
+
259
+ let selfAssessed = false;
260
+ if (gateName === 'review') {
261
+ const evaluatorReport = join(process.cwd(), '.morph', 'features', featureName, '4-review', 'evaluator-report.md');
262
+ if (!existsSync(evaluatorReport)) {
263
+ if (!options.selfAssessed) {
264
+ console.error(chalk.red('\n❌ Gate 3 exige o avaliador independente — `4-review/evaluator-report.md` não existe.'));
265
+ console.error(chalk.gray(' O score composto da feature vem do avaliador (persona `evaluator`, contexto fresco)'));
266
+ console.error(chalk.gray(' despachado pela morph-review, nunca de auto-avaliação de quem implementou.'));
267
+ console.error(chalk.gray(' Se o dispatch for impossível nesta sessão, aprove com --self-assessed —'));
268
+ console.error(chalk.gray(' a degradação fica registrada no feature.json e aparece no `list`/`fleet`.\n'));
269
+ process.exit(1);
270
+ }
271
+ selfAssessed = true;
272
+ }
273
+ }
274
+
158
275
  await setApprovalGate(featureName, gateName, true, {
159
276
  approvedBy,
160
277
  approvedAt: new Date().toISOString(),
161
- notes: options.notes
278
+ notes: options.notes,
279
+ ...(selfAssessed ? { selfAssessed: true } : {}),
162
280
  });
163
281
 
282
+ if (selfAssessed) {
283
+ console.log(chalk.yellow('\n⚠️ Gate 3 aprovado SEM avaliador independente (--self-assessed).'));
284
+ console.log(chalk.gray(' Registrado em feature.json → approvalGates.review.selfAssessed.'));
285
+ }
286
+
164
287
  console.log(chalk.green(`\n✅ Gate "${gateName}" approved for feature: ${featureName}`));
165
288
  console.log(chalk.gray(`Workflow can now proceed past ${gateName} phase\n`));
166
289
 
@@ -186,9 +186,9 @@ export async function createCommand(featureName, options = {}) {
186
186
  if (worktreeResult?.created && worktreeResult.path) {
187
187
  try {
188
188
  // feature.json in the root is NOT committed yet (create just
189
- // wrote it), so the branch `runWorktreeSetup` checked out from
190
- // HEAD does not have it — copy the freshly-scaffolded folder
191
- // into the worktree and re-materialize its local state.json.
189
+ // wrote it), so the branch `runWorktreeSetup` forked off the
190
+ // DEFAULT branch cannot have it — copy the freshly-scaffolded
191
+ // folder into the worktree and re-materialize its local state.json.
192
192
  cpSync(featureDir, join(worktreeResult.path, '.morph', 'features', featureName), { recursive: true });
193
193
  // The `.worktree` registration is ROOT-OWNED metadata (runWorktreeSetup
194
194
  // stamped it onto the root's feature.json, which cpSync just copied in).
@@ -12,8 +12,50 @@ import {
12
12
  branchExists,
13
13
  } from '../project/worktree.js';
14
14
  import { detectDefaultBranch, hasOriginRemote } from '../../lib/git/default-branch.js';
15
+ import { releaseClaim } from '../../../framework/hooks/shared/claim-registry.js';
15
16
  import { runArchive } from './archive.js';
16
17
 
18
+ /** workType → conventional-commit type. `hotfix` IS a fix; anything else is a chore. */
19
+ const PR_TYPE_BY_WORK_TYPE = { feature: 'feat', bug: 'fix', hotfix: 'fix', chore: 'chore' };
20
+
21
+ /** Git's soft limit for a subject line. */
22
+ const PR_TITLE_MAX = 72;
23
+
24
+ /**
25
+ * The PR title for `finish --pr`.
26
+ *
27
+ * Was always `morph: {slug}` — so every PR in a repo whose hand-made ones follow
28
+ * conventional commits (`fix(ui): coluna CRM nos dossiês…`) arrived shapeless
29
+ * and said nothing about what changed (D4).
30
+ *
31
+ * Now: `{type}({slug}): {subject}` — the type from the feature's `workType`, the
32
+ * slug kept as the scope (it is the traceability the old title at least had),
33
+ * and the subject from the feature's own identity: `name` first (a human
34
+ * deliberately titled it), then `description`, first line only. With neither,
35
+ * degrade to `{type}: {slug}` rather than emit an empty subject.
36
+ *
37
+ * @param {string} feature
38
+ * @param {object|null} fj - the feature.json
39
+ * @returns {string}
40
+ */
41
+ export function buildPrTitle(feature, fj) {
42
+ const type = PR_TYPE_BY_WORK_TYPE[fj?.workType] || 'chore';
43
+
44
+ const named = typeof fj?.name === 'string' ? fj.name.trim() : '';
45
+ // The default `name` IS the slug (create stamps it), which as a subject would
46
+ // only repeat the scope — treat it as absent.
47
+ const described = typeof fj?.description === 'string' ? fj.description.trim() : '';
48
+ const raw = (named && named !== feature ? named : described).split('\n')[0].trim();
49
+ if (!raw) return `${type}: ${feature}`;
50
+
51
+ const prefix = `${type}(${feature}): `;
52
+ const room = PR_TITLE_MAX - prefix.length;
53
+ // A scope so long there is no room left for a subject: drop the scope rather
54
+ // than ship a title that is all prefix.
55
+ if (room < 12) return `${type}: ${raw}`.slice(0, PR_TITLE_MAX);
56
+ return prefix + (raw.length <= room ? raw : `${raw.slice(0, room - 1)}…`);
57
+ }
58
+
17
59
  /**
18
60
  * `morph-spec finish <feature> --pr|--merge` — the single, symmetric close of a
19
61
  * feature's lifecycle, for BOTH the worktree flow and the common flow.
@@ -37,9 +79,22 @@ import { runArchive } from './archive.js';
37
79
  export async function finishCommand(feature, options = {}) {
38
80
  const cwd = options.cwd || process.cwd();
39
81
  const root = resolvePrimaryRoot(cwd);
40
- const emit = (obj) => console.log(JSON.stringify(obj));
41
82
  const rootNorm = resolve(root).replace(/\\/g, '/');
42
83
 
84
+ // A closed feature must not keep warning other sessions that someone is
85
+ // working on it (B1). Released on EVERY success path — hooking it into emit
86
+ // is the single point that covers merge/PR/--keep alike. Fail-open: a claim
87
+ // that outlives its feature only expires on its own TTL.
88
+ const emit = (obj) => {
89
+ if (obj?.finished === true) {
90
+ try {
91
+ const common = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd: root, stdio: 'pipe' }).toString().trim();
92
+ if (common) releaseClaim(feature, { gitCommonDir: resolve(root, common) });
93
+ } catch { /* fail-open */ }
94
+ }
95
+ console.log(JSON.stringify(obj));
96
+ };
97
+
43
98
  // Gate 0: valid kebab name — anti command-injection (the name flows into every
44
99
  // git/gh call below) and the same gate the worktree path always applied.
45
100
  if (!isValidFeatureName(feature)) {
@@ -85,6 +140,28 @@ export async function finishCommand(feature, options = {}) {
85
140
  }
86
141
  worktreePath = found.path;
87
142
  featureCwd = worktreePath;
143
+
144
+ // Validation 0b: the session's cwd must not be INSIDE the worktree we are
145
+ // about to tear down (A4). Windows holds a lock on the process's working
146
+ // directory, so `git worktree remove` fails — and by then the archive is
147
+ // committed, the branch merged, and the infra junctions already dropped.
148
+ // The result is a worse state than never having tried: an orphan directory,
149
+ // a de-registered worktree, and a `relinkHint` telling the user to repair
150
+ // by hand. Refuse HERE, before the first mutation — the cost is one
151
+ // `cd`, and it is the exact failure observed in the ProspectPRO session.
152
+ const cwdNorm = resolve(cwd).replace(/\\/g, '/');
153
+ const wtNorm = resolve(worktreePath).replace(/\\/g, '/');
154
+ if (cwdNorm === wtNorm || cwdNorm.startsWith(`${wtNorm}/`)) {
155
+ emit({
156
+ finished: false,
157
+ error: 'cwd-inside-worktree',
158
+ feature,
159
+ path: worktreePath,
160
+ root,
161
+ hint: `saia do worktree antes do finish — o \`git worktree remove\` não consegue apagar o diretório de trabalho do próprio processo. Rode de novo a partir de "${root}"`,
162
+ });
163
+ process.exit(1);
164
+ }
88
165
  } else {
89
166
  // COMMON flow: the feature branch must exist and be the ROOT's current
90
167
  // branch (so the archive commits on it and we can checkout the default to
@@ -120,7 +197,7 @@ export async function finishCommand(feature, options = {}) {
120
197
  emit({ finished: false, error: 'review-not-approved', feature });
121
198
  process.exit(1);
122
199
  }
123
- const prTitle = `morph: ${feature}`;
200
+ const prTitle = buildPrTitle(feature, fj);
124
201
  const prBody = (fj?.description && String(fj.description).trim())
125
202
  ? String(fj.description).trim()
126
203
  : `Feature ${feature} — integrada via morph-spec finish --pr.`;
@@ -346,7 +423,15 @@ function teardownExtras(teardown, worktreePath, branch, deleteBranch) {
346
423
  if (teardown.blockers.length) out.reparsePoints = teardown.blockers;
347
424
  if (teardown.ignoredBlocking.length) out.ignoredFiles = teardown.ignoredBlocking;
348
425
  out.cleanupHint = teardown.declineReason;
349
- out.relinkHint = `as junctions de infra foram soltas antes desta recusa — para continuar no worktree, rode \`morph-spec worktree link --all --target "${worktreePath}"\``;
426
+ // The unlink runs before the removal by design (it is what keeps `git
427
+ // worktree remove` from following the junctions out into the primary root),
428
+ // so a declined removal always passes through a moment with the infra
429
+ // loose. The teardown now re-links on that path — report which of the two
430
+ // actually happened instead of always announcing the worse one (A4).
431
+ out.relinked = teardown.relinked === true;
432
+ out.relinkHint = teardown.relinked
433
+ ? 'as junctions de infra foram soltas antes da tentativa e RESTAURADAS após a recusa — o worktree segue utilizável'
434
+ : `as junctions de infra foram soltas antes desta recusa e não puderam ser restauradas — para continuar no worktree, rode \`morph-spec worktree link --all --target "${worktreePath}"\``;
350
435
  }
351
436
  return out;
352
437
  }