@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.
Files changed (34) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +15 -0
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +6 -4
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +6 -3
  7. package/framework/MORPH.md +8 -2
  8. package/framework/hooks/claude-code/core/worktree-context.js +43 -0
  9. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  10. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  11. package/framework/skills/core/morph-apply/SKILL.md +2 -2
  12. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  13. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  14. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  15. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  16. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  17. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  18. package/framework/skills/core/morph-review/SKILL.md +1 -1
  19. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  20. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  21. package/package.json +1 -1
  22. package/src/commands/project/fleet.js +54 -0
  23. package/src/commands/project/worktree.js +56 -7
  24. package/src/commands/state/advance.js +8 -0
  25. package/src/commands/state/create.js +6 -0
  26. package/src/commands/state/handoff.js +49 -0
  27. package/src/lib/generators/handoff-generator.js +267 -0
  28. package/src/lib/generators/index.js +1 -3
  29. package/src/lib/worktree/env-allocator.js +149 -0
  30. package/src/lib/worktree/fleet-scanner.js +156 -0
  31. package/src/lib/context/context-builder.js +0 -289
  32. package/src/lib/generators/context-generator.js +0 -519
  33. package/src/lib/generators/mandate-generator.js +0 -468
  34. 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 './context-generator.js';
9
- export * from './recap-generator.js';
8
+ export * from './handoff-generator.js';
10
9
  export * from './metadata-extractor.js';
11
10
  export * from './architecture-scanner.js';
12
11
  export * from './scaffold-generator.js';
13
- export * from './mandate-generator.js';
@@ -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
+ }