@polymorphism-tech/morph-spec 8.1.2 → 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/worktree-context.js +43 -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 +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/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
|
@@ -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';
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fleet-scanner.js — aggregate view of every active feature across the fleet.
|
|
3
|
+
*
|
|
4
|
+
* A fleet is N Claude Code instances, one per feature worktree, plus whatever
|
|
5
|
+
* is active in the primary root. Each instance only sees its own cwd; the
|
|
6
|
+
* operator loses track of "what is each session doing, what is waiting on me".
|
|
7
|
+
* This scanner answers that from the primary root's vantage point: one entry
|
|
8
|
+
* per active feature, worktree or root-resident, with the operational facts
|
|
9
|
+
* (phase, pending gates, task counts, port block, handoff freshness, last
|
|
10
|
+
* commit, dirtiness) resolved per entry.
|
|
11
|
+
*
|
|
12
|
+
* Read-only and fail-open per FIELD: a corrupt feature.json or a git hiccup
|
|
13
|
+
* nulls that field, never the scan.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execSync, execFileSync } from 'child_process';
|
|
17
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
18
|
+
import { join, resolve } from 'path';
|
|
19
|
+
import { isGitRepo, resolvePrimaryRoot, parseWorktreeList } from '../../commands/project/worktree.js';
|
|
20
|
+
import { registryPath, resolveFleetConfig } from './env-allocator.js';
|
|
21
|
+
import {
|
|
22
|
+
getFeature,
|
|
23
|
+
derivePhaseForFeature,
|
|
24
|
+
getPendingGates,
|
|
25
|
+
} from '../../../framework/hooks/shared/state-reader.js';
|
|
26
|
+
|
|
27
|
+
function taskCounts(featureDir) {
|
|
28
|
+
try {
|
|
29
|
+
const data = JSON.parse(readFileSync(join(featureDir, '2-plan', 'tasks.json'), 'utf8'));
|
|
30
|
+
if (!Array.isArray(data.tasks)) return null;
|
|
31
|
+
return { done: data.tasks.filter((t) => t.status === 'done').length, total: data.tasks.length };
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function handoffInfo(featureDir) {
|
|
38
|
+
try {
|
|
39
|
+
const path = join(featureDir, 'handoff.md');
|
|
40
|
+
if (!existsSync(path)) return { exists: false, generatedAt: null };
|
|
41
|
+
const generatedAt = readFileSync(path, 'utf8').match(/gerado em (\d{4}-\d{2}-\d{2}T[\d:.]+Z)/)?.[1] || null;
|
|
42
|
+
return { exists: true, generatedAt };
|
|
43
|
+
} catch {
|
|
44
|
+
return { exists: false, generatedAt: null };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function lastCommitRelative(root, branch) {
|
|
49
|
+
try {
|
|
50
|
+
return execFileSync('git', ['log', '-1', '--format=%cr', branch], { cwd: root, stdio: 'pipe' })
|
|
51
|
+
.toString().trim() || null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function dirtyCount(worktreePath) {
|
|
58
|
+
try {
|
|
59
|
+
const out = execFileSync('git', ['status', '--porcelain'], { cwd: worktreePath, stdio: 'pipe' }).toString();
|
|
60
|
+
return out.split('\n').filter((l) => l.trim()).length;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildEntry(feature, { location, path, branch, root, allocations, blockSize }) {
|
|
67
|
+
let workType = null;
|
|
68
|
+
let status = 'unknown';
|
|
69
|
+
let phase = 'proposal';
|
|
70
|
+
let pendingGates = [];
|
|
71
|
+
try {
|
|
72
|
+
const fj = getFeature(feature, path);
|
|
73
|
+
workType = fj?.workType || null;
|
|
74
|
+
status = fj?.status || 'unknown';
|
|
75
|
+
phase = derivePhaseForFeature(feature, path);
|
|
76
|
+
pendingGates = getPendingGates(feature, path);
|
|
77
|
+
} catch { /* field-level fail-open */ }
|
|
78
|
+
|
|
79
|
+
const featureDir = join(path, '.morph', 'features', feature);
|
|
80
|
+
const alloc = allocations[feature];
|
|
81
|
+
return {
|
|
82
|
+
feature,
|
|
83
|
+
workType,
|
|
84
|
+
phase,
|
|
85
|
+
status,
|
|
86
|
+
pendingGates,
|
|
87
|
+
location,
|
|
88
|
+
path,
|
|
89
|
+
branch,
|
|
90
|
+
tasks: taskCounts(featureDir),
|
|
91
|
+
env: alloc && Number.isInteger(alloc.base)
|
|
92
|
+
? { base: alloc.base, range: [alloc.base, alloc.base + blockSize - 1] }
|
|
93
|
+
: null,
|
|
94
|
+
handoff: handoffInfo(featureDir),
|
|
95
|
+
lastCommit: location === 'worktree' && branch ? lastCommitRelative(root, branch) : null,
|
|
96
|
+
dirty: location === 'worktree' ? dirtyCount(path) : null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Scan the fleet from anywhere inside the repo.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} [cwd]
|
|
104
|
+
* @returns {{ root: string, entries: object[] }}
|
|
105
|
+
*/
|
|
106
|
+
export function scanFleet(cwd = process.cwd()) {
|
|
107
|
+
if (!isGitRepo(cwd)) {
|
|
108
|
+
return { root: resolve(cwd).replace(/\\/g, '/'), entries: [] };
|
|
109
|
+
}
|
|
110
|
+
const root = resolvePrimaryRoot(cwd);
|
|
111
|
+
|
|
112
|
+
let allocations = {};
|
|
113
|
+
try {
|
|
114
|
+
const common = execSync('git rev-parse --git-common-dir', { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
115
|
+
if (common) {
|
|
116
|
+
const reg = JSON.parse(readFileSync(registryPath(resolve(root, common)), 'utf8'));
|
|
117
|
+
if (reg && typeof reg.allocations === 'object' && reg.allocations !== null) allocations = reg.allocations;
|
|
118
|
+
}
|
|
119
|
+
} catch { /* no registry yet — env stays null per entry */ }
|
|
120
|
+
const { blockSize } = resolveFleetConfig(root);
|
|
121
|
+
|
|
122
|
+
const entries = [];
|
|
123
|
+
const inWorktree = new Set();
|
|
124
|
+
|
|
125
|
+
for (const e of parseWorktreeList(root)) {
|
|
126
|
+
const m = e.branch && e.branch.match(/^refs\/heads\/morph\/(.+)$/);
|
|
127
|
+
if (!m) continue; // primary root entry and non-morph worktrees
|
|
128
|
+
inWorktree.add(m[1]);
|
|
129
|
+
entries.push(buildEntry(m[1], {
|
|
130
|
+
location: 'worktree',
|
|
131
|
+
path: e.path,
|
|
132
|
+
branch: e.branch.replace(/^refs\/heads\//, ''),
|
|
133
|
+
root,
|
|
134
|
+
allocations,
|
|
135
|
+
blockSize,
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Root-resident features (no live worktree): whatever has a feature.json.
|
|
140
|
+
try {
|
|
141
|
+
const featuresDir = join(root, '.morph', 'features');
|
|
142
|
+
if (existsSync(featuresDir)) {
|
|
143
|
+
const names = readdirSync(featuresDir, { withFileTypes: true })
|
|
144
|
+
.filter((d) => d.isDirectory())
|
|
145
|
+
.map((d) => d.name)
|
|
146
|
+
.filter((name) => !inWorktree.has(name) && existsSync(join(featuresDir, name, 'feature.json')));
|
|
147
|
+
for (const name of names) {
|
|
148
|
+
entries.push(buildEntry(name, {
|
|
149
|
+
location: 'root', path: root, branch: null, root, allocations, blockSize,
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch { /* fail-open */ }
|
|
154
|
+
|
|
155
|
+
return { root, entries };
|
|
156
|
+
}
|