@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
|
@@ -4,7 +4,7 @@ description: Varre o codebase existente, detecta stack atual, compara com o stac
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar uma feature em projeto brownfield (codebase já existente)
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.1
|
|
7
|
+
cliVersion: "8.2.1"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-stack-scan
|
|
@@ -4,7 +4,7 @@ description: Localiza e carrega um standard específico do STANDARDS.json sob de
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: quando precisar consultar um standard específico durante a implementação
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.1
|
|
7
|
+
cliVersion: "8.2.1"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-standards
|
package/package.json
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `morph-spec fleet [--json]` — the fleet view.
|
|
3
|
+
*
|
|
4
|
+
* One line per active feature across the primary root and every morph/*
|
|
5
|
+
* worktree: phase, pending gates, task progress, port block, handoff
|
|
6
|
+
* freshness, last commit and dirtiness. This is the terminal surface the
|
|
7
|
+
* operator scans between sessions ("what is each instance doing, what is
|
|
8
|
+
* waiting on me") — `worktree list` remains the lean per-worktree view.
|
|
9
|
+
*
|
|
10
|
+
* Read-only, always exits 0 (fail-open — an empty fleet is an answer).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
import { scanFleet } from '../../lib/worktree/fleet-scanner.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ json?: boolean, cwd?: string }} [options]
|
|
18
|
+
*/
|
|
19
|
+
export function fleetCommand(options = {}) {
|
|
20
|
+
const fleet = scanFleet(options.cwd || process.cwd());
|
|
21
|
+
|
|
22
|
+
if (options.json) {
|
|
23
|
+
console.log(JSON.stringify(fleet));
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!fleet.entries.length) {
|
|
28
|
+
console.log(chalk.gray('Frota vazia — nenhuma feature ativa (raiz ou worktrees).'));
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const col = (s, w) => String(s ?? '-').padEnd(w);
|
|
33
|
+
console.log(chalk.white.bold(
|
|
34
|
+
col('FEATURE', 20) + col('TYPE', 9) + col('PHASE', 11) + col('TASKS', 8)
|
|
35
|
+
+ col('GATES PEND.', 20) + col('WHERE', 24) + col('PORTS', 12) + col('HANDOFF', 9) + 'ATIVIDADE',
|
|
36
|
+
));
|
|
37
|
+
for (const e of fleet.entries) {
|
|
38
|
+
const tasks = e.tasks ? `${e.tasks.done}/${e.tasks.total}` : '-';
|
|
39
|
+
const gates = e.pendingGates.length ? e.pendingGates.join(',') : '-';
|
|
40
|
+
const where = e.location === 'worktree' ? e.branch : 'raiz';
|
|
41
|
+
const ports = e.env ? `${e.env.range[0]}-${e.env.range[1]}` : '-';
|
|
42
|
+
const handoff = e.handoff?.exists ? 'sim' : '-';
|
|
43
|
+
const activity = [
|
|
44
|
+
e.lastCommit ? `commit ${e.lastCommit}` : null,
|
|
45
|
+
Number.isInteger(e.dirty) && e.dirty > 0 ? chalk.yellow(`${e.dirty} dirty`) : null,
|
|
46
|
+
].filter(Boolean).join(' · ') || '-';
|
|
47
|
+
console.log(
|
|
48
|
+
col(e.feature, 20) + col(e.workType || '-', 9) + col(e.phase, 11) + col(tasks, 8)
|
|
49
|
+
+ col(gates, 20) + col(where, 24) + col(ports, 12) + col(handoff, 9) + activity,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
console.log(chalk.gray(`\nRaiz primária: ${fleet.root}`));
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
@@ -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
|
|
|
@@ -145,6 +145,38 @@ function getDateSuffix() {
|
|
|
145
145
|
|
|
146
146
|
// ── Infrastructure linking ───────────────────────────────────────────────────
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* The infra directories morph itself links into a worktree — the ONLY ones
|
|
150
|
+
* `setupInfraLinks`/`worktreeLinkCommand --all`/`unlinkWorktreeInfraLinks` treat
|
|
151
|
+
* as KNOWN, and therefore the only ones `finish` may auto-remove via the
|
|
152
|
+
* already-proven-safe unlink mechanism (never by widening the reparse-point
|
|
153
|
+
* scan — see `findReparsePoints`).
|
|
154
|
+
*
|
|
155
|
+
* Always: `node_modules`, `.claude`. Conditionally: `.morph/framework` (client
|
|
156
|
+
* projects only) and `{project.frontendPath}/node_modules` — a monorepo
|
|
157
|
+
* frontend (e.g. a Next.js app under `apps/catalog-admin`) commonly has its OWN
|
|
158
|
+
* node_modules, a separate `npm install` from the root's, invisible to a static
|
|
159
|
+
* list. Fail-open: a missing/unreadable config.json just means no frontendPath.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} mainCwd - Absolute path to the main project root
|
|
162
|
+
* @returns {string[]} relative paths, root-first
|
|
163
|
+
*/
|
|
164
|
+
function resolveInfraLinkDirs(mainCwd) {
|
|
165
|
+
const dirs = ['node_modules', '.claude'];
|
|
166
|
+
if (existsSync(join(mainCwd, '.morph', 'framework'))) {
|
|
167
|
+
dirs.push(join('.morph', 'framework'));
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const configPath = join(mainCwd, '.morph', 'config', 'config.json');
|
|
171
|
+
if (existsSync(configPath)) {
|
|
172
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
173
|
+
const frontendPath = config?.project?.frontendPath;
|
|
174
|
+
if (frontendPath) dirs.push(join(frontendPath, 'node_modules'));
|
|
175
|
+
}
|
|
176
|
+
} catch { /* fail-open — no frontendPath to add */ }
|
|
177
|
+
return dirs;
|
|
178
|
+
}
|
|
179
|
+
|
|
148
180
|
/**
|
|
149
181
|
* Create junctions in the worktree for shared infrastructure directories
|
|
150
182
|
* (node_modules/, .claude/, .morph/framework/).
|
|
@@ -169,16 +201,9 @@ export function setupInfraLinks(worktreePath, mainCwd, opts = {}) {
|
|
|
169
201
|
const mainAbs = resolve(mainCwd);
|
|
170
202
|
const wtAbs = resolve(worktreePath);
|
|
171
203
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
} else {
|
|
176
|
-
dirLinks = ['node_modules', '.claude'];
|
|
177
|
-
// .morph/framework/ — client projects (in framework repo, framework/ is tracked)
|
|
178
|
-
if (existsSync(join(mainAbs, '.morph', 'framework'))) {
|
|
179
|
-
dirLinks.push(join('.morph', 'framework'));
|
|
180
|
-
}
|
|
181
|
-
}
|
|
204
|
+
const dirLinks = (Array.isArray(opts.dirs) && opts.dirs.length)
|
|
205
|
+
? opts.dirs
|
|
206
|
+
: resolveInfraLinkDirs(mainAbs);
|
|
182
207
|
|
|
183
208
|
for (const rel of dirLinks) {
|
|
184
209
|
const target = join(mainAbs, rel);
|
|
@@ -196,7 +221,7 @@ export function setupInfraLinks(worktreePath, mainCwd, opts = {}) {
|
|
|
196
221
|
// already speak for it. `lstat` reports a POSIX symlink and a Windows
|
|
197
222
|
// junction alike as a symbolic link, so the exclude lands identically on
|
|
198
223
|
// both platforms — as does the bug it prevents (see excludeInfraLinks).
|
|
199
|
-
|
|
224
|
+
excludeWorktreePaths(mainAbs, dirLinks.filter((rel) => {
|
|
200
225
|
try {
|
|
201
226
|
return lstatSync(join(wtAbs, rel)).isSymbolicLink();
|
|
202
227
|
} catch {
|
|
@@ -205,11 +230,13 @@ export function setupInfraLinks(worktreePath, mainCwd, opts = {}) {
|
|
|
205
230
|
}));
|
|
206
231
|
}
|
|
207
232
|
|
|
208
|
-
/** Marks the block `
|
|
233
|
+
/** Marks the block `excludeWorktreePaths` owns in `.git/info/exclude`. */
|
|
209
234
|
const EXCLUDE_MARKER = '# morph-spec: infra links inside worktrees (managed — see setupInfraLinks)';
|
|
210
235
|
|
|
211
236
|
/**
|
|
212
|
-
* Teach the repo's git to ignore
|
|
237
|
+
* Teach the repo's git to ignore worktree-carried paths morph itself creates —
|
|
238
|
+
* the infra links from `setupInfraLinks` and the fleet env manifest
|
|
239
|
+
* (`.morph/worktree.env`). Same mechanism, same reasons, for both.
|
|
213
240
|
*
|
|
214
241
|
* CRITICAL — without this, `worktree finish` is unusable on POSIX and
|
|
215
242
|
* destructive at worst, because git never sees a link as a link: it sees what
|
|
@@ -247,9 +274,9 @@ const EXCLUDE_MARKER = '# morph-spec: infra links inside worktrees (managed —
|
|
|
247
274
|
* not write costs a refused finish, never a wrong one.
|
|
248
275
|
*
|
|
249
276
|
* @param {string} mainCwd - Absolute path to the main project root
|
|
250
|
-
* @param {string[]} rels - worktree-relative
|
|
277
|
+
* @param {string[]} rels - worktree-relative paths to exclude
|
|
251
278
|
*/
|
|
252
|
-
function
|
|
279
|
+
function excludeWorktreePaths(mainCwd, rels) {
|
|
253
280
|
if (!rels.length) return;
|
|
254
281
|
try {
|
|
255
282
|
// `--git-common-dir`, never `join(mainCwd, '.git')`: the primary root may
|
|
@@ -416,10 +443,47 @@ export async function runWorktreeSetup(feature, options = {}) {
|
|
|
416
443
|
// Set up infrastructure junctions (node_modules, .claude, .morph/framework).
|
|
417
444
|
setupInfraLinks(worktreePath, cwd);
|
|
418
445
|
|
|
446
|
+
// Fleet env: allocate this feature's deterministic port block and write the
|
|
447
|
+
// worktree-local manifest (.morph/worktree.env), excluded from git via the
|
|
448
|
+
// same common-dir mechanism as the infra links (it must be invisible to the
|
|
449
|
+
// finish clean-checks). Under `.morph/` on purpose: that prefix is already
|
|
450
|
+
// shieldable at finish (backed up, never a removal blocker). Fail-open — a
|
|
451
|
+
// worktree without ports is degraded, never broken.
|
|
452
|
+
let envBlock = null;
|
|
453
|
+
try {
|
|
454
|
+
const { allocateEnvBlock, resolveFleetConfig, buildEnvFileContent } =
|
|
455
|
+
await import('../../lib/worktree/env-allocator.js');
|
|
456
|
+
const common = execSync('git rev-parse --git-common-dir', { cwd, stdio: 'pipe' }).toString().trim();
|
|
457
|
+
if (common) {
|
|
458
|
+
const gitCommonDir = resolve(cwd, common);
|
|
459
|
+
envBlock = allocateEnvBlock(feature, { gitCommonDir, ...resolveFleetConfig(cwd) });
|
|
460
|
+
let stack = '';
|
|
461
|
+
try {
|
|
462
|
+
const cfg = JSON.parse(readFileSync(join(cwd, '.morph', 'config', 'config.json'), 'utf8').replace(/^/, ''));
|
|
463
|
+
stack = cfg?.project?.stack || '';
|
|
464
|
+
} catch { /* fail-open — stackless env still carries PORT */ }
|
|
465
|
+
mkdirSync(join(worktreePath, '.morph'), { recursive: true });
|
|
466
|
+
writeFileSync(join(worktreePath, '.morph', 'worktree.env'), buildEnvFileContent(feature, envBlock, stack), 'utf8');
|
|
467
|
+
excludeWorktreePaths(cwd, [join('.morph', 'worktree.env')]);
|
|
468
|
+
}
|
|
469
|
+
} catch { envBlock = null; /* fail-open */ }
|
|
470
|
+
|
|
419
471
|
// Materialize a local, real state.json in the worktree (self-heal from the
|
|
420
472
|
// committed feature.json files) so hooks work from the very first session.
|
|
421
473
|
const materialized = await materializeState(worktreePath);
|
|
422
474
|
|
|
475
|
+
// Fleet handoff: the fresh instance that opens this worktree reads
|
|
476
|
+
// handoff.md before anything else (SessionStart hook points at it). The doc
|
|
477
|
+
// is derived and git-invisible (see handoff-generator). Fail-open — and a
|
|
478
|
+
// no-op in the create --worktree flow, where the feature dir only lands in
|
|
479
|
+
// the worktree after create's copy (create regenerates it then).
|
|
480
|
+
try {
|
|
481
|
+
if (existsSync(join(worktreePath, '.morph', 'features', feature, 'feature.json'))) {
|
|
482
|
+
const { writeHandoff } = await import('../../lib/generators/handoff-generator.js');
|
|
483
|
+
writeHandoff(feature, worktreePath);
|
|
484
|
+
}
|
|
485
|
+
} catch { /* fail-open */ }
|
|
486
|
+
|
|
423
487
|
// Persist in state (non-blocking — feature may not be in state yet).
|
|
424
488
|
// NOTE: imports from core/ (the lib/ path was a latent bug that silently
|
|
425
489
|
// no-op'd this whole block); loadState/saveState operate on the cwd's state.
|
|
@@ -442,6 +506,7 @@ export async function runWorktreeSetup(feature, options = {}) {
|
|
|
442
506
|
branch,
|
|
443
507
|
feature,
|
|
444
508
|
materialized: materialized.materialized,
|
|
509
|
+
...(envBlock ? { env: { base: envBlock.base, range: envBlock.range } } : {}),
|
|
445
510
|
nextStep: `Abra o Claude Code em ${worktreePath} para trabalhar nesta feature`,
|
|
446
511
|
};
|
|
447
512
|
} catch (err) {
|
|
@@ -490,7 +555,7 @@ export function worktreeLinkCommand(options = {}) {
|
|
|
490
555
|
}
|
|
491
556
|
|
|
492
557
|
const dirs = options.all
|
|
493
|
-
?
|
|
558
|
+
? resolveInfraLinkDirs(root)
|
|
494
559
|
: ['node_modules'];
|
|
495
560
|
|
|
496
561
|
const alreadyPresent = {};
|
|
@@ -524,7 +589,7 @@ export function worktreeLinkCommand(options = {}) {
|
|
|
524
589
|
* @param {string} cwd
|
|
525
590
|
* @returns {Array<{ path: string, branch: string|null, head: string|null }>}
|
|
526
591
|
*/
|
|
527
|
-
function parseWorktreeList(cwd) {
|
|
592
|
+
export function parseWorktreeList(cwd) {
|
|
528
593
|
let output;
|
|
529
594
|
try {
|
|
530
595
|
output = execSync('git worktree list --porcelain', { cwd, stdio: 'pipe' }).toString();
|
|
@@ -584,12 +649,10 @@ function findWorktreeForFeature(feature, root) {
|
|
|
584
649
|
return { path: match.path, branch: actual, expectedBranch };
|
|
585
650
|
}
|
|
586
651
|
|
|
587
|
-
/** The infra paths `setupInfraLinks` junctions into a worktree. */
|
|
588
|
-
const INFRA_LINK_RELS = ['node_modules', '.claude', join('.morph', 'framework')];
|
|
589
|
-
|
|
590
652
|
/**
|
|
591
653
|
* Drop the infra junctions a worktree carries (`node_modules`, `.claude`,
|
|
592
|
-
* `.morph/framework
|
|
654
|
+
* `.morph/framework`, and the `project.frontendPath` node_modules if
|
|
655
|
+
* configured) WITHOUT following them.
|
|
593
656
|
*
|
|
594
657
|
* CRITICAL — this is what keeps `worktree finish` from destroying the project.
|
|
595
658
|
* `git worktree remove` refuses a worktree still holding these ("contains
|
|
@@ -606,12 +669,19 @@ const INFRA_LINK_RELS = ['node_modules', '.claude', join('.morph', 'framework')]
|
|
|
606
669
|
* target's contents, and `rmdirSync` fails on a real non-empty directory — the
|
|
607
670
|
* safety that stops this from ever deleting genuine files.
|
|
608
671
|
*
|
|
672
|
+
* This is deliberately narrow to the KNOWN inventory from `resolveInfraLinkDirs`
|
|
673
|
+
* — never widened to auto-remove any reparse point `findReparsePoints` might
|
|
674
|
+
* discover elsewhere. An unknown/nested link (npm link, npm workspaces) must
|
|
675
|
+
* still decline the removal; see the CANARY tests pinning that in worktree.test.js.
|
|
676
|
+
*
|
|
609
677
|
* @param {string} worktreePath
|
|
678
|
+
* @param {string} mainCwd - Absolute path to the main project root, to resolve
|
|
679
|
+
* `project.frontendPath` from config.json
|
|
610
680
|
* @returns {string[]} relative paths actually unlinked
|
|
611
681
|
*/
|
|
612
|
-
export function unlinkWorktreeInfraLinks(worktreePath) {
|
|
682
|
+
export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
|
|
613
683
|
const removed = [];
|
|
614
|
-
for (const rel of
|
|
684
|
+
for (const rel of resolveInfraLinkDirs(mainCwd)) {
|
|
615
685
|
const p = join(worktreePath, rel);
|
|
616
686
|
let st;
|
|
617
687
|
try {
|
|
@@ -706,9 +776,60 @@ function findReparsePoints(dir, base = dir, acc = { points: [], unprovable: [] }
|
|
|
706
776
|
*/
|
|
707
777
|
const IGNORED_SHIELDABLE_PREFIXES = ['.morph/'];
|
|
708
778
|
|
|
779
|
+
/**
|
|
780
|
+
* Well-known, regenerable build artifacts — `bin/`, `obj/` (dotnet), a real
|
|
781
|
+
* `node_modules` left behind outside the setup-created junction, and
|
|
782
|
+
* `*.tsbuildinfo` (tsc incremental cache). These are the everyday leftovers of
|
|
783
|
+
* running a build/test cycle inside a worktree, not user data: they are fully
|
|
784
|
+
* reproducible from source, and requiring `--discard-ignored` for every finish
|
|
785
|
+
* that ever ran a build turns the flag from "explicit consent for a real risk"
|
|
786
|
+
* into a rubber stamp nobody reads anymore. Auto-discarding them (like any
|
|
787
|
+
* `discarded` entry, WITHOUT a backup — see the note below) is safe precisely
|
|
788
|
+
* because "regenerable" is a stronger guarantee than "backed up".
|
|
789
|
+
*
|
|
790
|
+
* Deliberately narrow — do NOT add speculative patterns (`dist/`, `.next/`)
|
|
791
|
+
* beyond what's proven to be pure noise; anything else stays in `blocking` by
|
|
792
|
+
* default, requiring the explicit `--discard-ignored` consent.
|
|
793
|
+
*
|
|
794
|
+
* @param {string} p - forward-slash relative path from `git status --ignored`
|
|
795
|
+
*/
|
|
796
|
+
/**
|
|
797
|
+
* Whether a dirty root-side path is EXACTLY a sibling feature's live worktree
|
|
798
|
+
* registration — see the call site (Validation 3 in worktreeFinishCommand)
|
|
799
|
+
* for why this is safe to exempt from the whole-root clean gate rather than a
|
|
800
|
+
* widened hole.
|
|
801
|
+
*
|
|
802
|
+
* PROVES it rather than assuming: the path must be exactly that sibling's
|
|
803
|
+
* `feature.json`, must parse, and its `worktree.path` must point at a
|
|
804
|
+
* currently-existing directory. A parse failure, a missing registration, or a
|
|
805
|
+
* STALE pointer (worktree already removed) is NOT exempted.
|
|
806
|
+
*
|
|
807
|
+
* @param {string} relPath - forward-slash path relative to `root`
|
|
808
|
+
* @param {string} root - absolute path to the primary root
|
|
809
|
+
* @param {string} feature - the feature THIS finish is acting on (never exempted — its own scope is the shed's job)
|
|
810
|
+
*/
|
|
811
|
+
function isLiveSiblingWorktreeRegistration(relPath, root, feature) {
|
|
812
|
+
const m = relPath.match(/^\.morph\/features\/([^/]+)\/feature\.json$/);
|
|
813
|
+
if (!m || m[1] === feature) return false;
|
|
814
|
+
try {
|
|
815
|
+
const fj = JSON.parse(readFileSync(join(root, relPath), 'utf8'));
|
|
816
|
+
const wtPath = fj?.worktree?.path;
|
|
817
|
+
return typeof wtPath === 'string' && wtPath.length > 0 && existsSync(wtPath);
|
|
818
|
+
} catch {
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function isDisposableBuildArtifact(p) {
|
|
824
|
+
if (/\.tsbuildinfo$/.test(p)) return true;
|
|
825
|
+
const segments = p.split('/').filter(Boolean);
|
|
826
|
+
return segments.some((s) => s === 'bin' || s === 'obj' || s === 'node_modules');
|
|
827
|
+
}
|
|
828
|
+
|
|
709
829
|
/**
|
|
710
830
|
* Split the worktree's GITIGNORED leftovers into what this command may remove
|
|
711
|
-
* (morph's own, after a backup
|
|
831
|
+
* (morph's own, after a backup, or a known disposable build artifact, without
|
|
832
|
+
* one) and what it must refuse to destroy.
|
|
712
833
|
*
|
|
713
834
|
* Why this exists: `git worktree remove` deletes gitignored files outright, and
|
|
714
835
|
* the clean-check that gates it uses `git status --porcelain`, which omits them
|
|
@@ -722,34 +843,46 @@ const IGNORED_SHIELDABLE_PREFIXES = ['.morph/'];
|
|
|
722
843
|
*
|
|
723
844
|
* @param {string} worktreePath
|
|
724
845
|
* @param {{ discardIgnored?: boolean }} options
|
|
725
|
-
* @returns {{ shieldable: string[], blocking: string[] }}
|
|
846
|
+
* @returns {{ shieldable: string[], blocking: string[], discarded: string[] }}
|
|
726
847
|
*/
|
|
727
848
|
function classifyIgnoredLeftovers(worktreePath, options = {}) {
|
|
728
849
|
let out;
|
|
729
850
|
try {
|
|
730
851
|
// `-z` + execFile: NUL-delimited, no shell, no C-style quoting to undo —
|
|
731
852
|
// paths with spaces or non-ASCII (a pt-BR codebase has both) stay intact.
|
|
732
|
-
|
|
853
|
+
// `=matching` (not the traditional default): an ignored directory whose own
|
|
854
|
+
// name matches no pattern — e.g. `MyProj/`, ignored only because EVERYTHING
|
|
855
|
+
// inside it (`bin/`, `obj/`) does — otherwise collapses into one opaque
|
|
856
|
+
// `MyProj/` entry, hiding the actual disposable-artifact names from
|
|
857
|
+
// isDisposableBuildArtifact. A directory that IS itself the matched pattern
|
|
858
|
+
// (`node_modules/`) still reports as one entry either way — verified
|
|
859
|
+
// empirically, not assumed.
|
|
860
|
+
out = execFileSync('git', ['status', '--porcelain', '--ignored=matching', '-z'], { cwd: worktreePath, stdio: 'pipe' }).toString();
|
|
733
861
|
} catch {
|
|
734
|
-
return { shieldable: [], blocking: ['<git status --ignored falhou — não foi possível enumerar o que seria destruído>'] };
|
|
862
|
+
return { shieldable: [], blocking: ['<git status --ignored falhou — não foi possível enumerar o que seria destruído>'], discarded: [] };
|
|
735
863
|
}
|
|
736
864
|
|
|
737
865
|
const shieldable = [];
|
|
738
|
-
const
|
|
866
|
+
const blockingRest = [];
|
|
867
|
+
const disposable = [];
|
|
739
868
|
for (const record of out.split('\0')) {
|
|
740
869
|
if (!record || record.length < 4) continue;
|
|
741
870
|
if (record.slice(0, 2) !== '!!') continue; // only ignored entries; the rest is the clean-check's job
|
|
742
871
|
const p = record.slice(3).replace(/\\/g, '/');
|
|
743
872
|
if (IGNORED_SHIELDABLE_PREFIXES.some((prefix) => p.startsWith(prefix))) shieldable.push(p);
|
|
744
|
-
else
|
|
873
|
+
else if (isDisposableBuildArtifact(p)) disposable.push(p);
|
|
874
|
+
else blockingRest.push(p);
|
|
745
875
|
}
|
|
746
|
-
// Explicit consent clears the block — but the list is NOT
|
|
747
|
-
// are the files that exist nowhere else, and they are the
|
|
748
|
-
// reporting them is the least this can do. (Backing them
|
|
749
|
-
// option: this bucket is where
|
|
750
|
-
// is unbounded. The user declared them
|
|
751
|
-
|
|
752
|
-
return { shieldable, blocking, discarded: [] };
|
|
876
|
+
// Explicit consent clears the rest of the block too — but the list is NOT
|
|
877
|
+
// thrown away. These are the files that exist nowhere else, and they are the
|
|
878
|
+
// ones about to die; reporting them is the least this can do. (Backing them
|
|
879
|
+
// up instead is not an option: this bucket is where arbitrarily large
|
|
880
|
+
// leftovers land, so a copy is unbounded. The user declared them
|
|
881
|
+
// disposable — they still get told.)
|
|
882
|
+
if (options.discardIgnored) return { shieldable, blocking: [], discarded: [...disposable, ...blockingRest] };
|
|
883
|
+
// Known build artifacts are discarded by default, no flag required — they
|
|
884
|
+
// are reproducible from source, unlike anything else in `blockingRest`.
|
|
885
|
+
return { shieldable, blocking: blockingRest, discarded: disposable };
|
|
753
886
|
}
|
|
754
887
|
|
|
755
888
|
/**
|
|
@@ -975,15 +1108,34 @@ export async function worktreeFinishCommand(feature, options = {}) {
|
|
|
975
1108
|
}
|
|
976
1109
|
: {};
|
|
977
1110
|
|
|
978
|
-
// Validation 3: primary root must be clean
|
|
979
|
-
|
|
1111
|
+
// Validation 3: primary root must be clean — except a SIBLING feature's own
|
|
1112
|
+
// LIVE worktree registration (see isLiveSiblingWorktreeRegistration). That
|
|
1113
|
+
// artifact is how `runWorktreeSetup`/`create --worktree` are DESIGNED to
|
|
1114
|
+
// leave the root for that sibling's entire life (decidePersistence in
|
|
1115
|
+
// verify.js and the anti-dupe guard above both read the root's copy for
|
|
1116
|
+
// `.worktree.path`), so refusing THIS feature's finish over it would block
|
|
1117
|
+
// every OTHER feature for as long as any sibling stays open in its own
|
|
1118
|
+
// worktree — the exact "orphaned scaffold collides with finish" friction
|
|
1119
|
+
// from real multi-feature use. Anything else is still unexplained dirt and
|
|
1120
|
+
// still blocks, fail-closed, exactly as before.
|
|
1121
|
+
let rootStatusRaw;
|
|
980
1122
|
try {
|
|
981
|
-
|
|
1123
|
+
// `--untracked-files=all`, not the default `normal`: the default collapses
|
|
1124
|
+
// an entirely-untracked directory into one entry (e.g. `.morph/features/
|
|
1125
|
+
// sibling-feat/`, verified empirically) — which would never match the
|
|
1126
|
+
// exact `.../feature.json` path isLiveSiblingWorktreeRegistration expects,
|
|
1127
|
+
// silently defeating the exemption for the very shape it exists to allow.
|
|
1128
|
+
rootStatusRaw = execFileSync('git', ['status', '--porcelain', '--untracked-files=all', '-z'], { cwd: root, stdio: 'pipe' }).toString();
|
|
982
1129
|
} catch (err) {
|
|
983
1130
|
console.log(JSON.stringify({ finished: false, error: 'primary-root-status-failed', detail: (err.message || '').slice(0, 200), feature, ...backupNote }));
|
|
984
1131
|
process.exit(1);
|
|
985
1132
|
}
|
|
986
|
-
|
|
1133
|
+
const unexplainedDirt = rootStatusRaw.split('\0').filter((record) => {
|
|
1134
|
+
if (!record || record.length < 4) return false;
|
|
1135
|
+
const p = record.slice(3).replace(/\\/g, '/');
|
|
1136
|
+
return !isLiveSiblingWorktreeRegistration(p, root, feature);
|
|
1137
|
+
});
|
|
1138
|
+
if (unexplainedDirt.length) {
|
|
987
1139
|
console.log(JSON.stringify({
|
|
988
1140
|
finished: false,
|
|
989
1141
|
error: 'primary-root-dirty',
|
|
@@ -1035,7 +1187,7 @@ export async function worktreeFinishCommand(feature, options = {}) {
|
|
|
1035
1187
|
// Both gates below therefore PROVE-or-DECLINE. Declining costs a stale
|
|
1036
1188
|
// directory the user can delete in one command; being wrong costs data that
|
|
1037
1189
|
// exists nowhere else.
|
|
1038
|
-
unlinkWorktreeInfraLinks(worktreePath);
|
|
1190
|
+
unlinkWorktreeInfraLinks(worktreePath, root);
|
|
1039
1191
|
|
|
1040
1192
|
const scan = findReparsePoints(worktreePath);
|
|
1041
1193
|
const ignored = classifyIgnoredLeftovers(worktreePath, options);
|
|
@@ -1084,6 +1236,15 @@ export async function worktreeFinishCommand(feature, options = {}) {
|
|
|
1084
1236
|
try {
|
|
1085
1237
|
execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' });
|
|
1086
1238
|
} catch { /* fail-open */ }
|
|
1239
|
+
|
|
1240
|
+
// Fleet env: the worktree is gone, so no server can be running on its
|
|
1241
|
+
// block — release it for the next feature. ONLY here: a kept/declined
|
|
1242
|
+
// worktree may still be serving on those ports. Fail-open metadata cleanup.
|
|
1243
|
+
try {
|
|
1244
|
+
const { releaseEnvBlock } = await import('../../lib/worktree/env-allocator.js');
|
|
1245
|
+
const common = execSync('git rev-parse --git-common-dir', { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
1246
|
+
if (common) releaseEnvBlock(feature, { gitCommonDir: resolve(root, common) });
|
|
1247
|
+
} catch { /* fail-open */ }
|
|
1087
1248
|
}
|
|
1088
1249
|
|
|
1089
1250
|
// The feature is now merged into the primary root — clear the `worktree`
|
|
@@ -1153,7 +1314,9 @@ export async function worktreeFinishCommand(feature, options = {}) {
|
|
|
1153
1314
|
...(worktreeRemoved && ignored.discarded.length
|
|
1154
1315
|
? {
|
|
1155
1316
|
discardedIgnored: ignored.discarded,
|
|
1156
|
-
discardedNote:
|
|
1317
|
+
discardedNote: options.discardIgnored
|
|
1318
|
+
? `--discard-ignored: os caminhos acima eram gitignored, existiam só no worktree e foram DESTRUÍDOS sem backup (não são copiados por poderem ser arbitrariamente grandes)`
|
|
1319
|
+
: `artefatos de build reconhecidos (bin/, obj/, node_modules/, *.tsbuildinfo) — descartados por padrão, sem backup (regeneráveis a partir do source)`,
|
|
1157
1320
|
}
|
|
1158
1321
|
: {}),
|
|
1159
1322
|
...(worktreeRemoved
|
|
@@ -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
|
+
}
|
|
@@ -39,9 +39,20 @@ export async function scoreCommand(featureName, taskId, scoreArg, options = {})
|
|
|
39
39
|
|
|
40
40
|
let dimensions;
|
|
41
41
|
if (options.dims) {
|
|
42
|
-
|
|
42
|
+
// Commander captures --dims as variadic (`<dims...>`), so an UNQUOTED
|
|
43
|
+
// comma list typed in PowerShell — `--dims 9.5,9,9,9` — that arrives as
|
|
44
|
+
// 4 separate argv tokens (confirmed: Commander binds only the first
|
|
45
|
+
// token to a non-variadic option and silently drops the rest) is not
|
|
46
|
+
// lost. Accept either shape: one comma-joined string (the documented,
|
|
47
|
+
// quoted form) or N separate tokens — split on both separators so a
|
|
48
|
+
// mix of the two never matters either.
|
|
49
|
+
const raw = Array.isArray(options.dims) ? options.dims.join(' ') : String(options.dims);
|
|
50
|
+
const parts = raw.split(/[,\s]+/).filter(Boolean);
|
|
43
51
|
if (parts.length !== DIM_KEYS.length) {
|
|
44
|
-
console.error(chalk.red(
|
|
52
|
+
console.error(chalk.red(
|
|
53
|
+
`\n❌ --dims expects ${DIM_KEYS.length} values in order a,c,q,t (architecture,contracts,code-quality,test-coverage) — got ${parts.length} (${JSON.stringify(parts)}).\n` +
|
|
54
|
+
` Quote the value under PowerShell to keep it as one argument: --dims "9.5,9,9,9"\n`
|
|
55
|
+
));
|
|
45
56
|
process.exit(1);
|
|
46
57
|
}
|
|
47
58
|
dimensions = {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { existsSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
|
-
import {
|
|
4
|
+
import { parseTasksJsonLenient } from '../../lib/tasks/tasks-json-parser.js';
|
|
5
5
|
import { planDispatch } from '../../lib/tasks/dispatch-planner.js';
|
|
6
6
|
import { renderMermaidFlowchart } from '../../lib/tasks/mermaid-renderer.js';
|
|
7
7
|
|
|
@@ -24,8 +24,16 @@ export async function dagCommand(featureName, options = {}) {
|
|
|
24
24
|
process.exit(1);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
//
|
|
28
|
-
|
|
27
|
+
// parseTasksJsonLenient validates the schema and throws with the error
|
|
28
|
+
// list, but defaults a missing `version` (legacy plans predating the
|
|
29
|
+
// field) instead of failing — the DAG analysis doesn't depend on it.
|
|
30
|
+
const { data, versionDefaulted } = await parseTasksJsonLenient(tasksPath);
|
|
31
|
+
if (versionDefaulted) {
|
|
32
|
+
console.error(chalk.yellow(
|
|
33
|
+
`⚠ tasks.json has no 'version' field — defaulting to "1.0" for this analysis.\n` +
|
|
34
|
+
` Consider adding it back explicitly (safe: protect-spec-files allows backfilling it).`
|
|
35
|
+
));
|
|
36
|
+
}
|
|
29
37
|
|
|
30
38
|
// --mermaid short-circuits before the dispatch analysis: visualizing the
|
|
31
39
|
// plan's DAG doesn't need a sequential-vs-parallel decision.
|