@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.
- package/CLAUDE.md +2 -0
- package/README.md +3 -7
- package/bin/morph-spec.js +1 -41
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +27 -38
- package/docs/QUICKSTART.md +31 -191
- package/framework/CLAUDE.md +2 -2
- package/framework/MORPH.md +2 -2
- package/framework/agents.json +0 -1
- package/framework/commands/morph-archive.md +1 -1
- package/framework/commands/morph-preflight.md +6 -7
- package/framework/docs/CLI.md +24 -21
- package/framework/hooks/README.md +2 -2
- package/framework/hooks/claude-code/core/validate-completion.js +0 -35
- package/framework/hooks/shared/e2e-check.js +44 -7
- package/framework/hooks/shared/heavy-command.js +12 -1
- package/framework/hooks/shared/next-step.js +4 -4
- package/framework/mcp/registry.json +4 -4
- package/framework/phases.json +4 -4
- package/framework/rules/ai-agents-standards.md +1 -1
- package/framework/rules/data-standards.md +1 -1
- package/framework/rules/hangfire-standards.md +1 -1
- package/framework/rules/neon-auth-standards.md +1 -1
- package/framework/rules/neon-standards.md +3 -3
- package/framework/schemas/config.schema.json +20 -1
- package/framework/schemas/feature.schema.json +1 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +8 -7
- package/framework/skills/core/morph-apply/references/hotfix.md +1 -1
- package/framework/skills/core/morph-apply/references/review.md +5 -5
- package/framework/skills/core/morph-plan/SKILL.md +22 -16
- package/framework/skills/core/morph-plan/references/critique.md +11 -8
- package/framework/skills/core/morph-plan/references/interview.md +18 -6
- package/framework/skills/core/morph-uiux/SKILL.md +2 -2
- package/framework/standards/STANDARDS.json +20 -24
- package/framework/standards/STANDARDS.meta.json +12 -19
- package/framework/standards/ai-agents/rag-custom-pgvector.md +1 -0
- package/framework/standards/architecture/stack-baseline.md +26 -3
- package/framework/standards/backend/database/postgresql/database.md +22 -1
- package/framework/standards/backend/dotnet/vsa-handler-patterns.md +33 -1
- package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +2 -1
- package/framework/standards/backend/integrations/neon-auth/neon-auth.md +10 -9
- package/framework/standards/infrastructure/docker/local-compose-isolation.md +2 -2
- package/framework/standards/infrastructure/neon/neon-pgvector.md +62 -20
- package/framework/standards/infrastructure/neon/neon-platform.md +61 -0
- package/framework/standards/infrastructure/neon/neon-setup.md +14 -13
- package/framework/standards/integration/mcp/mcp-tools.md +20 -20
- package/framework/templates/docs/plan.md +4 -1
- package/framework/templates/examples/design-system-examples.md +0 -3
- package/framework/templates/infrastructure/docker/docker-compose.template.yml +1 -1
- package/package.json +1 -1
- package/src/commands/project/doctor.js +0 -1
- package/src/commands/state/approve.js +9 -109
- package/src/commands/state/auto-approval.js +1 -1
- package/src/commands/state/create.js +1 -1
- package/src/commands/state/gate-check.js +2 -2
- package/src/commands/state/report-check.js +44 -2
- package/src/commands/validation/verify.js +7 -3
- package/src/core/state/state-manager.js +0 -24
- package/src/lib/detectors/index.js +1 -1
- package/src/lib/heavy/cleanup.js +8 -2
- package/src/lib/installers/memory-installer.js +1 -1
- package/src/lib/plan/checks.js +8 -1
- package/src/lib/plan/critique.js +23 -3
- package/src/lib/review-confirmation.js +67 -0
- package/src/lib/review-reports.js +1 -0
- package/src/lib/risk-detector.js +2 -2
- package/src/lib/tasks/test-nodes.js +5 -0
- package/src/lib/tasks/test-runner.js +37 -43
- package/src/lib/telemetry/telemetry-reader.js +1 -2
- package/src/lib/telemetry/verify-event.js +7 -0
- package/src/lib/validators/verify-cache.js +63 -0
- package/src/lib/validators/verify-runner.js +98 -23
- package/src/utils/agents-installer.js +2 -2
- package/src/utils/hooks-installer.js +0 -20
- package/bin/validate.js +0 -212
- package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
- package/framework/standards/infrastructure/neon/neon-pgrag.md +0 -243
- package/src/commands/observability/telemetry.js +0 -60
- package/src/commands/validation/validate-feature.js +0 -337
package/package.json
CHANGED
|
@@ -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 {
|
|
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 —
|
|
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
|
|
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 =
|
|
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')) {
|
package/src/lib/heavy/cleanup.js
CHANGED
|
@@ -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)\` —
|
|
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/ |
|
package/src/lib/plan/checks.js
CHANGED
|
@@ -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
|
-
|
|
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)) {
|
package/src/lib/plan/critique.js
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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;
|
package/src/lib/risk-detector.js
CHANGED
|
@@ -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 —
|
|
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,
|