@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.
Files changed (45) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +16 -1
  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 +8 -5
  7. package/framework/MORPH.md +28 -4
  8. package/framework/hooks/claude-code/core/protect-spec-files.js +7 -1
  9. package/framework/hooks/claude-code/core/worktree-context.js +43 -0
  10. package/framework/hooks/shared/build-check.js +8 -1
  11. package/framework/hooks/shared/payload-utils.js +16 -2
  12. package/framework/hooks/shared/phase-utils.js +2 -0
  13. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  14. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  15. package/framework/skills/core/morph-apply/SKILL.md +13 -8
  16. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  17. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  18. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  19. package/framework/skills/core/morph-implement/SKILL.md +22 -10
  20. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  21. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  22. package/framework/skills/core/morph-review/SKILL.md +19 -12
  23. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  24. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  25. package/package.json +1 -1
  26. package/src/commands/project/fleet.js +54 -0
  27. package/src/commands/project/worktree.js +206 -43
  28. package/src/commands/state/advance.js +8 -0
  29. package/src/commands/state/create.js +6 -0
  30. package/src/commands/state/handoff.js +49 -0
  31. package/src/commands/state/score.js +13 -2
  32. package/src/commands/tasks/dag.js +11 -3
  33. package/src/core/paths/output-schema.js +28 -0
  34. package/src/lib/generators/handoff-generator.js +267 -0
  35. package/src/lib/generators/index.js +1 -3
  36. package/src/lib/stack/stack-profile.js +40 -9
  37. package/src/lib/tasks/tasks-json-parser.js +21 -0
  38. package/src/lib/tasks/test-runner.js +26 -4
  39. package/src/lib/validators/validation-runner.js +4 -1
  40. package/src/lib/worktree/env-allocator.js +149 -0
  41. package/src/lib/worktree/fleet-scanner.js +156 -0
  42. package/src/lib/context/context-builder.js +0 -289
  43. package/src/lib/generators/context-generator.js +0 -519
  44. package/src/lib/generators/mandate-generator.js +0 -468
  45. 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.2"
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.2"
7
+ cliVersion: "8.2.1"
8
8
  ---
9
9
 
10
10
  # morph-standards
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "8.1.2",
3
+ "version": "8.2.1",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -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
- let dirLinks;
173
- if (Array.isArray(opts.dirs) && opts.dirs.length) {
174
- dirLinks = opts.dirs;
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
- excludeInfraLinks(mainAbs, dirLinks.filter((rel) => {
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 `excludeInfraLinks` owns in `.git/info/exclude`. */
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 the infra links a worktree carries.
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 link paths to exclude
277
+ * @param {string[]} rels - worktree-relative paths to exclude
251
278
  */
252
- function excludeInfraLinks(mainCwd, rels) {
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
- ? ['node_modules', '.claude', join('.morph', 'framework')]
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`) WITHOUT following them.
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 INFRA_LINK_RELS) {
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) and what it must refuse to destroy.
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
- out = execFileSync('git', ['status', '--porcelain', '--ignored', '-z'], { cwd: worktreePath, stdio: 'pipe' }).toString();
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 blocking = [];
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 blocking.push(p);
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 thrown away. These
747
- // are the files that exist nowhere else, and they are the ones about to die;
748
- // reporting them is the least this can do. (Backing them up instead is not an
749
- // option: this bucket is where `node_modules`/`dist`/`.next` land, so a copy
750
- // is unbounded. The user declared them disposable — they still get told.)
751
- if (options.discardIgnored) return { shieldable, blocking: [], discarded: blocking };
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
- let rootStatus;
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
- rootStatus = execFileSync('git', ['status', '--porcelain'], { cwd: root, stdio: 'pipe' }).toString();
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
- if (rootStatus.trim() !== '') {
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: `--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)`,
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
- const parts = String(options.dims).split(',').map(s => s.trim());
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(`\n❌ --dims expects ${DIM_KEYS.length} comma-separated values in order a,c,q,t (architecture,contracts,code-quality,test-coverage)\n`));
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 { parseTasksJson } from '../../lib/tasks/tasks-json-parser.js';
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
- // parseTasksJson validates the schema and throws with the error list.
28
- const data = await parseTasksJson(tasksPath);
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.