@polymorphism-tech/morph-spec 9.6.0 → 9.7.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 (65) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +12 -5
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/agents.json +3 -3
  7. package/framework/docs/CLI.md +35 -7
  8. package/framework/evals/review-checklist.md +2 -2
  9. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  10. package/framework/hooks/claude-code/core/README.md +4 -0
  11. package/framework/hooks/claude-code/core/test-budget.js +89 -0
  12. package/framework/hooks/claude-code/statusline.sh +11 -11
  13. package/framework/hooks/shared/e2e-check.js +0 -0
  14. package/framework/hooks/shared/heavy-command.js +60 -1
  15. package/framework/hooks/shared/host-services.js +433 -0
  16. package/framework/schemas/config.schema.json +53 -1
  17. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  18. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  19. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  20. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  21. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  22. package/framework/skills/core/morph-media/SKILL.md +1 -1
  23. package/framework/skills/core/morph-plan/SKILL.md +2 -2
  24. package/framework/skills/core/morph-review/SKILL.md +3 -2
  25. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  26. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  27. package/framework/standards/STANDARDS.json +2 -1
  28. package/framework/standards/infrastructure/docker/local-compose-isolation.md +14 -1
  29. package/framework/templates/IDE-SNIPPETS.md +262 -262
  30. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  31. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  32. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  33. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  34. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  35. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  36. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  37. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  38. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  39. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  40. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  41. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  42. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  43. package/framework/templates/state.template.json +17 -17
  44. package/framework/templates/ui/design-system.css +226 -226
  45. package/package.json +1 -1
  46. package/src/commands/e2e/e2e.js +47 -24
  47. package/src/commands/observability/cost.js +12 -0
  48. package/src/commands/project/worktree.js +7 -3
  49. package/src/commands/state/advance.js +3 -0
  50. package/src/commands/state/create.js +3 -3
  51. package/src/commands/state/delete.js +1 -1
  52. package/src/commands/state/finish.js +3 -1
  53. package/src/commands/validation/plan-check.js +1 -0
  54. package/src/dashboard/app/main.jsx +13 -13
  55. package/src/dashboard/vite.config.js +24 -24
  56. package/src/lib/e2e/stack-teardown.js +19 -4
  57. package/src/lib/review-reports.js +21 -10
  58. package/src/lib/tasks/test-runner.js +3 -0
  59. package/src/lib/telemetry/metrics.js +47 -3
  60. package/src/lib/validators/packages/package-validator.js +2 -2
  61. package/src/utils/agents-installer.js +4 -1
  62. package/src/utils/file-copier.js +26 -4
  63. package/src/utils/hooks-installer.js +4 -4
  64. package/framework/templates/.idea/morph-templates.xml +0 -92
  65. package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
@@ -1013,11 +1013,13 @@ export async function runWorktreeRemove(featureArg, options = {}) {
1013
1013
  // Read BEFORE the teardown: the entry is gone from `git worktree list` afterwards.
1014
1014
  const envKey = envKeyForWorktree(worktreePath, root);
1015
1015
 
1016
- // Compose stack down BEFORE the tree: a bind-mounted container holds the dir on Windows. Volumes survive.
1016
+ // Compose stack down BEFORE the tree: a bind-mounted container holds the dir on Windows.
1017
+ // A `morph/{f}` volume survives for the finish that reclaims it; a `morph-task/*` one has no later owner.
1018
+ const volumes = /^morph-task\//.test(branch ?? '');
1017
1019
  let e2e = null;
1018
1020
  try {
1019
1021
  const { tearDownFeatureStack } = await import('../../lib/e2e/stack-teardown.js');
1020
- e2e = tearDownFeatureStack(envKey || feature || basename(worktreePath), { root, worktreePath, volumes: false });
1022
+ e2e = tearDownFeatureStack(envKey || feature || basename(worktreePath), { root, worktreePath, volumes, _exec: options._exec });
1021
1023
  } catch { e2e = null; }
1022
1024
 
1023
1025
  // The stamp this removal's backup is written under — protected from the
@@ -1044,7 +1046,9 @@ export async function runWorktreeRemove(featureArg, options = {}) {
1044
1046
  ...(feature ? { feature } : {}),
1045
1047
  };
1046
1048
  if (teardown.unlinked.length) out.unlinked = teardown.unlinked;
1047
- if (e2e?.attempted) out.e2e = { status: e2e.status, cwd: e2e.cwd, volumes: false };
1049
+ if (e2e?.attempted || e2e?.hostServices) {
1050
+ out.e2e = { status: e2e.status, cwd: e2e.cwd, volumes, ...(e2e.hostServices ? { hostServices: e2e.hostServices } : {}) };
1051
+ }
1048
1052
  if (teardown.worktreeRemoved) {
1049
1053
  if (teardown.discarded.length) out.discardedIgnored = teardown.discarded;
1050
1054
  if (teardown.replicable.length) out.replicatedInRoot = teardown.replicable;
@@ -76,6 +76,9 @@ function resolveApprovedBy(options) {
76
76
 
77
77
  export function reportDotnetIgnores(added) {
78
78
  if (added.length) console.log(chalk.gray(` .gitignore: ${added.join(' ')} adicionados (projeto .NET) — commite antes do primeiro build.`));
79
+ for (const { entry, file } of added.skipped || []) {
80
+ console.log(chalk.yellow(` .gitignore: ${entry} não adicionado — esconderia fonte versionada (${file}); ignore a saída de build pelo caminho do projeto.`));
81
+ }
79
82
  }
80
83
 
81
84
  function reportDesignSeed({ mockups, designSystem }) {
@@ -9,6 +9,7 @@ import { runWorktreeSetup, materializeState, isGitRepo, branchExists, buildBranc
9
9
  import { detectDefaultBranch } from '../../lib/git/default-branch.js';
10
10
  import { writePlanSkeleton } from '../../lib/plan/plan-template.js';
11
11
  import { seedDotnetBuildIgnores } from '../../lib/stack/dotnet-build-ignores.js';
12
+ import { reportDotnetIgnores } from './advance.js';
12
13
 
13
14
  // Branches from the DEFAULT branch, never HEAD, so it never inherits another feature's commits.
14
15
  // `checkout: false` still CREATES the branch, which `finish` depends on. Fail-open: any git error
@@ -221,6 +222,7 @@ export async function createCommand(featureName, options = {}) {
221
222
  if (branchResult) out.branch = branchResult;
222
223
  if (planSkeleton) out.planSkeleton = planSkeleton;
223
224
  if (gitignoreAdded.length) out.gitignoreAdded = gitignoreAdded;
225
+ if (gitignoreAdded.skipped) out.gitignoreSkipped = gitignoreAdded.skipped;
224
226
  // Under --json the move goes to stderr, so it survives `... --json > out.json`.
225
227
  if (branchResult?.movedFrom) {
226
228
  console.error(chalk.yellow(branchMoveWarning(branchResult).join('\n')));
@@ -234,9 +236,7 @@ export async function createCommand(featureName, options = {}) {
234
236
  console.log(chalk.gray(` .morph/features/${featureName}/feature.json (canonical shape)`));
235
237
  console.log(chalk.gray(` status: ${finalFeature.status} | createdAt: ${finalFeature.createdAt}`));
236
238
  console.log(chalk.gray(` scaffolded: ${wt.scaffold.join(', ')}`));
237
- if (gitignoreAdded.length) {
238
- console.log(chalk.gray(` .gitignore: ${gitignoreAdded.join(' ')} adicionados (projeto .NET)`));
239
- }
239
+ reportDotnetIgnores(gitignoreAdded);
240
240
  if (planSkeleton?.written) {
241
241
  console.log(chalk.gray(` 2-plan/plan.md: esqueleto escrito (gramática: morph-spec plan-check --help)`));
242
242
  }
@@ -109,7 +109,7 @@ export async function deleteCommand(featureName, options = {}) {
109
109
  const root = resolvePrimaryRoot(cwd);
110
110
  // The stack comes down BEFORE the worktree, where its compose file and bind mount live. Fail-open.
111
111
  try {
112
- tearDownFeatureStack(featureName, { root, worktreePath: owner.tree, volumes: true });
112
+ tearDownFeatureStack(featureName, { root, worktreePath: owner.tree, volumes: true, images: 'local', _exec: options._exec });
113
113
  } catch { /* fail-open */ }
114
114
  const teardown = await teardownWorktree(owner.tree, {
115
115
  root,
@@ -901,7 +901,9 @@ function openPrFor(branch, cwd) {
901
901
  // Never throws: a stack that refuses to come down must not fail a finish that already merged.
902
902
  function reclaimStack(feature, { root, worktreePath, options }) {
903
903
  try {
904
- return tearDownFeatureStack(feature, { root, worktreePath, volumes: !options.keepVolumes });
904
+ return tearDownFeatureStack(feature, {
905
+ root, worktreePath, volumes: !options.keepVolumes, images: 'local', _exec: options._exec,
906
+ });
905
907
  } catch {
906
908
  return { attempted: false, cwd: worktreePath || root, status: 'fail', stampDeleted: false };
907
909
  }
@@ -57,6 +57,7 @@ export function planCheckCommand(featureName, options = {}) {
57
57
  const gitignoreAdded = result.written ? seedDotnetBuildIgnores(process.cwd(), { featureName }) : [];
58
58
  if (options.json) {
59
59
  if (gitignoreAdded.length) result.gitignoreAdded = gitignoreAdded;
60
+ if (gitignoreAdded.skipped) result.gitignoreSkipped = gitignoreAdded.skipped;
60
61
  console.log(JSON.stringify(result, null, 2));
61
62
  process.exit(result.exit);
62
63
  }
@@ -1,13 +1,13 @@
1
- import { StrictMode } from 'react';
2
- import { createRoot } from 'react-dom/client';
3
- import { BrowserRouter } from 'react-router-dom';
4
- import App from './App.jsx';
5
- import './styles/index.css';
6
-
7
- createRoot(document.getElementById('root')).render(
8
- <StrictMode>
9
- <BrowserRouter>
10
- <App />
11
- </BrowserRouter>
12
- </StrictMode>
13
- );
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { BrowserRouter } from 'react-router-dom';
4
+ import App from './App.jsx';
5
+ import './styles/index.css';
6
+
7
+ createRoot(document.getElementById('root')).render(
8
+ <StrictMode>
9
+ <BrowserRouter>
10
+ <App />
11
+ </BrowserRouter>
12
+ </StrictMode>
13
+ );
@@ -1,24 +1,24 @@
1
- import { defineConfig } from 'vite';
2
- import react from '@vitejs/plugin-react';
3
- import tailwindcss from '@tailwindcss/vite';
4
- import morphDataPlugin from './vite-plugin-morph-data.js';
5
- import { fileURLToPath } from 'url';
6
- import { dirname, resolve } from 'path';
7
-
8
- const __filename = fileURLToPath(import.meta.url);
9
- const __dirname = dirname(__filename);
10
-
11
- const frameworkDir = resolve(__dirname, '..', '..', 'framework');
12
- const projectDir = process.env.MORPH_PROJECT_DIR || process.cwd();
13
-
14
- export default defineConfig({
15
- root: __dirname,
16
- plugins: [
17
- react(),
18
- tailwindcss(),
19
- morphDataPlugin(frameworkDir, projectDir),
20
- ],
21
- server: {
22
- port: 3200,
23
- },
24
- });
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import tailwindcss from '@tailwindcss/vite';
4
+ import morphDataPlugin from './vite-plugin-morph-data.js';
5
+ import { fileURLToPath } from 'url';
6
+ import { dirname, resolve } from 'path';
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+
11
+ const frameworkDir = resolve(__dirname, '..', '..', 'framework');
12
+ const projectDir = process.env.MORPH_PROJECT_DIR || process.cwd();
13
+
14
+ export default defineConfig({
15
+ root: __dirname,
16
+ plugins: [
17
+ react(),
18
+ tailwindcss(),
19
+ morphDataPlugin(frameworkDir, projectDir),
20
+ ],
21
+ server: {
22
+ port: 3200,
23
+ },
24
+ });
@@ -13,12 +13,18 @@ import {
13
13
  resolveComposeFile,
14
14
  tearDownStack,
15
15
  } from '../../../framework/hooks/shared/e2e-check.js';
16
+ import { stopHostServices } from '../../../framework/hooks/shared/host-services.js';
16
17
  import { buildComposeEnv } from './compose-env.js';
17
18
 
18
- export function tearDownFeatureStack(feature, { root, worktreePath = null, volumes = true, _exec = defaultExec } = {}) {
19
+ export function tearDownFeatureStack(feature, { root, worktreePath = null, volumes = true, images, _exec = defaultExec } = {}) {
19
20
  const cwd = worktreePath && existsSync(worktreePath) ? worktreePath : root;
20
21
  const projectName = composeProjectName(feature);
21
22
  const gitCommonDir = resolveGitCommonDir(root);
23
+ // Before the compose lookup: a worktree already gone still leaves its host processes running.
24
+ const stopped = stopHostServicesSafely(gitCommonDir, projectName);
25
+ const hostServices = stopped.killed.length + stopped.skipped.length + stopped.warnings.length > 0
26
+ ? { hostServices: stopped }
27
+ : {};
22
28
 
23
29
  try {
24
30
  const config = readConfig(cwd) ?? readConfig(root);
@@ -33,25 +39,34 @@ export function tearDownFeatureStack(feature, { root, worktreePath = null, volum
33
39
 
34
40
  if (!composeFile) {
35
41
  // Nothing to bring down, but a stamp left behind would tell the next `up` a stack it cannot see is fresh.
36
- return { attempted: false, cwd, status: 'skip', stampDeleted: deleteStamp(gitCommonDir, projectName) };
42
+ return { attempted: false, cwd, status: 'skip', stampDeleted: deleteStamp(gitCommonDir, projectName), ...hostServices };
37
43
  }
38
44
 
39
45
  const env = {
40
46
  COMPOSE_PROJECT_NAME: projectName,
41
47
  ...buildComposeEnv({ cwd: composeCwd, config, featureName: feature, gitCommonDir }),
42
48
  };
43
- const result = tearDownStack(composeCwd, { composeFile, projectName, env, volumes }, _exec);
49
+ const result = tearDownStack(composeCwd, { composeFile, projectName, env, volumes, images }, _exec);
44
50
 
45
51
  return {
46
52
  attempted: true,
47
53
  cwd: composeCwd,
48
54
  status: result.status,
49
55
  stampDeleted: deleteStamp(gitCommonDir, projectName),
56
+ ...hostServices,
50
57
  ...(result.status === 'fail' && result.output ? { output: result.output } : {}),
51
58
  };
52
59
  } catch {
53
60
  // The stamp must not outlive the stack it described, even when the teardown itself blew up.
54
- return { attempted: false, cwd, status: 'fail', stampDeleted: deleteStamp(gitCommonDir, projectName) };
61
+ return { attempted: false, cwd, status: 'fail', stampDeleted: deleteStamp(gitCommonDir, projectName), ...hostServices };
62
+ }
63
+ }
64
+
65
+ function stopHostServicesSafely(gitCommonDir, projectName) {
66
+ try {
67
+ return stopHostServices(gitCommonDir, projectName);
68
+ } catch (err) {
69
+ return { killed: [], skipped: [], warnings: [`host services not stopped: ${err?.message ?? err}`] };
55
70
  }
56
71
  }
57
72
 
@@ -21,7 +21,8 @@ export const reviewFileName = (role) => OUTPUT_SCHEMA[REVIEW_ROLES[role].output]
21
21
  // Append-only: uma reavaliação acrescenta ao fim, e vale o ÚLTIMO token/seção.
22
22
  const VERDICT_RE = /\*\*Verdict:\*\*\s*([A-Za-z]+)/g;
23
23
  const REVIEWED_SHA_RE = /\*\*Reviewed-SHA:\*\*\s*`?([0-9a-f]{7,40})/gi;
24
- const sectionRe = (title) => new RegExp(`^##\\s+${title}\\s*$`, 'gim');
24
+ // A confirmation round nests its sections one level down (`###`); both levels count.
25
+ const sectionRe = (title) => new RegExp(`^(#{2,3})\\s+${title}\\s*$`, 'gim');
25
26
  const BLOCKERS_SECTION_RE = sectionRe('Bloqueadores');
26
27
  const CHECKS_SECTION_RE = sectionRe('Checks');
27
28
  const MEDIA_SECTION_RE = /^##\s+M[íi]dia\s+gerada\s*$/m;
@@ -37,7 +38,7 @@ export function requiredReviewRoles({ workType, securityRequired = false } = {})
37
38
  function sectionBodies(text, re) {
38
39
  return [...text.matchAll(re)].map((m) => {
39
40
  const rest = text.slice(m.index + m[0].length);
40
- const next = rest.search(/^#{1,2}\s/m);
41
+ const next = rest.search(new RegExp(`^#{1,${m[1].length}}\\s`, 'm'));
41
42
  return next === -1 ? rest : rest.slice(0, next);
42
43
  });
43
44
  }
@@ -50,13 +51,24 @@ function lastSectionBody(text, re) {
50
51
  const normalize = (s) => String(s ?? '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/\s+/g, ' ').trim();
51
52
  const cleanId = (s) => String(s ?? '').replace(/[*`_]/g, '').trim().toUpperCase();
52
53
 
53
- function tableRows(body) {
54
+ const isSeparator = (cells) => cells.every((c) => /^:?-{2,}:?$/.test(c) || c === '');
55
+
56
+ // A headed table counts only when its header names the `tipo` column: a status table a
57
+ // confirmation lists under "Nenhum." is not a blocker table.
58
+ function tableRows(body, keepHeader = () => true) {
54
59
  const rows = [];
55
- for (const line of body.split(/\r?\n/)) {
56
- if (!/^\s*\|/.test(line)) continue;
60
+ let skipping = false;
61
+ const lines = body.split(/\r?\n/);
62
+ for (const [i, line] of lines.entries()) {
63
+ if (!/^\s*\|/.test(line)) { skipping = false; continue; }
57
64
  const cells = line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
58
- if (cells.every((c) => /^:?-{2,}:?$/.test(c) || c === '')) continue;
59
- rows.push(cells);
65
+ if (isSeparator(cells)) continue;
66
+ const next = lines[i + 1]?.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
67
+ if (next && /^\s*\|/.test(lines[i + 1]) && isSeparator(next) && !skipping) {
68
+ skipping = !keepHeader(cells);
69
+ continue;
70
+ }
71
+ if (!skipping) rows.push(cells);
60
72
  }
61
73
  return rows;
62
74
  }
@@ -65,8 +77,7 @@ function tableRows(body) {
65
77
  function parseBlockers(body) {
66
78
  const blockers = [];
67
79
  const untyped = [];
68
- for (const cells of tableRows(body)) {
69
- if (/^#$|^id$/i.test(cells[0]) && /^tipo$/i.test(cells[1] ?? '')) continue;
80
+ for (const cells of tableRows(body, (header) => /^tipo$/i.test(header[1] ?? ''))) {
70
81
  const row = { id: cleanId(cells[0]), type: normalize(cells[1]), where: cells[2] ?? '', evidence: cells[3] ?? '' };
71
82
  (BLOCKER_TYPES.includes(row.type) ? blockers : untyped).push(row);
72
83
  }
@@ -78,7 +89,7 @@ const STATUS_BY_KEY = Object.fromEntries(CHECK_STATUSES.map((s) => [normalize(s)
78
89
  // Vale a última linha de cada Cn em todas as seções: uma confirmação traz só os Cn que mudou.
79
90
  function parseChecks(bodies) {
80
91
  const byId = new Map();
81
- for (const cells of bodies.flatMap(tableRows)) {
92
+ for (const cells of bodies.flatMap((body) => tableRows(body))) {
82
93
  const id = cleanId(cells[0]);
83
94
  if (!/^C\d+$/.test(id)) continue;
84
95
  const raw = String(cells[1] ?? '').replace(/[*`]/g, '').trim();
@@ -9,6 +9,7 @@ import { CONFIG_FILE_NAME } from '../../core/state/state-manager.js';
9
9
 
10
10
  // Per chained project: a fixed cap timed a long `a && b` chain out for being long, not broken.
11
11
  export const DEFAULT_TEST_TIMEOUT_PER_PROJECT = 120_000;
12
+ const TEST_OUTPUT_MAX_BUFFER = 256 * 1024 * 1024;
12
13
 
13
14
  /**
14
15
  * Fallback test command when the project declared no nodes; `projectCount` scales the timeout.
@@ -496,6 +497,8 @@ export function runTestSuite(projectPath = '.', opts = {}) {
496
497
  timeout,
497
498
  encoding: 'utf8',
498
499
  stdio: 'pipe',
500
+ // Node's 1 MiB default kills a large suite mid-run (ENOBUFS), which then reads as a red suite.
501
+ maxBuffer: TEST_OUTPUT_MAX_BUFFER,
499
502
  ...(env ? { env } : {}),
500
503
  });
501
504
  return { passed: true, output: output ?? '', exitCode: 0, timeoutMs: timeout };
@@ -5,6 +5,7 @@ import { ingestedBytes } from './telemetry-reader.js';
5
5
  import { producerOf } from '../../../framework/hooks/shared/command-tokens.js';
6
6
  import { isFailedToolCall } from '../../../framework/hooks/shared/telemetry-logger.js';
7
7
  import { GATE_EVENT, GATE_STEPS } from '../../../framework/hooks/shared/gate-event.js';
8
+ import { QUEUE_FULL_EXIT, REVIEW_AGENT_TYPES, morphVerifyCall, testRunKind } from '../../../framework/hooks/shared/heavy-command.js';
8
9
 
9
10
  export const RERUN_WINDOW_MS = 10 * 60 * 1000;
10
11
 
@@ -81,13 +82,53 @@ function newCounters() {
81
82
  };
82
83
  }
83
84
 
84
- function addTime(counters, klass, durationMs) {
85
- const slot = counters.timeByClass[klass] ||= { runs: 0, measuredMs: 0, unmeasured: 0 };
85
+ const newTimeSlot = () => ({ runs: 0, measuredMs: 0, unmeasured: 0 });
86
+
87
+ function addRun(slot, durationMs) {
86
88
  slot.runs += 1;
87
89
  if (Number.isFinite(durationMs)) slot.measuredMs += durationMs;
88
90
  else slot.unmeasured += 1;
89
91
  }
90
92
 
93
+ // `agent_type` carries the dispatch name when one is given (`ver-c`, `gate3-motor`), not the persona,
94
+ // so the metric also reads Gate 3 names; `byPersona` is the part the test-budget hook recognizes.
95
+ const REVIEW_NAME = /^(?:ver|rev|sec|g3|gate3|eval|review|reviewer|security)\d*-/;
96
+ const isReviewAgent = (type) => REVIEW_AGENT_TYPES.has(type) || type === 'morph-evaluator' || REVIEW_NAME.test(String(type ?? ''));
97
+
98
+ // Same split as the test-budget hook: review first, then an unfiltered verify, then the round count.
99
+ // A run refused by the full heavy queue ran no test.
100
+ function testOutsideVerify(bashEvents) {
101
+ const out = {
102
+ total: newTimeSlot(),
103
+ subagentVerify: newTimeSlot(),
104
+ implementerRound3Plus: newTimeSlot(),
105
+ reviewTests: { ...newTimeSlot(), byPersona: newTimeSlot() },
106
+ };
107
+ const byAgent = new Map();
108
+ for (const e of bashEvents) {
109
+ if (e.event === 'PostToolUseFailure' && e.exitCode === QUEUE_FULL_EXIT) continue;
110
+ const verify = morphVerifyCall(e.command);
111
+ if (!verify && !testRunKind(e.command)) continue;
112
+ addRun(out.total, e.durationMs);
113
+ if (!e.agentId) continue;
114
+ if (isReviewAgent(e.agentType)) {
115
+ addRun(out.reviewTests, e.durationMs);
116
+ if (REVIEW_AGENT_TYPES.has(e.agentType)) addRun(out.reviewTests.byPersona, e.durationMs);
117
+ continue;
118
+ }
119
+ if (!byAgent.has(e.agentId)) byAgent.set(e.agentId, []);
120
+ byAgent.get(e.agentId).push({ e, unfilteredVerify: Boolean(verify && !verify.filtered) });
121
+ }
122
+ for (const runs of byAgent.values()) {
123
+ runs.sort((a, b) => String(a.e.ts ?? '').localeCompare(String(b.e.ts ?? '')));
124
+ runs.forEach(({ e, unfilteredVerify }, round) => {
125
+ if (unfilteredVerify) addRun(out.subagentVerify, e.durationMs);
126
+ else if (round >= 2) addRun(out.implementerRound3Plus, e.durationMs);
127
+ });
128
+ }
129
+ return out;
130
+ }
131
+
91
132
  function finishCounters(c, turnsObserved) {
92
133
  return {
93
134
  toolCalls: c.toolCalls,
@@ -180,6 +221,7 @@ export function computeMetrics(events, opts = {}) {
180
221
  const reuse = { runs: 0, byTree: {}, skipReasons: {} };
181
222
  const gates = {};
182
223
  const openPauses = new Map();
224
+ const testBash = [];
183
225
  let v2 = 0;
184
226
  let legacy = 0;
185
227
  let turnsObserved = false;
@@ -257,10 +299,11 @@ export function computeMetrics(events, opts = {}) {
257
299
  }
258
300
 
259
301
  if (e.tool !== 'Bash' && e.tool !== 'PowerShell') continue;
302
+ if (e.tool === 'Bash' && typeof e.command === 'string') testBash.push(e);
260
303
  const { kind, klass } = classifyCommand(e.command);
261
304
  const git = gitCalls(e.command);
262
305
  for (const c of sinks) {
263
- addTime(c, klass, e.durationMs);
306
+ addRun(c.timeByClass[klass] ||= newTimeSlot(), e.durationMs);
264
307
  c.git.status += git.status;
265
308
  c.git.diff += git.diff;
266
309
  }
@@ -335,6 +378,7 @@ export function computeMetrics(events, opts = {}) {
335
378
  unchangedRepeats,
336
379
  },
337
380
  dotnetFullSuite: fullSuite,
381
+ testOutsideVerify: testOutsideVerify(testBash),
338
382
  reuse,
339
383
  gates,
340
384
  features: featureRows,
@@ -77,7 +77,7 @@ export class PackageValidator {
77
77
  }
78
78
 
79
79
  return {
80
- status: results.length === 0 ? 'ok' : 'error',
80
+ status: allIssues.some(i => i.level === 'error') ? 'error' : 'ok',
81
81
  totalFiles: csprojFiles.length,
82
82
  filesWithIssues: results.length,
83
83
  // The runner's contract: without these counts the findings read as `errors: 0`.
@@ -301,7 +301,7 @@ export async function validatePackages(projectPath = '.', options = {}) {
301
301
  validator.formatResults(results);
302
302
  }
303
303
 
304
- if (options.autoFix && results.status !== 'ok') {
304
+ if (options.autoFix && results.results?.length) {
305
305
  const fixedCount = await validator.autoFix(results);
306
306
  console.log(chalk.green(`\n✅ Auto-fixed ${fixedCount} issue(s)`));
307
307
  }
@@ -285,7 +285,10 @@ export const DISPATCH_CONTRACT = [
285
285
  '2. ESTADO: NÃO toque em `plan.md`, `checks.json`, `feature.json` nem `state.json`: o estado é do orquestrador.',
286
286
  '3. TESTE: a prova é do orquestrador (`morph-spec verify {feature}`, depois do seu retorno), não sua. Escreva os testes pelo orçamento: ' +
287
287
  'um por Cn `prova: teste`, variantes em tabela (`[Theory]`/`it.each`); `leitura` sem teste. NÃO rode teste a cada marco nem faça ciclo red→green: ' +
288
- 'escreva código e testes, compile, e rode SÓ o filtro dos seus testes UMA vez, em primeiro plano, antes do relatório; se falhar, corrija e rode só os que falharam. ' +
288
+ 'escreva código e testes, compile, e rode SÓ o filtro dos seus testes UMA vez, em primeiro plano, antes do relatório ' +
289
+ '(`vitest related <arquivos da fatia> --run`; `dotnet test --filter "FullyQualifiedName~<Classe>" --no-build` após o build); ' +
290
+ 'se falhar, corrija e rode só os que falharam. Teto: 3 rodadas (`verify --filter` conta, exit 3 não); ' +
291
+ 'o hook nega a 4ª e o `verify` sem `--filter`: grave `PARCIAL` com o que ainda falha e devolva. ' +
289
292
  'Grave a saída em arquivo (`… > .morph/logs/test-last.log 2>&1`, sem nada depois) e leia o arquivo — um pipe troca o exit do teste pelo do último comando. ' +
290
293
  'Em batch paralelo SEM isolamento, só compile. Com `isolation: "worktree"`, o mesmo vale no seu worktree, e o último ato é o commit na branch dele.',
291
294
  '4. SOBREVIVÊNCIA: o limite de turnos corta sem aviso: commite `chore(F{n}): wip …` a cada marco (um arquivo ou uma fatia escrita), não só ao fim — ' +
@@ -1,4 +1,5 @@
1
1
  import fs from 'fs-extra';
2
+ import { execFileSync } from 'child_process';
2
3
  import { join, dirname, resolve } from 'path';
3
4
  import { fileURLToPath } from 'url';
4
5
  import { WORKTREE_DIR_NAMES } from '../lib/worktree/dirs.js';
@@ -140,18 +141,39 @@ const ignoredDir = (line) => line.trim()
140
141
  .replace(/^\[(\w)(\w)\]/, (_, a, b) => (a.toLowerCase() === b.toLowerCase() ? a : `[${a}${b}]`))
141
142
  .toLowerCase();
142
143
 
143
- /** Never rewrites an existing line. @returns {string[]} the entries added */
144
+ function trackedUnder(projectPath, dirs) {
145
+ let files;
146
+ try {
147
+ files = execFileSync('git', ['ls-files', '-z'], { cwd: projectPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\0');
148
+ } catch { return new Map(); }
149
+ const found = new Map();
150
+ for (const file of files) {
151
+ const segments = file.toLowerCase().split('/').slice(0, -1);
152
+ for (const dir of dirs) if (!found.has(dir) && segments.includes(dir)) found.set(dir, file);
153
+ }
154
+ return found;
155
+ }
156
+
157
+ /**
158
+ * Never rewrites an existing line. Build output is never versioned, so a tracked file
159
+ * under a `bin`/`obj` segment is source that entry would hide: the entry is skipped.
160
+ * @returns {string[]} the entries added; `.skipped` lists `{ entry, file }` when any was skipped
161
+ */
144
162
  export function ensureDotnetBuildIgnores(projectPath) {
145
163
  const gitignorePath = join(projectPath, '.gitignore');
146
164
  const content = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : '';
147
165
  const present = new Set(content.split(/\r?\n/).filter((l) => !l.trim().startsWith('#')).map(ignoredDir));
148
- const missing = DOTNET_BUILD_DIRS.filter((dir) => !present.has(dir)).map((dir) => `${dir}/`);
149
- if (!missing.length) return [];
166
+ const absent = DOTNET_BUILD_DIRS.filter((dir) => !present.has(dir));
167
+ const tracked = absent.length ? trackedUnder(projectPath, absent) : new Map();
168
+ const missing = absent.filter((dir) => !tracked.has(dir)).map((dir) => `${dir}/`);
169
+ const skipped = [...tracked].map(([dir, file]) => ({ entry: `${dir}/`, file }));
170
+ const result = (added) => (skipped.length ? Object.assign(added, { skipped }) : added);
171
+ if (!missing.length) return result([]);
150
172
  const eol = content.includes('\r\n') ? '\r\n' : '\n';
151
173
  const lead = content && !content.endsWith('\n') ? eol : '';
152
174
  const block = [...(content ? [''] : []), '# .NET build output', ...missing, ''].join(eol);
153
175
  fs.writeFileSync(gitignorePath, content + lead + block);
154
- return missing;
176
+ return result(missing);
155
177
  }
156
178
 
157
179
  // Without the LF pin, Windows autocrlf marks every rewritten feature.json dirty
@@ -5,7 +5,7 @@ import { homedir } from 'os';
5
5
  import { execSync } from 'child_process';
6
6
  import { realEffects } from '../lib/installers/effects.js';
7
7
 
8
- const HOOKS_VERSION = '3.13.0';
8
+ const HOOKS_VERSION = '3.14.0';
9
9
 
10
10
  const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
11
11
 
@@ -64,7 +64,7 @@ const MORPH_HOOKS = [
64
64
  hooks: [{
65
65
  type: 'command',
66
66
  command: 'node framework/hooks/claude-code/core/chain.js',
67
- args: ['pre-bash-denylist', 'conventional-commit', 'block-destructive-worktree-removal', 'block-raw-worktree-add', 'warn-bash-source-write', 'heavy-gate', 'worktree-env-inject']
67
+ args: ['pre-bash-denylist', 'test-budget', 'conventional-commit', 'block-destructive-worktree-removal', 'block-raw-worktree-add', 'warn-bash-source-write', 'heavy-gate', 'worktree-env-inject']
68
68
  }]
69
69
  },
70
70
 
@@ -117,7 +117,7 @@ const MORPH_HOOKS = [
117
117
  hooks: [{
118
118
  type: 'command',
119
119
  command: 'node framework/hooks/claude-code/core/chain.js',
120
- args: ['telemetry-log', 'loop-detect', 'graph-refresh-kick', 'heavy-release'],
120
+ args: ['telemetry-log', 'loop-detect', 'graph-refresh-kick', 'heavy-release', 'test-budget'],
121
121
  timeout: 10
122
122
  }]
123
123
  },
@@ -129,7 +129,7 @@ const MORPH_HOOKS = [
129
129
  hooks: [{
130
130
  type: 'command',
131
131
  command: 'node framework/hooks/claude-code/core/chain.js',
132
- args: ['telemetry-log', 'heavy-release'],
132
+ args: ['telemetry-log', 'heavy-release', 'test-budget'],
133
133
  timeout: 5,
134
134
  async: true
135
135
  }]
@@ -1,92 +0,0 @@
1
- <templateSet group="MORPH Templates">
2
- <template name="morph-pascal" value="{{pascalCase $FEATURE_NAME$}}" description="Convert to PascalCase (UserAuth)" toReformat="false" toShortenFQNames="true">
3
- <variable name="FEATURE_NAME" expression="" defaultValue="&quot;FEATURE_NAME&quot;" alwaysStopAt="true" />
4
- <context>
5
- <option name="OTHER" value="true" />
6
- </context>
7
- </template>
8
-
9
- <template name="morph-camel" value="{{camelCase $FEATURE_NAME$}}" description="Convert to camelCase (userAuth)" toReformat="false" toShortenFQNames="true">
10
- <variable name="FEATURE_NAME" expression="" defaultValue="&quot;FEATURE_NAME&quot;" alwaysStopAt="true" />
11
- <context>
12
- <option name="OTHER" value="true" />
13
- </context>
14
- </template>
15
-
16
- <template name="morph-snake" value="{{snakeCase $FEATURE_NAME$}}" description="Convert to snake_case (user_auth)" toReformat="false" toShortenFQNames="true">
17
- <variable name="FEATURE_NAME" expression="" defaultValue="&quot;FEATURE_NAME&quot;" alwaysStopAt="true" />
18
- <context>
19
- <option name="OTHER" value="true" />
20
- </context>
21
- </template>
22
-
23
- <template name="morph-title" value="{{titleCase $FEATURE_NAME$}}" description="Convert to Title Case (User Auth)" toReformat="false" toShortenFQNames="true">
24
- <variable name="FEATURE_NAME" expression="" defaultValue="&quot;FEATURE_NAME&quot;" alwaysStopAt="true" />
25
- <context>
26
- <option name="OTHER" value="true" />
27
- </context>
28
- </template>
29
-
30
- <template name="morph-kebab" value="{{kebabCase $FEATURE_NAME$}}" description="Convert to kebab-case (user-auth)" toReformat="false" toShortenFQNames="true">
31
- <variable name="FEATURE_NAME" expression="" defaultValue="&quot;FEATURE_NAME&quot;" alwaysStopAt="true" />
32
- <context>
33
- <option name="OTHER" value="true" />
34
- </context>
35
- </template>
36
-
37
- <template name="morph-if" value="{{#if $CONDITION$}}&#10; $END$&#10;{{/if}}" description="Handlebars if block" toReformat="false" toShortenFQNames="true">
38
- <variable name="CONDITION" expression="" defaultValue="&quot;condition&quot;" alwaysStopAt="true" />
39
- <context>
40
- <option name="OTHER" value="true" />
41
- </context>
42
- </template>
43
-
44
- <template name="morph-if-else" value="{{#if $CONDITION$}}&#10; $TRUE_BRANCH$&#10;{{else}}&#10; $FALSE_BRANCH$&#10;{{/if}}" description="Handlebars if-else block" toReformat="false" toShortenFQNames="true">
45
- <variable name="CONDITION" expression="" defaultValue="&quot;condition&quot;" alwaysStopAt="true" />
46
- <variable name="TRUE_BRANCH" expression="" defaultValue="&quot;&quot;" alwaysStopAt="true" />
47
- <variable name="FALSE_BRANCH" expression="" defaultValue="&quot;&quot;" alwaysStopAt="true" />
48
- <context>
49
- <option name="OTHER" value="true" />
50
- </context>
51
- </template>
52
-
53
- <template name="morph-each" value="{{#each $ITEMS$}}&#10; {{this.$PROPERTY$}}&#10;{{/each}}" description="Handlebars each loop" toReformat="false" toShortenFQNames="true">
54
- <variable name="ITEMS" expression="" defaultValue="&quot;items&quot;" alwaysStopAt="true" />
55
- <variable name="PROPERTY" expression="" defaultValue="&quot;name&quot;" alwaysStopAt="true" />
56
- <context>
57
- <option name="OTHER" value="true" />
58
- </context>
59
- </template>
60
-
61
- <template name="morph-service" value="namespace {{NAMESPACE}}.Application.Features.{{pascalCase FEATURE_NAME}}.Services;&#10;&#10;public class {{pascalCase FEATURE_NAME}}Service(&#10; I{{pascalCase FEATURE_NAME}}Repository repository,&#10; ILogger&lt;{{pascalCase FEATURE_NAME}}Service&gt; logger) : I{{pascalCase FEATURE_NAME}}Service&#10;{&#10; public async Task&lt;{{pascalCase FEATURE_NAME}}Dto?&gt; GetByIdAsync(int id, CancellationToken cancellationToken = default)&#10; {&#10; $END$&#10; }&#10;}" description="MORPH .NET Service class template" toReformat="false" toShortenFQNames="true">
62
- <context>
63
- <option name="C_SHARP" value="true" />
64
- </context>
65
- </template>
66
-
67
- <template name="morph-repository" value="public class {{pascalCase FEATURE_NAME}}Repository(AppDbContext context) : I{{pascalCase FEATURE_NAME}}Repository&#10;{&#10; private readonly DbSet&lt;{{pascalCase FEATURE_NAME}}&gt; _dbSet = context.Set&lt;{{pascalCase FEATURE_NAME}}&gt;();&#10;&#10; public async Task&lt;{{pascalCase FEATURE_NAME}}?&gt; GetByIdAsync(int id, CancellationToken cancellationToken = default)&#10; {&#10; $END$&#10; }&#10;}" description="MORPH .NET Repository class template" toReformat="false" toShortenFQNames="true">
68
- <context>
69
- <option name="C_SHARP" value="true" />
70
- </context>
71
- </template>
72
-
73
- <template name="morph-component" value="@page &quot;/{{kebabCase FEATURE_NAME}}&quot;&#10;@inject I{{pascalCase FEATURE_NAME}}Service {{pascalCase FEATURE_NAME}}Service&#10;@inject ILogger&lt;{{pascalCase FEATURE_NAME}}Page&gt; Logger&#10;&#10;&lt;PageTitle&gt;{{pascalCase FEATURE_NAME}}&lt;/PageTitle&gt;&#10;&#10;&lt;div class=&quot;container-fluid&quot;&gt;&#10; $END$&#10;&lt;/div&gt;&#10;&#10;@code {&#10;&#10;}" description="MORPH Blazor component template" toReformat="false" toShortenFQNames="true">
74
- <context>
75
- <option name="HTML" value="true" />
76
- </context>
77
- </template>
78
-
79
- <template name="morph-migration" value="public partial class Add{{pascalCase FEATURE_NAME}} : Migration&#10;{&#10; protected override void Up(MigrationBuilder migrationBuilder)&#10; {&#10; migrationBuilder.CreateTable(&#10; name: &quot;{{pascalCase FEATURE_NAME}}s&quot;,&#10; columns: table =&gt; new&#10; {&#10; Id = table.Column&lt;int&gt;(type: &quot;int&quot;, nullable: false)&#10; .Annotation(&quot;SqlServer:Identity&quot;, &quot;1, 1&quot;),&#10; $END$&#10; },&#10; constraints: table =&gt;&#10; {&#10; table.PrimaryKey(&quot;PK_{{pascalCase FEATURE_NAME}}s&quot;, x =&gt; x.Id);&#10; });&#10; }&#10;&#10; protected override void Down(MigrationBuilder migrationBuilder)&#10; {&#10; migrationBuilder.DropTable(name: &quot;{{pascalCase FEATURE_NAME}}s&quot;);&#10; }&#10;}" description="MORPH EF Core migration template" toReformat="false" toShortenFQNames="true">
80
- <context>
81
- <option name="C_SHARP" value="true" />
82
- </context>
83
- </template>
84
-
85
- <template name="morph-ts-contract" value="export interface {{pascalCase FEATURE_NAME}} {&#10; id: number;&#10; $FIELD1$&#10;}&#10;&#10;export interface Create{{pascalCase FEATURE_NAME}}Request {&#10; $FIELD2$&#10;}&#10;&#10;export interface Update{{pascalCase FEATURE_NAME}}Request {&#10; $END$&#10;}" description="MORPH TypeScript contract template" toReformat="false" toShortenFQNames="true">
86
- <variable name="FIELD1" expression="" defaultValue="&quot;&quot;" alwaysStopAt="true" />
87
- <variable name="FIELD2" expression="" defaultValue="&quot;&quot;" alwaysStopAt="true" />
88
- <context>
89
- <option name="TypeScript" value="true" />
90
- </context>
91
- </template>
92
- </templateSet>