@polymorphism-tech/morph-spec 8.1.1 → 8.2.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 (37) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +15 -0
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +6 -4
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +6 -3
  7. package/framework/MORPH.md +8 -2
  8. package/framework/hooks/claude-code/core/validate-completion.js +2 -1
  9. package/framework/hooks/claude-code/core/worktree-context.js +43 -0
  10. package/framework/hooks/shared/build-check.js +97 -24
  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 +2 -2
  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/package.json +1 -1
  24. package/src/commands/project/fleet.js +54 -0
  25. package/src/commands/project/worktree.js +56 -7
  26. package/src/commands/state/advance.js +8 -0
  27. package/src/commands/state/create.js +6 -0
  28. package/src/commands/state/handoff.js +49 -0
  29. package/src/lib/generators/handoff-generator.js +267 -0
  30. package/src/lib/generators/index.js +1 -3
  31. package/src/lib/validators/verify-runner.js +24 -7
  32. package/src/lib/worktree/env-allocator.js +149 -0
  33. package/src/lib/worktree/fleet-scanner.js +156 -0
  34. package/src/lib/context/context-builder.js +0 -289
  35. package/src/lib/generators/context-generator.js +0 -519
  36. package/src/lib/generators/mandate-generator.js +0 -468
  37. package/src/lib/generators/recap-generator.js +0 -285
@@ -35,7 +35,7 @@
35
35
  */
36
36
 
37
37
  import { execSync, execFileSync } from 'child_process';
38
- import { existsSync, mkdirSync, symlinkSync, readFileSync, appendFileSync, lstatSync, unlinkSync, rmdirSync, readdirSync, cpSync } from 'fs';
38
+ import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, appendFileSync, lstatSync, unlinkSync, rmdirSync, readdirSync, cpSync } from 'fs';
39
39
  import { join, resolve, dirname, relative } from 'path';
40
40
  import chalk from 'chalk';
41
41
 
@@ -196,7 +196,7 @@ export function setupInfraLinks(worktreePath, mainCwd, opts = {}) {
196
196
  // already speak for it. `lstat` reports a POSIX symlink and a Windows
197
197
  // junction alike as a symbolic link, so the exclude lands identically on
198
198
  // both platforms — as does the bug it prevents (see excludeInfraLinks).
199
- excludeInfraLinks(mainAbs, dirLinks.filter((rel) => {
199
+ excludeWorktreePaths(mainAbs, dirLinks.filter((rel) => {
200
200
  try {
201
201
  return lstatSync(join(wtAbs, rel)).isSymbolicLink();
202
202
  } catch {
@@ -205,11 +205,13 @@ export function setupInfraLinks(worktreePath, mainCwd, opts = {}) {
205
205
  }));
206
206
  }
207
207
 
208
- /** Marks the block `excludeInfraLinks` owns in `.git/info/exclude`. */
208
+ /** Marks the block `excludeWorktreePaths` owns in `.git/info/exclude`. */
209
209
  const EXCLUDE_MARKER = '# morph-spec: infra links inside worktrees (managed — see setupInfraLinks)';
210
210
 
211
211
  /**
212
- * Teach the repo's git to ignore the infra links a worktree carries.
212
+ * Teach the repo's git to ignore worktree-carried paths morph itself creates —
213
+ * the infra links from `setupInfraLinks` and the fleet env manifest
214
+ * (`.morph/worktree.env`). Same mechanism, same reasons, for both.
213
215
  *
214
216
  * CRITICAL — without this, `worktree finish` is unusable on POSIX and
215
217
  * destructive at worst, because git never sees a link as a link: it sees what
@@ -247,9 +249,9 @@ const EXCLUDE_MARKER = '# morph-spec: infra links inside worktrees (managed —
247
249
  * not write costs a refused finish, never a wrong one.
248
250
  *
249
251
  * @param {string} mainCwd - Absolute path to the main project root
250
- * @param {string[]} rels - worktree-relative link paths to exclude
252
+ * @param {string[]} rels - worktree-relative paths to exclude
251
253
  */
252
- function excludeInfraLinks(mainCwd, rels) {
254
+ function excludeWorktreePaths(mainCwd, rels) {
253
255
  if (!rels.length) return;
254
256
  try {
255
257
  // `--git-common-dir`, never `join(mainCwd, '.git')`: the primary root may
@@ -416,10 +418,47 @@ export async function runWorktreeSetup(feature, options = {}) {
416
418
  // Set up infrastructure junctions (node_modules, .claude, .morph/framework).
417
419
  setupInfraLinks(worktreePath, cwd);
418
420
 
421
+ // Fleet env: allocate this feature's deterministic port block and write the
422
+ // worktree-local manifest (.morph/worktree.env), excluded from git via the
423
+ // same common-dir mechanism as the infra links (it must be invisible to the
424
+ // finish clean-checks). Under `.morph/` on purpose: that prefix is already
425
+ // shieldable at finish (backed up, never a removal blocker). Fail-open — a
426
+ // worktree without ports is degraded, never broken.
427
+ let envBlock = null;
428
+ try {
429
+ const { allocateEnvBlock, resolveFleetConfig, buildEnvFileContent } =
430
+ await import('../../lib/worktree/env-allocator.js');
431
+ const common = execSync('git rev-parse --git-common-dir', { cwd, stdio: 'pipe' }).toString().trim();
432
+ if (common) {
433
+ const gitCommonDir = resolve(cwd, common);
434
+ envBlock = allocateEnvBlock(feature, { gitCommonDir, ...resolveFleetConfig(cwd) });
435
+ let stack = '';
436
+ try {
437
+ const cfg = JSON.parse(readFileSync(join(cwd, '.morph', 'config', 'config.json'), 'utf8').replace(/^/, ''));
438
+ stack = cfg?.project?.stack || '';
439
+ } catch { /* fail-open — stackless env still carries PORT */ }
440
+ mkdirSync(join(worktreePath, '.morph'), { recursive: true });
441
+ writeFileSync(join(worktreePath, '.morph', 'worktree.env'), buildEnvFileContent(feature, envBlock, stack), 'utf8');
442
+ excludeWorktreePaths(cwd, [join('.morph', 'worktree.env')]);
443
+ }
444
+ } catch { envBlock = null; /* fail-open */ }
445
+
419
446
  // Materialize a local, real state.json in the worktree (self-heal from the
420
447
  // committed feature.json files) so hooks work from the very first session.
421
448
  const materialized = await materializeState(worktreePath);
422
449
 
450
+ // Fleet handoff: the fresh instance that opens this worktree reads
451
+ // handoff.md before anything else (SessionStart hook points at it). The doc
452
+ // is derived and git-invisible (see handoff-generator). Fail-open — and a
453
+ // no-op in the create --worktree flow, where the feature dir only lands in
454
+ // the worktree after create's copy (create regenerates it then).
455
+ try {
456
+ if (existsSync(join(worktreePath, '.morph', 'features', feature, 'feature.json'))) {
457
+ const { writeHandoff } = await import('../../lib/generators/handoff-generator.js');
458
+ writeHandoff(feature, worktreePath);
459
+ }
460
+ } catch { /* fail-open */ }
461
+
423
462
  // Persist in state (non-blocking — feature may not be in state yet).
424
463
  // NOTE: imports from core/ (the lib/ path was a latent bug that silently
425
464
  // no-op'd this whole block); loadState/saveState operate on the cwd's state.
@@ -442,6 +481,7 @@ export async function runWorktreeSetup(feature, options = {}) {
442
481
  branch,
443
482
  feature,
444
483
  materialized: materialized.materialized,
484
+ ...(envBlock ? { env: { base: envBlock.base, range: envBlock.range } } : {}),
445
485
  nextStep: `Abra o Claude Code em ${worktreePath} para trabalhar nesta feature`,
446
486
  };
447
487
  } catch (err) {
@@ -524,7 +564,7 @@ export function worktreeLinkCommand(options = {}) {
524
564
  * @param {string} cwd
525
565
  * @returns {Array<{ path: string, branch: string|null, head: string|null }>}
526
566
  */
527
- function parseWorktreeList(cwd) {
567
+ export function parseWorktreeList(cwd) {
528
568
  let output;
529
569
  try {
530
570
  output = execSync('git worktree list --porcelain', { cwd, stdio: 'pipe' }).toString();
@@ -1084,6 +1124,15 @@ export async function worktreeFinishCommand(feature, options = {}) {
1084
1124
  try {
1085
1125
  execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' });
1086
1126
  } catch { /* fail-open */ }
1127
+
1128
+ // Fleet env: the worktree is gone, so no server can be running on its
1129
+ // block — release it for the next feature. ONLY here: a kept/declined
1130
+ // worktree may still be serving on those ports. Fail-open metadata cleanup.
1131
+ try {
1132
+ const { releaseEnvBlock } = await import('../../lib/worktree/env-allocator.js');
1133
+ const common = execSync('git rev-parse --git-common-dir', { cwd: root, stdio: 'pipe' }).toString().trim();
1134
+ if (common) releaseEnvBlock(feature, { gitCommonDir: resolve(root, common) });
1135
+ } catch { /* fail-open */ }
1087
1136
  }
1088
1137
 
1089
1138
  // The feature is now merged into the primary root — clear the `worktree`
@@ -86,6 +86,14 @@ export async function advanceCommand(featureName, options = {}) {
86
86
  if (!existsSync(nextPath)) mkdirSync(nextPath, { recursive: true });
87
87
  }
88
88
 
89
+ // Fleet handoff: a phase transition is exactly when a session hands the
90
+ // feature over (to a new instance, or to itself after /compact) — refresh
91
+ // the derived handoff.md so the next reader starts from the new phase.
92
+ try {
93
+ const { writeHandoff } = await import('../../lib/generators/handoff-generator.js');
94
+ writeHandoff(featureName, process.cwd());
95
+ } catch { /* fail-open */ }
96
+
89
97
  console.log(chalk.green(`\n✅ Advanced "${featureName}" past the ${phase} phase.`));
90
98
  if (gate) console.log(chalk.gray(` Gate "${gate}" approved (${approvedBy}).`));
91
99
  if (nextDir) console.log(chalk.gray(` Scaffolded ${nextDir}/ — ready for the next phase's first artifact.\n`));
@@ -155,6 +155,12 @@ export async function createCommand(featureName, options = {}) {
155
155
  // the copy (no features yet), so its `materialized:false` was stale.
156
156
  const remat = await materializeState(worktreeResult.path);
157
157
  worktreeResult.materialized = remat.materialized;
158
+ // Same reason the setup path generates one: the new instance's first
159
+ // read. Regenerated here because setup ran before the copy above.
160
+ try {
161
+ const { writeHandoff } = await import('../../lib/generators/handoff-generator.js');
162
+ writeHandoff(featureName, worktreeResult.path);
163
+ } catch { /* fail-open */ }
158
164
  } catch { /* fail-open — the feature already exists in root */ }
159
165
  }
160
166
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `morph-spec handoff <feature>` — generate the compressed session-handoff
3
+ * document (`.morph/features/{f}/handoff.md`).
4
+ *
5
+ * The fleet workflow hands a feature between Claude Code instances (a new
6
+ * worktree session, a resumed session after a phase transition). The handoff
7
+ * is the sanctioned way to carry context across: derived, regenerable, and
8
+ * pointer-based (see handoff-generator.js). This command is also called
9
+ * in-process by `worktree setup`, `create --worktree` and `advance` — the CLI
10
+ * surface exists for the LLM/user to refresh the document on demand.
11
+ */
12
+
13
+ import chalk from 'chalk';
14
+ import { buildHandoff, writeHandoff } from '../../lib/generators/handoff-generator.js';
15
+
16
+ /**
17
+ * @param {string} featureName
18
+ * @param {{ json?: boolean }} [options]
19
+ */
20
+ export function handoffCommand(featureName, options = {}) {
21
+ const result = buildHandoff(featureName, process.cwd());
22
+ if (!result) {
23
+ if (options.json) {
24
+ console.log(JSON.stringify({ error: 'feature-not-found', feature: featureName }));
25
+ } else {
26
+ console.error(chalk.red(`\n❌ Feature not found: ${featureName}\n`));
27
+ }
28
+ process.exit(1);
29
+ }
30
+
31
+ const path = writeHandoff(featureName, process.cwd());
32
+ if (!path) {
33
+ if (options.json) {
34
+ console.log(JSON.stringify({ error: 'write-failed', feature: featureName }));
35
+ } else {
36
+ console.error(chalk.red(`\n❌ Could not write handoff.md for ${featureName}\n`));
37
+ }
38
+ process.exit(1);
39
+ }
40
+
41
+ if (options.json) {
42
+ console.log(JSON.stringify({ path, feature: featureName, ...result.meta }));
43
+ return;
44
+ }
45
+
46
+ console.log(chalk.green(`\n✅ Handoff gerado: ${path}`));
47
+ console.log(chalk.gray(` fase ${result.meta.phase} · tasks ${result.meta.tasksDone}/${result.meta.tasksTotal} · ${result.meta.generatedAt}`));
48
+ console.log(chalk.gray(' A próxima sessão lê este documento antes de continuar (ponteiros, não cópias).\n'));
49
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * handoff-generator.js — compressed session-handoff document (fleet).
3
+ *
4
+ * A fleet runs one Claude Code instance per feature worktree. What travels
5
+ * between sessions must be a HANDOFF, not an export: a compact document that
6
+ * points at the feature's own files instead of copying their contents, so a
7
+ * fresh instance starts focused instead of inheriting a wall of stale context.
8
+ *
9
+ * Deterministic by construction — everything here is derived from feature.json,
10
+ * tasks.json, decisions.md and the worktree env manifest. The document is
11
+ * therefore REGENERABLE (never hand-edited, never merged): `morph-spec handoff`
12
+ * rebuilds it, and setup/create/advance regenerate it at the moments a new
13
+ * session is most likely to pick the feature up.
14
+ *
15
+ * Every section is fail-open: a missing tasks.json or a corrupt decisions.md
16
+ * degrades that section, never the document.
17
+ */
18
+
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'fs';
20
+ import { join, resolve } from 'path';
21
+ import { execSync } from 'child_process';
22
+ import { derivePhaseForFeature, getPendingGates } from '../../../framework/hooks/shared/state-reader.js';
23
+
24
+ const PHASE_DIRS = {
25
+ proposal: '0-proposal',
26
+ uiux: '1-design',
27
+ plan: '2-plan',
28
+ implement: '3-implement',
29
+ review: '4-review',
30
+ };
31
+
32
+ /** Ordered pointer candidates — only existing files make it into the doc. */
33
+ const POINTER_CANDIDATES = [
34
+ ['0-proposal/proposal.md', 'proposta e critérios de aceite'],
35
+ ['2-plan/spec.md', 'especificação técnica'],
36
+ ['2-plan/mandate.md', 'contrato de implementação (leia antes de cada task)'],
37
+ ['2-plan/tasks.json', 'DAG de tasks (status vivo)'],
38
+ ['decisions.md', 'decisões registradas'],
39
+ ['3-implement/recap.md', 'recap da implementação'],
40
+ ['4-review/review-report.md', 'relatório do Gate 3'],
41
+ ];
42
+
43
+ function readJson(path) {
44
+ return JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''));
45
+ }
46
+
47
+ function taskSummary(featureDir) {
48
+ try {
49
+ const tasksPath = join(featureDir, '2-plan', 'tasks.json');
50
+ if (!existsSync(tasksPath)) return null;
51
+ const data = readJson(tasksPath);
52
+ if (!Array.isArray(data.tasks)) return null;
53
+ const done = data.tasks.filter((t) => t.status === 'done').length;
54
+ const doneIds = new Set(data.tasks.filter((t) => t.status === 'done').map((t) => t.id));
55
+ const ready = data.tasks.filter(
56
+ (t) => t.status === 'pending' && Array.isArray(t.dependencies) && t.dependencies.every((d) => doneIds.has(d)),
57
+ );
58
+ return { done, total: data.tasks.length, ready };
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function decisionTitles(featureDir, limit = 5) {
65
+ try {
66
+ const path = join(featureDir, 'decisions.md');
67
+ if (!existsSync(path)) return [];
68
+ const titles = [...readFileSync(path, 'utf8').matchAll(/^##\s+(.+)$/gm)].map((m) => m[1].trim());
69
+ return titles.slice(-limit);
70
+ } catch {
71
+ return [];
72
+ }
73
+ }
74
+
75
+ function readWorktreeEnv(cwd) {
76
+ try {
77
+ const path = join(cwd, '.morph', 'worktree.env');
78
+ if (!existsSync(path)) return null;
79
+ const content = readFileSync(path, 'utf8');
80
+ const get = (key) => content.match(new RegExp(`^${key}=(.+)$`, 'm'))?.[1] || null;
81
+ const port = get('PORT');
82
+ return port ? { port, range: get('MORPH_PORT_RANGE') } : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function nextAction(feature, phase, tasks, featureName) {
89
+ const gates = feature.approvals || feature.approvalGates || {};
90
+ const approved = (g) => gates[g]?.approved === true;
91
+
92
+ switch (phase) {
93
+ case 'proposal':
94
+ // An approved Gate 1 with no plan artifact yet means "plan work is open":
95
+ // the derived phase only flips when 2-plan/spec.md lands (derivePhase is
96
+ // artifact-first). Pointing back at the proposal here would be wrong.
97
+ return approved('proposal')
98
+ ? `Gate 1 aprovado — produza \`2-plan/spec.md\` + \`mandate.md\` + \`tasks.json\` (a fase avança com o primeiro artefato; Gate 2 em seguida).`
99
+ : `Complete \`0-proposal/proposal.md\` e aprove o Gate 1 (\`morph-spec advance ${featureName}\`). Skill: /morph-proposal ${featureName}.`;
100
+ case 'uiux':
101
+ return approved('uiux')
102
+ ? `Gate de uiux aprovado — produza \`2-plan/spec.md\` + \`mandate.md\` + \`tasks.json\` (Gate 2 em seguida).`
103
+ : `Complete \`1-design/design-system.md\` e aprove o gate de uiux (\`morph-spec advance ${featureName}\`).`;
104
+ case 'plan':
105
+ return `Produza \`2-plan/spec.md\` + \`mandate.md\` + \`tasks.json\` e aprove o Gate 2 (\`morph-spec advance ${featureName}\`).`;
106
+ case 'implement': {
107
+ const ready = tasks?.ready?.map((t) => `${t.id} "${t.title}"`).join(', ');
108
+ return ready
109
+ ? `Execute as tasks ready — ${ready} — via /morph-apply ${featureName} (verify antes do juiz, barra 9).`
110
+ : `Todas as tasks concluídas ou bloqueadas — rode \`morph-spec advance ${featureName}\` para escancarar a review, ou destrave dependências. Skill: /morph-apply ${featureName}.`;
111
+ }
112
+ case 'review':
113
+ return approved('review')
114
+ ? `Gate 3 aprovado — feche: \`morph-spec worktree finish ${featureName}\` e depois \`morph-spec archive ${featureName}\` na raiz.`
115
+ : `Produza \`4-review/review-report.md\` (avaliador independente) e aprove o Gate 3; depois \`worktree finish\` + \`archive\`.`;
116
+ default:
117
+ return `Rode \`morph-spec status ${featureName}\` para orientar o próximo passo.`;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Build the handoff document for a feature, resolved against `cwd` (primary
123
+ * root or worktree — wherever the feature's `.morph/features/{f}` lives).
124
+ *
125
+ * @param {string} featureName
126
+ * @param {string} [cwd]
127
+ * @returns {{ markdown: string, meta: { generatedAt: string, phase: string, tasksDone: number, tasksTotal: number } } | null}
128
+ */
129
+ export function buildHandoff(featureName, cwd = process.cwd()) {
130
+ const featureDir = join(cwd, '.morph', 'features', featureName);
131
+ if (!existsSync(join(featureDir, 'feature.json'))) return null;
132
+
133
+ let feature;
134
+ try {
135
+ feature = readJson(join(featureDir, 'feature.json'));
136
+ } catch {
137
+ return null;
138
+ }
139
+
140
+ let phase = 'proposal';
141
+ let pendingGates = [];
142
+ try {
143
+ phase = derivePhaseForFeature(featureName, cwd) || 'proposal';
144
+ pendingGates = getPendingGates(featureName, cwd) || [];
145
+ } catch { /* fail-open — proposal defaults keep the doc useful */ }
146
+
147
+ const generatedAt = new Date().toISOString();
148
+ const tasks = taskSummary(featureDir);
149
+ const env = readWorktreeEnv(cwd);
150
+ const decisions = decisionTitles(featureDir);
151
+ const worktree = feature.worktree || null;
152
+
153
+ const gates = feature.approvals || feature.approvalGates || {};
154
+ const gateLine = Object.entries(gates)
155
+ .map(([name, g]) => (g?.approved ? `${name} ✔${g.approvedBy ? ` (${g.approvedBy})` : ''}` : `${name} pendente`))
156
+ .join(' · ') || '—';
157
+
158
+ const relFeatureDir = `.morph/features/${featureName}`;
159
+ const pointers = POINTER_CANDIDATES
160
+ .filter(([rel]) => existsSync(join(featureDir, rel)))
161
+ .map(([rel, why], i) => `${i + 1}. \`${relFeatureDir}/${rel}\` — ${why}`);
162
+
163
+ const lines = [
164
+ `# Handoff — ${featureName}`,
165
+ '',
166
+ `> Documento derivado, gerado em ${generatedAt} por \`morph-spec handoff\`.`,
167
+ '> Regenere com o comando — nunca edite à mão. Aponta para os arquivos da',
168
+ '> feature em vez de copiá-los: leia os ponteiros, não confie em resumo velho.',
169
+ '',
170
+ '## Identidade',
171
+ '',
172
+ `- Feature: **${featureName}** (${feature.workType || 'feature'}) · fase: **${phase}** · status: ${feature.status || '—'}`,
173
+ worktree?.path
174
+ ? `- Local: worktree \`${worktree.path}\` · branch \`${worktree.branch || `morph/${featureName}`}\``
175
+ : '- Local: raiz primária (sem worktree registrado)',
176
+ env
177
+ ? `- Ambiente: PORT ${env.port}${env.range ? ` (bloco ${env.range})` : ''} — fonte \`.morph/worktree.env\`; suba dev servers SEMPRE nessas portas`
178
+ : null,
179
+ '',
180
+ '## Estado',
181
+ '',
182
+ `- Gates: ${gateLine}`,
183
+ tasks
184
+ ? `- Tasks: ${tasks.done}/${tasks.total} done · ready agora: ${tasks.ready.length ? tasks.ready.map((t) => `**${t.id}** "${t.title}"`).join(', ') : 'nenhuma'}`
185
+ : '- Tasks: sem tasks.json ainda (fase de planejamento não concluída)',
186
+ pendingGates.length ? `- Gates pendentes: ${pendingGates.join(', ')}` : null,
187
+ '',
188
+ '## Leia nesta ordem (ponteiros — nada copiado)',
189
+ '',
190
+ ...(pointers.length ? pointers : ['1. (nenhum artefato ainda — feature recém-criada)']),
191
+ decisions.length ? '' : null,
192
+ decisions.length ? `Últimas decisões: ${decisions.map((d) => `"${d}"`).join(' · ')}` : null,
193
+ '',
194
+ '## Próxima ação',
195
+ '',
196
+ nextAction(feature, phase, tasks, featureName),
197
+ '',
198
+ '## Retomar',
199
+ '',
200
+ worktree?.path ? `- \`cd ${worktree.path}\` e abra o Claude Code (uma instância por worktree).` : '- Abra o Claude Code na raiz do projeto.',
201
+ `- Visão da frota: \`morph-spec fleet\` · status: \`morph-spec status ${featureName}\``,
202
+ '',
203
+ ].filter((l) => l !== null);
204
+
205
+ return {
206
+ markdown: `${lines.join('\n')}`,
207
+ meta: {
208
+ generatedAt,
209
+ phase,
210
+ tasksDone: tasks?.done ?? 0,
211
+ tasksTotal: tasks?.total ?? 0,
212
+ },
213
+ };
214
+ }
215
+
216
+ /** Marks the exclude block this generator owns in `.git/info/exclude`. */
217
+ const HANDOFF_EXCLUDE_MARKER = '# morph-spec: derived handoff docs (managed — see handoff-generator)';
218
+ const HANDOFF_EXCLUDE_PATTERN = '/.morph/**/handoff.md';
219
+
220
+ /**
221
+ * The handoff is DERIVED from committed sources, so it is never committed
222
+ * itself — regenerate > synchronize, the same principle as the materialized
223
+ * state.json. Left visible, every `worktree setup`/`advance` would drop an
224
+ * untracked file into the very `git status` the finish gates read. The
225
+ * common-dir `.git/info/exclude` hides it in every worktree of the repo
226
+ * (including the primary root); `**` also covers a feature folder after
227
+ * `archive` moves it. Idempotent and fail-open — a handoff that shows up as
228
+ * untracked is noise, never data loss.
229
+ */
230
+ function ensureHandoffExcluded(cwd) {
231
+ try {
232
+ const common = execSync('git rev-parse --git-common-dir', { cwd, stdio: 'pipe' }).toString().trim();
233
+ if (!common) return;
234
+ const infoDir = join(resolve(cwd, common), 'info');
235
+ const excludePath = join(infoDir, 'exclude');
236
+ const current = existsSync(excludePath) ? readFileSync(excludePath, 'utf8') : '';
237
+ const present = new Set(current.split(/\r?\n/).map((l) => l.trim()));
238
+ if (present.has(HANDOFF_EXCLUDE_PATTERN)) return;
239
+ mkdirSync(infoDir, { recursive: true });
240
+ appendFileSync(excludePath, [
241
+ current && !current.endsWith('\n') ? '\n' : '',
242
+ present.has(HANDOFF_EXCLUDE_MARKER) ? '' : `${HANDOFF_EXCLUDE_MARKER}\n`,
243
+ `${HANDOFF_EXCLUDE_PATTERN}\n`,
244
+ ].join(''));
245
+ } catch { /* fail-open */ }
246
+ }
247
+
248
+ /**
249
+ * Write `.morph/features/{f}/handoff.md`. Returns the written path, or null
250
+ * when the feature does not exist (nothing is created).
251
+ *
252
+ * @param {string} featureName
253
+ * @param {string} [cwd]
254
+ * @returns {string|null}
255
+ */
256
+ export function writeHandoff(featureName, cwd = process.cwd()) {
257
+ const result = buildHandoff(featureName, cwd);
258
+ if (!result) return null;
259
+ const path = join(cwd, '.morph', 'features', featureName, 'handoff.md');
260
+ try {
261
+ ensureHandoffExcluded(cwd);
262
+ writeFileSync(path, result.markdown, 'utf8');
263
+ return path;
264
+ } catch {
265
+ return null;
266
+ }
267
+ }
@@ -5,9 +5,7 @@
5
5
  */
6
6
 
7
7
  export * from './design-system-generator.js';
8
- export * from './context-generator.js';
9
- export * from './recap-generator.js';
8
+ export * from './handoff-generator.js';
10
9
  export * from './metadata-extractor.js';
11
10
  export * from './architecture-scanner.js';
12
11
  export * from './scaffold-generator.js';
13
- export * from './mandate-generator.js';
@@ -18,7 +18,7 @@
18
18
  import { existsSync, readFileSync } from 'fs';
19
19
  import { join } from 'path';
20
20
 
21
- import { runBuildCheck, getBuildCommand } from '../../../framework/hooks/shared/build-check.js';
21
+ import { runBuildCheck, getBuildSteps } from '../../../framework/hooks/shared/build-check.js';
22
22
  import { runTestSuite, resolveTestCommand } from '../tasks/test-runner.js';
23
23
  import { runValidation } from './validation-runner.js';
24
24
  import { loadRubric, aggregateScores } from './score-aggregator.js';
@@ -58,12 +58,13 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
58
58
  } = options;
59
59
 
60
60
  const stack = readStack(projectPath);
61
+ const paths = readProjectPaths(projectPath);
61
62
 
62
63
  const nodes = {};
63
64
  const feedback = [];
64
65
 
65
66
  // 1. build
66
- nodes.build = await runBuildNode({ projectPath, stack, timeout, skipBuild, _runBuildCheck });
67
+ nodes.build = await runBuildNode({ projectPath, stack, paths, timeout, skipBuild, _runBuildCheck });
67
68
  appendBuildFeedback(feedback, nodes.build);
68
69
 
69
70
  // 2. tests — skipped entirely (no invocation) when build already failed.
@@ -94,20 +95,20 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
94
95
  // Build node
95
96
  // ============================================================================
96
97
 
97
- async function runBuildNode({ projectPath, stack, timeout, skipBuild, _runBuildCheck }) {
98
+ async function runBuildNode({ projectPath, stack, paths = {}, timeout, skipBuild, _runBuildCheck }) {
98
99
  if (skipBuild) {
99
100
  return { status: 'skip', command: null, durationMs: 0, reason: 'requested', output: '' };
100
101
  }
101
102
 
102
- const hasTsConfig = existsSync(join(projectPath, 'tsconfig.json'));
103
- const command = getBuildCommand(stack, hasTsConfig);
104
- if (!command) {
103
+ const steps = getBuildSteps(stack, { cwd: projectPath, frontendPath: paths.frontendPath, backendPath: paths.backendPath });
104
+ if (steps.length === 0) {
105
105
  const reason = stack ? `no build command for stack "${stack}"` : 'no stack configured';
106
106
  return { status: 'skip', command: null, durationMs: 0, reason, output: '' };
107
107
  }
108
+ const command = steps.map((s) => s.command).join(' && ');
108
109
 
109
110
  const start = Date.now();
110
- const result = await _runBuildCheck(projectPath, stack, timeout);
111
+ const result = await _runBuildCheck(projectPath, stack, timeout, paths);
111
112
  const durationMs = Date.now() - start;
112
113
 
113
114
  return {
@@ -311,6 +312,22 @@ function readStack(projectPath) {
311
312
  }
312
313
  }
313
314
 
315
+ /** Read frontend/backend sub-paths for a monorepo, so the build node can run
316
+ * each stack's build command from its own directory instead of assuming a
317
+ * single root (see getBuildSteps() in build-check.js). Absent in single-stack
318
+ * projects — every consumer treats missing paths as "use projectPath". */
319
+ function readProjectPaths(projectPath) {
320
+ try {
321
+ const configPath = join(projectPath, '.morph', 'config', 'config.json');
322
+ if (!existsSync(configPath)) return {};
323
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
324
+ const { frontendPath, backendPath } = config?.project ?? {};
325
+ return { frontendPath, backendPath };
326
+ } catch {
327
+ return {};
328
+ }
329
+ }
330
+
314
331
  function linesOf(text) {
315
332
  const trimmed = (text || '').trim();
316
333
  return trimmed ? trimmed.split('\n') : [];