@polymorphism-tech/morph-spec 9.1.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 (104) hide show
  1. package/CLAUDE.md +14 -78
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +14 -1
  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 +4 -3
  8. package/framework/MORPH.md +7 -10
  9. package/framework/agents.json +15 -12
  10. package/framework/commands/morph-status.md +2 -2
  11. package/framework/docs/CLI.md +24 -16
  12. package/framework/evals/review-checklist.md +15 -2
  13. package/framework/hooks/README.md +8 -44
  14. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  15. package/framework/hooks/claude-code/core/state-sync.js +1 -1
  16. package/framework/hooks/claude-code/core/worktree-context.js +23 -0
  17. package/framework/hooks/claude-code/statusline.py +3 -2
  18. package/framework/hooks/claude-code/statusline.sh +11 -11
  19. package/framework/hooks/shared/gate-event.js +18 -0
  20. package/framework/hooks/shared/next-step.js +107 -0
  21. package/framework/hooks/shared/slice-progress.js +18 -5
  22. package/framework/hooks/shared/state-writer.js +6 -1
  23. package/framework/mcp/registry.json +31 -1
  24. package/framework/rules/nextjs-standards.md +1 -1
  25. package/framework/schemas/feature.schema.json +1 -0
  26. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  27. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  28. package/framework/skills/core/morph-apply/SKILL.md +6 -4
  29. package/framework/skills/core/morph-hotfix/SKILL.md +3 -3
  30. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  31. package/framework/skills/core/morph-media/SKILL.md +1 -1
  32. package/framework/skills/core/morph-plan/SKILL.md +8 -6
  33. package/framework/skills/core/morph-review/SKILL.md +9 -6
  34. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  35. package/framework/skills/core/morph-uiux/SKILL.md +2 -1
  36. package/framework/standards/STANDARDS.json +2 -2
  37. package/framework/standards/STANDARDS.meta.json +1 -1
  38. package/framework/standards/ai-agents/setup.md +2 -0
  39. package/framework/standards/backend/dotnet/testing.md +10 -4
  40. package/framework/standards/frontend/nextjs/testing.md +10 -6
  41. package/framework/standards/integration/mcp/mcp-tools.md +3 -1
  42. package/framework/templates/.idea/morph-templates.xml +92 -0
  43. package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
  44. package/framework/templates/IDE-SNIPPETS.md +262 -262
  45. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  46. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  47. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  48. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  49. package/framework/templates/docs/plan.md +3 -1
  50. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  51. package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
  52. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  53. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  54. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  55. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  56. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  57. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  58. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  59. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  60. package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
  61. package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
  62. package/framework/templates/state.template.json +17 -17
  63. package/framework/templates/ui/design-system.css +226 -226
  64. package/package.json +1 -1
  65. package/src/commands/observability/cost.js +12 -0
  66. package/src/commands/project/status.js +6 -68
  67. package/src/commands/state/advance.js +1 -1
  68. package/src/commands/state/approve.js +2 -2
  69. package/src/commands/state/gate-check.js +2 -2
  70. package/src/commands/state/gate-decision.js +4 -1
  71. package/src/commands/state/next.js +24 -0
  72. package/src/commands/state/report-check.js +3 -1
  73. package/src/commands/validation/mutate.js +9 -6
  74. package/src/commands/validation/plan-check.js +4 -2
  75. package/src/commands/validation/tests-audit.js +7 -0
  76. package/src/commands/validation/validate-feature.js +4 -2
  77. package/src/commands/validation/verify.js +21 -16
  78. package/src/core/state/state-manager.js +4 -0
  79. package/src/dashboard/app/main.jsx +13 -13
  80. package/src/dashboard/app/pages/FeatureDetail.jsx +6 -2
  81. package/src/dashboard/scanner/features-scanner.js +5 -2
  82. package/src/dashboard/vite.config.js +24 -24
  83. package/src/lib/ci/workflow-scaffold.js +12 -0
  84. package/src/lib/e2e/applicability.js +37 -0
  85. package/src/lib/generators/handoff-generator.js +11 -25
  86. package/src/lib/mutation/adapters.js +6 -17
  87. package/src/lib/mutation/runner.js +23 -75
  88. package/src/lib/mutation/trx.js +0 -1
  89. package/src/lib/next-step.js +10 -0
  90. package/src/lib/plan/checks.js +29 -2
  91. package/src/lib/plan/plan-parser.js +6 -2
  92. package/src/lib/review-reports.js +6 -3
  93. package/src/lib/risk-detector.js +20 -7
  94. package/src/lib/stack/stack-scan.js +13 -1
  95. package/src/lib/telemetry/metrics.js +46 -0
  96. package/src/lib/tests-audit/audit.js +96 -0
  97. package/src/lib/validators/code-budget/code-budget-validator.js +1 -1
  98. package/src/lib/validators/validation-runner.js +6 -38
  99. package/src/lib/validators/verify-runner.js +37 -22
  100. package/src/utils/agents-installer.js +24 -1
  101. package/framework/hooks/git/commit-msg/conventional-commits.sh +0 -33
  102. package/framework/hooks/git/pre-commit/agents.sh +0 -25
  103. package/framework/hooks/git/pre-commit/orchestrator.sh +0 -64
  104. package/framework/hooks/git/pre-push/run-tests.sh +0 -44
@@ -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
 
@@ -15,7 +15,7 @@ import { groupByNode, loadPlan } from '../../lib/mutation/plan.js';
15
15
  import { dotnetSubset, jsSubset, mutatedTypesOf, testFilesCiting, testProjectDirsOf } from '../../lib/mutation/subset.js';
16
16
  import { isFinished } from '../../dashboard/scanner/feature-status.js';
17
17
  import { dotnetAdapter, genericAdapter, spawnExec } from '../../lib/mutation/adapters.js';
18
- import { runMutation } from '../../lib/mutation/runner.js';
18
+ import { noSubsetReason, runMutation } from '../../lib/mutation/runner.js';
19
19
  import { renderSection, summarize } from '../../lib/mutation/report.js';
20
20
  import { readJournal, restoreSnapshot } from '../../lib/mutation/snapshot.js';
21
21
 
@@ -101,8 +101,7 @@ function testDirsOf(node, root, { projectsOnly = false } = {}) {
101
101
  return testProjectDirsOf(root, projectsOnly ? targets.filter((t) => !/\.slnx?$/i.test(t)) : targets, cwd);
102
102
  }
103
103
 
104
- function subsetFor({ node, group, root, options }) {
105
- if (options.subset === false) return { usable: false, reason: '--no-subset' };
104
+ function subsetFor({ node, group, root }) {
106
105
  const mutatedFiles = group.regions.map((r) => r.file);
107
106
  if (node.runner === 'dotnet') {
108
107
  const base = detectDefaultBranch(root);
@@ -193,6 +192,12 @@ export async function mutateCommand(featureArg, options = {}) {
193
192
  return fail(`${named}\nNó e2e: true declarado: ${e2eIds}. ${e2eManualProtocol(feature, uncited[0].e2e[0])}`, EXIT.usage);
194
193
  }
195
194
 
195
+ const subsets = groups.map((group) => subsetFor({ node: group.node, group, root }));
196
+ const noSubset = subsets.findIndex((s) => !s.usable);
197
+ if (noSubset >= 0) {
198
+ return fail(`suíte "${groups[noSubset].node.id}": ${noSubsetReason(subsets[noSubset].reason)}.`, EXIT.usage);
199
+ }
200
+
196
201
  if (!options.allowDirty) {
197
202
  const dirty = gitOut(root, ['status', '--porcelain', '--', ...new Set(regions.map((r) => r.file))]).trim();
198
203
  if (dirty) return fail(`há alteração não commitada nos arquivos a mutar:\n${dirty}\nCommite o fix ANTES de mutar (ou use --allow-dirty sabendo que o snapshot é o único caminho de volta).`, EXIT.usage);
@@ -207,13 +212,11 @@ export async function mutateCommand(featureArg, options = {}) {
207
212
  const exec = options.exec ?? spawnExec;
208
213
 
209
214
  const merged = { wallMs: 0, mutants: [], timings: [], restore: { files: [], ok: true, treeClean: true }, binary: { proof: 'unavailable', projects: [] }, baseline: {}, method: { schemata: 0, rebuild: 0, subset: {} } };
210
- const subsets = [];
211
215
  try {
212
216
  for (const [index, group] of groups.entries()) {
213
217
  const workDir = join(base, runId, `g${index}`);
214
218
  mkdirSync(workDir, { recursive: true });
215
- const subset = subsetFor({ node: group.node, group, root, options });
216
- subsets.push(subset);
219
+ const subset = subsets[index];
217
220
  const timeoutMs = options.timeoutMs ?? group.node.timeoutMs ?? 900_000;
218
221
  const make = group.node.runner === 'dotnet' ? dotnetAdapter : genericAdapter;
219
222
  const adapter = make({ node: group.node, root, workDir, exec, subset, timeoutMs });
@@ -5,7 +5,7 @@ import { atomicWriteJson } from '../../core/state/state-manager.js';
5
5
  import { isGateApproved } from '../../../framework/hooks/shared/state-reader.js';
6
6
  import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
7
7
  import { parsePlan } from '../../lib/plan/plan-parser.js';
8
- import { buildChecks, knownPersonas, parallelGroups, planHash, validatePlan } from '../../lib/plan/checks.js';
8
+ import { buildChecks, knownPersonas, parallelGroups, planHash, planRuleOptions, validatePlan } from '../../lib/plan/checks.js';
9
9
  import { knownSurfaces } from '../../lib/specs/screen-checklist.js';
10
10
  import { revocationWarnings } from '../../lib/specs/adr-index.js';
11
11
  import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
@@ -25,7 +25,9 @@ export function runPlanCheck(featureName, cwd = process.cwd()) {
25
25
 
26
26
  const text = readFileSync(planPath, 'utf8');
27
27
  const plan = parsePlan(text);
28
- const { errors, warnings } = validatePlan(plan, { personas: knownPersonas(cwd), surfaces: knownSurfaces(cwd) });
28
+ const { errors, warnings } = validatePlan(plan, {
29
+ personas: knownPersonas(cwd), surfaces: knownSurfaces(cwd), ...planRuleOptions(cwd, featureName),
30
+ });
29
31
  warnings.push(...revocationWarnings(cwd, plan));
30
32
  const groups = errors.length === 0 ? parallelGroups(plan.slices) : [];
31
33
  const hash = planHash(text);
@@ -0,0 +1,7 @@
1
+ import { auditTests, renderTestsAudit } from '../../lib/tests-audit/audit.js';
2
+
3
+ export function testsAuditCommand(options = {}) {
4
+ const result = auditTests(process.cwd());
5
+ console.log(options.json ? JSON.stringify(result, null, 2) : renderTestsAudit(result));
6
+ if (!result.ok) process.exit(2);
7
+ }
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import chalk from 'chalk';
4
4
  import { parsePlan } from '../../lib/plan/plan-parser.js';
5
- import { knownPersonas, planHash, validatePlan } from '../../lib/plan/checks.js';
5
+ import { knownPersonas, planHash, planRuleOptions, validatePlan } from '../../lib/plan/checks.js';
6
6
 
7
7
  // Matched against section markers (headings and line-start bold labels), never whole-file
8
8
  // substrings. required → ERROR, recommended → WARNING, optional → never penalized; an array is any-of.
@@ -115,7 +115,9 @@ function validateOutput(featurePath, outputFile) {
115
115
  // `planDir` is `.morph/features/{f}/2-plan`, so the project is four levels up.
116
116
  function validatePlanOutput(content, planDir, result) {
117
117
  const plan = parsePlan(content);
118
- const { errors, warnings } = validatePlan(plan, { personas: knownPersonas(path.resolve(planDir, '..', '..', '..', '..')) });
118
+ const root = path.resolve(planDir, '..', '..', '..', '..');
119
+ const feature = path.basename(path.resolve(planDir, '..'));
120
+ const { errors, warnings } = validatePlan(plan, { personas: knownPersonas(root), ...planRuleOptions(root, feature) });
119
121
  result.errors.push(...errors);
120
122
  result.warnings.push(...warnings);
121
123
  result.sections.found.push(...plan.sections.filter((x) => x.key).map((x) => x.name));
@@ -81,10 +81,8 @@ function decidePersistence({ cwd, featureName, noPersist }) {
81
81
  }
82
82
 
83
83
  function baselineStampBlock(baseline) {
84
- if (!baseline?.recorded) return baseline?.reason ? `record-baseline: ${baseline.reason}` : 'record-baseline';
85
- const fell = baseline.updates.filter((u) => u.from !== null && u.to < u.from);
86
- if (fell.length === 0) return null;
87
- return `record-baseline: a contagem caiu em ${fell.map((u) => `${u.id} (${u.from} → ${u.to})`).join(', ')}`;
84
+ if (baseline?.recorded) return null;
85
+ return baseline?.reason ? `record-baseline: ${baseline.reason}` : 'record-baseline';
88
86
  }
89
87
 
90
88
  // The single writer of both node maps the schema fixes (`verification.nodes` and the history
@@ -154,6 +152,17 @@ async function runLocalVerify(featureName, options) {
154
152
  }
155
153
  }
156
154
 
155
+ function exitWithReusedStamp(featureName, reused, why, options) {
156
+ console.error(chalk.green(`\n↪ ${why} (sha ${reused.sha.slice(0, 8)}, ${reused.ts}): nada mudou fora de .morph/ desde aquela rodada verde.\n`));
157
+ if (options.json) {
158
+ console.log(JSON.stringify({
159
+ feature: featureName, status: reused.status, nodes: reused.nodes, e2eReason: reused.e2eReason ?? null,
160
+ reused: { sha: reused.sha, ts: reused.ts, source: reused.source ?? 'local' }, persisted: false, reason: 'stamp-reused',
161
+ }, null, 2));
162
+ }
163
+ process.exit(0);
164
+ }
165
+
157
166
  // Exit 2 is "the command cannot mean what you typed" (unknown feature, `--filter` matching
158
167
  // nothing, `--record-baseline` without config.json, `--linux` without Docker): never a verdict.
159
168
  export async function verifyCommand(featureName, options = {}) {
@@ -178,6 +187,8 @@ export async function verifyCommand(featureName, options = {}) {
178
187
 
179
188
  let ci = null;
180
189
  if (options.ci) {
190
+ const fresh = reusableLocalStamp(process.cwd(), feature.verification);
191
+ if (fresh) exitWithReusedStamp(featureName, fresh, 'stamp verde e fresco — o CI não roda de novo', options);
181
192
  try {
182
193
  const outcome = await runCiVerify(process.cwd(), featureName, readMorphConfig(process.cwd()), {
183
194
  log: (line) => console.error(chalk.gray(` ${line}`)),
@@ -189,17 +200,6 @@ export async function verifyCommand(featureName, options = {}) {
189
200
  console.error(chalk.yellow(`\n⏳ ${outcome.reason}. Não é vermelho e não há stamp: não repita em laço — siga e deixe o gate-check decidir.\n`));
190
201
  process.exit(3);
191
202
  } else {
192
- const reused = reusableLocalStamp(process.cwd(), feature.verification);
193
- if (reused) {
194
- console.error(chalk.green(`\n↪ CI indisponível (${outcome.reason}) — stamp fresco reaproveitado (sha ${reused.sha.slice(0, 8)}, ${reused.ts}): nada mudou fora de .morph/ desde aquela rodada verde.\n`));
195
- if (options.json) {
196
- console.log(JSON.stringify({
197
- feature: featureName, status: reused.status, nodes: reused.nodes, e2eReason: reused.e2eReason ?? null,
198
- reused: { sha: reused.sha, ts: reused.ts, source: reused.source ?? 'local' }, persisted: false, reason: 'stamp-reused',
199
- }, null, 2));
200
- }
201
- process.exit(0);
202
- }
203
203
  console.error(chalk.yellow(`\n↪ CI indisponível (${outcome.reason}) — verify local.\n`));
204
204
  }
205
205
  } catch (err) {
@@ -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) {
@@ -1,13 +1,13 @@
1
- import { StrictMode } from 'react';
2
- import { createRoot } from 'react-dom/client';
3
- import { BrowserRouter } from 'react-router-dom';
4
- import App from './App.jsx';
5
- import './styles/index.css';
6
-
7
- createRoot(document.getElementById('root')).render(
8
- <StrictMode>
9
- <BrowserRouter>
10
- <App />
11
- </BrowserRouter>
12
- </StrictMode>
13
- );
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { BrowserRouter } from 'react-router-dom';
4
+ import App from './App.jsx';
5
+ import './styles/index.css';
6
+
7
+ createRoot(document.getElementById('root')).render(
8
+ <StrictMode>
9
+ <BrowserRouter>
10
+ <App />
11
+ </BrowserRouter>
12
+ </StrictMode>
13
+ );
@@ -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 || []);
@@ -1,24 +1,24 @@
1
- import { defineConfig } from 'vite';
2
- import react from '@vitejs/plugin-react';
3
- import tailwindcss from '@tailwindcss/vite';
4
- import morphDataPlugin from './vite-plugin-morph-data.js';
5
- import { fileURLToPath } from 'url';
6
- import { dirname, resolve } from 'path';
7
-
8
- const __filename = fileURLToPath(import.meta.url);
9
- const __dirname = dirname(__filename);
10
-
11
- const frameworkDir = resolve(__dirname, '..', '..', 'framework');
12
- const projectDir = process.env.MORPH_PROJECT_DIR || process.cwd();
13
-
14
- export default defineConfig({
15
- root: __dirname,
16
- plugins: [
17
- react(),
18
- tailwindcss(),
19
- morphDataPlugin(frameworkDir, projectDir),
20
- ],
21
- server: {
22
- port: 3200,
23
- },
24
- });
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import tailwindcss from '@tailwindcss/vite';
4
+ import morphDataPlugin from './vite-plugin-morph-data.js';
5
+ import { fileURLToPath } from 'url';
6
+ import { dirname, resolve } from 'path';
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+
11
+ const frameworkDir = resolve(__dirname, '..', '..', 'framework');
12
+ const projectDir = process.env.MORPH_PROJECT_DIR || process.cwd();
13
+
14
+ export default defineConfig({
15
+ root: __dirname,
16
+ plugins: [
17
+ react(),
18
+ tailwindcss(),
19
+ morphDataPlugin(frameworkDir, projectDir),
20
+ ],
21
+ server: {
22
+ port: 3200,
23
+ },
24
+ });
@@ -2,6 +2,7 @@ import { existsSync, readdirSync } from 'fs';
2
2
  import { join, posix, relative, resolve } from 'path';
3
3
  import { PACKAGE_LOCATIONS } from '../detectors/index.js';
4
4
  import { CI_ARTIFACT, CI_RESULT_FILE } from './github-ci.js';
5
+ import { discoverDotnetSolution } from '../tasks/test-runner.js';
5
6
 
6
7
  const LOCKFILES = [
7
8
  ['package-lock.json', 'npm ci'],
@@ -50,6 +51,16 @@ function dotnetSetup(root, config) {
50
51
  ];
51
52
  }
52
53
 
54
+ // The verify build node runs `dotnet build --no-restore` (right on a warm tree): a fresh checkout restores first,
55
+ // against the same target the build node resolves.
56
+ function dotnetRestore(root, config) {
57
+ if (!usesDotnet(root, config)) return [];
58
+ const backend = config?.project?.backendPath;
59
+ if (typeof backend === 'string') return step('dotnet restore', backend, 'dotnet restore');
60
+ const { target } = discoverDotnetSolution(root);
61
+ return step('dotnet restore', '.', target && target !== '.' ? `dotnet restore "${target}"` : 'dotnet restore');
62
+ }
63
+
53
64
  const step = (name, dir, run) => [
54
65
  ` - name: ${name}`,
55
66
  ...(dir === '.' ? [] : [` working-directory: ${posix.normalize(dir)}`]),
@@ -94,6 +105,7 @@ export function renderCiWorkflow(root, config) {
94
105
  lines.push(...step(`install (${pkg.dir})`, pkg.dir, pkg.install));
95
106
  if (pkg.playwright) lines.push(...step(`playwright browsers (${pkg.dir})`, pkg.dir, 'npx playwright install --with-deps chromium'));
96
107
  }
108
+ lines.push(...dotnetRestore(root, config));
97
109
  lines.push(
98
110
  ' - name: morph-spec verify',
99
111
  ' shell: bash',
@@ -0,0 +1,37 @@
1
+ import { basename } from 'path';
2
+ import { readPlanChecks } from '../plan/checks.js';
3
+ import { changedPathsAgainst, detectDefaultBranch } from '../git/default-branch.js';
4
+ import { resolveComposeFile } from '../../../framework/hooks/shared/e2e-check.js';
5
+ import { E2E_SPEC_LOCATIONS } from '../../../framework/hooks/shared/frontend-locations.js';
6
+
7
+ export const E2E_NOT_APPLICABLE = 'not applicable: no `prova: e2e` scenario and the diff touches no UI, e2e spec or container file';
8
+
9
+ const UI_FILE = /\.(tsx|jsx|vue|svelte|astro|css|scss|sass|less|html)$/i;
10
+ const RUNTIME_FILE = /(^|\/)(dockerfile[^/]*|\.dockerignore|(docker-)?compose[^/]*\.ya?ml|playwright\.config\.[cm]?[jt]s)$/i;
11
+ const E2E_DIR = /(^|\/)e2e\//i;
12
+ const KNOWN_PACKAGE_DIRS = E2E_SPEC_LOCATIONS.filter((dir) => dir !== '.');
13
+
14
+ function packageDirsOf(config) {
15
+ const declared = [config?.project?.frontendPath, ...(config?.project?.tests ?? []).filter((n) => n?.runner !== 'dotnet').map((n) => n?.cwd)];
16
+ const extra = declared.filter((d) => typeof d === 'string').map((d) => d.replace(/\\/g, '/').replace(/^\.\/|\/$/g, '')).filter((d) => d && d !== '.');
17
+ return [...KNOWN_PACKAGE_DIRS, ...extra];
18
+ }
19
+
20
+ const touchesRuntime = (file, composeFile, packageDirs) => UI_FILE.test(file)
21
+ || RUNTIME_FILE.test(file)
22
+ || E2E_DIR.test(file)
23
+ || (composeFile && basename(file) === basename(composeFile))
24
+ || packageDirs.some((dir) => file === dir || file.startsWith(`${dir}/`));
25
+
26
+ /** Unknown (no git, no base, empty diff) is applicable: the e2e only stands down on evidence. */
27
+ export function e2eApplicability(projectPath, featureName, config) {
28
+ const { checks } = readPlanChecks(projectPath, featureName);
29
+ if (checks?.checks?.some((c) => c.proof === 'e2e')) return { applicable: true, reason: 'prova: e2e' };
30
+ const base = detectDefaultBranch(projectPath);
31
+ const changed = base ? changedPathsAgainst(base, projectPath).filter((f) => !f.startsWith('.morph/')) : [];
32
+ if (changed.length === 0) return { applicable: true, reason: 'diff unknown' };
33
+ const composeFile = resolveComposeFile(projectPath, config);
34
+ const packageDirs = packageDirsOf(config);
35
+ const hit = changed.find((f) => touchesRuntime(f, composeFile, packageDirs));
36
+ return hit ? { applicable: true, reason: hit } : { applicable: false, reason: E2E_NOT_APPLICABLE };
37
+ }
@@ -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
  '',
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { dotnetBuildCommands, withFilter, withTrx, withoutBuild } from './commands.js';
5
- import { methodOf, parseTrx } from './trx.js';
5
+ import { parseTrx } from './trx.js';
6
6
  import { stripAnsi } from '../tasks/test-runner.js';
7
7
 
8
8
  const MAX_CAPTURE = 16 * 1024 * 1024;
@@ -39,14 +39,10 @@ export function dotnetAdapter({ node, root, workDir, exec, subset, timeoutMs })
39
39
  const trxDir = join(workDir, 'trx');
40
40
  mkdirSync(trxDir, { recursive: true });
41
41
  const base = withoutBuild(node.command);
42
- const filterOf = { subset: () => subset.filter, names: (filter) => filter };
43
- const commandFor = (scope, label, filter) => withTrx(
44
- scope === 'full' ? base : withFilter(base, filterOf[scope](filter)),
45
- { dir: trxDir, name: `${label}.trx` },
46
- );
47
- const runTests = async ({ scope, label, env, filter }) => {
42
+ const commandFor = (label) => withTrx(withFilter(base, subset.filter), { dir: trxDir, name: `${label}.trx` });
43
+ const runTests = async ({ label, env }) => {
48
44
  const trxPath = join(trxDir, `${label}.trx`);
49
- const r = await exec(commandFor(scope, label, filter), { cwd, env, timeoutMs, label });
45
+ const r = await exec(commandFor(label), { cwd, env, timeoutMs, label });
50
46
  if (!existsSync(trxPath)) {
51
47
  return { outcome: 'crash', executed: null, total: null, failed: 0, failedTests: [], durationMs: r.durationMs, note: r.timedOut ? `timeout de ${timeoutMs} ms` : `sem TRX (exit ${r.status})` };
52
48
  }
@@ -62,12 +58,6 @@ export function dotnetAdapter({ node, root, workDir, exec, subset, timeoutMs })
62
58
  buildCommands: dotnetBuildCommands(node, root),
63
59
  cwd,
64
60
  runTests,
65
- reproduce: ({ names, label, env }) => runTests({
66
- scope: 'names',
67
- label,
68
- env,
69
- filter: [...new Set(names.map(methodOf))].map((n) => `FullyQualifiedName~${n}`).join('|'),
70
- }),
71
61
  };
72
62
  }
73
63
 
@@ -78,9 +68,8 @@ export function genericAdapter({ node, root, exec, subset, timeoutMs }) {
78
68
  hasSubset: Boolean(subset?.usable),
79
69
  buildCommands: [],
80
70
  cwd,
81
- async runTests({ scope, label, env }) {
82
- const command = scope === 'subset' ? subset.command : node.command;
83
- const r = await exec(command, { cwd, env, timeoutMs, label });
71
+ async runTests({ label, env }) {
72
+ const r = await exec(subset.command, { cwd, env, timeoutMs, label });
84
73
  if (r.timedOut) return { outcome: 'crash', executed: null, total: null, failed: 0, failedTests: [], durationMs: r.durationMs, note: `timeout de ${timeoutMs} ms` };
85
74
  if (r.status === 0) return { outcome: 'green', executed: null, total: null, failed: 0, failedTests: [], durationMs: r.durationMs };
86
75
  const failedTests = genericFailures(`${r.stdout}\n${r.stderr}`);