@polymorphism-tech/morph-spec 9.5.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 (80) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +13 -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 +43 -11
  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/heavy-gate.js +5 -3
  12. package/framework/hooks/claude-code/core/test-budget.js +89 -0
  13. package/framework/hooks/claude-code/statusline.sh +11 -11
  14. package/framework/hooks/shared/e2e-check.js +0 -0
  15. package/framework/hooks/shared/feature-locator.js +7 -5
  16. package/framework/hooks/shared/heavy-command.js +72 -1
  17. package/framework/hooks/shared/heavy-config.js +28 -1
  18. package/framework/hooks/shared/heavy-slots.js +41 -5
  19. package/framework/hooks/shared/host-services.js +433 -0
  20. package/framework/schemas/config.schema.json +65 -2
  21. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  22. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  23. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  24. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  25. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  26. package/framework/skills/core/morph-media/SKILL.md +1 -1
  27. package/framework/skills/core/morph-plan/SKILL.md +3 -3
  28. package/framework/skills/core/morph-review/SKILL.md +3 -2
  29. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  30. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  31. package/framework/standards/STANDARDS.json +2 -1
  32. package/framework/standards/infrastructure/docker/local-compose-isolation.md +15 -1
  33. package/framework/templates/IDE-SNIPPETS.md +262 -262
  34. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  35. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  36. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  37. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  38. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  39. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  40. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  41. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  42. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  43. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  44. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  45. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  46. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  47. package/framework/templates/state.template.json +17 -17
  48. package/framework/templates/ui/design-system.css +226 -226
  49. package/package.json +1 -1
  50. package/src/commands/e2e/e2e.js +50 -24
  51. package/src/commands/observability/cost.js +12 -0
  52. package/src/commands/project/env.js +3 -3
  53. package/src/commands/project/heavy.js +10 -3
  54. package/src/commands/project/worktree.js +9 -4
  55. package/src/commands/state/advance.js +3 -0
  56. package/src/commands/state/create.js +3 -3
  57. package/src/commands/state/delete.js +1 -1
  58. package/src/commands/state/finish.js +20 -4
  59. package/src/commands/validation/mutate.js +2 -2
  60. package/src/commands/validation/plan-check.js +1 -0
  61. package/src/commands/validation/verify.js +3 -0
  62. package/src/dashboard/app/main.jsx +13 -13
  63. package/src/dashboard/vite.config.js +24 -24
  64. package/src/lib/e2e/compose-env.js +26 -3
  65. package/src/lib/e2e/stack-teardown.js +19 -4
  66. package/src/lib/heavy/gate.js +23 -4
  67. package/src/lib/heavy/live-stacks.js +64 -0
  68. package/src/lib/review-reports.js +21 -10
  69. package/src/lib/risk-detector.js +9 -1
  70. package/src/lib/tasks/test-runner.js +3 -0
  71. package/src/lib/telemetry/metrics.js +47 -3
  72. package/src/lib/validators/packages/package-validator.js +2 -2
  73. package/src/lib/worktree/docker-audit.js +7 -3
  74. package/src/lib/worktree/env-allocator.js +27 -5
  75. package/src/lib/worktree/ignored-triage.js +8 -0
  76. package/src/utils/agents-installer.js +4 -1
  77. package/src/utils/file-copier.js +26 -4
  78. package/src/utils/hooks-installer.js +4 -4
  79. package/framework/templates/.idea/morph-templates.xml +0 -92
  80. package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
@@ -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();
@@ -1,5 +1,7 @@
1
1
  import { MAX_SLICES } from './plan/checks.js';
2
2
  import { BLOCKER_TYPES, reviewFileName } from './review-reports.js';
3
+ import { isTestPath } from './graph/sanitize.js';
4
+ import { isTestArtifact } from './worktree/ignored-triage.js';
3
5
 
4
6
  const SECURITY_PATH_RE = /(^|[/._-])(auth|login|senha|password|token|secret|crypto|permiss|rbac|acl|lgpd|pii|privacy|payment|pagamento|billing|cobranca|invoice|asaas|stripe|migrations?)([/._-]|$)/i;
5
7
 
@@ -135,8 +137,14 @@ export function verifierModelFor({ promptModelTouched = false, generatesMedia =
135
137
  return { model: reasons.length ? 'opus' : 'sonnet', reasons };
136
138
  }
137
139
 
140
+ // Teste e artefato de teste não são superfície de ataque: `e2e/login.spec.ts` não pede a persona security.
141
+ const E2E_DIR_RE = /(^|\/)e2e\//;
142
+ const isTestOnly = (f) => isTestPath(f) || E2E_DIR_RE.test(f) || isTestArtifact(f);
143
+
138
144
  export function securityReviewFor(changedFiles = []) {
139
- const risky = changedFiles.filter((f) => SECURITY_PATH_RE.test(f.replaceAll('\\', '/')));
145
+ const risky = changedFiles
146
+ .map((f) => f.replaceAll('\\', '/'))
147
+ .filter((f) => SECURITY_PATH_RE.test(f) && !isTestOnly(f));
140
148
  return { required: risky.length > 0, reasons: risky.length ? [`caminho sensível: ${firstOf(risky)}`] : [] };
141
149
  }
142
150
 
@@ -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
  }
@@ -12,9 +12,9 @@ import {
12
12
  import { allocateEnvBlock, allocatedFeatures, portLayout, resolveFleetConfig } from './env-allocator.js';
13
13
 
14
14
  const PROJECT_PROBE = 'probe';
15
- const MORPH_PROJECT_PREFIX = composeProjectName(PROJECT_PROBE).slice(0, -PROJECT_PROBE.length);
15
+ export const MORPH_PROJECT_PREFIX = composeProjectName(PROJECT_PROBE).slice(0, -PROJECT_PROBE.length);
16
16
 
17
- function resolveGitCommonDir(cwd) {
17
+ export function resolveGitCommonDir(cwd) {
18
18
  try {
19
19
  const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
20
20
  cwd, stdio: 'pipe', encoding: 'utf-8', windowsHide: true,
@@ -57,6 +57,10 @@ function publishedPorts(entry) {
57
57
  return ports;
58
58
  }
59
59
 
60
+ export function stackBelongsTo(entry, root) {
61
+ return belongsToRoot(entry, norm(root));
62
+ }
63
+
60
64
  function composeProjectOf(entry) {
61
65
  const labels = String(entry?.Labels ?? '');
62
66
  for (const part of labels.split(',')) {
@@ -73,7 +77,7 @@ function containerNameOf(entry) {
73
77
  }
74
78
 
75
79
  // `null` = unreadable, distinct from `[]` = docker answered with nothing running.
76
- function parseDockerJson(raw) {
80
+ export function parseDockerJson(raw) {
77
81
  const text = String(raw ?? '').trim();
78
82
  if (!text) return [];
79
83
  const parsed = parseComposePs(text);
@@ -9,6 +9,22 @@ const DEFAULT_BASE_PORT = 4000;
9
9
  const DEFAULT_BLOCK_SIZE = 10;
10
10
  const MAX_PORT = 65535;
11
11
 
12
+ // Chromium's kRestrictedPorts (net/base/port_util.cc): the browser refuses them with
13
+ // ERR_UNSAFE_PORT, so a block holding one is a stack Playwright and the verifier cannot open.
14
+ const BROWSER_UNSAFE_PORTS = new Set([
15
+ 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102, 103,
16
+ 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513,
17
+ 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720,
18
+ 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080,
19
+ ]);
20
+
21
+ export function blockHasUnsafePort(base, blockSize) {
22
+ for (let port = base; port < base + blockSize; port += 1) {
23
+ if (BROWSER_UNSAFE_PORTS.has(port)) return true;
24
+ }
25
+ return false;
26
+ }
27
+
12
28
  export function registryPath(gitCommonDir) {
13
29
  return join(gitCommonDir, 'morph-spec', 'env-registry.json');
14
30
  }
@@ -47,23 +63,29 @@ export function resolveFleetConfig(rootPath) {
47
63
  }
48
64
  }
49
65
 
50
- /** First free index wins, so released slots are reused instead of creeping toward the port ceiling. */
66
+ /**
67
+ * First free index wins, so released slots are reused instead of creeping toward the port ceiling.
68
+ * A block with a browser-unsafe port is never handed out, and one recorded before this rule is moved.
69
+ */
51
70
  export function allocateEnvBlock(feature, { gitCommonDir, basePort = DEFAULT_BASE_PORT, blockSize = DEFAULT_BLOCK_SIZE }) {
52
71
  const registry = readRegistry(gitCommonDir);
53
72
 
54
73
  const existing = registry.allocations[feature];
55
74
  if (existing && Number.isInteger(existing.index)) {
56
75
  const base = basePort + existing.index * blockSize;
57
- return { index: existing.index, base, range: [base, base + blockSize - 1], created: false };
76
+ if (!blockHasUnsafePort(base, blockSize)) {
77
+ return { index: existing.index, base, range: [base, base + blockSize - 1], created: false };
78
+ }
58
79
  }
59
80
 
60
81
  const used = new Set(
61
- Object.values(registry.allocations)
62
- .map((a) => a?.index)
82
+ Object.entries(registry.allocations)
83
+ .filter(([name]) => name !== feature)
84
+ .map(([, a]) => a?.index)
63
85
  .filter((i) => Number.isInteger(i)),
64
86
  );
65
87
  let index = 0;
66
- while (used.has(index)) index += 1;
88
+ while (used.has(index) || blockHasUnsafePort(basePort + index * blockSize, blockSize)) index += 1;
67
89
 
68
90
  const base = basePort + index * blockSize;
69
91
  if (base + blockSize - 1 > MAX_PORT) {
@@ -18,6 +18,14 @@ const DISPOSABLE_FILE_RE = /\.tsbuildinfo$/;
18
18
  const REPLICABLE_MAX_FILES = 500;
19
19
  const REPLICABLE_MAX_BYTES = 8 * 1024 * 1024;
20
20
 
21
+ // Narrower than the build set on purpose: an untracked `bin/` may be new code, a `test-results/` never is.
22
+ const TEST_ARTIFACT_DIR_NAMES = new Set(['test-results', 'TestResults', 'playwright-report', 'blob-report']);
23
+
24
+ /** @param {string} p - forward-slash relative path */
25
+ export function isTestArtifact(p) {
26
+ return p.split('/').filter(Boolean).some((seg) => TEST_ARTIFACT_DIR_NAMES.has(seg));
27
+ }
28
+
21
29
  /** @param {string} p - forward-slash relative path */
22
30
  export function isDisposableBuildArtifact(p) {
23
31
  if (DISPOSABLE_FILE_RE.test(p)) return true;
@@ -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>