@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.
Files changed (53) hide show
  1. package/CLAUDE.md +14 -78
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +7 -0
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +3 -3
  8. package/framework/MORPH.md +5 -8
  9. package/framework/agents.json +9 -6
  10. package/framework/commands/morph-status.md +2 -2
  11. package/framework/docs/CLI.md +8 -2
  12. package/framework/evals/review-checklist.md +3 -0
  13. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  14. package/framework/hooks/claude-code/core/worktree-context.js +23 -0
  15. package/framework/hooks/claude-code/statusline.py +3 -2
  16. package/framework/hooks/shared/gate-event.js +18 -0
  17. package/framework/hooks/shared/next-step.js +107 -0
  18. package/framework/hooks/shared/slice-progress.js +18 -5
  19. package/framework/hooks/shared/state-writer.js +6 -1
  20. package/framework/mcp/registry.json +31 -1
  21. package/framework/schemas/feature.schema.json +1 -0
  22. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  23. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  24. package/framework/skills/core/morph-apply/SKILL.md +4 -2
  25. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  26. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  27. package/framework/skills/core/morph-media/SKILL.md +1 -1
  28. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  29. package/framework/skills/core/morph-review/SKILL.md +1 -1
  30. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  31. package/framework/skills/core/morph-uiux/SKILL.md +2 -1
  32. package/framework/standards/STANDARDS.meta.json +1 -1
  33. package/framework/standards/ai-agents/setup.md +2 -0
  34. package/framework/standards/integration/mcp/mcp-tools.md +3 -1
  35. package/package.json +1 -1
  36. package/src/commands/observability/cost.js +12 -0
  37. package/src/commands/project/status.js +6 -68
  38. package/src/commands/state/advance.js +1 -1
  39. package/src/commands/state/gate-check.js +2 -2
  40. package/src/commands/state/gate-decision.js +4 -1
  41. package/src/commands/state/next.js +24 -0
  42. package/src/commands/state/report-check.js +3 -1
  43. package/src/commands/validation/verify.js +6 -1
  44. package/src/core/state/state-manager.js +4 -0
  45. package/src/dashboard/app/pages/FeatureDetail.jsx +6 -2
  46. package/src/dashboard/scanner/features-scanner.js +5 -2
  47. package/src/lib/generators/handoff-generator.js +11 -25
  48. package/src/lib/next-step.js +10 -0
  49. package/src/lib/risk-detector.js +18 -7
  50. package/src/lib/stack/stack-scan.js +13 -1
  51. package/src/lib/telemetry/metrics.js +46 -0
  52. package/src/lib/validators/verify-runner.js +1 -1
  53. 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
- nextSteps: getNextSteps(feature)
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 nextSteps = getNextSteps(feature);
262
- if (nextSteps.length > 0) {
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 PARCIAL:`));
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
- '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
+ '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 { counts: { total: progress.total, done: progress.done, partial: progress.partial, pending: progress.pending }, list };
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 nextAction(feature, phase, slices, featureName) {
50
- const gates = feature.approvals || feature.approvalGates || {};
51
- const approved = (g) => gates[g]?.approved === true;
52
-
53
- switch (phase) {
54
- case 'plan':
55
- return `Escreva \`2-plan/plan.md\`, rode \`morph-spec plan-check ${featureName}\` até zero erro e aprove o gate do plano (\`morph-spec advance ${featureName}\`). Skill: /morph-plan ${featureName}.`;
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
- nextAction(feature, phase, slices, featureName),
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
+ }
@@ -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
- rows.push({ layer: 'IA', detected: has(/^Microsoft\.(Agents\.AI|Extensions\.AI)/).join(', ') || 'ausente', expected: 'MAF quando houver agentes', ok: true });
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
  },