@polymorphism-tech/morph-spec 8.1.2 → 8.2.1
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 +16 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +6 -4
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +8 -5
- package/framework/MORPH.md +28 -4
- package/framework/hooks/claude-code/core/protect-spec-files.js +7 -1
- package/framework/hooks/claude-code/core/worktree-context.js +43 -0
- package/framework/hooks/shared/build-check.js +8 -1
- package/framework/hooks/shared/payload-utils.js +16 -2
- package/framework/hooks/shared/phase-utils.js +2 -0
- 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 +13 -8
- 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 +22 -10
- 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 +19 -12
- 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 +206 -43
- 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/commands/state/score.js +13 -2
- package/src/commands/tasks/dag.js +11 -3
- package/src/core/paths/output-schema.js +28 -0
- package/src/lib/generators/handoff-generator.js +267 -0
- package/src/lib/generators/index.js +1 -3
- package/src/lib/stack/stack-profile.js +40 -9
- package/src/lib/tasks/tasks-json-parser.js +21 -0
- package/src/lib/tasks/test-runner.js +26 -4
- package/src/lib/validators/validation-runner.js +4 -1
- 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
|
@@ -49,6 +49,11 @@ export const OUTPUT_SCHEMA = {
|
|
|
49
49
|
|
|
50
50
|
// 4-review/
|
|
51
51
|
reviewReport: { filename: 'review-report.md', phaseDir: '4-review', phase: 'review', protected: false },
|
|
52
|
+
// Durable report the dispatched morph-evaluator sub-agent writes itself
|
|
53
|
+
// (MORPH.md §6) — the orchestrator reads it and synthesizes reviewReport
|
|
54
|
+
// from it; the two never collide because one is raw sub-agent output and
|
|
55
|
+
// the other is the orchestrator's curated Gate 3 presentation.
|
|
56
|
+
evaluatorReport:{ filename: 'evaluator-report.md', phaseDir: '4-review', phase: 'review', protected: false },
|
|
52
57
|
};
|
|
53
58
|
|
|
54
59
|
// ============================================================================
|
|
@@ -89,6 +94,29 @@ export function getFeatureDir(featureName) {
|
|
|
89
94
|
return join('.morph', 'features', featureName).replace(/\\/g, '/');
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Per-task sub-agent report — the durable "return value" channel for a
|
|
99
|
+
* dispatched implementation task (MORPH.md §6). A sub-agent's final text
|
|
100
|
+
* message may never reach the orchestrator (idle_notification with no text
|
|
101
|
+
* is an observed failure mode); the file it writes here always does. Doesn't
|
|
102
|
+
* fit OUTPUT_SCHEMA's one-fixed-filename-per-type model since the filename
|
|
103
|
+
* is parameterized by taskId, so it's a sibling helper instead of a schema
|
|
104
|
+
* entry.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} featureName
|
|
107
|
+
* @param {string} taskId - e.g. "T7"
|
|
108
|
+
* @returns {string} morph-relative, forward-slash path
|
|
109
|
+
*/
|
|
110
|
+
export function getTaskReportPath(featureName, taskId) {
|
|
111
|
+
return join('.morph', 'features', featureName, '3-implement', 'reports', `${taskId}.md`)
|
|
112
|
+
.replace(/\\/g, '/');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Absolute filesystem variant of {@link getTaskReportPath}. */
|
|
116
|
+
export function getAbsoluteTaskReportPath(projectRoot, featureName, taskId) {
|
|
117
|
+
return join(projectRoot, '.morph', 'features', featureName, '3-implement', 'reports', `${taskId}.md`);
|
|
118
|
+
}
|
|
119
|
+
|
|
92
120
|
/**
|
|
93
121
|
* Get all output paths for a feature, ready for state.json `outputs` block.
|
|
94
122
|
*/
|
|
@@ -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';
|
|
@@ -51,24 +51,55 @@ function readStack(cwd) {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** Merge two or more profiles' validator lists (deduped) and boolean flags. */
|
|
55
|
+
function mergeProfiles(profiles) {
|
|
56
|
+
return {
|
|
57
|
+
isDotnet: profiles.some((p) => p.isDotnet),
|
|
58
|
+
isNextjs: profiles.some((p) => p.isNextjs),
|
|
59
|
+
defaultValidators: [...new Set(profiles.flatMap((p) => p.defaultValidators))],
|
|
60
|
+
checkpointValidators: [...new Set(profiles.flatMap((p) => p.checkpointValidators))],
|
|
61
|
+
buildCommand: profiles.map((p) => p.buildCommand).find(Boolean) ?? null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
/**
|
|
55
66
|
* Get the StackProfile for the current project.
|
|
67
|
+
*
|
|
68
|
+
* `stack` may be a compound value (e.g. "dotnet-nextjs", "dotnet+nextjs") —
|
|
69
|
+
* the framework's own convention for monorepos (see stack-filter.js's
|
|
70
|
+
* getProjectTags, which splits project.stack the same way). Each segment is
|
|
71
|
+
* resolved independently and matching profiles are merged, instead of the
|
|
72
|
+
* whole compound string failing every exact-match check and silently
|
|
73
|
+
* collapsing to the empty profile (0 default validators).
|
|
56
74
|
*/
|
|
57
75
|
export function getStackProfile(cwd, stackOverride) {
|
|
58
76
|
const root = cwd ?? process.cwd();
|
|
59
77
|
const raw = stackOverride ?? readStack(root);
|
|
60
78
|
const stack = raw?.toLowerCase() ?? 'unknown';
|
|
61
79
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
80
|
+
const segments = stack.split(/[-+]/).filter(Boolean);
|
|
81
|
+
|
|
82
|
+
// Single-segment stack (the overwhelming common case): resolve exactly as
|
|
83
|
+
// before, byte-for-byte, including the 'unknown' → dotnet fallback.
|
|
84
|
+
if (segments.length <= 1) {
|
|
85
|
+
let profile;
|
|
86
|
+
if (NEXTJS_STACKS.has(stack)) {
|
|
87
|
+
profile = PROFILES.nextjs;
|
|
88
|
+
} else if (NO_VALIDATOR_STACKS.has(stack)) {
|
|
89
|
+
profile = PROFILES.empty;
|
|
90
|
+
} else if (DOTNET_STACKS.has(stack) || stack === 'unknown') {
|
|
91
|
+
profile = PROFILES.dotnet;
|
|
92
|
+
} else {
|
|
93
|
+
profile = PROFILES.empty;
|
|
94
|
+
}
|
|
95
|
+
return { stack, ...profile };
|
|
71
96
|
}
|
|
72
97
|
|
|
98
|
+
// Compound stack: resolve each recognized segment and merge.
|
|
99
|
+
const matched = [];
|
|
100
|
+
if (segments.some((s) => DOTNET_STACKS.has(s))) matched.push(PROFILES.dotnet);
|
|
101
|
+
if (segments.some((s) => NEXTJS_STACKS.has(s))) matched.push(PROFILES.nextjs);
|
|
102
|
+
|
|
103
|
+
const profile = matched.length > 0 ? mergeProfiles(matched) : PROFILES.empty;
|
|
73
104
|
return { stack, ...profile };
|
|
74
105
|
}
|
|
@@ -98,6 +98,27 @@ export async function parseTasksJson(filePath) {
|
|
|
98
98
|
return data;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Lenient variant for read-only analysis commands (e.g. `morph-spec dag`):
|
|
103
|
+
* a missing `version` field is defaulted to "1.0" instead of failing
|
|
104
|
+
* validation — legacy tasks.json predating the field is still analyzable.
|
|
105
|
+
* All other schema errors still throw exactly like `parseTasksJson`.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} filePath - Absolute path to tasks.json
|
|
108
|
+
* @returns {Promise<{ data: object, versionDefaulted: boolean }>}
|
|
109
|
+
*/
|
|
110
|
+
export async function parseTasksJsonLenient(filePath) {
|
|
111
|
+
const data = await fs.readJson(filePath);
|
|
112
|
+
const versionDefaulted = data.version === undefined;
|
|
113
|
+
if (versionDefaulted) data.version = '1.0';
|
|
114
|
+
|
|
115
|
+
const { valid, errors } = validateTasksJson(data);
|
|
116
|
+
if (!valid) {
|
|
117
|
+
throw new Error(`Invalid tasks.json:\n ${errors.join('\n ')}`);
|
|
118
|
+
}
|
|
119
|
+
return { data, versionDefaulted };
|
|
120
|
+
}
|
|
121
|
+
|
|
101
122
|
/**
|
|
102
123
|
* Synchronous variant — used by hooks.
|
|
103
124
|
*/
|
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
* Resolution order for test command:
|
|
5
5
|
* 1. .morph/config/config.json → project.testCommand
|
|
6
6
|
* 2. .sln or .csproj present at project root → "dotnet test"
|
|
7
|
-
* 3.
|
|
8
|
-
*
|
|
7
|
+
* 3. Nested *Tests.csproj anywhere in the tree (monorepo without a root
|
|
8
|
+
* .sln) → "dotnet test <proj> --nologo -v q" for each match
|
|
9
|
+
* 4. package.json present AND has scripts.test → "npm test"
|
|
10
|
+
* 5. null (no test suite detected)
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
import { execSync } from 'child_process';
|
|
12
14
|
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
13
15
|
import { join } from 'path';
|
|
16
|
+
import { globSync } from 'glob';
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* Resolve the test command for a project.
|
|
@@ -46,7 +49,26 @@ export function resolveTestCommand(projectPath = '.') {
|
|
|
46
49
|
// Unreadable project directory — fall through to next detection
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
// 3.
|
|
52
|
+
// 3. No root-level project file — look for a nested test project
|
|
53
|
+
// (e.g. tests/MyApp.Tests/MyApp.Tests.csproj), common in monorepos that
|
|
54
|
+
// have no root .sln. Run each matched project explicitly rather than a
|
|
55
|
+
// bare `dotnet test` (which finds nothing to build at the root).
|
|
56
|
+
try {
|
|
57
|
+
const testProjects = globSync('**/*Tests.csproj', {
|
|
58
|
+
cwd: projectPath,
|
|
59
|
+
ignore: ['**/bin/**', '**/obj/**', '**/node_modules/**'],
|
|
60
|
+
});
|
|
61
|
+
if (testProjects.length > 0) {
|
|
62
|
+
return testProjects
|
|
63
|
+
.sort()
|
|
64
|
+
.map((p) => `dotnet test "${p}" --nologo -v q`)
|
|
65
|
+
.join(' && ');
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Glob failure — fall through to next detection
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 4. package.json present AND has scripts.test → npm test
|
|
50
72
|
const packageJsonPath = join(projectPath, 'package.json');
|
|
51
73
|
if (existsSync(packageJsonPath)) {
|
|
52
74
|
try {
|
|
@@ -59,7 +81,7 @@ export function resolveTestCommand(projectPath = '.') {
|
|
|
59
81
|
}
|
|
60
82
|
}
|
|
61
83
|
|
|
62
|
-
//
|
|
84
|
+
// 5. Nothing found
|
|
63
85
|
return null;
|
|
64
86
|
}
|
|
65
87
|
|
|
@@ -124,7 +124,10 @@ export async function runValidation(projectPath, featureName, options = {}) {
|
|
|
124
124
|
const _profileForContracts = getStackProfile(projectPath);
|
|
125
125
|
const contractsType = resolveContractsOutputType(_profileForContracts.stack);
|
|
126
126
|
const contractsPath = getAbsoluteOutputPath(projectPath, featureName, contractsType);
|
|
127
|
-
|
|
127
|
+
// Positive check on isDotnet (contract-compliance is a VSA/.NET concern) —
|
|
128
|
+
// NOT `!isNextjs`, which used to wrongly suppress it for a compound
|
|
129
|
+
// "dotnet-nextjs" stack that has a .NET backend but is ALSO isNextjs.
|
|
130
|
+
if (existsSync(contractsPath) && _profileForContracts.isDotnet) {
|
|
128
131
|
validatorIds.push('contract-compliance');
|
|
129
132
|
}
|
|
130
133
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* env-allocator.js — deterministic per-worktree port-block allocation (fleet).
|
|
3
|
+
*
|
|
4
|
+
* Two parallel features that both start `next dev` (or `dotnet run`) collide on
|
|
5
|
+
* the same default port, and the failure is invisible until one server kills
|
|
6
|
+
* the other. Worktrees isolate files and branches, not runtime — this module
|
|
7
|
+
* closes that gap by giving every feature worktree its own stable block of
|
|
8
|
+
* ports.
|
|
9
|
+
*
|
|
10
|
+
* The registry lives at `<git-common-dir>/morph-spec/env-registry.json` — under
|
|
11
|
+
* `.git/` on purpose, for the same reasons as `finish-backup/`: it can never
|
|
12
|
+
* dirty the `git status` the worktree-finish gates read, it survives
|
|
13
|
+
* `git clean -fdx`, and the COMMON dir is the one location every linked
|
|
14
|
+
* worktree of the repo shares.
|
|
15
|
+
*
|
|
16
|
+
* Allocation is deterministic (first free index), idempotent per feature, and
|
|
17
|
+
* released only when `worktree finish` actually removed the worktree. Writes
|
|
18
|
+
* are tmp+rename so a crash never leaves a torn registry; a corrupt registry
|
|
19
|
+
* is treated as empty (fail-open — the cost is a re-allocation, never a crash).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
23
|
+
import { join } from 'path';
|
|
24
|
+
|
|
25
|
+
const DEFAULT_BASE_PORT = 4000;
|
|
26
|
+
const DEFAULT_BLOCK_SIZE = 10;
|
|
27
|
+
const MAX_PORT = 65535;
|
|
28
|
+
|
|
29
|
+
/** Registry file path for a repo's common git dir. */
|
|
30
|
+
export function registryPath(gitCommonDir) {
|
|
31
|
+
return join(gitCommonDir, 'morph-spec', 'env-registry.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readRegistry(gitCommonDir) {
|
|
35
|
+
try {
|
|
36
|
+
const raw = readFileSync(registryPath(gitCommonDir), 'utf8').replace(/^/, '');
|
|
37
|
+
const parsed = JSON.parse(raw);
|
|
38
|
+
if (parsed && typeof parsed.allocations === 'object' && parsed.allocations !== null) {
|
|
39
|
+
return parsed;
|
|
40
|
+
}
|
|
41
|
+
} catch { /* fail-open: corrupt or absent registry restarts empty */ }
|
|
42
|
+
return { version: 1, allocations: {} };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeRegistry(gitCommonDir, registry) {
|
|
46
|
+
const path = registryPath(gitCommonDir);
|
|
47
|
+
mkdirSync(join(gitCommonDir, 'morph-spec'), { recursive: true });
|
|
48
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
49
|
+
writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
|
|
50
|
+
renameSync(tmp, path);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read fleet overrides from the project's committed config.json.
|
|
55
|
+
* Fail-open: missing/malformed config (or a Windows BOM) → defaults.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} rootPath - primary root of the project
|
|
58
|
+
* @returns {{ basePort: number, blockSize: number }}
|
|
59
|
+
*/
|
|
60
|
+
export function resolveFleetConfig(rootPath) {
|
|
61
|
+
const defaults = { basePort: DEFAULT_BASE_PORT, blockSize: DEFAULT_BLOCK_SIZE };
|
|
62
|
+
try {
|
|
63
|
+
const raw = readFileSync(join(rootPath, '.morph', 'config', 'config.json'), 'utf8').replace(/^/, '');
|
|
64
|
+
const cfg = JSON.parse(raw);
|
|
65
|
+
const fleet = cfg?.fleet || {};
|
|
66
|
+
return {
|
|
67
|
+
basePort: Number.isInteger(fleet.basePort) && fleet.basePort > 0 ? fleet.basePort : defaults.basePort,
|
|
68
|
+
blockSize: Number.isInteger(fleet.blockSize) && fleet.blockSize > 0 ? fleet.blockSize : defaults.blockSize,
|
|
69
|
+
};
|
|
70
|
+
} catch {
|
|
71
|
+
return defaults;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Allocate (or return the existing) port block for a feature.
|
|
77
|
+
* First-free-index policy: released slots are reused, so long-lived repos
|
|
78
|
+
* don't creep toward the port ceiling.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} feature
|
|
81
|
+
* @param {{ gitCommonDir: string, basePort?: number, blockSize?: number }} opts
|
|
82
|
+
* @returns {{ index: number, base: number, range: [number, number], created: boolean }}
|
|
83
|
+
*/
|
|
84
|
+
export function allocateEnvBlock(feature, { gitCommonDir, basePort = DEFAULT_BASE_PORT, blockSize = DEFAULT_BLOCK_SIZE }) {
|
|
85
|
+
const registry = readRegistry(gitCommonDir);
|
|
86
|
+
|
|
87
|
+
const existing = registry.allocations[feature];
|
|
88
|
+
if (existing && Number.isInteger(existing.index)) {
|
|
89
|
+
const base = basePort + existing.index * blockSize;
|
|
90
|
+
return { index: existing.index, base, range: [base, base + blockSize - 1], created: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const used = new Set(
|
|
94
|
+
Object.values(registry.allocations)
|
|
95
|
+
.map((a) => a?.index)
|
|
96
|
+
.filter((i) => Number.isInteger(i)),
|
|
97
|
+
);
|
|
98
|
+
let index = 0;
|
|
99
|
+
while (used.has(index)) index += 1;
|
|
100
|
+
|
|
101
|
+
const base = basePort + index * blockSize;
|
|
102
|
+
if (base + blockSize - 1 > MAX_PORT) {
|
|
103
|
+
throw new Error(`env-allocator: port block ${base}-${base + blockSize - 1} exceeds ${MAX_PORT}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
registry.allocations[feature] = { index, base, createdAt: new Date().toISOString() };
|
|
107
|
+
writeRegistry(gitCommonDir, registry);
|
|
108
|
+
return { index, base, range: [base, base + blockSize - 1], created: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Release a feature's block. Returns false when nothing was allocated.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} feature
|
|
115
|
+
* @param {{ gitCommonDir: string }} opts
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
export function releaseEnvBlock(feature, { gitCommonDir }) {
|
|
119
|
+
const registry = readRegistry(gitCommonDir);
|
|
120
|
+
if (!registry.allocations[feature]) return false;
|
|
121
|
+
delete registry.allocations[feature];
|
|
122
|
+
writeRegistry(gitCommonDir, registry);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Render the `.morph/worktree.env` contents for a block, stack-aware.
|
|
128
|
+
* `PORT` covers next/vite/node dev servers; dotnet gets its own URL on base+1
|
|
129
|
+
* so a full-stack worktree runs web and api side by side inside one block.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} feature
|
|
132
|
+
* @param {{ base: number, range: [number, number] }} block
|
|
133
|
+
* @param {string} [stack] - config.json `project.stack` (e.g. "nextjs+dotnet")
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
export function buildEnvFileContent(feature, block, stack = '') {
|
|
137
|
+
const lines = [
|
|
138
|
+
`# Managed by morph-spec — fleet env desta worktree (feature: ${feature}).`,
|
|
139
|
+
'# Derivado e local: nunca commitar, nunca editar à mão (regenerado pelo worktree setup).',
|
|
140
|
+
`MORPH_FEATURE=${feature}`,
|
|
141
|
+
`MORPH_PORT_BASE=${block.base}`,
|
|
142
|
+
`MORPH_PORT_RANGE=${block.range[0]}-${block.range[1]}`,
|
|
143
|
+
`PORT=${block.base}`,
|
|
144
|
+
];
|
|
145
|
+
if (/\bdotnet\b/i.test(String(stack))) {
|
|
146
|
+
lines.push(`ASPNETCORE_URLS=http://localhost:${block.base + 1}`);
|
|
147
|
+
}
|
|
148
|
+
return `${lines.join('\n')}\n`;
|
|
149
|
+
}
|