@polymorphism-tech/morph-spec 8.35.0 → 8.36.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 (55) hide show
  1. package/CLAUDE.md +11 -9
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +2 -1
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/MORPH.md +24 -23
  8. package/framework/docs/CLI.md +187 -13
  9. package/framework/evals/proof-rubrics/eval-de-modelo.md +16 -0
  10. package/framework/evals/rubrics/condensed.md +3 -1
  11. package/framework/evals/rubrics/test-coverage.md +8 -0
  12. package/framework/hooks/claude-code/core/worktree-env-inject.js +43 -0
  13. package/framework/hooks/shared/payload-utils.js +15 -2
  14. package/framework/hooks/shared/phase-utils.js +1 -0
  15. package/framework/phases.json +1 -1
  16. package/framework/rules/morph-workflow.md +1 -1
  17. package/framework/schemas/config.schema.json +7 -0
  18. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  19. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  20. package/framework/skills/core/morph-apply/SKILL.md +21 -10
  21. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  22. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  23. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  24. package/framework/skills/core/morph-implement/SKILL.md +20 -3
  25. package/framework/skills/core/morph-media/SKILL.md +1 -1
  26. package/framework/skills/core/morph-plan/SKILL.md +41 -3
  27. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  28. package/framework/skills/core/morph-review/SKILL.md +11 -2
  29. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  30. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  31. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  32. package/framework/standards/STANDARDS.json +2 -2
  33. package/framework/standards/ai-agents/prompt-sources.md +4 -3
  34. package/framework/standards/ai-agents/testing-ai.md +14 -13
  35. package/framework/templates/dotnet/ai-kit/README.md +14 -8
  36. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +11 -3
  37. package/package.json +1 -1
  38. package/src/commands/project/doctor.js +59 -3
  39. package/src/commands/project/worktree.js +68 -3
  40. package/src/commands/state/finish.js +48 -5
  41. package/src/commands/tasks/dag.js +13 -1
  42. package/src/commands/validation/verify.js +47 -18
  43. package/src/core/paths/output-schema.js +7 -0
  44. package/src/lib/detectors/index.js +225 -217
  45. package/src/lib/git/pr-files.js +56 -0
  46. package/src/lib/risk-detector.js +38 -18
  47. package/src/lib/specs/done-criteria-check.js +200 -0
  48. package/src/lib/specs/usecase-check.js +129 -5
  49. package/src/lib/tasks/dotnet-test-introspection.js +517 -0
  50. package/src/lib/tasks/test-nodes.js +190 -20
  51. package/src/lib/tasks/test-runner.js +122 -2
  52. package/src/lib/tasks/test-scope.js +567 -0
  53. package/src/lib/validators/traceability-check.js +9 -2
  54. package/src/lib/validators/verify-runner.js +150 -18
  55. package/src/lib/worktree/docker-audit.js +5 -1
@@ -1,4 +1,4 @@
1
- import { join, relative } from 'path';
1
+ import { join, relative, resolve } from 'path';
2
2
  import { execSync } from 'child_process';
3
3
  import { existsSync } from 'fs';
4
4
  import { platform, homedir } from 'os';
@@ -9,6 +9,7 @@ import { pathExists, readJson } from '../../utils/file-copier.js';
9
9
  import { checkClaudeMdImport } from '../../utils/claude-md-injector.js';
10
10
  import { buildPrIndex } from '../../lib/git/pr-index.js';
11
11
  import { pendingPrFeatures, MORPH_BRANCH_RE } from '../../lib/git/pending-prs.js';
12
+ import { prCarriesFeature } from '../../lib/git/pr-files.js';
12
13
  import {
13
14
  checkCLIOutdated,
14
15
  checkProjectOutdated,
@@ -19,9 +20,27 @@ import { checkMissingAgents, checkAgentsDrift } from '../../utils/agents-install
19
20
  import { getProjectTags } from '../../lib/stack-filter.js';
20
21
  import { auditGraph } from '../../lib/graph/graph-health.js';
21
22
  import { auditFeatureLifecycle } from '../../core/state/lifecycle-audit.js';
22
- import { validateTestNodes, morphConfigPath } from '../../lib/tasks/test-nodes.js';
23
+ import { validateTestNodes, morphConfigPath, discoverFrontendPackages } from '../../lib/tasks/test-nodes.js';
24
+ import { samePathCase } from '../../lib/worktree/docker-audit.js';
23
25
 
24
26
  const isWindows = platform() === 'win32';
27
+
28
+ /**
29
+ * Resolved-path equality for a `cwd` string against the project root —
30
+ * `"./src/web"`, `"src/web/"` and (on win32) `"src\\web"` must compare equal
31
+ * to `"src/web"`. Case-insensitive on win32 only, through `samePathCase` of
32
+ * `src/lib/worktree/docker-audit.js` — the one copy of that rule (#87 F5): NTFS
33
+ * itself is case-insensitive, so two spellings that differ only in case are
34
+ * the same directory there and nowhere else.
35
+ *
36
+ * @param {string} root - Project root the cwd is relative to
37
+ * @param {string|null|undefined} cwd
38
+ * @returns {string}
39
+ */
40
+ function normCwd(root, cwd) {
41
+ return samePathCase(resolve(root, String(cwd ?? '.')).replace(/\\/g, '/'));
42
+ }
43
+
25
44
  /**
26
45
  * Check which required Claude Code plugins are installed.
27
46
  * @param {Object} pluginsData - The `plugins` object from installed_plugins.json
@@ -402,6 +421,37 @@ async function doctorFullCommand(frameworkRoot) {
402
421
  broken.length > 0
403
422
  ? broken.map((n) => n.reason ?? `invalid-test-node: ${n.id}`).join('; ')
404
423
  : '');
424
+
425
+ // #87 PR B: a vitest/jest package.json declared tests[] never mentions is
426
+ // exactly the shape F30 exploited — a real suite sitting outside the gate.
427
+ // WARN-only, never ✗: the fail-closed barrier is `verify`, this just informs.
428
+ // Compared by RESOLVED path, not raw string: "./src/web", "src/web/" and
429
+ // (win32) "src\\web" all address the same node as "src/web" and must not
430
+ // produce a spurious warning just because the spelling differs.
431
+ const declaredCwds = new Set(declared.map((n) => normCwd(frameworkRoot, n.cwd)));
432
+ const uncoveredPkgs = discoverFrontendPackages(frameworkRoot, testsConfig)
433
+ .filter((pkg) => !declaredCwds.has(normCwd(frameworkRoot, pkg.cwd)));
434
+ if (uncoveredPkgs.length > 0) {
435
+ check(
436
+ `config tests[] cobre package.json de frontend — sem nó: ${uncoveredPkgs.map((p) => `${p.cwd} (${p.runner})`).join(', ')}`,
437
+ false, true,
438
+ );
439
+ }
440
+ } else if (typeof testsConfig?.project?.testCommand === 'string' && testsConfig.project.testCommand.trim()) {
441
+ // Mesma checagem, para o caso testCommand-only: o comando é OPACO (uma
442
+ // string de shell), então cobertura aqui é só "o comando MENCIONA o
443
+ // runner do package.json" — não confirmar isso seria ruído para quem já
444
+ // roda vitest por dentro do testCommand.
445
+ const testCommand = testsConfig.project.testCommand;
446
+ const uncoveredPkgs = discoverFrontendPackages(frameworkRoot, testsConfig)
447
+ .filter((pkg) => !testCommand.includes(pkg.runner));
448
+ if (uncoveredPkgs.length > 0) {
449
+ console.log(chalk.cyan('\n .morph/config/config.json — project.testCommand'));
450
+ check(
451
+ `config testCommand cobre package.json de frontend — sem nó: ${uncoveredPkgs.map((p) => `${p.cwd} (${p.runner})`).join(', ')}`,
452
+ false, true,
453
+ );
454
+ }
405
455
  }
406
456
  }
407
457
 
@@ -688,7 +738,10 @@ const PR_STALE_DAYS = 7;
688
738
  * revisão que nenhuma superfície local reivindica.
689
739
  * pr-merged-not-landed — o PR mergeou mas `archive/{f}` não está aqui. O
690
740
  * archive viaja no diff do PR, então ou falta um
691
- * `git pull`, ou ele não estava no diff.
741
+ * `git pull`, ou ele não estava no diff. Branch
742
+ * `morph/*` cujo diff nunca tocou
743
+ * `.morph/{features,archive}/{f}/` não conta: o nome
744
+ * não prova feature (ver pr-files.js).
692
745
  *
693
746
  * Offline-safe: índice `null` devolve `null`, NUNCA lista vazia — lista vazia
694
747
  * afirmaria "perguntei e não achei nada", que é diferente de "não pude
@@ -718,6 +771,9 @@ function auditPullRequests(root) {
718
771
  if (!m || pr.state !== 'MERGED') continue;
719
772
  const feature = m[1];
720
773
  if (existsSync(join(root, '.morph', 'archive', feature))) continue;
774
+ // Só `false` (diff lido, sem a feature) cala o aviso; `null` — não pude
775
+ // ler o diff — o mantém, ou um `git pull` realmente faltando ficaria mudo.
776
+ if (prCarriesFeature(root, pr.number, feature) === false) continue;
721
777
  issues.push({
722
778
  feature,
723
779
  type: 'pr-merged-not-landed',
@@ -1303,19 +1303,46 @@ function classifyRemovalError(stderr) {
1303
1303
  * @returns {Promise<{ removed: boolean, failure: {kind: string, detail: string}|null }>}
1304
1304
  */
1305
1305
  async function attemptWorktreeRemoval(worktreePath, root) {
1306
- let lastStderr = '';
1306
+ const stderrs = [];
1307
1307
  for (let round = 0; round < 2; round++) {
1308
1308
  for (const args of [['worktree', 'remove', worktreePath], ['worktree', 'remove', '--force', worktreePath]]) {
1309
1309
  try {
1310
1310
  execFileSync('git', args, { cwd: root, stdio: 'pipe' });
1311
1311
  return { removed: true, failure: null };
1312
1312
  } catch (err) {
1313
- lastStderr = err?.stderr ? String(err.stderr) : (err?.message || '');
1313
+ stderrs.push(err?.stderr ? String(err.stderr) : (err?.message || ''));
1314
1314
  }
1315
1315
  }
1316
1316
  if (round === 0) await new Promise((r) => setTimeout(r, 200));
1317
1317
  }
1318
- return { removed: false, failure: classifyRemovalError(lastStderr) };
1318
+ return { removed: false, failure: pickRemovalFailure(stderrs) };
1319
+ }
1320
+
1321
+ /**
1322
+ * Which of the attempts' errors the user should read.
1323
+ *
1324
+ * The LAST one used to win, and it was the worst choice: when the first
1325
+ * `git worktree remove` deletes the content and de-registers the tree but the
1326
+ * directory itself is held (a session with its cwd there), every later attempt
1327
+ * answers only `'…' is not a working tree` — an echo that hid the real cause
1328
+ * (the lock) and made the hint send the user to a command that can no longer
1329
+ * reach the tree. A lock anywhere wins; otherwise the first REAL error wins.
1330
+ *
1331
+ * @param {string[]} stderrs - one entry per failed attempt, in order
1332
+ * @returns {{ kind: string, detail: string, alreadyDeregistered: boolean }}
1333
+ */
1334
+ export function pickRemovalFailure(stderrs) {
1335
+ const list = (stderrs || []).map((s) => String(s || '')).filter((s) => s.trim());
1336
+ const notAWorktree = (s) => /is not a working tree/i.test(s);
1337
+ const classified = list.map(classifyRemovalError);
1338
+ const firstReal = list.findIndex((s) => !notAWorktree(s));
1339
+ const chosen = classified.find((c) => c.kind === 'in-use')
1340
+ || (firstReal >= 0 ? classified[firstReal] : classified[classified.length - 1])
1341
+ || { kind: 'unknown', detail: '' };
1342
+ // Git dropped the registration BETWEEN attempts: a failure for another reason
1343
+ // followed by "is not a working tree". Only the directory is left.
1344
+ const alreadyDeregistered = firstReal >= 0 && list.slice(firstReal + 1).some(notAWorktree);
1345
+ return { ...chosen, alreadyDeregistered };
1319
1346
  }
1320
1347
 
1321
1348
  /**
@@ -1451,6 +1478,10 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1451
1478
  ? `worktree NÃO removido: o backup falhou. Estes arquivos existem só neste worktree e a remoção os apagaria: ${sample(ignored.shieldable)}. `
1452
1479
  + (ignored.replicable.length ? `(${sample(ignored.replicable)} são idênticos aos da raiz — esses não são o problema.) ` : '')
1453
1480
  + `O backup iria para \`${backupDir}\`; libere espaço/permissão ali e rode \`${removeCmd}\`, ou copie os arquivos você mesmo e rode \`${removeCmd} --discard-ignored\` para pular o backup`
1481
+ : removalFailure?.alreadyDeregistered
1482
+ ? `worktree NÃO removido por inteiro: a 1ª tentativa do git já apagou o conteúdo e desregistrou o worktree, mas o diretório "${worktreePath}" ficou`
1483
+ + (removalFailure.kind === 'in-use' ? ' — EM USO por outro processo (instância do Claude Code, VS Code, dev server ou terminal com cwd nele)' : '')
1484
+ + `${removalFailure.detail ? ` (${removalFailure.detail})` : ''}. \`${removeCmd}\` não o alcança mais — o git não o conhece. Feche o que o usa, confira que está vazio e sem junction, e apague só o diretório (sem recursão)`
1454
1485
  : removalFailure?.kind === 'in-use'
1455
1486
  ? `worktree NÃO removido: o diretório está EM USO por outro processo (instância do Claude Code, VS Code, dev server ou terminal com cwd em "${worktreePath}"). A infra compartilhada ficou DESMONTADA de propósito — feche o que está usando o diretório e rode \`${removeCmd}\`. Se preferir continuar trabalhando nele, restaure a infra com \`morph-spec worktree link --all\``
1456
1487
  : `worktree NÃO removido: a remoção falhou${removalFailure?.detail ? ` (${removalFailure.detail})` : ''}. A infra compartilhada ficou DESMONTADA de propósito — rode \`${removeCmd}\` para tentar de novo, ou \`morph-spec worktree link --all\` para voltar a trabalhar nele`;
@@ -1497,6 +1528,30 @@ export function cwdIsInsideWorktree(cwd, worktreePath) {
1497
1528
  return cwdNorm === wtNorm || cwdNorm.startsWith(`${wtNorm}/`);
1498
1529
  }
1499
1530
 
1531
+ /**
1532
+ * Is the Claude Code SESSION that ran this command anchored in the worktree?
1533
+ *
1534
+ * Not the same question as `cwdIsInsideWorktree`. That one sees the PROCESS
1535
+ * cwd, and `cd <root> && morph-spec finish` answers it "no" — while the session
1536
+ * stays anchored in the worktree: its CLAUDE_PROJECT_DIR is the worktree, and
1537
+ * every project hook runs `node "$CLAUDE_PROJECT_DIR/.morph/framework/hooks/…"`.
1538
+ * Tearing that worktree down unlinks the junction those hooks resolve through,
1539
+ * and the directory stays held by the session — so the removal fails half-way
1540
+ * and every hook of every later tool call dies with MODULE_NOT_FOUND.
1541
+ *
1542
+ * The anchor arrives as MORPH_SESSION_PROJECT_DIR, exported by the
1543
+ * `worktree-env-inject` PreToolUse hook (the Bash tool does not export
1544
+ * CLAUDE_PROJECT_DIR itself); CLAUDE_PROJECT_DIR is honoured when present.
1545
+ *
1546
+ * @param {string} worktreePath
1547
+ * @param {NodeJS.ProcessEnv} [env]
1548
+ * @returns {boolean}
1549
+ */
1550
+ export function sessionAnchoredInWorktree(worktreePath, env = process.env) {
1551
+ const anchor = env.MORPH_SESSION_PROJECT_DIR || env.CLAUDE_PROJECT_DIR || '';
1552
+ return anchor ? cwdIsInsideWorktree(anchor, worktreePath) : false;
1553
+ }
1554
+
1500
1555
  // ── remove ───────────────────────────────────────────────────────────────
1501
1556
 
1502
1557
  /**
@@ -1580,6 +1635,16 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
1580
1635
  hint: `saia do worktree antes de removê-lo — o processo trava o próprio diretório de trabalho. Rode de novo a partir de "${root}"` }, 1);
1581
1636
  }
1582
1637
 
1638
+ // The SESSION's anchor, not the process cwd (see sessionAnchoredInWorktree):
1639
+ // refusing here, before the junctions are touched, leaves the session's hooks
1640
+ // intact. Unlike finish there is nothing else to do in this command, so the
1641
+ // refusal IS the deferral.
1642
+ if (sessionAnchoredInWorktree(worktreePath)) {
1643
+ const again = featureAddressable && feature ? `morph-spec worktree remove ${feature}` : `morph-spec worktree remove --target "${worktreePath}"`;
1644
+ emit({ removed: false, error: 'session-anchored-in-worktree', path: worktreePath, root,
1645
+ hint: `a sessão do Claude Code que rodou este comando está ancorada neste worktree — os hooks dela resolvem \`.morph/framework\` por ele, e desmontá-lo quebraria todo tool call seguinte (e o diretório seguiria travado pela sessão). Feche esta sessão e rode \`${again}\` de uma sessão aberta em "${root}"` }, 1);
1646
+ }
1647
+
1583
1648
  // `--if-clean` — the WorktreeRemove hook's contract: Claude Code fires it
1584
1649
  // when it believes the tree is disposable, but "disposable" is the user's
1585
1650
  // call, not the harness's. Any uncommitted or untracked file refuses the
@@ -11,6 +11,7 @@ import {
11
11
  buildBranchName,
12
12
  branchExists,
13
13
  cwdIsInsideWorktree,
14
+ sessionAnchoredInWorktree,
14
15
  classifyIgnoredLeftovers,
15
16
  } from '../project/worktree.js';
16
17
  import { detectDefaultBranch, hasOriginRemote } from '../../lib/git/default-branch.js';
@@ -587,6 +588,14 @@ export function finishPreflight({ feature, root, worktreePath, cwd, mode, branch
587
588
 
588
589
  const inside = cwdIsInsideWorktree(cwd, worktreePath);
589
590
  out.cwdInsideWorktree = inside;
591
+ if (!inside && sessionAnchoredInWorktree(worktreePath)) {
592
+ out.teardownDeferred = 'session-anchored';
593
+ warnings.push(
594
+ `a sessão do Claude Code está ancorada em ${out.worktreePath}: o finish vai integrar, mas ADIAR o desmonte `
595
+ + '(desmontar agora quebraria os hooks desta sessão). Depois, feche a sessão e rode '
596
+ + `\`morph-spec worktree remove ${feature}\` de uma sessão aberta na raiz`,
597
+ );
598
+ }
590
599
  if (inside) {
591
600
  warnings.push(
592
601
  `você está DENTRO de ${out.worktreePath}: o finish de verdade recusa aqui, antes de qualquer mutação `
@@ -705,9 +714,16 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
705
714
  // mount and the port block all belong to that tree, and once it is gone
706
715
  // there is nothing left to address the containers by.
707
716
  const e2e = reclaimStack(feature, { root, worktreePath, options });
708
- const teardown = await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: true });
717
+ // The session that ran this finish may be ANCHORED in the worktree (its
718
+ // hooks resolve `.morph/framework` through it): tearing it down now would
719
+ // kill every hook of that session. The integration is done; only the
720
+ // teardown waits (see sessionAnchoredInWorktree).
721
+ const deferred = sessionAnchoredInWorktree(worktreePath);
722
+ const teardown = deferred
723
+ ? null
724
+ : await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: true });
709
725
  dropFromRootIndex(root, feature);
710
- emit({ ...mergeOutput({ feature, branch: expectedBranch, defaultBranch, publish, worktreePath, teardown, shedBackup }), ...ucNote, e2e });
726
+ emit({ ...mergeOutput({ feature, branch: expectedBranch, defaultBranch, publish, worktreePath, teardown, shedBackup, deferred }), ...ucNote, e2e });
711
727
  process.exit(0);
712
728
  }
713
729
 
@@ -787,6 +803,7 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
787
803
  const e2e = isWorktreeFlow
788
804
  ? (options.keep ? null : reclaimStack(feature, { root, worktreePath, options }))
789
805
  : reclaimStack(feature, { root, worktreePath: null, options });
806
+ let teardownDeferred = false;
790
807
  if (isWorktreeFlow && !options.keep) {
791
808
  // Shed the transient root-side scaffold BEFORE teardown — exactly as --merge
792
809
  // does. The real `create --worktree` leaves the root's .morph/features/{f}
@@ -796,7 +813,13 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
796
813
  // the feature in BOTH features/ and archive/ at once — active and archived.
797
814
  // The branch/PR owns the authoritative copy; the root scaffold is transient.
798
815
  shedBackup = shedRootFeatureScope(root, feature).shedBackup;
799
- teardown = await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: false });
816
+ // Same deferral as --merge: never tear down the worktree the running
817
+ // session is anchored in (see sessionAnchoredInWorktree).
818
+ if (sessionAnchoredInWorktree(worktreePath)) {
819
+ teardownDeferred = true;
820
+ } else {
821
+ teardown = await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: false });
822
+ }
800
823
  // The shed removed the root's features/{f} scaffold from disk, but runArchive
801
824
  // dropped the feature only from the WORKTREE's index (its cwd). Drop it from
802
825
  // the ROOT index too — exactly as --merge does — or `doctor`'s lifecycle audit
@@ -835,7 +858,13 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
835
858
  worktree: { path: worktreePath, branch: expectedBranch },
836
859
  hint: `worktree preservado em ${worktreePath} — feche depois com \`morph-spec worktree remove ${feature}\`.`,
837
860
  }
838
- : {}),
861
+ : teardownDeferred
862
+ ? {
863
+ teardownDeferred: 'session-anchored',
864
+ worktree: { path: worktreePath, branch: expectedBranch },
865
+ hint: deferredTeardownHint(feature, worktreePath),
866
+ }
867
+ : {}),
839
868
  }),
840
869
  };
841
870
  emit(out);
@@ -955,7 +984,7 @@ function mergeNextStep(publish) {
955
984
  * publish: ReturnType<typeof publicationState>, worktreePath?: string|null,
956
985
  * teardown?: object|null, shedBackup?: string|null, kept?: boolean }} args
957
986
  */
958
- function mergeOutput({ feature, branch, defaultBranch, publish, worktreePath = null, teardown = null, shedBackup = null, kept = false }) {
987
+ function mergeOutput({ feature, branch, defaultBranch, publish, worktreePath = null, teardown = null, shedBackup = null, kept = false, deferred = false }) {
959
988
  return {
960
989
  finished: true,
961
990
  integrated: true,
@@ -964,6 +993,7 @@ function mergeOutput({ feature, branch, defaultBranch, publish, worktreePath = n
964
993
  merged: branch,
965
994
  into: defaultBranch,
966
995
  ...(kept ? { kept: true } : {}),
996
+ ...(deferred ? { teardownDeferred: 'session-anchored', worktree: { path: worktreePath, branch }, hint: deferredTeardownHint(feature, worktreePath, { deleteBranch: true }) } : {}),
967
997
  worktreeRemoved: teardown ? teardown.worktreeRemoved : false,
968
998
  nextStep: mergeNextStep(publish),
969
999
  publish,
@@ -981,6 +1011,19 @@ function mergeOutput({ feature, branch, defaultBranch, publish, worktreePath = n
981
1011
  };
982
1012
  }
983
1013
 
1014
+ /**
1015
+ * Hint for a teardown deferred because the session is anchored in the worktree
1016
+ * (both modes). Names the safe command, never `git worktree remove`. `--merge`
1017
+ * would have deleted the (now integrated) branch, so its hint carries
1018
+ * `--delete-branch`; `--pr` keeps the branch — the PR needs it.
1019
+ */
1020
+ function deferredTeardownHint(feature, worktreePath, { deleteBranch = false } = {}) {
1021
+ return `worktree mantido em ${worktreePath}: a sessão do Claude Code que rodou o finish está ancorada nele, `
1022
+ + 'e desmontá-lo agora quebraria todos os hooks dela (eles resolvem `.morph/framework` por ele) — '
1023
+ + `o diretório, aliás, seguiria travado pela sessão. Feche esta sessão e rode \`morph-spec worktree remove ${feature}${deleteBranch ? ' --delete-branch' : ''}\` `
1024
+ + 'de uma sessão aberta na raiz do repo.';
1025
+ }
1026
+
984
1027
  /** Shared teardown reporting for both modes (kept identical to the old finish). */
985
1028
  function teardownExtras(teardown, worktreePath, branch, deleteBranch) {
986
1029
  const out = {};
@@ -5,6 +5,7 @@ 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
  import { checkUseCases } from '../../lib/specs/usecase-check.js';
8
+ import { checkArtifactCriteria } from '../../lib/specs/done-criteria-check.js';
8
9
 
9
10
  /**
10
11
  * `morph-spec dag <feature>` — analyze the tasks.json DAG and print the
@@ -51,10 +52,16 @@ export async function dagCommand(featureName, options = {}) {
51
52
  // against a plan that points at a use case nobody can read.
52
53
  const ucCheck = checkUseCases(data, join(planDir, 'usecases'));
53
54
 
55
+ // Artifact-postcondition warning (issue #87). Lexical, not wiring — it
56
+ // runs over ALL tasks even when the feature has no use cases at all
57
+ // (checkUseCases short-circuits above in that case), because a task's
58
+ // own doneCriteria can describe an artifact with no use case in sight.
59
+ const artifactCriteria = checkArtifactCriteria(data, join(planDir, 'usecases'));
60
+
54
61
  const plan = planDispatch(data);
55
62
 
56
63
  if (options.json) {
57
- console.log(JSON.stringify({ ...plan, useCases: ucCheck }, null, 2));
64
+ console.log(JSON.stringify({ ...plan, useCases: ucCheck, artifactCriteria }, null, 2));
58
65
  if (ucCheck.errors.length) process.exit(1);
59
66
  return;
60
67
  }
@@ -71,6 +78,11 @@ export async function dagCommand(featureName, options = {}) {
71
78
  console.error(chalk.yellow(`\n⚠ Use cases — ${plural(ucCheck.warnings.length, 'aviso', 'avisos')}`));
72
79
  for (const w of ucCheck.warnings) console.error(chalk.yellow(` ${w}`));
73
80
  }
81
+ if (artifactCriteria.warnings.length) {
82
+ console.error(chalk.yellow(`\n⚠ Critérios de artefato — ${plural(artifactCriteria.warnings.length, 'aviso', 'avisos')}`));
83
+ for (const w of artifactCriteria.warnings) console.error(chalk.yellow(` ${w}`));
84
+ console.error(chalk.gray(' Teste que lê texto de arquivo não prova comportamento (morph-plan §2, "Por que artefato").'));
85
+ }
74
86
 
75
87
  console.log(chalk.bold(`\n📊 DAG — ${featureName}`));
76
88
  console.log('━'.repeat(60));
@@ -1,9 +1,8 @@
1
1
  import chalk from 'chalk';
2
- import { existsSync, readFileSync } from 'fs';
2
+ import { readFileSync } from 'fs';
3
3
  import { resolve, join } from 'path';
4
4
  import { getFeature, updateFeature } from '../../core/state/state-manager.js';
5
- import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
6
- import { runVerify, VerifyUsageError } from '../../lib/validators/verify-runner.js';
5
+ import { runVerify, readTask, VerifyUsageError } from '../../lib/validators/verify-runner.js';
7
6
  import { resolvePrimaryRoot } from '../project/worktree.js';
8
7
  import { getHeadSha } from '../../lib/git/default-branch.js';
9
8
  import { EVAL_RUNNER } from '../../lib/tasks/eval-node.js';
@@ -15,6 +14,28 @@ function statusIcon(status) {
15
14
  return chalk.gray('–');
16
15
  }
17
16
 
17
+ /**
18
+ * One line per test run naming what the task-scope recorte did to it (#87):
19
+ * narrowed (how many selectors, from how many test files), whole, out of scope
20
+ * or full, each with its reason. A green `tests` line alone cannot say that the
21
+ * task ran 12 tests instead of 3.082. Feature-scope runs carry no selection and
22
+ * print nothing extra; `feedback[]` is untouched either way.
23
+ *
24
+ * @param {Array<{id: string, selection: object|null}>} [runs]
25
+ * @returns {string[]}
26
+ */
27
+ function selectionLines(runs = []) {
28
+ return runs
29
+ .filter((run) => run.selection)
30
+ .map((run) => {
31
+ const { mode, reason, selectors = [], files = [] } = run.selection;
32
+ const detail = mode === 'narrowed'
33
+ ? `${selectors.length} selector(s) from ${files.length} test file(s)`
34
+ : (reason ?? '');
35
+ return chalk.gray(` ↳ ${run.id.padEnd(10)} ${mode.padEnd(12)} ${detail}`);
36
+ });
37
+ }
38
+
18
39
  /**
19
40
  * Human-readable rendering of a runVerify() result — mirrors the
20
41
  * ✓/✗-per-node + feedback-list style of formatScoreBreakdown() in
@@ -51,6 +72,7 @@ function formatVerifyResult(result) {
51
72
  detail = `${node.durationMs}ms`;
52
73
  }
53
74
  lines.push(` ${icon} ${name.padEnd(11)} ${node.status.padEnd(5)} ${chalk.gray(detail)}`);
75
+ if (name === 'tests') lines.push(...selectionLines(node.runs));
54
76
  }
55
77
 
56
78
  if (result.feedback.length > 0) {
@@ -166,17 +188,6 @@ function evalsStatusOf(result) {
166
188
  return 'skip';
167
189
  }
168
190
 
169
- /** Read tasks.json's task list for a feature; [] if missing/corrupt. */
170
- function readTasks(featureName) {
171
- try {
172
- const tasksPath = getAbsoluteOutputPath(process.cwd(), featureName, 'tasks');
173
- if (!existsSync(tasksPath)) return [];
174
- return JSON.parse(readFileSync(tasksPath, 'utf8')).tasks || [];
175
- } catch {
176
- return [];
177
- }
178
- }
179
-
180
191
  /**
181
192
  * `morph-spec verify <feature> [task]` — the deterministic code-before-judge
182
193
  * gate: build + tests + validators, BEFORE the LLM eval. See verify-runner.js
@@ -194,7 +205,8 @@ function readTasks(featureName) {
194
205
  * `morph-spec score` gives for the same situation);
195
206
  * - `--filter` that matches no declared node (a filtered-to-empty run is
196
207
  * green and proves nothing);
197
- * - `--record-baseline` with nowhere to write (no `config.json`);
208
+ * - `--record-baseline` with nowhere to write (no `config.json`), or with a
209
+ * task id (a recorte de task is never a fleet baseline, #87);
198
210
  * - `--linux` without Docker.
199
211
  * The last three arrive as `VerifyUsageError` from the runner.
200
212
  */
@@ -210,9 +222,10 @@ export async function verifyCommand(featureName, taskId, options = {}) {
210
222
  process.exit(2);
211
223
  }
212
224
 
225
+ // The runner's own reader (one reader of tasks.json for verify, #87): null
226
+ // means tasks.json or the task is missing, and both are this usage error.
213
227
  if (taskId) {
214
- const tasks = readTasks(featureName);
215
- if (!tasks.some((t) => t.id === taskId)) {
228
+ if (!readTask(process.cwd(), featureName, taskId)) {
216
229
  console.error(chalk.red(`\n❌ Task "${taskId}" not found in tasks.json for feature "${featureName}"\n`));
217
230
  process.exit(2);
218
231
  }
@@ -232,6 +245,9 @@ export async function verifyCommand(featureName, taskId, options = {}) {
232
245
  refreshCache: options.refreshCache,
233
246
  linux: options.linux,
234
247
  linuxImage: options.linuxImage,
248
+ // Task scope: switch the recorte off (#87). At feature scope nothing is
249
+ // ever narrowed, so the flag is a documented no-op there, not an error.
250
+ full: options.full,
235
251
  });
236
252
  } catch (err) {
237
253
  // A wrong invocation is exit 2, like an unknown feature or task — never a
@@ -249,9 +265,22 @@ export async function verifyCommand(featureName, taskId, options = {}) {
249
265
  // the suite in order to write `expectedCount`. Persisting a `verification`
250
266
  // stamp from it would let a maintenance run stand in for the proof a gate
251
267
  // asks for — the same class of confusion as a hand-typed baseline.
268
+ //
269
+ // `--filter` at FEATURE scope is the other side of the same hole (#87): the
270
+ // run is narrowed to a subset of nodes, but the stamp it would write carries
271
+ // `scope: 'feature'` — the one scope risk-detector.js trusts unconditionally.
272
+ // A partial run must not stand in for the proof a gate asks for, same as
273
+ // `--record-baseline` above. Task-scope `--filter` is unaffected: that stamp
274
+ // already says `scope: 'task:{id}'`, and `taskVerifications` never claimed
275
+ // to cover more than the task in the first place. Checked AFTER `--no-persist`
276
+ // (via the `!options.noPersist` guard) so an explicit `--no-persist` keeps its
277
+ // own reason instead of being reported as 'filtered' — the flags are both
278
+ // "don't write", but decidePersistence() already owns the former's reason.
252
279
  const { persist, reason } = options.recordBaseline
253
280
  ? { persist: false, reason: 'record-baseline' }
254
- : decidePersistence({ cwd: process.cwd(), featureName, noPersist: options.noPersist });
281
+ : (!options.noPersist && !taskId && options.filter)
282
+ ? { persist: false, reason: 'filtered' }
283
+ : decidePersistence({ cwd: process.cwd(), featureName, noPersist: options.noPersist });
255
284
 
256
285
  let compact = null;
257
286
  if (persist) {
@@ -72,6 +72,13 @@ export const OUTPUT_SCHEMA = {
72
72
 
73
73
  // 3-implement/
74
74
  recap: { filename: 'recap.md', phaseDir: '3-implement', phase: 'implement', protected: false },
75
+ // N files, one per task whose doneCriteria judges what a MODEL answers
76
+ // (morph-plan §5). Runtime evidence: input, model, output, verdict — pointing
77
+ // at the `runner: "evals"` node when the stack declares one. CONDITIONAL by
78
+ // construction: never in phases.json requiredOutputs. Readers: morph-review
79
+ // dispatch prompt, proof rubric eval-de-modelo, test-coverage Q7.
80
+ evidencias: { dirname: 'evidencias', filenamePattern: '^T\\d+\\.md$', displayFilename: '{taskId}.md',
81
+ phaseDir: '3-implement', phase: 'implement', protected: false, multi: true },
75
82
 
76
83
  // 4-review/
77
84
  reviewReport: { filename: 'review-report.md', phaseDir: '4-review', phase: 'review', protected: false },