@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.
- package/CLAUDE.md +11 -9
- package/README.md +2 -2
- package/bin/morph-spec.js +2 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/MORPH.md +24 -23
- package/framework/docs/CLI.md +187 -13
- package/framework/evals/proof-rubrics/eval-de-modelo.md +16 -0
- package/framework/evals/rubrics/condensed.md +3 -1
- package/framework/evals/rubrics/test-coverage.md +8 -0
- package/framework/hooks/claude-code/core/worktree-env-inject.js +43 -0
- package/framework/hooks/shared/payload-utils.js +15 -2
- package/framework/hooks/shared/phase-utils.js +1 -0
- package/framework/phases.json +1 -1
- package/framework/rules/morph-workflow.md +1 -1
- package/framework/schemas/config.schema.json +7 -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 +21 -10
- 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 +20 -3
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +41 -3
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +11 -2
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +2 -2
- package/framework/standards/ai-agents/prompt-sources.md +4 -3
- package/framework/standards/ai-agents/testing-ai.md +14 -13
- package/framework/templates/dotnet/ai-kit/README.md +14 -8
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +11 -3
- package/package.json +1 -1
- package/src/commands/project/doctor.js +59 -3
- package/src/commands/project/worktree.js +68 -3
- package/src/commands/state/finish.js +48 -5
- package/src/commands/tasks/dag.js +13 -1
- package/src/commands/validation/verify.js +47 -18
- package/src/core/paths/output-schema.js +7 -0
- package/src/lib/detectors/index.js +225 -217
- package/src/lib/git/pr-files.js +56 -0
- package/src/lib/risk-detector.js +38 -18
- package/src/lib/specs/done-criteria-check.js +200 -0
- package/src/lib/specs/usecase-check.js +129 -5
- package/src/lib/tasks/dotnet-test-introspection.js +517 -0
- package/src/lib/tasks/test-nodes.js +190 -20
- package/src/lib/tasks/test-runner.js +122 -2
- package/src/lib/tasks/test-scope.js +567 -0
- package/src/lib/validators/traceability-check.js +9 -2
- package/src/lib/validators/verify-runner.js +150 -18
- 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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
:
|
|
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 },
|