@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.
- package/README.md +2 -2
- package/bin/morph-spec.js +15 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +6 -4
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +6 -3
- package/framework/MORPH.md +8 -2
- package/framework/hooks/claude-code/core/validate-completion.js +2 -1
- package/framework/hooks/claude-code/core/worktree-context.js +43 -0
- package/framework/hooks/shared/build-check.js +97 -24
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +2 -2
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/fleet.js +54 -0
- package/src/commands/project/worktree.js +56 -7
- package/src/commands/state/advance.js +8 -0
- package/src/commands/state/create.js +6 -0
- package/src/commands/state/handoff.js +49 -0
- package/src/lib/generators/handoff-generator.js +267 -0
- package/src/lib/generators/index.js +1 -3
- package/src/lib/validators/verify-runner.js +24 -7
- package/src/lib/worktree/env-allocator.js +149 -0
- package/src/lib/worktree/fleet-scanner.js +156 -0
- package/src/lib/context/context-builder.js +0 -289
- package/src/lib/generators/context-generator.js +0 -519
- package/src/lib/generators/mandate-generator.js +0 -468
- 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
|
-
|
|
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 `
|
|
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
|
|
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
|
|
252
|
+
* @param {string[]} rels - worktree-relative paths to exclude
|
|
251
253
|
*/
|
|
252
|
-
function
|
|
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 './
|
|
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,
|
|
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
|
|
103
|
-
|
|
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') : [];
|