@polymorphism-tech/morph-spec 10.0.0 → 10.2.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 (81) hide show
  1. package/CLAUDE.md +2 -0
  2. package/README.md +3 -7
  3. package/bin/morph-spec.js +1 -41
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +27 -38
  6. package/docs/QUICKSTART.md +31 -191
  7. package/framework/CLAUDE.md +2 -2
  8. package/framework/MORPH.md +2 -2
  9. package/framework/agents.json +0 -1
  10. package/framework/commands/morph-archive.md +1 -1
  11. package/framework/commands/morph-preflight.md +6 -7
  12. package/framework/docs/CLI.md +24 -21
  13. package/framework/hooks/README.md +2 -2
  14. package/framework/hooks/claude-code/core/validate-completion.js +0 -35
  15. package/framework/hooks/shared/e2e-check.js +44 -7
  16. package/framework/hooks/shared/heavy-command.js +12 -1
  17. package/framework/hooks/shared/next-step.js +4 -4
  18. package/framework/mcp/registry.json +4 -4
  19. package/framework/phases.json +4 -4
  20. package/framework/rules/ai-agents-standards.md +1 -1
  21. package/framework/rules/data-standards.md +1 -1
  22. package/framework/rules/hangfire-standards.md +1 -1
  23. package/framework/rules/neon-auth-standards.md +1 -1
  24. package/framework/rules/neon-standards.md +3 -3
  25. package/framework/schemas/config.schema.json +20 -1
  26. package/framework/schemas/feature.schema.json +1 -0
  27. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  28. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  29. package/framework/skills/core/morph-apply/SKILL.md +8 -7
  30. package/framework/skills/core/morph-apply/references/hotfix.md +1 -1
  31. package/framework/skills/core/morph-apply/references/review.md +5 -5
  32. package/framework/skills/core/morph-plan/SKILL.md +22 -16
  33. package/framework/skills/core/morph-plan/references/critique.md +11 -8
  34. package/framework/skills/core/morph-plan/references/interview.md +18 -6
  35. package/framework/skills/core/morph-uiux/SKILL.md +2 -2
  36. package/framework/standards/STANDARDS.json +20 -24
  37. package/framework/standards/STANDARDS.meta.json +12 -19
  38. package/framework/standards/ai-agents/rag-custom-pgvector.md +1 -0
  39. package/framework/standards/architecture/stack-baseline.md +26 -3
  40. package/framework/standards/backend/database/postgresql/database.md +22 -1
  41. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +33 -1
  42. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +2 -1
  43. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +10 -9
  44. package/framework/standards/infrastructure/docker/local-compose-isolation.md +2 -2
  45. package/framework/standards/infrastructure/neon/neon-pgvector.md +62 -20
  46. package/framework/standards/infrastructure/neon/neon-platform.md +61 -0
  47. package/framework/standards/infrastructure/neon/neon-setup.md +14 -13
  48. package/framework/standards/integration/mcp/mcp-tools.md +20 -20
  49. package/framework/templates/docs/plan.md +4 -1
  50. package/framework/templates/examples/design-system-examples.md +0 -3
  51. package/framework/templates/infrastructure/docker/docker-compose.template.yml +1 -1
  52. package/package.json +1 -1
  53. package/src/commands/project/doctor.js +0 -1
  54. package/src/commands/state/approve.js +9 -109
  55. package/src/commands/state/auto-approval.js +1 -1
  56. package/src/commands/state/create.js +1 -1
  57. package/src/commands/state/gate-check.js +2 -2
  58. package/src/commands/state/report-check.js +44 -2
  59. package/src/commands/validation/verify.js +7 -3
  60. package/src/core/state/state-manager.js +0 -24
  61. package/src/lib/detectors/index.js +1 -1
  62. package/src/lib/heavy/cleanup.js +8 -2
  63. package/src/lib/installers/memory-installer.js +1 -1
  64. package/src/lib/plan/checks.js +8 -1
  65. package/src/lib/plan/critique.js +23 -3
  66. package/src/lib/review-confirmation.js +67 -0
  67. package/src/lib/review-reports.js +1 -0
  68. package/src/lib/risk-detector.js +2 -2
  69. package/src/lib/tasks/test-nodes.js +5 -0
  70. package/src/lib/tasks/test-runner.js +37 -43
  71. package/src/lib/telemetry/telemetry-reader.js +1 -2
  72. package/src/lib/telemetry/verify-event.js +7 -0
  73. package/src/lib/validators/verify-cache.js +63 -0
  74. package/src/lib/validators/verify-runner.js +98 -23
  75. package/src/utils/agents-installer.js +2 -2
  76. package/src/utils/hooks-installer.js +0 -20
  77. package/bin/validate.js +0 -212
  78. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  79. package/framework/standards/infrastructure/neon/neon-pgrag.md +0 -243
  80. package/src/commands/observability/telemetry.js +0 -60
  81. package/src/commands/validation/validate-feature.js +0 -337
@@ -50,9 +50,6 @@
50
50
  - *--loading : Estado de loading
51
51
  - *--error : Estado de erro
52
52
 
53
- Validacao:
54
- morph-spec validate-css src/MyApp.Web
55
-
56
53
  =========================================================================== */
57
54
 
58
55
 
@@ -56,7 +56,7 @@ services:
56
56
 
57
57
  # PostgreSQL Database (Local development)
58
58
  postgres:
59
- image: postgres:15-alpine
59
+ image: pgvector/pgvector:pg17
60
60
  restart: unless-stopped
61
61
  ports:
62
62
  - "${POSTGRES_PORT:-5432}:5432"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "10.0.0",
3
+ "version": "10.2.0",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -80,7 +80,6 @@ const REQUIRED_COMMAND_FILES = [
80
80
  'src/commands/project/status.js',
81
81
  'src/commands/project/worktree.js',
82
82
  'src/commands/state/approve.js',
83
- 'src/commands/validation/validate-feature.js',
84
83
  'src/commands/scaffold/scaffold.js',
85
84
  'src/commands/mcp/mcp-setup.js',
86
85
  'src/commands/context/context-refresh.js',
@@ -1,9 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import { execFileSync } from 'child_process';
4
- import { existsSync } from 'fs';
5
- import { join } from 'path';
6
- import { getFeature, setApprovalGate, getApprovalGate, getApprovalHistory, derivePhase } from '../../core/state/state-manager.js';
4
+ import { getFeature, setApprovalGate, getApprovalGate } from '../../core/state/state-manager.js';
7
5
  import { unmetPrereqs } from '../../core/state/gate-order.js';
8
6
  import { reviewReports, reviewFileName, REVIEW_ROLES, VERDICT_TOKEN, REVIEWED_SHA_TOKEN } from '../../lib/review-reports.js';
9
7
  import { shouldPauseGate } from '../../lib/risk-detector.js';
@@ -12,6 +10,7 @@ import { autoApprovalRefusal, printRefusal } from './auto-approval.js';
12
10
  import { readTrustFromConfig } from './gate-decision.js';
13
11
  import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
14
12
  import { treeMovedSince } from '../../lib/git/evidence-staleness.js';
13
+ import { reviewConfirmation } from '../../lib/review-confirmation.js';
15
14
  import { seedDotnetBuildIgnores } from '../../lib/stack/dotnet-build-ignores.js';
16
15
  import { reportDotnetIgnores } from './advance.js';
17
16
 
@@ -31,6 +30,11 @@ function reviewStaleness(reports) {
31
30
  return { status: 'skip' };
32
31
  }
33
32
 
33
+ const confirmation = reviewConfirmation(cwd, reports, head);
34
+ const excused = new Set(
35
+ confirmation.confirmed ? confirmation.moved.filter((m) => m.skippable).map((m) => m.role) : [],
36
+ );
37
+
34
38
  let unstamped = null;
35
39
  for (const [role, report] of Object.entries(reports)) {
36
40
  if (report.status === 'missing') continue;
@@ -48,7 +52,7 @@ function reviewStaleness(reports) {
48
52
  unstamped ??= { status: 'unstamped', file, head };
49
53
  continue;
50
54
  }
51
- if (!treeMovedSince(cwd, reviewed, head)) continue;
55
+ if (excused.has(role) || !treeMovedSince(cwd, reviewed, head)) continue;
52
56
  let ahead = 0;
53
57
  try {
54
58
  ahead = Number(execFileSync('git', ['rev-list', '--count', `${reviewed}..${head}`], { cwd, stdio: 'pipe' }).toString().trim()) || 0;
@@ -218,7 +222,7 @@ export async function approveCommand(featureName, gateName, options = {}) {
218
222
  for (const role of unusable) {
219
223
  const { status } = reports[role];
220
224
  const what = status === 'missing' ? 'não existe'
221
- : status === 'partial' ? 'está PARCIAL — retome o mesmo agente por SendMessage'
225
+ : status === 'partial' ? 'está PARCIAL — despache o mesmo papel de novo dizendo o que já está no relatório'
222
226
  : `não tem \`${VERDICT_TOKEN} SHIP|FIX\``;
223
227
  console.error(chalk.gray(` ${reviewFileName(role)} ${what}.`));
224
228
  }
@@ -280,110 +284,6 @@ program
280
284
  .description('Approve a phase gate to allow workflow progression')
281
285
  .action(approveCommand);
282
286
 
283
- export async function approvalStatusCommand(featureName, options = {}) {
284
- try {
285
- const feature = getFeature(featureName);
286
- if (!feature) {
287
- console.error(chalk.red(`\n❌ Feature not found: ${featureName}\n`));
288
- process.exit(1);
289
- }
290
-
291
- // Phase derived from the filesystem + approved gates, never "unknown".
292
- const featurePath = join(process.cwd(), '.morph', 'features', featureName);
293
- const phase = feature.phase || derivePhase(featurePath);
294
-
295
- // The uiux branch is optional: a feature that never took it has no
296
- // 1-design/ folder and no uiux approval — that is "n/a", not "rejected".
297
- const uiuxGate = getApprovalGate(featureName, 'uiux');
298
- const uiuxTaken = uiuxGate?.approved === true || existsSync(join(featurePath, '1-design'));
299
-
300
- if (options.json) {
301
- const status = {
302
- feature: featureName,
303
- phase,
304
- status: feature.status,
305
- uiuxTaken,
306
- gates: {}
307
- };
308
-
309
- for (const gateName of VALID_GATES) {
310
- status.gates[gateName] = getApprovalGate(featureName, gateName) || { approved: false };
311
- }
312
-
313
- console.log(JSON.stringify(status, null, 2));
314
- return;
315
- }
316
-
317
- console.log(chalk.bold(`\n📋 Approval Status for: ${featureName}`));
318
- console.log('━'.repeat(60));
319
-
320
- console.log(chalk.cyan(`\nCurrent Phase: ${phase}`));
321
- console.log(chalk.gray(`Feature Status: ${feature.status || 'unknown'}\n`));
322
-
323
- console.log(chalk.bold('Approval Gates:'));
324
-
325
- for (const gateName of VALID_GATES) {
326
- const gateStatus = getApprovalGate(featureName, gateName);
327
-
328
- if (!gateStatus) {
329
- console.log(` ${chalk.gray('○')} ${gateName}: ${chalk.gray('Not yet evaluated')}`);
330
- continue;
331
- }
332
-
333
- if (gateName === 'uiux' && !uiuxTaken) {
334
- console.log(` ${chalk.gray('—')} ${gateName}: ${chalk.gray('n/a (ramo de design não tomado)')}`);
335
- continue;
336
- }
337
-
338
- if (gateStatus.approved) {
339
- console.log(` ${chalk.green('✓')} ${gateName}: ${chalk.green('Approved')}`);
340
- if (gateStatus.timestamp) {
341
- console.log(chalk.gray(` Approved at: ${gateStatus.timestamp}`));
342
- }
343
- if (gateStatus.approvedBy) {
344
- console.log(chalk.gray(` Approved by: ${gateStatus.approvedBy}`));
345
- }
346
- } else {
347
- // Distinguish "never evaluated" (initialized approved:false, no
348
- // timestamp) from an explicit rejection/revocation (has timestamp).
349
- const neverEvaluated = !gateStatus.timestamp && !gateStatus.rejectedAt && !gateStatus.revokedAt;
350
- if (neverEvaluated) {
351
- console.log(` ${chalk.gray('○')} ${gateName}: ${chalk.gray('Pending (not yet evaluated)')}`);
352
- continue;
353
- }
354
- console.log(` ${chalk.red('✗')} ${gateName}: ${chalk.red('Rejected / Revoked')}`);
355
- if (gateStatus.reason) {
356
- console.log(chalk.gray(` Reason: ${gateStatus.reason}`));
357
- }
358
- if (gateStatus.timestamp) {
359
- console.log(chalk.gray(` Rejected at: ${gateStatus.timestamp}`));
360
- }
361
- }
362
- }
363
-
364
- const history = getApprovalHistory(featureName);
365
- if (history && history.length > 0) {
366
- console.log(chalk.bold('\nApproval History:'));
367
- history.slice(-5).forEach(entry => {
368
- const status = entry.approved ? chalk.green('✓ Approved') : chalk.red('✗ Rejected');
369
- console.log(` ${status} ${entry.gate} - ${entry.timestamp}`);
370
- });
371
- }
372
-
373
- console.log('\n' + '━'.repeat(60) + '\n');
374
-
375
- } catch (error) {
376
- console.error(chalk.red(`\n❌ Error showing approval status: ${error.message}\n`));
377
- process.exit(1);
378
- }
379
- }
380
-
381
- program
382
- .command('approval-status <feature>')
383
- .alias('status')
384
- .description('Show approval status for all gates')
385
- .action(approvalStatusCommand);
386
-
387
287
  if (import.meta.url === `file://${process.argv[1]}`) {
388
288
  if (process.argv.length > 2) {
389
289
  program.parse(process.argv);
@@ -30,7 +30,7 @@ export function autoApprovalRefusal({ cwd, featureName, feature, gate, approvedB
30
30
  }
31
31
 
32
32
  if (gate === 'plan') {
33
- const block = planAutoApprovalBlock(cwd, featureName);
33
+ const block = planAutoApprovalBlock(cwd, featureName, feature);
34
34
  if (block) {
35
35
  return {
36
36
  title: `O gate "plan" não pode ser auto-aprovado — ${block}.`,
@@ -132,7 +132,7 @@ export async function createCommand(featureName, options = {}) {
132
132
  const alreadyExists = existsSync(join(process.cwd(), '.morph', 'features', featureName, 'feature.json'));
133
133
  if (!alreadyExists && (description === null || String(description).trim() === '')) {
134
134
  console.error(chalk.red(`\n❌ A feature needs a description at birth: "${featureName}"`));
135
- console.error(chalk.gray(' Pass --description "<what/why>" (or --request "<pedido>", which seeds it).'));
135
+ console.error(chalk.gray(' Pass --description "<what/why>" (or --request '<pedido>', which seeds it).'));
136
136
  console.error(chalk.gray(' This keeps features identifiable — no more anonymous scaffolds.\n'));
137
137
  process.exit(1);
138
138
  }
@@ -59,9 +59,9 @@ export function collectReviewSignal(cwd, featureName, feature, planChecks = read
59
59
  }
60
60
 
61
61
  /** Why an automatic signature may not close the plan gate: the rule that makes gate-check pause it. */
62
- export function planAutoApprovalBlock(cwd, featureName) {
62
+ export function planAutoApprovalBlock(cwd, featureName, feature) {
63
63
  const planChecks = readPlanChecks(cwd, featureName);
64
- const workType = getFeature(featureName)?.workType || 'feature';
64
+ const workType = feature?.workType || 'feature';
65
65
  const reason = planChecksPause(planChecks.status)
66
66
  ?? planCritiquePause(readPlanCritique(cwd, featureName, { workType, planChecks }))
67
67
  ?? rungPause({ gate: 'plan', rungs: pausingRungs(planChecks.checks) })?.reason;
@@ -1,4 +1,5 @@
1
1
  import chalk from 'chalk';
2
+ import { execFileSync } from 'child_process';
2
3
  import { existsSync, readFileSync } from 'fs';
3
4
  import { join } from 'path';
4
5
  import { implementProgress, parseReport, reportPath } from '../../../framework/hooks/shared/report-progress.js';
@@ -14,6 +15,8 @@ import { isTestPath } from '../../lib/graph/sanitize.js';
14
15
  import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
15
16
  import { collectReviewRequirement } from './gate-check.js';
16
17
  import { featureGeneratesMedia } from '../../lib/plan/media.js';
18
+ import { resolveBaseRef } from '../../lib/git/branch-classifier.js';
19
+ import { reviewConfirmation } from '../../lib/review-confirmation.js';
17
20
 
18
21
  /** Each review report: complete when it carries a readable verdict and is not partial. */
19
22
  export function inspectReviewReports(cwd, feature) {
@@ -75,10 +78,44 @@ function treeFor(cwd, feature, beforeEval) {
75
78
  const tree = treeState(cwd, { snapshot });
76
79
  if (!beforeEval) return { tree };
77
80
  const reviewModels = reviewDispatch(cwd, feature);
81
+ const gate3 = gate3Briefing(cwd, feature);
78
82
  const next = snapshotTree(cwd);
79
- if (!next) return { tree, beforeEval: null, reviewModels };
83
+ if (!next) return { tree, beforeEval: null, reviewModels, gate3 };
80
84
  atomicWriteJson(path, next);
81
- return { tree, beforeEval: { at: next.at, files: Object.keys(next.files).length }, reviewModels };
85
+ return { tree, beforeEval: { at: next.at, files: Object.keys(next.files).length }, reviewModels, gate3 };
86
+ }
87
+
88
+ function gitLine(cwd, args) {
89
+ try {
90
+ return execFileSync('git', args, { cwd, stdio: 'pipe' }).toString().trim() || null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function readVerification(cwd, feature) {
97
+ try {
98
+ return JSON.parse(readFileSync(join(cwd, '.morph', 'features', feature, 'feature.json'), 'utf8')).verification ?? null;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ // What every dispatch needs, in one read. `redispatch: null` = no report yet, every required role goes.
105
+ function gate3Briefing(cwd, feature) {
106
+ const sha = gitLine(cwd, ['rev-parse', 'HEAD']);
107
+ const defaultRef = resolveBaseRef(detectDefaultBranch(cwd), cwd);
108
+ const reports = reviewReports(cwd, feature);
109
+ const anyReport = Object.values(reports).some((report) => report.status !== 'missing');
110
+ const moved = anyReport && sha ? reviewConfirmation(cwd, reports, sha).moved : [];
111
+ const { checks } = readPlanChecks(cwd, feature);
112
+ return {
113
+ base: defaultRef ? gitLine(cwd, ['merge-base', defaultRef, 'HEAD']) : null,
114
+ sha,
115
+ flaky: readVerification(cwd, feature)?.flaky ?? [],
116
+ e2eChecks: (checks?.checks ?? []).filter((check) => check.proof === 'e2e').map((check) => check.id),
117
+ redispatch: anyReport ? moved.filter((role) => !role.skippable).map((role) => role.role) : null,
118
+ };
82
119
  }
83
120
 
84
121
  export function reportCheckCommand(feature, options = {}) {
@@ -128,6 +165,11 @@ function printHuman(obj) {
128
165
  const m = obj.reviewModels;
129
166
  console.log(chalk.gray(` dispatch: verifier ${m.verifier.model}, reviewer ${m.reviewer.model}${m.security.required ? `, security ${m.security.model}` : ''}`));
130
167
  }
168
+ if (obj.gate3) {
169
+ const g = obj.gate3;
170
+ console.log(chalk.gray(` gate 3: base ${g.base?.slice(0, 8) ?? '-'}, sha ${g.sha?.slice(0, 8) ?? '-'}`
171
+ + `${g.redispatch ? `, voltam: ${g.redispatch.join(', ') || 'nenhum'}` : ''}${g.flaky.length ? `, instáveis: ${g.flaky.join(', ')}` : ''}`));
172
+ }
131
173
  printTree(obj.tree);
132
174
  console.log('');
133
175
  }
@@ -109,12 +109,14 @@ const PARTIAL_RUN_FLAGS = [['filter', 'filtered'], ['skipTests', 'skip-tests'],
109
109
  const CI_EXCLUSIVE_FLAGS = [
110
110
  ['filter', '--filter'], ['skipTests', '--skip-tests'], ['skipBuild', '--skip-build'], ['skipE2e', '--skip-e2e'],
111
111
  ['freshE2e', '--fresh-e2e'], ['linux', '--linux'], ['recordBaseline', '--record-baseline'], ['refreshCache', '--refresh-cache'],
112
- ['noPersist', '--no-persist'],
112
+ ['noPersist', '--no-persist'], ['fresh', '--fresh'],
113
113
  ];
114
114
 
115
- async function runLocalVerify(featureName, options) {
115
+ async function runLocalVerify(featureName, options, fingerprint) {
116
116
  try {
117
117
  return await runVerify(process.cwd(), featureName, {
118
+ fingerprint,
119
+ fresh: options.fresh,
118
120
  timeout: options.timeout,
119
121
  skipTests: options.skipTests,
120
122
  skipBuild: options.skipBuild,
@@ -214,11 +216,12 @@ export async function verifyCommand(featureName, options = {}) {
214
216
  // Taken before the run: it is the tree the run is about to read.
215
217
  const fingerprint = treeFingerprint(process.cwd());
216
218
 
217
- const result = ci ? ci.result : await runLocalVerify(featureName, options);
219
+ const result = ci ? ci.result : await runLocalVerify(featureName, options, fingerprint);
218
220
 
219
221
  const cleanup = ci ? { status: 'skipped', reason: 'ci' } : afterHeavyRun({
220
222
  projectPath: process.cwd(),
221
223
  config: readHeavyConfig(process.cwd()),
224
+ onlyUnderPressure: true,
222
225
  });
223
226
 
224
227
  const historyKey = 'verifications.feature';
@@ -254,6 +257,7 @@ export async function verifyCommand(featureName, options = {}) {
254
257
  e2eReason: result.nodes.e2e.status === 'skip' ? (result.nodes.e2e.reason ?? null) : null,
255
258
  sha: ci ? ci.run.headSha : getHeadSha(process.cwd()),
256
259
  ts: now,
260
+ ...(result.flaky?.length ? { flaky: result.flaky } : {}),
257
261
  ...(ci ? { source: 'ci', ciRun: ci.run.url } : {}),
258
262
  };
259
263
  await updateFeature(featureName, 'verification', stamp);
@@ -521,27 +521,3 @@ export function getApprovalGate(featureName, gate) {
521
521
  return feature.approvalGates[gate] || null;
522
522
  }
523
523
 
524
- export function getApprovalHistory(featureName) {
525
- const state = loadState();
526
- const feature = state.features[featureName];
527
-
528
- if (!feature || !feature.approvalGates) {
529
- return [];
530
- }
531
-
532
- const history = [];
533
-
534
- Object.entries(feature.approvalGates).forEach(([gate, data]) => {
535
- if (data.timestamp) {
536
- history.push({
537
- gate,
538
- approved: data.approved,
539
- timestamp: data.timestamp,
540
- approvedBy: data.approvedBy || data.rejectedBy,
541
- reason: data.reason
542
- });
543
- }
544
- });
545
-
546
- return history.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
547
- }
@@ -54,7 +54,7 @@ export function detectProject(targetPath, opts = {}) {
54
54
  if (depNames.includes('@neondatabase/serverless') || depNames.includes('postgres') || depNames.includes('pg')) {
55
55
  structure.integrations.push('neon');
56
56
  }
57
- if (depNames.includes('@neondatabase/auth')) {
57
+ if (depNames.includes('@neon/auth') || depNames.includes('@neondatabase/auth')) {
58
58
  structure.integrations.push('neon-auth');
59
59
  }
60
60
  if (depNames.includes('stripe')) {
@@ -1,4 +1,5 @@
1
1
  import { readdirSync } from 'node:fs';
2
+ import { freemem } from 'node:os';
2
3
  import { getBuildSteps } from '../../../framework/hooks/shared/build-check.js';
3
4
  import { listProcesses, defaultProcExec } from '../procs/list.js';
4
5
  import { readProjectConfig } from '../../../framework/hooks/shared/heavy-config.js';
@@ -8,6 +9,7 @@ const DOTNET_ACTIVE = /\bdotnet(?:\.exe)?["']?\s+(?:build|test|publish|pack|msbu
8
9
  const IDLE_WORKER = /\/nodemode:\d|vbcscompiler|razorserver/i;
9
10
  const SHUTDOWN_TIMEOUT_MS = 30_000;
10
11
  const TAIL_CHARS = 400;
12
+ const GB = 1024 ** 3;
11
13
 
12
14
  function hasDotnet(projectPath) {
13
15
  const config = readProjectConfig(projectPath);
@@ -32,14 +34,18 @@ export function dotnetBusy(procs) {
32
34
  });
33
35
  }
34
36
 
35
- export function afterHeavyRun({ projectPath, config, env = process.env, deps = {} }) {
37
+ export function afterHeavyRun({ projectPath, config, env = process.env, onlyUnderPressure = false, deps = {} }) {
36
38
  const {
37
- exec = defaultProcExec, list = listProcesses, dotnet = hasDotnet, exclusive = runExclusive,
39
+ exec = defaultProcExec, list = listProcesses, dotnet = hasDotnet, exclusive = runExclusive, freeMem = freemem,
38
40
  } = deps;
39
41
 
40
42
  if (!config.enabled) return { status: 'skipped', reason: 'semáforo desligado: sem ele nada prova que outra sessão não está compilando' };
41
43
  if (!config.shutdownBuildServer) return { status: 'skipped', reason: 'desligado em heavy.shutdownBuildServer' };
42
44
  if (!dotnet(projectPath)) return { status: 'skipped', reason: 'projeto sem .NET' };
45
+ // A warm compiler is what makes the next round short; twice the floor keeps the next queue open.
46
+ if (onlyUnderPressure && freeMem() / GB >= config.minFreeMemGb * 2) {
47
+ return { status: 'skipped', reason: 'RAM livre de sobra: o compilador fica quente para a próxima rodada' };
48
+ }
43
49
 
44
50
  const guarded = exclusive({
45
51
  dir: heavyDir(env), label: 'build-server-shutdown', maxHoldMs: config.maxHoldMinutes * 60_000, env,
@@ -39,7 +39,7 @@ metadata:
39
39
 
40
40
  | Command | Skill invoked |
41
41
  |---------|---------------|
42
- | \`/morph-plan {feature}\` | \`Skill(morph-plan)\` — entrevista → plan.md → plan-check → gate do plano |
42
+ | \`/morph-plan {feature}\` | \`Skill(morph-plan)\` — tese → plan.md → plan-check → gate do plano |
43
43
  | \`/morph-apply {feature}\` | \`Skill(morph-apply)\` — fatias do plano → verify → review → Gate 3 |
44
44
  | \`/morph-status [feature]\` | inspect state.json |
45
45
  | \`/morph-archive {feature}\` | move completed feature to .morph/archive/ |
@@ -123,9 +123,16 @@ export function validatePlan(plan, { workType = null, riskRules = true, text = n
123
123
  errors.push(...statesErrors(plan, at));
124
124
  warnings.push(...defaultsWarnings(plan, at));
125
125
 
126
- if (plan.sections.some((s) => !s.key && /^fatias$/i.test(s.name.trim()))) {
126
+ const isSlices = (s) => /^fatias$/i.test(s.name.trim());
127
+ if (plan.sections.some((s) => !s.key && isSlices(s))) {
127
128
  warnings.push('## Fatias não é lida desde a 10.0: o plano inteiro se implementa numa sessão, e os cenários definem o pronto');
128
129
  }
130
+ const known = new Set(Object.values(SECTIONS).map(foldRisk));
131
+ for (const s of plan.sections.filter((section) => !section.key && !isSlices(section))) {
132
+ warnings.push(known.has(foldRisk(s.name))
133
+ ? `## ${s.name} repetida${at(s.line)} — só a primeira é lida, o conteúdo desta se perde`
134
+ : `## ${s.name} não é seção do plano${at(s.line)} — o plan-check não a lê`);
135
+ }
129
136
 
130
137
  for (const r of plan.reuse) {
131
138
  if (!REUSE_VERDICTS.includes(r.verdict)) {
@@ -1,8 +1,21 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
2
  import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
3
+ import { featureGeneratesMedia } from './media.js';
3
4
 
4
5
  export const CRITIQUE_EXEMPT_WORK_TYPES = ['chore', 'hotfix'];
5
6
 
7
+ const SMALL_PLAN_MAX_CHECKS = 3;
8
+ const SMALL_PLAN_PROOFS = ['teste', 'leitura'];
9
+
10
+ /** A plan this size has little for a fresh reader to catch; a rung or a proof outside the code does. */
11
+ export function isSmallPlan(checks, planText = '') {
12
+ const scenarios = checks?.checks ?? [];
13
+ return scenarios.length > 0
14
+ && scenarios.length <= SMALL_PLAN_MAX_CHECKS
15
+ && scenarios.every((scenario) => SMALL_PLAN_PROOFS.includes(scenario.proof) && !scenario.rung)
16
+ && !featureGeneratesMedia(planText);
17
+ }
18
+
6
19
  const PLAN_HASH_RE = /^\*\*Plan-Hash:\*\*\s*`?([0-9a-f]{64})`?\s*$/m;
7
20
  const APPLIED_RE = /^\*\*Aplicado-em:\*\*\s*`?([0-9a-f]{64})`?\s*$/gm;
8
21
 
@@ -21,8 +34,15 @@ export function readPlanCritique(cwd, featureName, { workType, planChecks }) {
21
34
  if (CRITIQUE_EXEMPT_WORK_TYPES.includes(workType)) return 'exempt';
22
35
  const currentHash = planChecks?.status === 'fresh' ? planChecks.checks?.planHash : null;
23
36
  if (!currentHash) return null;
37
+ if (isSmallPlan(planChecks.checks, readText(getAbsoluteOutputPath(cwd, featureName, 'plan')) ?? '')) return 'exempt';
24
38
  const path = getAbsoluteOutputPath(cwd, featureName, 'planCritique');
25
- let text = null;
26
- try { text = existsSync(path) ? readFileSync(path, 'utf8') : null; } catch { text = null; }
27
- return critiqueStatus(text, currentHash);
39
+ return critiqueStatus(readText(path), currentHash);
40
+ }
41
+
42
+ function readText(path) {
43
+ try {
44
+ return existsSync(path) ? readFileSync(path, 'utf8') : null;
45
+ } catch {
46
+ return null;
47
+ }
28
48
  }
@@ -0,0 +1,67 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { basename } from 'path';
3
+ import { treeMovedSince } from './git/evidence-staleness.js';
4
+ import { isTestOnly, securityReviewFor } from './risk-detector.js';
5
+
6
+ function resolveCommit(cwd, ref) {
7
+ try {
8
+ return execFileSync('git', ['rev-parse', '--verify', `${ref}^{commit}`], { cwd, stdio: 'pipe' }).toString().trim();
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ function changedBetween(cwd, from, to) {
15
+ try {
16
+ return execFileSync('git', ['diff', '--name-only', '--no-renames', from, to, '--', '.', ':(exclude).morph'], { cwd, stdio: 'pipe' })
17
+ .toString().split('\n').map((line) => line.trim()).filter(Boolean);
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function citedByBlockers(reports) {
24
+ return Object.values(reports)
25
+ .flatMap((report) => report.rounds ?? [])
26
+ .flatMap((round) => round.blockers ?? [])
27
+ .map((blocker) => `${blocker.where} ${blocker.evidence}`)
28
+ .join('\n');
29
+ }
30
+
31
+ const openedBlockers = (report) => (report.rounds ?? []).some((round) => (round.blockers ?? []).length > 0);
32
+ const isCleanShip = (report) => report.verdict === 'SHIP' && (report.blockers ?? []).length === 0;
33
+
34
+ function deltaIsNarrow(role, delta, cited) {
35
+ if (delta === null) return false;
36
+ if (role === 'security') return !securityReviewFor(delta).required;
37
+ return delta.every((file) => isTestOnly(file) || cited.includes(file) || cited.includes(basename(file)));
38
+ }
39
+
40
+ /**
41
+ * After a fix, a role that shipped clean need not return when the delta stays inside what the
42
+ * blockers named (security: when it touches no sensitive path). `moved` lists the roles behind
43
+ * `head`; `skippable` says it holds; `confirmed` says a role that opened blockers re-judged `head`,
44
+ * without which nothing is skippable at approval.
45
+ */
46
+ export function reviewConfirmation(cwd, reports, head) {
47
+ const cited = citedByBlockers(reports);
48
+ const moved = [];
49
+ let confirmed = false;
50
+
51
+ for (const [role, report] of Object.entries(reports)) {
52
+ if (report.status === 'missing' || !report.reviewedSha) continue;
53
+ const reviewed = resolveCommit(cwd, report.reviewedSha);
54
+ if (!reviewed) continue;
55
+ if (!treeMovedSince(cwd, reviewed, head)) {
56
+ if (openedBlockers(report)) confirmed = true;
57
+ continue;
58
+ }
59
+ moved.push({
60
+ role,
61
+ reviewed,
62
+ skippable: isCleanShip(report) && deltaIsNarrow(role, changedBetween(cwd, reviewed, head), cited),
63
+ });
64
+ }
65
+
66
+ return { moved, confirmed };
67
+ }
@@ -32,6 +32,7 @@ const SCREEN_CHECKLIST_SECTION_RE = sectionRe('Checklist\\s+de\\s+tela');
32
32
 
33
33
  /** Hotfix: só o verificador, escrito pelo orquestrador sem dispatch. Chore: só o revisor (review leve). */
34
34
  export function requiredReviewRoles({ workType, securityRequired = false } = {}) {
35
+ // Sem `security` mesmo em caminho sensível: o humano assina todo hotfix.
35
36
  if (workType === 'hotfix') return ['verifier'];
36
37
  const base = workType === 'chore' ? ['reviewer'] : ['verifier', 'reviewer'];
37
38
  return securityRequired ? [...base, 'security'] : base;
@@ -83,7 +83,7 @@ export function reviewPause(signal) {
83
83
  + ' auto-avaliação de quem implementou não decide o ship';
84
84
  }
85
85
  if (r.status === 'partial') {
86
- return `\`${file}\` está PARCIAL — retome o mesmo agente por SendMessage; parcial não é veredito`;
86
+ return `\`${file}\` está PARCIAL — despache o mesmo papel de novo dizendo o que já está no relatório; parcial não é veredito`;
87
87
  }
88
88
  if (r.status !== 'ok') {
89
89
  return `\`${file}\` sem \`**Verdict:** SHIP|FIX\` — o relatório não seguiu o contrato`;
@@ -144,7 +144,7 @@ export function verifierModelFor({ promptModelTouched = false, generatesMedia =
144
144
 
145
145
  // Teste e artefato de teste não são superfície de ataque: `e2e/login.spec.ts` não pede a persona security.
146
146
  const E2E_DIR_RE = /(^|\/)e2e\//;
147
- const isTestOnly = (f) => isTestPath(f) || E2E_DIR_RE.test(f) || isTestArtifact(f);
147
+ export const isTestOnly = (f) => isTestPath(f) || E2E_DIR_RE.test(f) || isTestArtifact(f);
148
148
 
149
149
  export function securityReviewFor(changedFiles = []) {
150
150
  const risky = changedFiles
@@ -83,6 +83,7 @@ function makeNode(partial) {
83
83
  runner,
84
84
  expectedCount: keepsBaseline(runner) ? partial.expectedCount ?? null : null,
85
85
  timeoutMs: partial.timeoutMs ?? null,
86
+ parallel: partial.parallel ?? false,
86
87
  build: partial.build ?? [],
87
88
  e2e: partial.e2e ?? false,
88
89
  linuxImage: partial.linuxImage ?? null,
@@ -133,6 +134,9 @@ function firstProblem(raw, projectPath) {
133
134
  if (raw.timeoutMs !== undefined && !(Number.isInteger(raw.timeoutMs) && raw.timeoutMs > 0)) {
134
135
  return 'timeoutMs must be an integer > 0';
135
136
  }
137
+ if (raw.parallel !== undefined && typeof raw.parallel !== 'boolean') {
138
+ return 'parallel must be a boolean';
139
+ }
136
140
  if (raw.build !== undefined && (!Array.isArray(raw.build) || raw.build.some((b) => typeof b !== 'string'))) {
137
141
  return 'build must be an array of strings';
138
142
  }
@@ -208,6 +212,7 @@ export function validateTestNodes(config, projectPath = null) {
208
212
  runner: entry.runner ?? inferRunner(entry.command),
209
213
  expectedCount: entry.expectedCount ?? null,
210
214
  timeoutMs: entry.timeoutMs ?? null,
215
+ parallel: entry.parallel ?? false,
211
216
  build: entry.build ?? [],
212
217
  e2e: entry.e2e ?? false,
213
218
  linuxImage: entry.linuxImage ?? null,