@polymorphism-tech/morph-spec 9.2.0 → 9.3.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 +14 -78
- package/README.md +2 -2
- package/bin/morph-spec.js +7 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +3 -3
- package/framework/MORPH.md +5 -8
- package/framework/agents.json +9 -6
- package/framework/commands/morph-status.md +2 -2
- package/framework/docs/CLI.md +8 -2
- package/framework/evals/review-checklist.md +3 -0
- package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
- package/framework/hooks/claude-code/core/worktree-context.js +23 -0
- package/framework/hooks/claude-code/statusline.py +3 -2
- package/framework/hooks/shared/gate-event.js +18 -0
- package/framework/hooks/shared/next-step.js +107 -0
- package/framework/hooks/shared/slice-progress.js +18 -5
- package/framework/hooks/shared/state-writer.js +6 -1
- package/framework/mcp/registry.json +31 -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 +4 -2
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-machine/SKILL.md +1 -1
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +2 -1
- package/framework/standards/STANDARDS.meta.json +1 -1
- package/framework/standards/ai-agents/setup.md +2 -0
- package/framework/standards/integration/mcp/mcp-tools.md +3 -1
- package/package.json +1 -1
- package/src/commands/observability/cost.js +12 -0
- package/src/commands/project/status.js +6 -68
- package/src/commands/state/advance.js +1 -1
- package/src/commands/state/gate-check.js +2 -2
- package/src/commands/state/gate-decision.js +4 -1
- package/src/commands/state/next.js +24 -0
- package/src/commands/state/report-check.js +3 -1
- package/src/commands/validation/verify.js +6 -1
- package/src/core/state/state-manager.js +4 -0
- package/src/dashboard/app/pages/FeatureDetail.jsx +6 -2
- package/src/dashboard/scanner/features-scanner.js +5 -2
- package/src/lib/generators/handoff-generator.js +11 -25
- package/src/lib/next-step.js +10 -0
- package/src/lib/risk-detector.js +18 -7
- package/src/lib/stack/stack-scan.js +13 -1
- package/src/lib/telemetry/metrics.js +46 -0
- package/src/lib/validators/verify-runner.js +1 -1
- package/src/utils/agents-installer.js +23 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { loadState, getFeature, derivePhase, deriveOutputs } from '../../core/state/state-manager.js';
|
|
3
3
|
import { sliceProgress } from '../../../framework/hooks/shared/slice-progress.js';
|
|
4
|
+
import { formatNext } from '../../../framework/hooks/shared/next-step.js';
|
|
5
|
+
import { nextStepFor } from '../../lib/next-step.js';
|
|
4
6
|
|
|
5
7
|
const PHASE_SYMBOLS = {
|
|
6
8
|
completed: '✅',
|
|
@@ -87,60 +89,6 @@ function calculateCompletionPercentage(feature) {
|
|
|
87
89
|
return Math.round((completedPhases / totalPhases) * 100);
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
function getNextSteps(feature) {
|
|
91
|
-
const steps = [];
|
|
92
|
-
|
|
93
|
-
const phaseApprovalMap = {
|
|
94
|
-
plan: 'plan',
|
|
95
|
-
uiux: 'uiux',
|
|
96
|
-
implement: 'plan',
|
|
97
|
-
review: 'review'
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
const relevantGate = phaseApprovalMap[feature.phase];
|
|
101
|
-
const approvalGates = feature.approvalGates || {};
|
|
102
|
-
|
|
103
|
-
if (relevantGate && approvalGates[relevantGate] && !approvalGates[relevantGate].approved) {
|
|
104
|
-
steps.push({
|
|
105
|
-
type: 'approval',
|
|
106
|
-
message: `Approve ${relevantGate} to advance`,
|
|
107
|
-
command: `morph-spec approve ${feature.name || 'feature'} ${relevantGate}`
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const requiredOutputs = feature.phase === 'uiux' ? [] : PHASE_OUTPUTS[feature.phase] || [];
|
|
112
|
-
const missingOutputs = requiredOutputs.filter(o => !feature.outputs[o]?.created);
|
|
113
|
-
|
|
114
|
-
if (missingOutputs.length > 0) {
|
|
115
|
-
steps.push({
|
|
116
|
-
type: 'output',
|
|
117
|
-
message: `Create missing outputs: ${missingOutputs.join(', ')}`,
|
|
118
|
-
command: null
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const hasApprovalPending = relevantGate && approvalGates[relevantGate] && !approvalGates[relevantGate].approved;
|
|
123
|
-
if (missingOutputs.length === 0 && !hasApprovalPending) {
|
|
124
|
-
const currentPhaseIndex = PHASE_ORDER.findIndex(p => p.key === feature.phase);
|
|
125
|
-
if (currentPhaseIndex >= 0 && currentPhaseIndex < PHASE_ORDER.length - 1) {
|
|
126
|
-
const nextPhase = PHASE_ORDER[currentPhaseIndex + 1];
|
|
127
|
-
steps.push({
|
|
128
|
-
type: 'advance',
|
|
129
|
-
message: `Advance to ${nextPhase.name}`,
|
|
130
|
-
command: `morph-spec phase advance ${feature.name || 'feature'}`
|
|
131
|
-
});
|
|
132
|
-
} else if (currentPhaseIndex === PHASE_ORDER.length - 1) {
|
|
133
|
-
steps.push({
|
|
134
|
-
type: 'complete',
|
|
135
|
-
message: 'Feature complete! Consider archiving.',
|
|
136
|
-
command: null
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return steps;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
92
|
export async function statusCommand(featureName, options = {}) {
|
|
145
93
|
if (!featureName) {
|
|
146
94
|
console.error(chalk.red('✗ Error: Feature name required'));
|
|
@@ -191,7 +139,7 @@ export async function statusCommand(featureName, options = {}) {
|
|
|
191
139
|
})),
|
|
192
140
|
slices,
|
|
193
141
|
approvals: feature.approvalGates || {},
|
|
194
|
-
|
|
142
|
+
next: nextStepFor(featureName, process.cwd())
|
|
195
143
|
};
|
|
196
144
|
|
|
197
145
|
console.log(JSON.stringify(status, null, 2));
|
|
@@ -235,6 +183,7 @@ export async function statusCommand(featureName, options = {}) {
|
|
|
235
183
|
console.log(` Total: ${slices.total}`);
|
|
236
184
|
console.log(chalk.green(` ✓ Feitas: ${slices.done} (${percentage}%)`));
|
|
237
185
|
if (slices.partial > 0) console.log(chalk.cyan(` ▶ Parciais: ${slices.partial}`));
|
|
186
|
+
if (slices.blocked > 0) console.log(chalk.yellow(` ⏸ Bloqueadas (esperam decisão): ${slices.blocked}`));
|
|
238
187
|
if (slices.pending > 0) console.log(chalk.gray(` ⬜ Pendentes: ${slices.pending}`));
|
|
239
188
|
console.log('');
|
|
240
189
|
}
|
|
@@ -258,19 +207,8 @@ export async function statusCommand(featureName, options = {}) {
|
|
|
258
207
|
console.log('');
|
|
259
208
|
}
|
|
260
209
|
|
|
261
|
-
const
|
|
262
|
-
if (
|
|
263
|
-
console.log(chalk.white.bold('Next Steps:'));
|
|
264
|
-
nextSteps.forEach((step, index) => {
|
|
265
|
-
console.log(chalk.yellow(` ${index + 1}. ${step.message}`));
|
|
266
|
-
if (step.command) {
|
|
267
|
-
console.log(chalk.gray(` → ${step.command}`));
|
|
268
|
-
}
|
|
269
|
-
});
|
|
270
|
-
console.log('');
|
|
271
|
-
} else {
|
|
272
|
-
console.log(chalk.green('✓ Nada pendente.\n'));
|
|
273
|
-
}
|
|
210
|
+
const next = nextStepFor(featureName, process.cwd());
|
|
211
|
+
if (next) console.log(chalk.yellow(`${formatNext(next)}\n`));
|
|
274
212
|
|
|
275
213
|
console.log(chalk.gray('─'.repeat(60)));
|
|
276
214
|
console.log(chalk.gray('Run with --verbose for detailed output'));
|
|
@@ -143,7 +143,7 @@ export async function advanceCommand(featureName, options = {}) {
|
|
|
143
143
|
process.exit(1);
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
console.error(chalk.red(`\n❌ A fase "${phase}" não pode ser encerrada — artefato(s) obrigatório(s) ausente(s) ou
|
|
146
|
+
console.error(chalk.red(`\n❌ A fase "${phase}" não pode ser encerrada — artefato(s) obrigatório(s) ausente(s), PARCIAL ou BLOQUEADO:`));
|
|
147
147
|
for (const rel of missingArtifacts) console.error(chalk.gray(` • ${rel}`));
|
|
148
148
|
console.error(chalk.gray('\n Produza o artefato e rode de novo, ou passe --force para avançar assim mesmo.\n'));
|
|
149
149
|
process.exit(1);
|
|
@@ -189,10 +189,10 @@ export async function gateCheckCommand(featureName, gateName, options = {}) {
|
|
|
189
189
|
const computed = shouldPauseGate({ trust }, signals);
|
|
190
190
|
const manual = feature.gateDecisions?.[gateName];
|
|
191
191
|
const manualPause = manual?.source === 'manual' && manual.pause === true;
|
|
192
|
-
const { pause, reason } = manualPause ? { pause: true, reason: manual.reason } : computed;
|
|
192
|
+
const { pause, reason, signal } = manualPause ? { pause: true, reason: manual.reason, signal: 'manual' } : computed;
|
|
193
193
|
|
|
194
194
|
const entry = await persistGateDecision(featureName, gateName, {
|
|
195
|
-
pause, reason, signals, trust, cwd, source: manualPause ? 'manual' : 'gate-check',
|
|
195
|
+
pause, reason, signal, signals, trust, cwd, source: manualPause ? 'manual' : 'gate-check',
|
|
196
196
|
});
|
|
197
197
|
|
|
198
198
|
if (options.json) {
|
|
@@ -4,6 +4,7 @@ import { join } from 'path';
|
|
|
4
4
|
import { getFeature, updateFeature } from '../../core/state/state-manager.js';
|
|
5
5
|
import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
|
|
6
6
|
import { mockupsHash } from '../../lib/specs/screen-checklist.js';
|
|
7
|
+
import { recordGateEvent } from '../../../framework/hooks/shared/gate-event.js';
|
|
7
8
|
|
|
8
9
|
export const VALID_GATES = ['plan', 'uiux', 'review'];
|
|
9
10
|
|
|
@@ -31,7 +32,7 @@ export function readTrustFromConfig() {
|
|
|
31
32
|
* The ONE write path of `gateDecisions.{gate}`, shared by `gate-decision` and `gate-check`.
|
|
32
33
|
* Does not validate `gateName`. A uiux decision records the `mockupsHash` it saw.
|
|
33
34
|
*/
|
|
34
|
-
export async function persistGateDecision(featureName, gateName, { pause, reason, signals = {}, trust, cwd = process.cwd(), source } = {}) {
|
|
35
|
+
export async function persistGateDecision(featureName, gateName, { pause, reason, signal, signals = {}, trust, cwd = process.cwd(), source } = {}) {
|
|
35
36
|
const entry = {
|
|
36
37
|
pause,
|
|
37
38
|
reason: reason || (pause ? 'risco detectado' : 'nenhum sinal de risco'),
|
|
@@ -43,6 +44,7 @@ export async function persistGateDecision(featureName, gateName, { pause, reason
|
|
|
43
44
|
};
|
|
44
45
|
|
|
45
46
|
await updateFeature(featureName, `gateDecisions.${gateName}`, entry);
|
|
47
|
+
recordGateEvent(cwd, { feature: featureName, gate: gateName, step: 'decision', pause, signal, source });
|
|
46
48
|
return entry;
|
|
47
49
|
}
|
|
48
50
|
|
|
@@ -91,6 +93,7 @@ export async function gateDecisionCommand(featureName, gateName, options = {}) {
|
|
|
91
93
|
const entry = await persistGateDecision(featureName, gateName, {
|
|
92
94
|
pause,
|
|
93
95
|
reason: options.reason,
|
|
96
|
+
signal: 'manual',
|
|
94
97
|
signals,
|
|
95
98
|
trust: options.trust,
|
|
96
99
|
source: 'manual',
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { formatNext } from '../../../framework/hooks/shared/next-step.js';
|
|
3
|
+
import { nextStepFor } from '../../lib/next-step.js';
|
|
4
|
+
import { resolveFeatureTree } from '../../lib/worktree/feature-truth.js';
|
|
5
|
+
|
|
6
|
+
// Read-only: decides in the tree that owns the feature, since the root copy of a worktree feature is a birth skeleton.
|
|
7
|
+
export function nextCommand(featureName, options = {}) {
|
|
8
|
+
const owner = resolveFeatureTree(featureName, process.cwd());
|
|
9
|
+
const next = owner.source === 'none' || owner.source === 'branch-only' ? null : nextStepFor(featureName, owner.tree);
|
|
10
|
+
|
|
11
|
+
if (!next) {
|
|
12
|
+
const error = owner.source === 'branch-only' ? 'feature-not-in-tree' : 'feature-not-found';
|
|
13
|
+
if (options.json) console.log(JSON.stringify({ error, feature: featureName, branch: owner.branch ?? null }));
|
|
14
|
+
else console.error(chalk.red(`\n❌ ${error}: ${featureName}${owner.branch ? ` (morph-spec worktree setup ${featureName})` : ''}\n`));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (options.json) {
|
|
19
|
+
console.log(JSON.stringify({ ...next, feature: featureName, tree: owner.tree }));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
console.log(formatNext(next));
|
|
23
|
+
if (owner.tree !== process.cwd()) console.log(chalk.gray(` árvore dona: ${owner.tree}`));
|
|
24
|
+
}
|
|
@@ -130,6 +130,7 @@ function decide(report, observed, stale) {
|
|
|
130
130
|
}
|
|
131
131
|
// PARCIAL: the agent stopped short (turn ceiling) and resumes from its last
|
|
132
132
|
// `wip` commit — completing it DEGRADADO would stamp unfinished work as done.
|
|
133
|
+
if (report.exists && report.blocked) return { verdict: 'report-blocked', action: 'ask-human' };
|
|
133
134
|
if (report.exists && report.partial) return { verdict: 'report-incomplete', action: 'resume' };
|
|
134
135
|
if (report.exists) return { verdict: 'report-incomplete', action: 'complete-degraded' };
|
|
135
136
|
if (!observed.available) return { verdict: 'report-missing-evidence-unknown', action: 'redispatch' };
|
|
@@ -151,7 +152,8 @@ const ACTION_HINT = {
|
|
|
151
152
|
'refresh-from-evidence': 'o relatório está COMPLETO mas VELHO: o agente da fatia seguiu escrevendo arquivos depois de gravá-lo, então ele descreve um estado já ultrapassado. NÃO re-despache e não confie no texto — confira os arquivos em `observed` (evidência real, não auto-declarada) e atualize o relatório a partir deles.',
|
|
152
153
|
'complete-degraded': 'complete os itens faltantes a partir da mensagem de retorno do sub-agent, carimbe o relatório como DEGRADADO e registre nele a falha de protocolo.',
|
|
153
154
|
resume: 'relatório PARCIAL: o sub-agent parou antes do fim (limite de turnos) e a fatia NÃO está pronta. Não carimbe DEGRADADO — retome o MESMO agente por `SendMessage` (cole no recado `tree.dirty` e `tree.commits` desta saída e o que falta) ou re-despache só o que falta, a partir do último commit `wip`.',
|
|
154
|
-
'
|
|
155
|
+
'ask-human': 'relatório BLOQUEADO: o sub-agent parou numa decisão que não é dele (requisito que contradiz o plano ou um ADR, escolha do humano) e o relatório traz a pergunta e as opções. NÃO re-despache — a mesma pergunta volta. Se o plano ou um ADR já decide, decida você e registre no relatório; senão, faça a pergunta por `AskUserQuestion`. Depois retome o MESMO agente por `SendMessage` com a resposta.',
|
|
156
|
+
'accept-degraded':'o TRABALHO entrou — não re-despache. Grave você mesmo o relatório da fatia a partir da mensagem de retorno, carimbado como DEGRADADO, com a falha de protocolo registrada nele.',
|
|
155
157
|
redispatch: 're-despache a fatia UMA vez com o mesmo prompt. Segunda ausência sem trabalho entregue = falha real: escale ao humano.',
|
|
156
158
|
};
|
|
157
159
|
|
|
@@ -240,6 +240,7 @@ export async function verifyCommand(featureName, options = {}) {
|
|
|
240
240
|
status: result.status,
|
|
241
241
|
nodes: nodeStatuses(result),
|
|
242
242
|
attempts: (prevEntry?.attempts ?? 0) + 1,
|
|
243
|
+
failStreak: result.status === 'fail' ? (prevEntry?.failStreak ?? 0) + 1 : 0,
|
|
243
244
|
updatedAt: now,
|
|
244
245
|
};
|
|
245
246
|
await updateFeature(featureName, historyKey, compact);
|
|
@@ -277,8 +278,12 @@ export async function verifyCommand(featureName, options = {}) {
|
|
|
277
278
|
}
|
|
278
279
|
|
|
279
280
|
if (options.json) {
|
|
281
|
+
// Each node's tail is already in `feedback`, prefixed by node: printing it twice doubles what the session ingests.
|
|
282
|
+
const withoutOutput = ({ output: _, ...rest }) => rest;
|
|
283
|
+
const nodes = Object.fromEntries(Object.entries(result.nodes).map(([k, node]) => [k,
|
|
284
|
+
Array.isArray(node.runs) ? { ...withoutOutput(node), runs: node.runs.map(withoutOutput) } : withoutOutput(node)]));
|
|
280
285
|
const output = {
|
|
281
|
-
...result, persisted: persist, ...(persist ? {} : { reason }), repeatOf, cleanup,
|
|
286
|
+
...result, nodes, persisted: persist, ...(persist ? {} : { reason }), repeatOf, cleanup,
|
|
282
287
|
...(ci ? { source: 'ci', ciRun: { id: ci.run.databaseId, url: ci.run.url, sha: ci.run.headSha }, workflowCreated: ci.created } : {}),
|
|
283
288
|
};
|
|
284
289
|
console.log(JSON.stringify(output, null, 2));
|
|
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, mkdirS
|
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
3
|
import { getAllOutputPaths, getOutputPath } from '../paths/output-schema.js';
|
|
4
4
|
import { derivePhaseFrom, diskArtifactProbe } from '../../../framework/hooks/shared/state-reader.js';
|
|
5
|
+
import { recordGateEvent } from '../../../framework/hooks/shared/gate-event.js';
|
|
5
6
|
|
|
6
7
|
const STATE_FILE_NAME = '.morph/state.json';
|
|
7
8
|
const FEATURES_DIR_NAME = '.morph/features';
|
|
@@ -649,6 +650,9 @@ export async function setApprovalGate(featureName, gate, approved, metadata = {}
|
|
|
649
650
|
|
|
650
651
|
state.features[featureName] = feature;
|
|
651
652
|
saveState(state);
|
|
653
|
+
recordGateEvent(process.cwd(), {
|
|
654
|
+
feature: featureName, gate, step: approved ? 'approve' : 'unapprove', approvedBy: feature.approvalGates[gate].approvedBy,
|
|
655
|
+
});
|
|
652
656
|
}
|
|
653
657
|
|
|
654
658
|
export function getApprovalGate(featureName, gate) {
|
|
@@ -26,11 +26,12 @@ const TABS = [
|
|
|
26
26
|
const SLICE_STATUS = {
|
|
27
27
|
feito: { label: 'feita', tone: 'text-[var(--color-ok)]', icon: CheckCircle },
|
|
28
28
|
parcial: { label: 'parcial', tone: 'text-[var(--color-phase-implement)]', icon: CircleDashed },
|
|
29
|
+
bloqueado: { label: 'bloqueada', tone: 'text-[var(--color-warn)]', icon: PauseCircle },
|
|
29
30
|
pendente: { label: 'pendente', tone: 'text-[var(--color-faint)]', icon: Circle },
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
function isReady(sl, byId) {
|
|
33
|
-
if (sl.status === 'feito') return false;
|
|
34
|
+
if (sl.status === 'feito' || sl.status === 'bloqueado') return false;
|
|
34
35
|
return (sl.depends || []).every((d) => byId[d]?.status === 'feito');
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -67,6 +68,8 @@ export function nextAction(f) {
|
|
|
67
68
|
if (paused) return `O ${GATE_LABEL[paused.gate] || paused.gate} pausou${paused.reason ? `: ${paused.reason}` : ''}. A decisão é sua, no Claude Code.`;
|
|
68
69
|
if (f.inEvaluation) return 'Os relatórios do Gate 3 chegaram. A decisão sai dos vereditos, dos bloqueadores e dos cenários provados.';
|
|
69
70
|
if (f.phase === 'implement' && f.slicesList?.length) {
|
|
71
|
+
const blocked = f.slicesList.find((sl) => sl.status === 'bloqueado');
|
|
72
|
+
if (blocked) return `${blocked.id} está bloqueada: o relatório traz uma pergunta que só você responde, no Claude Code.`;
|
|
70
73
|
const open = f.slicesList.find((sl) => sl.status === 'parcial') || f.slicesList.find((sl) => sl.status === 'pendente');
|
|
71
74
|
if (open) return `${f.slices.done} de ${f.slices.total} fatias feitas. Próxima: ${open.id}, ${open.title}.`;
|
|
72
75
|
return 'Todas as fatias feitas. Falta o verify da feature e a revisão.';
|
|
@@ -330,6 +333,7 @@ function SliceRow({ sl, f, open, onToggle, ready }) {
|
|
|
330
333
|
const BOARD_COLUMNS = [
|
|
331
334
|
{ id: 'pendente', label: 'Pendentes' },
|
|
332
335
|
{ id: 'parcial', label: 'Parciais' },
|
|
336
|
+
{ id: 'bloqueado', label: 'Bloqueadas' },
|
|
333
337
|
{ id: 'feito', label: 'Feitas' },
|
|
334
338
|
];
|
|
335
339
|
|
|
@@ -389,7 +393,7 @@ function SlicesTab({ f, view = 'list', onView = () => {} }) {
|
|
|
389
393
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
390
394
|
<Segmented label="Visualização das fatias" value={view} onChange={onView} options={[{ id: 'list', label: 'Lista', icon: Rows }, { id: 'board', label: 'Quadro', icon: Kanban }]} />
|
|
391
395
|
<p className="text-sm text-[var(--color-dim)]">
|
|
392
|
-
{f.slices.done} de {f.slices.total} feitas{f.slices.partial ? `, ${f.slices.partial} parciais` : ''}{ready ? `, ${ready} prontas para começar` : ''}
|
|
396
|
+
{f.slices.done} de {f.slices.total} feitas{f.slices.partial ? `, ${f.slices.partial} parciais` : ''}{f.slices.blocked ? `, ${f.slices.blocked} bloqueadas` : ''}{ready ? `, ${ready} prontas para começar` : ''}
|
|
393
397
|
</p>
|
|
394
398
|
</div>
|
|
395
399
|
{view === 'board' ? <SlicesBoard f={f} byId={byId} /> : (
|
|
@@ -123,10 +123,13 @@ export function readSlices(dir) {
|
|
|
123
123
|
covers: Array.isArray(x.covers) ? x.covers : [],
|
|
124
124
|
};
|
|
125
125
|
});
|
|
126
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
counts: { total: progress.total, done: progress.done, partial: progress.partial, blocked: progress.blocked, pending: progress.pending },
|
|
128
|
+
list,
|
|
129
|
+
};
|
|
127
130
|
}
|
|
128
131
|
|
|
129
|
-
const NO_SLICES = { total: 0, done: 0, partial: 0, pending: 0 };
|
|
132
|
+
const NO_SLICES = { total: 0, done: 0, partial: 0, blocked: 0, pending: 0 };
|
|
130
133
|
|
|
131
134
|
function gatesOf(feature) {
|
|
132
135
|
const skipped = new Set(feature.skippedPhases || []);
|
|
@@ -6,6 +6,8 @@ import { join } from 'path';
|
|
|
6
6
|
import { ensureCommonExclude } from '../../../framework/hooks/shared/git-exclude.js';
|
|
7
7
|
import { derivePhaseForFeature, getPendingGates } from '../../../framework/hooks/shared/state-reader.js';
|
|
8
8
|
import { sliceProgress, readySlices } from '../../../framework/hooks/shared/slice-progress.js';
|
|
9
|
+
import { formatNext } from '../../../framework/hooks/shared/next-step.js';
|
|
10
|
+
import { nextStepFor } from '../next-step.js';
|
|
9
11
|
|
|
10
12
|
const POINTER_CANDIDATES = [
|
|
11
13
|
['2-plan/plan.md', 'plano: problema, cenários com prova, decisões e fatias'],
|
|
@@ -46,29 +48,13 @@ function readWorktreeEnv(cwd) {
|
|
|
46
48
|
}
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
function
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
case 'uiux':
|
|
57
|
-
return approved('uiux')
|
|
58
|
-
? `Gate de uiux aprovado — implemente as fatias do plano via /morph-apply ${featureName}.`
|
|
59
|
-
: `Complete \`1-design/design-system.md\` e aprove o gate de uiux (\`morph-spec advance ${featureName}\`).`;
|
|
60
|
-
case 'implement': {
|
|
61
|
-
const ready = readySlices(slices).map((s) => `${s.id} "${s.title}"`).join(', ');
|
|
62
|
-
return ready
|
|
63
|
-
? `Implemente as fatias prontas — ${ready} — via /morph-apply ${featureName} (cada fatia grava 3-implement/reports/{id}.md).`
|
|
64
|
-
: `Todas as fatias têm relatório final — rode \`morph-spec verify ${featureName}\` e siga para a review. Skill: /morph-apply ${featureName}.`;
|
|
65
|
-
}
|
|
66
|
-
case 'review':
|
|
67
|
-
return approved('review')
|
|
68
|
-
? `Gate 3 aprovado — feche: \`morph-spec finish ${featureName} --pr\` (abre PR) ou \`--merge\` (merge local na default). Arquiva + integra num passo.`
|
|
69
|
-
: `Rode /morph-review ${featureName} (verificador ∥ revisor, segurança se exigida) e aprove o Gate 3; depois \`morph-spec finish ${featureName} --pr|--merge\`.`;
|
|
70
|
-
default:
|
|
71
|
-
return `Rode \`morph-spec status ${featureName}\` para orientar o próximo passo.`;
|
|
51
|
+
function nextLine(featureName, cwd) {
|
|
52
|
+
const fallback = `Rode \`morph-spec next ${featureName}\`.`;
|
|
53
|
+
try {
|
|
54
|
+
const next = nextStepFor(featureName, cwd);
|
|
55
|
+
return next ? formatNext(next) : fallback;
|
|
56
|
+
} catch {
|
|
57
|
+
return fallback;
|
|
72
58
|
}
|
|
73
59
|
}
|
|
74
60
|
|
|
@@ -129,7 +115,7 @@ export function buildHandoff(featureName, cwd = process.cwd(), opts = {}) {
|
|
|
129
115
|
'',
|
|
130
116
|
`- Gates: ${gateLine}`,
|
|
131
117
|
slices
|
|
132
|
-
? `- Fatias: ${slices.done}/${slices.total} feitas${slices.partial ? ` · ${slices.partial} parcial(is)` : ''} · prontas agora: ${readySlices(slices).map((s) => `**${s.id}** "${s.title}"`).join(', ') || 'nenhuma'}`
|
|
118
|
+
? `- Fatias: ${slices.done}/${slices.total} feitas${slices.partial ? ` · ${slices.partial} parcial(is)` : ''}${slices.blocked ? ` · ${slices.blocked} bloqueada(s)` : ''} · prontas agora: ${readySlices(slices).map((s) => `**${s.id}** "${s.title}"`).join(', ') || 'nenhuma'}`
|
|
133
119
|
: '- Fatias: sem checks.json ainda (rode plan-check)',
|
|
134
120
|
pendingGates.length ? `- Gates pendentes: ${pendingGates.join(', ')}` : null,
|
|
135
121
|
'',
|
|
@@ -141,7 +127,7 @@ export function buildHandoff(featureName, cwd = process.cwd(), opts = {}) {
|
|
|
141
127
|
'',
|
|
142
128
|
'## Próxima ação',
|
|
143
129
|
'',
|
|
144
|
-
|
|
130
|
+
nextLine(featureName, cwd),
|
|
145
131
|
'',
|
|
146
132
|
'## Retomar',
|
|
147
133
|
'',
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { collectNextSignals, decideNext } from '../../framework/hooks/shared/next-step.js';
|
|
2
|
+
import { treeMovedSince } from './git/evidence-staleness.js';
|
|
3
|
+
|
|
4
|
+
/** The CLI collector: the hook-readable facts plus verify staleness (git). Null without feature.json. */
|
|
5
|
+
export function nextStepFor(featureName, tree) {
|
|
6
|
+
const signals = collectNextSignals(featureName, tree);
|
|
7
|
+
if (!signals) return null;
|
|
8
|
+
signals.verificationStale = treeMovedSince(tree, signals.verification?.sha);
|
|
9
|
+
return { ...decideNext(signals), phase: signals.phase, failStreak: signals.failStreak };
|
|
10
|
+
}
|
package/src/lib/risk-detector.js
CHANGED
|
@@ -158,23 +158,25 @@ export function shouldPauseGate(config, signals) {
|
|
|
158
158
|
const trust = config?.trust ?? 'auto';
|
|
159
159
|
|
|
160
160
|
if (trust === 'manual') {
|
|
161
|
-
return { pause: true, reason: 'trust mode is manual' };
|
|
161
|
+
return { pause: true, signal: 'trust-manual', reason: 'trust mode is manual' };
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
if (FORCED_PAUSE_WORK_TYPES.includes(signals.workType)) {
|
|
165
165
|
return {
|
|
166
166
|
pause: true,
|
|
167
|
+
signal: 'hotfix',
|
|
167
168
|
reason: 'hotfix: aprovação humana obrigatória no ship (política de tipo — ignora trust:auto)',
|
|
168
169
|
};
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
if (signals.designSystemDivergence) {
|
|
172
|
-
return { pause: true, reason: 'design-system divergence detected' };
|
|
173
|
+
return { pause: true, signal: 'design-system', reason: 'design-system divergence detected' };
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
if (Array.isArray(signals.standardsViolations) && signals.standardsViolations.length > 0) {
|
|
176
177
|
return {
|
|
177
178
|
pause: true,
|
|
179
|
+
signal: 'standards',
|
|
178
180
|
reason: `standards violations detected: ${signals.standardsViolations.join(', ')}`,
|
|
179
181
|
};
|
|
180
182
|
}
|
|
@@ -183,6 +185,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
183
185
|
if (signals.verificationStatus === 'fail') {
|
|
184
186
|
return {
|
|
185
187
|
pause: true,
|
|
188
|
+
signal: 'verify-red',
|
|
186
189
|
reason: 'último morph-spec verify está VERMELHO — rode verify e corrija antes de aprovar o gate',
|
|
187
190
|
};
|
|
188
191
|
}
|
|
@@ -191,6 +194,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
191
194
|
if (signals.gate === 'review' && !signals.verificationStatus) {
|
|
192
195
|
return {
|
|
193
196
|
pause: true,
|
|
197
|
+
signal: 'verify-missing',
|
|
194
198
|
reason: 'nenhum `morph-spec verify` carimbado nesta feature — rode `morph-spec verify {feature}` antes do ship',
|
|
195
199
|
};
|
|
196
200
|
}
|
|
@@ -200,7 +204,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
200
204
|
if (signals.gate === 'review' && signals.e2eStatus === 'skip') {
|
|
201
205
|
const klass = classifyE2eSkip(signals.e2eReason);
|
|
202
206
|
if (E2E_SKIP_CLASSES_THAT_PAUSE.includes(klass)) {
|
|
203
|
-
return { pause: true, reason: e2ePauseReason(klass, signals.e2eReason) };
|
|
207
|
+
return { pause: true, signal: 'e2e-skip', reason: e2ePauseReason(klass, signals.e2eReason) };
|
|
204
208
|
}
|
|
205
209
|
}
|
|
206
210
|
|
|
@@ -208,13 +212,14 @@ export function shouldPauseGate(config, signals) {
|
|
|
208
212
|
if (signals.gate === 'review' && signals.verificationStale === true) {
|
|
209
213
|
return {
|
|
210
214
|
pause: true,
|
|
215
|
+
signal: 'verify-stale',
|
|
211
216
|
reason: 'verification stamp desatualizado (o código mudou depois do commit do stamp) — rode `morph-spec verify` de novo antes do ship',
|
|
212
217
|
};
|
|
213
218
|
}
|
|
214
219
|
|
|
215
220
|
if (signals.gate === 'review') {
|
|
216
221
|
const reason = reviewPause(signals.reviewReports);
|
|
217
|
-
if (reason) return { pause: true, reason };
|
|
222
|
+
if (reason) return { pause: true, signal: 'review-reports', reason };
|
|
218
223
|
}
|
|
219
224
|
|
|
220
225
|
// Prompt e modelo não são lidos por build nem por teste. Depois dos relatórios de review de propósito:
|
|
@@ -229,6 +234,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
229
234
|
if (signals.evalsStatus === null || signals.evalsStatus === undefined) {
|
|
230
235
|
return {
|
|
231
236
|
pause: true,
|
|
237
|
+
signal: 'prompt-model-no-eval',
|
|
232
238
|
reason: `mudou prompt/modelo (${where}) e o projeto não declara nó de eval`
|
|
233
239
|
+ ' — nenhum build ou teste unitário fica vermelho por um prompt trocado;'
|
|
234
240
|
+ ' declare um `runner: "evals"` em project.tests[] ou registre a dispensa com `morph-spec gate-decision`',
|
|
@@ -237,6 +243,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
237
243
|
if (signals.evalsStatus === 'skip') {
|
|
238
244
|
return {
|
|
239
245
|
pause: true,
|
|
246
|
+
signal: 'prompt-model-eval-skip',
|
|
240
247
|
reason: `mudou prompt/modelo (${where}) e o eval não foi executado neste verify`
|
|
241
248
|
+ ' — rode `morph-spec verify {feature}` (escopo de feature) antes do ship',
|
|
242
249
|
};
|
|
@@ -250,6 +257,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
250
257
|
&& signals.mediaClaims.section !== true) {
|
|
251
258
|
return {
|
|
252
259
|
pause: true,
|
|
260
|
+
signal: 'media-claims',
|
|
253
261
|
reason: '`4-review/verifier.md` sem a seção `## Mídia gerada` — as quatro afirmações de mídia'
|
|
254
262
|
+ ' (custo, revisão humana, marcação, hot path) não foram provadas'
|
|
255
263
|
+ ' (evals/review-checklist.md, "Verificador")',
|
|
@@ -259,7 +267,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
259
267
|
// Sem o checklist de tela completo, paginação, ordenação, filtro e os três estados não viram cenário.
|
|
260
268
|
if (signals.gate === 'uiux') {
|
|
261
269
|
const reason = uiuxChecklistPause(signals.screenChecklist);
|
|
262
|
-
if (reason) return { pause: true, reason };
|
|
270
|
+
if (reason) return { pause: true, signal: 'uiux-checklist', reason };
|
|
263
271
|
}
|
|
264
272
|
|
|
265
273
|
// Cobra-se só o item devido (`uncovered`): tela toda "não se aplica", design legado ou ausente não devem nada.
|
|
@@ -268,6 +276,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
268
276
|
if (section !== true) {
|
|
269
277
|
return {
|
|
270
278
|
pause: true,
|
|
279
|
+
signal: 'screen-checklist',
|
|
271
280
|
reason: `\`4-review/verifier.md\` sem a seção \`## Checklist de tela\` — o design decidiu ${firstOf(uncovered)}`
|
|
272
281
|
+ ' e o verificador não registrou a conferência contra a tela entregue'
|
|
273
282
|
+ ' (evals/review-checklist.md, "Contrato de tela")',
|
|
@@ -275,6 +284,7 @@ export function shouldPauseGate(config, signals) {
|
|
|
275
284
|
}
|
|
276
285
|
return {
|
|
277
286
|
pause: true,
|
|
287
|
+
signal: 'screen-checklist',
|
|
278
288
|
reason: `a seção \`## Checklist de tela\` não confere ${firstOf(uncovered)}`
|
|
279
289
|
+ ' — uma linha por item decidido de cada tela, mesmo quando não verificável',
|
|
280
290
|
};
|
|
@@ -282,17 +292,18 @@ export function shouldPauseGate(config, signals) {
|
|
|
282
292
|
|
|
283
293
|
if (signals.gate === 'plan') {
|
|
284
294
|
const reason = planChecksPause(signals.planChecks);
|
|
285
|
-
if (reason) return { pause: true, reason };
|
|
295
|
+
if (reason) return { pause: true, signal: 'plan-checks', reason };
|
|
286
296
|
}
|
|
287
297
|
|
|
288
298
|
if (typeof signals.sliceCount === 'number' && signals.sliceCount > MAX_SLICES) {
|
|
289
299
|
return {
|
|
290
300
|
pause: true,
|
|
301
|
+
signal: 'slice-count',
|
|
291
302
|
reason: `${signals.sliceCount} fatias, acima de ${MAX_SLICES} — o plano é grande demais para uma feature`,
|
|
292
303
|
};
|
|
293
304
|
}
|
|
294
305
|
|
|
295
|
-
return { pause: false, reason: '' };
|
|
306
|
+
return { pause: false, signal: null, reason: '' };
|
|
296
307
|
}
|
|
297
308
|
|
|
298
309
|
export function getRiskSignals(signals) {
|
|
@@ -99,7 +99,19 @@ function auditDotnet(files, read) {
|
|
|
99
99
|
const otherDb = has(/^(Microsoft\.EntityFrameworkCore\.SqlServer|Pomelo\.EntityFrameworkCore\.MySql|MySql\.|MySqlConnector|Microsoft\.Data\.SqlClient|System\.Data\.SqlClient)/);
|
|
100
100
|
rows.push({ layer: 'ORM', detected: ef.length > 0 ? 'EF Core' : (packages.has('Dapper') ? 'Dapper' : 'ausente'), expected: 'EF Core', ok: ef.length > 0 });
|
|
101
101
|
rows.push({ layer: 'Banco', detected: npgsql.length > 0 ? 'Postgres (Npgsql)' : (otherDb.join(', ') || 'ausente'), expected: 'Neon (Postgres)', ok: npgsql.length > 0 && otherDb.length === 0 });
|
|
102
|
-
|
|
102
|
+
const semanticKernel = has(/^Microsoft\.SemanticKernel/);
|
|
103
|
+
const kernelFunction = grepFiles(source, read, /\[KernelFunction\b/);
|
|
104
|
+
const hostedAgents = has(/^Azure\.AI\.(Projects|Agents\.Persistent)\b/);
|
|
105
|
+
const floatingAi = has(/^(Microsoft\.Agents\.AI|Microsoft\.Extensions\.AI|OpenAI$|Google\.GenAI$|ModelContextProtocol)/)
|
|
106
|
+
.filter((p) => /\*|^[[(]/.test(packages.get(p) ?? ''));
|
|
107
|
+
rows.push({ layer: 'IA', detected: has(/^Microsoft\.(Agents\.AI|Extensions\.AI|SemanticKernel)/).join(', ') || 'ausente', expected: 'MAF quando houver agentes', ok: semanticKernel.length === 0 && kernelFunction.length === 0 && hostedAgents.length === 0 });
|
|
108
|
+
const antiPatterns = '`morph-spec standards --show ai-agents-setup#7-what-not-to-do-anti-patterns`';
|
|
109
|
+
if (semanticKernel.length > 0 || kernelFunction.length > 0) {
|
|
110
|
+
add('Alto', `Semantic Kernel em vez de MAF (${semanticKernel.join(', ') || '`[KernelFunction]`'})`, [...(semanticKernel.length > 0 ? projects : []), ...kernelFunction],
|
|
111
|
+
`\`Microsoft.Agents.AI\` (\`agent.RunAsync()\`, tool por \`AIFunctionFactory.Create()\`); ${antiPatterns}`);
|
|
112
|
+
}
|
|
113
|
+
if (hostedAgents.length > 0) add('Alto', `Serviço de agente hospedado (${hostedAgents.join(', ')})`, projects, `modelo direto por \`IChatClient\`; ${antiPatterns}`);
|
|
114
|
+
for (const p of floatingAi) add('Médio', `Versão flutuante de SDK de IA (${p} ${packages.get(p)})`, [], 'versão exata, a do pin (`morph-spec doctor --ai`)');
|
|
103
115
|
rows.push({ layer: 'Jobs', detected: has(/^Hangfire/).length > 0 ? 'Hangfire' : 'ausente', expected: 'Hangfire só com razão real', ok: true });
|
|
104
116
|
if (packages.has('Dapper') && ef.length === 0) add('Bloqueante', 'ORM diferente (Dapper puro sem EF Core)', projects, 'migrar o acesso a dados para EF Core');
|
|
105
117
|
if (otherDb.length > 0) add('Bloqueante', `Banco diferente (${otherDb.join(', ')})`, projects, 'migrar para Neon (Postgres, Npgsql)');
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
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
|
+
import { GATE_EVENT, GATE_STEPS } from '../../../framework/hooks/shared/gate-event.js';
|
|
7
8
|
|
|
8
9
|
export const RERUN_WINDOW_MS = 10 * 60 * 1000;
|
|
9
10
|
|
|
@@ -122,6 +123,43 @@ function reuseStateOf(e) {
|
|
|
122
123
|
return { state: 'absent', reason: null };
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
const newGateSlot = () => ({ decisions: 0, paused: 0, autoPassed: 0, approvedAfterPause: 0, reworkedAfterPause: 0, unapproved: 0, bySignal: {} });
|
|
127
|
+
|
|
128
|
+
// A pause stays open until the next approve or a decision that no longer pauses on the same signal:
|
|
129
|
+
// re-running gate-check over the same unchanged pause is not rework.
|
|
130
|
+
function trackGate(gates, open, e) {
|
|
131
|
+
if (!e.gate || !GATE_STEPS.includes(e.step)) return;
|
|
132
|
+
const slot = gates[e.gate] ||= newGateSlot();
|
|
133
|
+
const key = `${e.feature || ''}\u0000${e.gate}`;
|
|
134
|
+
const pending = open.get(key);
|
|
135
|
+
const bySignal = (signal) => slot.bySignal[signal || 'desconhecido'] ||= { paused: 0, approvedAfterPause: 0, reworkedAfterPause: 0 };
|
|
136
|
+
const credit = (field) => {
|
|
137
|
+
slot[field] += 1;
|
|
138
|
+
bySignal(pending.signal)[field] += 1;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if (e.step === 'unapprove') {
|
|
142
|
+
slot.unapproved += 1;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (e.step === 'approve') {
|
|
146
|
+
if (pending) credit('approvedAfterPause');
|
|
147
|
+
open.delete(key);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
slot.decisions += 1;
|
|
151
|
+
if (pending && e.pause && e.signal === pending.signal) return;
|
|
152
|
+
if (pending) credit(e.source === 'manual' && !e.pause ? 'approvedAfterPause' : 'reworkedAfterPause');
|
|
153
|
+
if (e.pause) {
|
|
154
|
+
slot.paused += 1;
|
|
155
|
+
bySignal(e.signal).paused += 1;
|
|
156
|
+
open.set(key, { signal: e.signal });
|
|
157
|
+
} else {
|
|
158
|
+
slot.autoPassed += 1;
|
|
159
|
+
open.delete(key);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
125
163
|
/** The returned shape is documented in framework/docs/CLI.md, "cost --metrics". */
|
|
126
164
|
export function computeMetrics(events, opts = {}) {
|
|
127
165
|
const top = Number.isInteger(opts.top) && opts.top > 0 ? opts.top : 10;
|
|
@@ -140,6 +178,8 @@ export function computeMetrics(events, opts = {}) {
|
|
|
140
178
|
const lastFingerprint = new Map();
|
|
141
179
|
const unchangedRepeats = {};
|
|
142
180
|
const reuse = { runs: 0, byTree: {}, skipReasons: {} };
|
|
181
|
+
const gates = {};
|
|
182
|
+
const openPauses = new Map();
|
|
143
183
|
let v2 = 0;
|
|
144
184
|
let legacy = 0;
|
|
145
185
|
let turnsObserved = false;
|
|
@@ -166,6 +206,11 @@ export function computeMetrics(events, opts = {}) {
|
|
|
166
206
|
continue;
|
|
167
207
|
}
|
|
168
208
|
|
|
209
|
+
if (e.event === GATE_EVENT) {
|
|
210
|
+
trackGate(gates, openPauses, e);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
169
214
|
if (e.event === 'Verify') {
|
|
170
215
|
if (e.fingerprint && e.persisted !== false) {
|
|
171
216
|
if (lastFingerprint.get(fkey) === e.fingerprint) {
|
|
@@ -291,6 +336,7 @@ export function computeMetrics(events, opts = {}) {
|
|
|
291
336
|
},
|
|
292
337
|
dotnetFullSuite: fullSuite,
|
|
293
338
|
reuse,
|
|
339
|
+
gates,
|
|
294
340
|
features: featureRows,
|
|
295
341
|
};
|
|
296
342
|
}
|
|
@@ -713,7 +713,7 @@ async function runE2eNode({
|
|
|
713
713
|
command: result.command ?? null,
|
|
714
714
|
durationMs,
|
|
715
715
|
reason: result.reason ?? null,
|
|
716
|
-
output: result.output || '',
|
|
716
|
+
output: tailLines(result.output || '', OUTPUT_TAIL_LINES),
|
|
717
717
|
count: Number.isInteger(result.count) ? result.count : null,
|
|
718
718
|
warnings: Array.isArray(result.warnings) ? result.warnings : [],
|
|
719
719
|
},
|