@polymorphism-tech/morph-spec 9.8.0 → 10.1.1

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 (202) hide show
  1. package/CLAUDE.md +12 -10
  2. package/README.md +4 -4
  3. package/bin/morph-spec.js +40 -43
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +28 -36
  6. package/docs/QUICKSTART.md +31 -191
  7. package/framework/CLAUDE.md +24 -30
  8. package/framework/MORPH.md +47 -123
  9. package/framework/agents.json +6 -374
  10. package/framework/commands/morph-apply.md +1 -1
  11. package/framework/commands/morph-archive.md +4 -3
  12. package/framework/commands/morph-hotfix.md +8 -30
  13. package/framework/commands/morph-infra.md +2 -2
  14. package/framework/commands/morph-media.md +10 -9
  15. package/framework/commands/morph-preflight.md +3 -3
  16. package/framework/commands/morph-review.md +15 -0
  17. package/framework/commands/morph-status.md +1 -1
  18. package/framework/docs/CLI.md +84 -95
  19. package/framework/evals/review-checklist.md +45 -53
  20. package/framework/hooks/README.md +4 -4
  21. package/framework/hooks/claude-code/core/README.md +1 -1
  22. package/framework/hooks/claude-code/core/denylist.json +4 -4
  23. package/framework/hooks/claude-code/core/gate-guard.js +9 -0
  24. package/framework/hooks/claude-code/core/masked-exit.js +49 -0
  25. package/framework/hooks/claude-code/core/state-sync.js +4 -1
  26. package/framework/hooks/claude-code/core/telemetry-log.js +1 -2
  27. package/framework/hooks/claude-code/core/test-budget.js +10 -70
  28. package/framework/hooks/claude-code/core/validate-completion.js +11 -24
  29. package/framework/hooks/claude-code/core/worktree-context.js +5 -22
  30. package/framework/hooks/claude-code/core/worktree-create.js +6 -45
  31. package/framework/hooks/claude-code/core/worktree-env-inject.js +2 -10
  32. package/framework/hooks/shared/e2e-check.js +0 -0
  33. package/framework/hooks/shared/gate-event.js +2 -2
  34. package/framework/hooks/shared/graph-refresh.js +20 -3
  35. package/framework/hooks/shared/heavy-command.js +32 -1
  36. package/framework/hooks/shared/heavy-config.js +1 -1
  37. package/framework/hooks/shared/next-step.js +63 -24
  38. package/framework/hooks/shared/phase-utils.js +6 -9
  39. package/framework/hooks/shared/report-progress.js +86 -0
  40. package/framework/hooks/shared/state-reader.js +3 -2
  41. package/framework/hooks/statusline.js +332 -0
  42. package/framework/mcp/registry.json +4 -4
  43. package/framework/phases.json +32 -10
  44. package/framework/rules/ai-agents-standards.md +10 -2
  45. package/framework/rules/csharp-standards.md +4 -0
  46. package/framework/rules/data-standards.md +1 -1
  47. package/framework/rules/ef-standards.md +20 -0
  48. package/framework/rules/frontend-standards.md +1 -1
  49. package/framework/rules/hangfire-standards.md +1 -1
  50. package/framework/rules/implement-report.md +25 -0
  51. package/framework/rules/infrastructure-standards.md +2 -0
  52. package/framework/rules/neon-auth-standards.md +1 -1
  53. package/framework/rules/neon-standards.md +3 -3
  54. package/framework/rules/nextjs-standards.md +7 -0
  55. package/framework/rules/plan-grammar.md +53 -0
  56. package/framework/rules/ui-design.md +27 -0
  57. package/framework/schemas/config.schema.json +11 -18
  58. package/framework/schemas/feature.schema.json +7 -1
  59. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  60. package/framework/skills/core/create-umbraco-lp/references/umbraco-lp-patterns.md +1 -1
  61. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +2 -2
  62. package/framework/skills/core/morph-apply/SKILL.md +39 -166
  63. package/framework/skills/core/morph-apply/references/debugging.md +8 -0
  64. package/framework/skills/core/morph-apply/references/hotfix.md +42 -0
  65. package/framework/skills/core/morph-apply/references/machine.md +29 -0
  66. package/framework/skills/core/{morph-media/SKILL.md → morph-apply/references/media.md} +11 -74
  67. package/framework/skills/core/morph-apply/references/review.md +65 -0
  68. package/framework/skills/core/morph-plan/SKILL.md +49 -109
  69. package/framework/skills/core/morph-plan/references/critique.md +43 -0
  70. package/framework/skills/core/morph-plan/references/interview.md +37 -0
  71. package/framework/skills/core/morph-uiux/SKILL.md +47 -64
  72. package/framework/skills/core/morph-uiux/surfaces.json +32 -0
  73. package/framework/standards/STANDARDS.json +51 -28
  74. package/framework/standards/STANDARDS.meta.json +16 -19
  75. package/framework/standards/ai-agents/agent-session.md +2 -3
  76. package/framework/standards/ai-agents/context-providers.md +2 -2
  77. package/framework/standards/ai-agents/durable-workflows-hitl.md +2 -2
  78. package/framework/standards/ai-agents/providers/model-registry.md +2 -2
  79. package/framework/standards/ai-agents/rag-custom-pgvector.md +7 -6
  80. package/framework/standards/ai-agents/setup.md +1 -1
  81. package/framework/standards/ai-agents/tool-adapters.md +2 -2
  82. package/framework/standards/architecture/stack-baseline.md +70 -0
  83. package/framework/standards/backend/database/postgresql/database.md +22 -1
  84. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +33 -1
  85. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +2 -1
  86. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +12 -13
  87. package/framework/standards/frontend/design-system/ai-image-generation.md +51 -12
  88. package/framework/standards/frontend/design-system/ai-video-generation.md +50 -4
  89. package/framework/standards/frontend/design-system/art-direction.md +2 -2
  90. package/framework/standards/frontend/scroll-driven/frame-scrub.md +12 -2
  91. package/framework/standards/frontend/scroll-driven/scroll-components.md +76 -4
  92. package/framework/standards/infrastructure/docker/local-compose-isolation.md +2 -2
  93. package/framework/standards/infrastructure/neon/neon-pgvector.md +62 -20
  94. package/framework/standards/infrastructure/neon/neon-platform.md +61 -0
  95. package/framework/standards/infrastructure/neon/neon-setup.md +14 -13
  96. package/framework/standards/integration/mcp/mcp-tools.md +20 -20
  97. package/framework/templates/REGISTRY.json +1 -2
  98. package/framework/templates/context/ARCHITECTURE.md +1 -1
  99. package/framework/templates/docs/plan.md +15 -12
  100. package/framework/templates/docs/ui-mockups.md +17 -105
  101. package/framework/templates/infrastructure/docker/docker-compose.template.yml +1 -1
  102. package/package.json +1 -1
  103. package/src/commands/e2e/e2e.js +88 -23
  104. package/src/commands/graph/diff.js +74 -0
  105. package/src/commands/graph/status.js +1 -1
  106. package/src/commands/observability/cost.js +2 -4
  107. package/src/commands/observability/telemetry.js +1 -1
  108. package/src/commands/project/doctor.js +23 -39
  109. package/src/commands/project/fleet.js +5 -6
  110. package/src/commands/project/init.js +63 -36
  111. package/src/commands/project/stack-scan.js +3 -42
  112. package/src/commands/project/stack-set.js +26 -0
  113. package/src/commands/project/status.js +12 -13
  114. package/src/commands/project/update.js +39 -3
  115. package/src/commands/project/worktree.js +3 -14
  116. package/src/commands/state/advance.js +17 -17
  117. package/src/commands/state/approve.js +0 -6
  118. package/src/commands/state/archive.js +27 -1
  119. package/src/commands/state/auto-approval.js +2 -2
  120. package/src/commands/state/create.js +1 -6
  121. package/src/commands/state/gate-check.js +38 -31
  122. package/src/commands/state/gate-decision.js +3 -2
  123. package/src/commands/state/next.js +1 -0
  124. package/src/commands/state/recap.js +67 -0
  125. package/src/commands/state/report-check.js +36 -178
  126. package/src/commands/validation/plan-check.js +16 -11
  127. package/src/commands/validation/validate-feature.js +3 -3
  128. package/src/commands/validation/verify.js +3 -6
  129. package/src/core/paths/output-schema.js +3 -3
  130. package/src/core/state/state-manager.js +2 -4
  131. package/src/dashboard/app/components/CommandPalette.jsx +2 -5
  132. package/src/dashboard/app/components/GatePauses.jsx +116 -0
  133. package/src/dashboard/app/components/SessionMap.jsx +77 -0
  134. package/src/dashboard/app/pages/Activity.jsx +4 -0
  135. package/src/dashboard/app/pages/AgentMap.jsx +1 -2
  136. package/src/dashboard/app/pages/FeatureDetail.jsx +129 -186
  137. package/src/dashboard/app/pages/Features.jsx +7 -7
  138. package/src/dashboard/app/pages/PhasePipeline.jsx +1 -1
  139. package/src/dashboard/app/pages/Portfolio.jsx +40 -5
  140. package/src/dashboard/scanner/activity-scanner.js +29 -0
  141. package/src/dashboard/scanner/agents-scanner.js +11 -14
  142. package/src/dashboard/scanner/features-scanner.js +45 -24
  143. package/src/dashboard/scanner/project-api.js +11 -2
  144. package/src/dashboard/scanner/skills-scanner.js +11 -1
  145. package/src/lib/detectors/index.js +1 -1
  146. package/src/lib/e2e/applicability.js +3 -1
  147. package/src/lib/git/tree-state.js +1 -16
  148. package/src/lib/graph/diff-tree.js +127 -0
  149. package/src/lib/graph/graphify-runtime.js +12 -5
  150. package/src/lib/installers/memory-installer.js +3 -3
  151. package/src/lib/installers/rules-installer.js +57 -0
  152. package/src/lib/installers/stack-assets.js +17 -0
  153. package/src/lib/installers/stack-sync.js +219 -0
  154. package/src/lib/plan/checks.js +61 -109
  155. package/src/lib/plan/critique.js +28 -0
  156. package/src/lib/plan/media.js +42 -0
  157. package/src/lib/plan/plan-html.js +125 -0
  158. package/src/lib/plan/plan-parser.js +34 -45
  159. package/src/lib/recap/piper.js +36 -0
  160. package/src/lib/recap/recap.js +232 -0
  161. package/src/lib/review-reports.js +28 -4
  162. package/src/lib/risk-detector.js +90 -11
  163. package/src/lib/specs/screen-checklist.js +33 -48
  164. package/src/lib/stack/dotnet-build-ignores.js +8 -9
  165. package/src/lib/telemetry/metrics.js +27 -29
  166. package/src/lib/validators/reuse/reuse-validator.js +1 -9
  167. package/src/lib/validators/verify-runner.js +0 -7
  168. package/src/lib/worktree/fleet-scanner.js +7 -20
  169. package/src/lib/worktree/worktree-audit.js +0 -37
  170. package/src/scripts/setup-infra.js +15 -50
  171. package/src/utils/agents-installer.js +27 -201
  172. package/src/utils/hooks-installer.js +11 -95
  173. package/framework/hooks/claude-code/core/warn-bash-source-write.js +0 -23
  174. package/framework/hooks/claude-code/statusline.py +0 -699
  175. package/framework/hooks/claude-code/statusline.sh +0 -11
  176. package/framework/hooks/shared/agent-reap.js +0 -74
  177. package/framework/hooks/shared/slice-progress.js +0 -110
  178. package/framework/skills/core/morph-hotfix/SKILL.md +0 -133
  179. package/framework/skills/core/morph-machine/SKILL.md +0 -46
  180. package/framework/skills/core/morph-review/SKILL.md +0 -129
  181. package/framework/skills/core/morph-standards/SKILL.md +0 -52
  182. package/framework/standards/PROVENANCE.json +0 -119
  183. package/framework/standards/infrastructure/neon/neon-pgrag.md +0 -243
  184. package/src/commands/project/worktree-reap.js +0 -34
  185. package/src/commands/state/handoff.js +0 -65
  186. package/src/commands/validation/mutate.js +0 -263
  187. package/src/commands/validation/scout-check.js +0 -28
  188. package/src/lib/generators/handoff-generator.js +0 -172
  189. package/src/lib/mutation/adapters.js +0 -79
  190. package/src/lib/mutation/commands.js +0 -61
  191. package/src/lib/mutation/dotnet-introspection.js +0 -281
  192. package/src/lib/mutation/manifest.js +0 -144
  193. package/src/lib/mutation/plan.js +0 -44
  194. package/src/lib/mutation/report.js +0 -68
  195. package/src/lib/mutation/runner.js +0 -301
  196. package/src/lib/mutation/schemata.js +0 -60
  197. package/src/lib/mutation/snapshot.js +0 -65
  198. package/src/lib/mutation/subset.js +0 -109
  199. package/src/lib/mutation/trx.js +0 -50
  200. package/src/lib/scout/scout-check.js +0 -293
  201. package/src/lib/validators/traceability-check.js +0 -103
  202. package/src/lib/worktree/agent-reaper.js +0 -247
@@ -4,6 +4,7 @@ import { markdownLines } from '../../../framework/hooks/shared/errata.js';
4
4
 
5
5
  export const PROOF_KINDS = ['teste', 'e2e', 'eval', 'manual', 'leitura'];
6
6
  export const RISK_KINDS = ['regra', 'dinheiro', 'contrato', 'concorrência', 'dado', 'regressão'];
7
+ export const REWORK_RUNGS = ['refazer', 'desfazer', 'migrar', 'irreversível'];
7
8
  export const REUSE_VERDICTS = ['REUSAR', 'ESTENDER', 'CRIAR'];
8
9
  export const ADR_SCOPES = ['feature', 'projeto'];
9
10
 
@@ -11,44 +12,29 @@ export const SECTIONS = {
11
12
  problem: 'Problema',
12
13
  outOfScope: 'Fora de escopo',
13
14
  assumptions: 'Suposições não confirmadas',
15
+ defaults: 'Padrões assumidos',
16
+ states: 'Estados',
14
17
  scenarios: 'Cenários',
15
18
  decisions: 'Decisões',
16
19
  architecture: 'Arquitetura',
17
20
  reuse: 'Reuso',
18
- slices: 'Fatias',
19
21
  contract: 'Contrato de implementação',
20
22
  errata: 'Errata',
21
23
  };
22
24
 
23
25
  export const CONFLICTS_HEADING = 'Conflitos com o acervo';
24
26
 
25
- const SLICE_KEYS = {
26
- persona: 'persona',
27
- 'superfície': 'surface',
28
- superficie: 'surface',
29
- standards: 'standards',
30
- arquivos: 'files',
31
- depende: 'depends',
32
- cobre: 'covers',
33
- };
34
-
35
27
  const DASH = String.raw`\s+[—–-]\s+`;
36
28
  const SCENARIO_RE = new RegExp(String.raw`^[-*]\s+\*\*(C\d+)\*\*${DASH}(.+)$`);
37
29
  const ADR_RE = new RegExp(String.raw`^###\s+(ADR-\d+)${DASH}(.+?)\s*$`);
38
- const SLICE_RE = new RegExp(String.raw`^###\s+(F\d+)${DASH}(.+?)\s*$`);
39
30
  const PROOF_RE = /^\s*prova\s*:\s*(.*?)\s*$/i;
40
31
  const RISK_RE = /^\s*risco\s*:\s*(.*?)\s*$/i;
32
+ const RUNG_RE = /^\s*degrau\s*:\s*(.*?)\s*$/i;
41
33
  const WHY_RE = new RegExp(String.raw`^\s*>\s*` + ARTIFACT_MARKER_RE.source + String.raw`\s*(.*?)\s*$`, 'i');
42
34
  const SCOPE_RE = /^\s*\*\*Alcance:\*\*\s*(.*?)\s*$/i;
43
- const SLICE_LINE_RE = /^\s*[-*]\s+([\p{L}]+)\s*:\s*(.*?)\s*$/u;
44
35
 
45
36
  const fold = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim();
46
37
 
47
- const listOf = (value) => value
48
- .split(',')
49
- .map((v) => v.replace(/`/g, '').trim())
50
- .filter((v) => v && !/^[—–-]$/.test(v) && fold(v) !== 'nenhum' && fold(v) !== 'nenhuma');
51
-
52
38
  function splitSections(lines) {
53
39
  const sections = [];
54
40
  let current = null;
@@ -71,7 +57,7 @@ function parseScenarios(lines) {
71
57
  if (line.fenced) { current = null; continue; }
72
58
  const m = SCENARIO_RE.exec(line.raw);
73
59
  if (m) {
74
- current = { id: m[1], text: m[2].trim(), proof: null, risk: null, artifactWhy: null, line: line.n };
60
+ current = { id: m[1], text: m[2].trim(), proof: null, risk: null, rung: null, artifactWhy: null, line: line.n };
75
61
  scenarios.push(current);
76
62
  continue;
77
63
  }
@@ -83,6 +69,8 @@ function parseScenarios(lines) {
83
69
  if (proof && current.proof === null) { current.proof = proof[1]; continue; }
84
70
  const risk = RISK_RE.exec(line.raw);
85
71
  if (risk && current.risk === null) { current.risk = risk[1]; continue; }
72
+ const rung = RUNG_RE.exec(line.raw);
73
+ if (rung && current.rung === null) { current.rung = rung[1]; continue; }
86
74
  if (/^\s*>/.test(line.raw)) continue;
87
75
  current.text = `${current.text} ${line.raw.trim()}`;
88
76
  }
@@ -149,34 +137,34 @@ function parseReuse(lines) {
149
137
  return { header, rows };
150
138
  }
151
139
 
152
- function parseSlices(lines) {
153
- const slices = [];
154
- let current = null;
140
+ function parseDefaults(lines) {
141
+ const items = [];
142
+ let none = false;
143
+ let comment = false;
155
144
  for (const line of lines) {
156
145
  if (line.fenced) continue;
157
- const m = SLICE_RE.exec(line.raw);
158
- if (m) {
159
- current = {
160
- id: m[1], title: m[2], persona: null, surface: null,
161
- standards: [], files: [], depends: [], covers: [], unknownKeys: [], line: line.n,
162
- };
163
- slices.push(current);
164
- continue;
165
- }
166
- if (/^###\s/.test(line.raw)) { current = null; continue; }
167
- if (!current) continue;
168
- const kv = SLICE_LINE_RE.exec(line.raw);
169
- if (!kv) continue;
170
- const key = SLICE_KEYS[fold(kv[1])] ?? SLICE_KEYS[kv[1].toLowerCase()];
171
- if (!key) { current.unknownKeys.push(kv[1]); continue; }
172
- if (key === 'persona' || key === 'surface') {
173
- const value = kv[2].replace(/`/g, '').trim();
174
- current[key] = value && !/^[—–-]$/.test(value) ? value : null;
175
- } else {
176
- current[key] = listOf(kv[2]);
177
- }
146
+ if (comment || line.raw.includes('<!--')) { comment = !line.raw.includes('-->'); continue; }
147
+ const m = /^\s*(\d+)[.)]\s+(.+?)\s*$/.exec(line.raw);
148
+ if (m) items.push({ n: Number(m[1]), text: m[2], line: line.n });
149
+ else if (/^\s*nenhum\.?\s*$/i.test(line.raw)) none = true;
150
+ }
151
+ return { items, none };
152
+ }
153
+
154
+ const TRANSITION_RE = /^\s*[-*]\s+(.+?)\s*(?:→|->)\s*(.+?)\s*:\s*(.+?)\s*$/;
155
+
156
+ function parseStates(lines) {
157
+ const transitions = [];
158
+ const invalid = [];
159
+ let comment = false;
160
+ for (const line of lines) {
161
+ if (line.fenced || !line.raw.trim()) continue;
162
+ if (comment || line.raw.includes('<!--')) { comment = !line.raw.includes('-->'); continue; }
163
+ const m = TRANSITION_RE.exec(line.raw);
164
+ if (m) transitions.push({ from: m[1], to: m[2], trigger: m[3], line: line.n });
165
+ else invalid.push({ line: line.n, raw: line.raw.trim() });
178
166
  }
179
- return slices;
167
+ return { transitions, invalid };
180
168
  }
181
169
 
182
170
  /** Pure: no validation, no I/O; `checks.js` judges what this returns. */
@@ -201,7 +189,8 @@ export function parsePlan(text) {
201
189
  conflicts: decisions.conflicts,
202
190
  reuse: reuse.rows,
203
191
  reuseHeader: reuse.header,
204
- slices: parseSlices(body('slices')),
192
+ defaults: parseDefaults(body('defaults')),
193
+ states: parseStates(body('states')),
205
194
  errata: errata ? errata.lines.map((l) => l.raw).join('\n').trim() : null,
206
195
  sectionsAfterErrata: errata ? sections.filter((s) => s.line > errata.line).map((s) => s.name) : [],
207
196
  };
@@ -0,0 +1,36 @@
1
+ import { spawnSync } from 'child_process';
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';
3
+ import { homedir, tmpdir } from 'os';
4
+ import { join } from 'path';
5
+ import { findFirstOnPath } from '../../../framework/hooks/shared/morph-cli.js';
6
+
7
+ export const PIPER_MODEL_DIR = join(homedir(), '.morph-spec', 'piper');
8
+
9
+ /** The piper binary and a voice model, or why narration falls back to the system voice. */
10
+ export function resolvePiper(env = process.env) {
11
+ const bin = findFirstOnPath(process.platform === 'win32' ? ['piper.exe'] : ['piper'], env);
12
+ if (!bin) return { ok: false, reason: 'piper fora do PATH (pip install piper-tts)' };
13
+ const model = env.MORPH_PIPER_MODEL
14
+ || [join(PIPER_MODEL_DIR, 'pt_BR-faber-medium.onnx')].find((m) => existsSync(m));
15
+ if (!model || !existsSync(model)) {
16
+ return { ok: false, reason: `sem modelo de voz: baixe pt_BR-faber-medium.onnx (+ .onnx.json) para ${PIPER_MODEL_DIR}, ou aponte MORPH_PIPER_MODEL` };
17
+ }
18
+ return { ok: true, bin, model };
19
+ }
20
+
21
+ /** `{ [sceneId]: dataUri }`, or null when piper fails on any scene: half-narrated is worse than the system voice. */
22
+ export function narrateWithPiper(scenes, piper, { run = spawnSync } = {}) {
23
+ const dir = mkdtempSync(join(tmpdir(), 'morph-recap-'));
24
+ try {
25
+ const audio = {};
26
+ for (const s of scenes) {
27
+ const out = join(dir, `${s.id}.wav`);
28
+ const r = run(piper.bin, ['--model', piper.model, '--output_file', out], { input: s.narration, windowsHide: true, stdio: ['pipe', 'ignore', 'pipe'] });
29
+ if (r.status !== 0 || !existsSync(out)) return null;
30
+ audio[s.id] = `data:audio/wav;base64,${readFileSync(out).toString('base64')}`;
31
+ }
32
+ return audio;
33
+ } finally {
34
+ rmSync(dir, { recursive: true, force: true });
35
+ }
36
+ }
@@ -0,0 +1,232 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { parsePlan } from '../plan/plan-parser.js';
5
+ import { stateMachineSvg } from '../plan/plan-html.js';
6
+ import { reviewReportsFromDir } from '../review-reports.js';
7
+ import { loadGraph, getFreshness } from '../graph/graph-loader.js';
8
+ import { parseDiff, buildDiffTree, declarationDrift } from '../graph/diff-tree.js';
9
+ import { detectDefaultBranch } from '../git/default-branch.js';
10
+
11
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
12
+ const GATE_LABEL = { plan: 'Plano', uiux: 'Design', review: 'Gate 3' };
13
+
14
+ const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
15
+ const readText = (path) => { try { return readFileSync(path, 'utf8'); } catch { return null; } };
16
+ const git = (cwd, ...args) => execFileSync('git', args, { cwd, stdio: 'pipe', maxBuffer: 64 << 20 }).toString();
17
+
18
+ /**
19
+ * What changed on the branch: the call tree when the graph is of HEAD and its lines hold, else the
20
+ * plain file and line counts. Never throws; outside git it is null.
21
+ */
22
+ export function changeSummary(cwd, { base = null } = {}) {
23
+ let from = base;
24
+ try {
25
+ from ??= git(cwd, 'merge-base', detectDefaultBranch(cwd) || 'main', 'HEAD').trim();
26
+ } catch {
27
+ return null;
28
+ }
29
+ let stat = null;
30
+ try {
31
+ const m = /(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/.exec(git(cwd, 'diff', '--shortstat', from, 'HEAD'));
32
+ stat = { files: Number(m?.[1] ?? 0), added: Number(m?.[2] ?? 0), removed: Number(m?.[3] ?? 0) };
33
+ } catch {
34
+ return null;
35
+ }
36
+ const loaded = loadGraph(cwd);
37
+ if (!loaded.ok || getFreshness(cwd, loaded.graph).state !== 'fresh') return { stat, tree: null };
38
+ try {
39
+ const tree = buildDiffTree(loaded.graph, parseDiff(git(cwd, 'diff', '-U0', '--no-renames', '--no-color', from, 'HEAD')));
40
+ const drift = declarationDrift(tree, (f) => { try { return git(cwd, 'show', `HEAD:${f}`).split(/\r?\n/); } catch { return []; } });
41
+ return { stat, tree: drift.length ? null : tree };
42
+ } catch {
43
+ return { stat, tree: null };
44
+ }
45
+ }
46
+
47
+ export function buildRecap(featureDir, feature, { changes = null, now = new Date() } = {}) {
48
+ const fj = readJson(join(featureDir, 'feature.json')) ?? {};
49
+ const planText = readText(join(featureDir, '2-plan', 'plan.md'));
50
+ const plan = planText ? parsePlan(planText) : null;
51
+ const verifier = reviewReportsFromDir(featureDir).verifier;
52
+ const proved = new Map((verifier?.checks ?? []).map((c) => [c.id, c]));
53
+ const scenarios = (plan?.scenarios ?? []).map((c) => ({ id: c.id, text: c.text, status: proved.get(c.id)?.status ?? null, evidence: proved.get(c.id)?.evidence ?? '' }));
54
+ const gates = Object.entries(fj.approvalGates ?? {})
55
+ .filter(([gate]) => GATE_LABEL[gate])
56
+ .map(([gate, g]) => ({
57
+ gate, approved: g === true || g?.approved === true, approvedBy: g?.approvedBy ?? null,
58
+ paused: fj.gateDecisions?.[gate]?.pause === true, reason: fj.gateDecisions?.[gate]?.reason ?? null,
59
+ }));
60
+ const started = Date.parse(fj.createdAt ?? '');
61
+ return {
62
+ feature,
63
+ description: fj.description ?? null,
64
+ workType: fj.workType ?? null,
65
+ days: Number.isNaN(started) ? null : Math.max(0, Math.round((now - started) / 86400000)),
66
+ scenarios,
67
+ transitions: plan?.states?.transitions ?? [],
68
+ gates,
69
+ changes,
70
+ };
71
+ }
72
+
73
+ function flatten(tree, limit) {
74
+ const out = [];
75
+ const walk = (n, depth) => {
76
+ if (out.length >= limit) return;
77
+ out.push({ depth, mark: n.mark, label: n.label, file: n.file, again: n.repeat || n.cycle });
78
+ n.children.forEach((c) => walk(c, depth + 1));
79
+ };
80
+ tree.roots.forEach((r) => walk(r, 0));
81
+ return out;
82
+ }
83
+
84
+ /** One scene per part of the story; `narration` is what is spoken and shown as the caption. */
85
+ export function recapScenes(r) {
86
+ const scenes = [];
87
+ scenes.push({
88
+ id: 'abertura',
89
+ title: r.feature,
90
+ narration: `${r.feature}. ${r.description ?? ''} ${r.days !== null ? `Do início ao fim, ${r.days === 0 ? 'menos de um dia' : `${r.days} dia${r.days > 1 ? 's' : ''}`}.` : ''}`.replace(/\s+/g, ' ').trim(),
91
+ body: `<p class="lead">${esc(r.description ?? '')}</p>${r.days !== null ? `<p class="big">${r.days === 0 ? '< 1' : r.days} <span>dia${r.days === 1 ? '' : 's'}</span></p>` : ''}`,
92
+ });
93
+ if (r.scenarios.length) {
94
+ const ok = r.scenarios.filter((c) => c.status === 'PROVADO').length;
95
+ scenes.push({
96
+ id: 'cenarios',
97
+ title: 'O que ficou provado',
98
+ narration: `${ok} de ${r.scenarios.length} cenários provados pelo verificador. ${r.scenarios.slice(0, 4).map((c) => `${c.id}: ${c.text}`).join(' ')}`,
99
+ body: `<ul class="checks">${r.scenarios.map((c, i) => `<li style="--i:${i}"><b class="${c.status === 'PROVADO' ? 'ok' : 'warn'}">${c.status === 'PROVADO' ? '✓' : '·'}</b><span><em>${esc(c.id)}</em> ${esc(c.text)}${c.evidence ? `<small>${esc(c.evidence)}</small>` : ''}</span></li>`).join('')}</ul>`,
100
+ });
101
+ }
102
+ if (r.transitions.length) {
103
+ const states = new Set(r.transitions.flatMap((t) => [t.from, t.to]));
104
+ scenes.push({
105
+ id: 'estados',
106
+ title: 'Como os estados andam',
107
+ narration: `${states.size} estados e ${r.transitions.length} transições. ${r.transitions.slice(0, 4).map((t) => `De ${t.from} para ${t.to}, quando ${t.trigger}.`).join(' ')}`,
108
+ body: `<div class="machine-box">${stateMachineSvg(r.transitions)}</div>`,
109
+ });
110
+ }
111
+ if (r.changes) {
112
+ const { stat, tree } = r.changes;
113
+ const rows = tree ? flatten(tree, 14) : [];
114
+ scenes.push({
115
+ id: 'mudancas',
116
+ title: 'O que mudou no código',
117
+ narration: tree
118
+ ? `${tree.counts.added} símbolos novos e ${tree.counts.modified} alterados em ${stat.files} arquivos. ${tree.roots.slice(0, 3).map((n) => n.label.replace(/\(\)$/, '')).join(', ')} são os pontos de entrada.`
119
+ : `${stat.files} arquivos, ${stat.added} linhas a mais e ${stat.removed} a menos.`,
120
+ body: `<p class="stat"><b>${stat.files}</b> arquivos <b class="ok">+${stat.added}</b> <b class="bad">−${stat.removed}</b></p>${rows.length
121
+ ? `<ul class="tree">${rows.map((n, i) => `<li style="--i:${i};--d:${n.depth}"><b class="${n.mark === '+' ? 'ok' : 'warn'}">${n.mark}</b> ${esc(n.label)}${n.again ? ' <small>↑</small>' : ''} <small>${esc(n.file)}</small></li>`).join('')}</ul>`
122
+ : ''}`,
123
+ });
124
+ }
125
+ if (r.gates.length) {
126
+ const human = r.gates.filter((g) => g.paused);
127
+ scenes.push({
128
+ id: 'gates',
129
+ title: 'Por onde passou',
130
+ narration: `${r.gates.filter((g) => g.approved).length} gates aprovados. ${human.length ? `Pausou para você em ${human.map((g) => GATE_LABEL[g.gate]).join(' e ')}.` : 'Nenhuma pausa para você.'}`,
131
+ body: `<ol class="gates">${r.gates.map((g, i) => `<li style="--i:${i}"><b class="${g.approved ? 'ok' : 'warn'}">${g.approved ? '✓' : '·'}</b> ${GATE_LABEL[g.gate]}${g.approvedBy ? ` <small>por ${esc(g.approvedBy)}</small>` : ''}${g.paused && g.reason ? `<small class="why">pausou: ${esc(g.reason)}</small>` : ''}</li>`).join('')}</ol>`,
132
+ });
133
+ }
134
+ return scenes;
135
+ }
136
+
137
+ const SCENE_MS = 9000;
138
+
139
+ /** `audio`: optional `{ [sceneId]: dataUri }`; without it the page narrates with the system voice. */
140
+ export function renderRecapHtml(r, { audio = null } = {}) {
141
+ const scenes = recapScenes(r);
142
+ const data = JSON.stringify(scenes.map((s) => ({ id: s.id, narration: s.narration, audio: audio?.[s.id] ?? null }))).replace(/</g, '\\u003c');
143
+ return `<!doctype html>
144
+ <html lang="pt-BR">
145
+ <head>
146
+ <meta charset="utf-8">
147
+ <meta name="viewport" content="width=device-width, initial-scale=1">
148
+ <title>Recap — ${esc(r.feature)}</title>
149
+ <style>
150
+ :root { --bg:#0f1115; --card:#171a21; --fg:#e8ebf0; --dim:#b3bac6; --faint:#7f8796; --hair:#2a2f3a; --ok:#5cc28a; --warn:#e2a54b; --bad:#f07178; --accent:#8fb0ff; }
151
+ @media (prefers-color-scheme: light) { :root { --bg:#f6f7f9; --card:#fff; --fg:#14171c; --dim:#4b5260; --faint:#7a8291; --hair:#dfe3ea; --ok:#2f7d4f; --warn:#a35f00; --bad:#b4232f; --accent:#2d5bd7; } }
152
+ * { box-sizing:border-box; }
153
+ body { margin:0; min-height:100vh; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; display:flex; flex-direction:column; }
154
+ .stage { flex:1; position:relative; max-width:960px; width:100%; margin:0 auto; padding:40px 16px 16px; }
155
+ .scene { position:absolute; inset:40px 16px 16px; opacity:0; transform:translateY(12px); transition:opacity .6s, transform .6s; pointer-events:none; overflow:auto; }
156
+ .scene.on { opacity:1; transform:none; pointer-events:auto; }
157
+ h1 { font-size:clamp(26px, 5vw, 44px); margin:0 0 20px; letter-spacing:-.02em; }
158
+ .lead { font-size:20px; color:var(--dim); max-width:60ch; }
159
+ .big { font-size:72px; font-weight:700; margin:24px 0 0; } .big span { font-size:20px; color:var(--faint); font-weight:400; }
160
+ ul, ol { list-style:none; padding:0; margin:0; }
161
+ .on li { animation:rise .5s both; animation-delay:calc(var(--i) * 120ms + 300ms); }
162
+ @keyframes rise { from { opacity:0; transform:translateY(8px); } }
163
+ @media (prefers-reduced-motion: reduce) { .scene, .on li { transition:none; animation:none; } }
164
+ .checks li, .gates li { display:flex; gap:12px; padding:10px 0; border-bottom:1px solid var(--hair); }
165
+ .checks em { font-style:normal; font-weight:600; color:var(--accent); margin-right:6px; }
166
+ small { display:block; color:var(--faint); font-size:13px; }
167
+ .tree li small, .gates li small { display:inline; margin-left:6px; }
168
+ .gates .why { display:block; margin:4px 0 0; }
169
+ .tree li { padding:4px 0 4px calc(var(--d) * 22px); font:14px/1.5 ui-monospace, monospace; }
170
+ .stat { font-size:22px; } .stat b { margin-right:4px; }
171
+ .ok { color:var(--ok); } .warn { color:var(--warn); } .bad { color:var(--bad); }
172
+ .machine-box { background:var(--card); border:1px solid var(--hair); border-radius:12px; padding:12px; overflow-x:auto; }
173
+ .machine .state rect { fill:var(--bg); stroke:var(--accent); stroke-width:1.5; } .machine .state text { fill:var(--fg); font:600 13px system-ui; }
174
+ .machine .edge { fill:none; stroke:var(--faint); stroke-width:1.5; } .machine .arrow-head { fill:var(--faint); }
175
+ .machine .edge-label { fill:var(--dim); font:12px system-ui; paint-order:stroke; stroke:var(--card); stroke-width:4px; }
176
+ .bar { position:sticky; bottom:0; background:var(--card); border-top:1px solid var(--hair); }
177
+ .caption { max-width:960px; margin:0 auto; padding:12px 16px 4px; color:var(--dim); min-height:3.2em; }
178
+ .controls { max-width:960px; margin:0 auto; padding:4px 16px 12px; display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
179
+ button { background:transparent; color:var(--fg); border:1px solid var(--hair); border-radius:999px; padding:6px 14px; font:inherit; font-size:14px; cursor:pointer; min-height:36px; }
180
+ button[aria-pressed="true"] { border-color:var(--accent); color:var(--accent); }
181
+ .dots { margin-left:auto; display:flex; gap:6px; } .dots i { width:8px; height:8px; border-radius:50%; background:var(--hair); } .dots i.on { background:var(--accent); }
182
+ </style>
183
+ </head>
184
+ <body>
185
+ <main class="stage">
186
+ ${scenes.map((s, i) => `<section class="scene${i === 0 ? ' on' : ''}" data-scene="${i}" aria-hidden="${i !== 0}"><h1>${esc(s.title)}</h1>${s.body}</section>`).join('\n')}
187
+ </main>
188
+ <div class="bar">
189
+ <p class="caption" aria-live="polite"></p>
190
+ <div class="controls">
191
+ <button id="play">▶ Tocar</button><button id="prev">←</button><button id="next">→</button><button id="voice" aria-pressed="true">Narração</button>
192
+ <span class="dots">${scenes.map(() => '<i></i>').join('')}</span>
193
+ </div>
194
+ </div>
195
+ <script>
196
+ const SCENES = ${data};
197
+ const els = [...document.querySelectorAll('.scene')], dots = [...document.querySelectorAll('.dots i')];
198
+ const caption = document.querySelector('.caption'), voiceBtn = document.getElementById('voice'), playBtn = document.getElementById('play');
199
+ let at = 0, playing = false, timer = null, clip = null;
200
+ const voiceOn = () => voiceBtn.getAttribute('aria-pressed') === 'true';
201
+ function stopSound() { clearTimeout(timer); if (clip) { clip.pause(); clip = null; } if (window.speechSynthesis) speechSynthesis.cancel(); }
202
+ function advance() { if (playing && at < SCENES.length - 1) show(at + 1); else { playing = false; playBtn.textContent = '▶ Tocar'; } }
203
+ function speak(s) {
204
+ if (!playing) return;
205
+ if (voiceOn() && s.audio) { clip = new Audio(s.audio); clip.onended = advance; clip.play().catch(() => { timer = setTimeout(advance, ${SCENE_MS}); }); return; }
206
+ if (voiceOn() && window.speechSynthesis) {
207
+ const u = new SpeechSynthesisUtterance(s.narration); u.lang = 'pt-BR';
208
+ const v = speechSynthesis.getVoices().find((x) => x.lang && x.lang.toLowerCase().startsWith('pt'));
209
+ if (v) u.voice = v;
210
+ u.onend = advance; u.onerror = () => { timer = setTimeout(advance, ${SCENE_MS}); };
211
+ speechSynthesis.speak(u); return;
212
+ }
213
+ timer = setTimeout(advance, ${SCENE_MS});
214
+ }
215
+ function show(i) {
216
+ stopSound(); at = i;
217
+ els.forEach((e, k) => { e.classList.toggle('on', k === i); e.setAttribute('aria-hidden', String(k !== i)); });
218
+ dots.forEach((d, k) => d.classList.toggle('on', k === i));
219
+ caption.textContent = SCENES[i].narration;
220
+ speak(SCENES[i]);
221
+ }
222
+ playBtn.onclick = () => { playing = !playing; playBtn.textContent = playing ? '❚❚ Pausar' : '▶ Tocar'; if (playing) show(at); else stopSound(); };
223
+ document.getElementById('prev').onclick = () => show(Math.max(0, at - 1));
224
+ document.getElementById('next').onclick = () => show(Math.min(SCENES.length - 1, at + 1));
225
+ voiceBtn.onclick = () => { voiceBtn.setAttribute('aria-pressed', String(!voiceOn())); if (!voiceOn()) stopSound(); };
226
+ document.addEventListener('keydown', (e) => { if (e.key === 'ArrowRight') document.getElementById('next').click(); if (e.key === 'ArrowLeft') document.getElementById('prev').click(); if (e.key === ' ') { e.preventDefault(); playBtn.click(); } });
227
+ caption.textContent = SCENES[0].narration; dots[0].classList.add('on');
228
+ </script>
229
+ </body>
230
+ </html>
231
+ `;
232
+ }
@@ -1,12 +1,14 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { OUTPUT_SCHEMA, getAbsoluteOutputPath } from '../core/paths/output-schema.js';
4
- import { PARTIAL_MARKER } from '../../framework/hooks/shared/slice-progress.js';
4
+ import { PARTIAL_MARKER } from '../../framework/hooks/shared/report-progress.js';
5
5
 
6
6
  export const VERDICT_TOKEN = '**Verdict:**';
7
7
  export const REVIEWED_SHA_TOKEN = '**Reviewed-SHA:**';
8
8
  export const VERDICTS = ['SHIP', 'FIX'];
9
- export const BLOCKER_TYPES = ['check', 'teste', 'regressao', 'seguranca', 'escopo', 'arquitetura', 'performance'];
9
+ export const BLOCKER_TYPES = ['check', 'teste', 'regressao', 'seguranca', 'escopo', 'arquitetura', 'performance', 'irreversivel'];
10
+ // The plan is frozen by then: the session cannot declare the rung itself, only the human can accept it.
11
+ export const HUMAN_BLOCKER_TYPES = ['irreversivel'];
10
12
  export const CHECK_STATUSES = ['PROVADO', 'NÃO PROVADO', 'FALHA'];
11
13
 
12
14
  export const REVIEW_ROLES = {
@@ -101,11 +103,31 @@ function parseChecks(bodies) {
101
103
  return [...byId.values()];
102
104
  }
103
105
 
104
- /** Um relatório de review lido do texto. `checks` é `null` sem a seção `## Checks`. */
106
+ // A verdict at the start of a line opens a round; one quoted inside a note does not.
107
+ const ROUND_START_RE = /^[ \t>*_]*\*\*Verdict:\*\*/gm;
108
+
109
+ function splitRounds(text) {
110
+ const starts = [...text.matchAll(ROUND_START_RE)].map((m) => m.index);
111
+ if (starts.length < 2) return [text];
112
+ return starts.map((s, i) => text.slice(i === 0 ? 0 : s, starts[i + 1] ?? text.length));
113
+ }
114
+
115
+ /**
116
+ * Um relatório de review lido do texto. `checks` é `null` sem a seção `## Checks`.
117
+ * `rounds`: uma entrada por veredito, na ordem do arquivo, com o que aquela rodada escreveu.
118
+ */
105
119
  export function parseReviewReport(text) {
106
120
  if (typeof text !== 'string') {
107
- return { partial: false, verdict: undefined, blockers: [], untyped: [], superseded: [], checks: null, reviewedSha: null };
121
+ return { partial: false, verdict: undefined, blockers: [], untyped: [], superseded: [], checks: null, reviewedSha: null, rounds: [] };
108
122
  }
123
+ const parsed = splitRounds(text).map(parseRound);
124
+ const last = parsed[parsed.length - 1];
125
+ const rounds = parsed.map((r) => ({ verdict: r.verdict ?? null, reviewedSha: r.reviewedSha, blockers: r.blockers, checks: r.checks }));
126
+ // Blockers belong to the round that wrote them: a confirmation without `## Bloqueadores` closes the earlier ones.
127
+ return { ...parseRound(text), blockers: last.blockers, untyped: last.untyped, rounds };
128
+ }
129
+
130
+ function parseRound(text) {
109
131
  const firstLine = text.split(/\r?\n/).find((l) => l.trim() !== '') ?? '';
110
132
  const verdicts = [...text.matchAll(VERDICT_RE)].map((m) => m[1].toUpperCase());
111
133
  const blockersBody = lastSectionBody(text, BLOCKERS_SECTION_RE);
@@ -182,8 +204,10 @@ export function reviewSignal(reports, { required, checkIds }) {
182
204
  verdict: r.verdict ?? null,
183
205
  blockers: r.blockers.length,
184
206
  blockerIds: r.blockers.map((b) => b.id),
207
+ humanBlockerIds: r.blockers.filter((b) => HUMAN_BLOCKER_TYPES.includes(b.type)).map((b) => b.id),
185
208
  untyped: r.untyped.length,
186
209
  superseded: r.superseded,
210
+ rounds: r.rounds.length,
187
211
  };
188
212
  }
189
213
  out.checks = !required.includes('verifier')
@@ -1,5 +1,5 @@
1
- import { MAX_SLICES } from './plan/checks.js';
2
1
  import { BLOCKER_TYPES, reviewFileName } from './review-reports.js';
2
+ import { FIX_ATTEMPTS } from '../../framework/hooks/shared/next-step.js';
3
3
  import { isTestPath } from './graph/sanitize.js';
4
4
  import { isTestArtifact } from './worktree/ignored-triage.js';
5
5
 
@@ -55,7 +55,7 @@ function firstOf(items) {
55
55
 
56
56
  // Exportada porque approve/advance `auto` recusam pela mesma regra. Sem design de tela não conta,
57
57
  // e o legado chega sem problemas: a auditoria o isenta. Checklist
58
- // completo ainda deve o protótipo e os prints 1280/390 (`prototypes`).
58
+ // completo ainda deve o protótipo, os prints 1280/390 e os estados (`prototypes`).
59
59
  export function uiuxChecklistPause(screenChecklist) {
60
60
  if (screenChecklist?.mockups !== true) return null;
61
61
  const problems = screenChecklist.problems ?? [];
@@ -95,6 +95,11 @@ export function reviewPause(signal) {
95
95
  return `${untyped} bloqueador(es) sem tipo de evidência (${BLOCKER_TYPES.join(' | ')})`
96
96
  + ' — sem evidência tipada não é bloqueador: reclassifique como nota ou complete a evidência';
97
97
  }
98
+ for (const [role, r] of present) {
99
+ if ((r.humanBlockerIds?.length ?? 0) > 0) {
100
+ return `${role} achou mudança irreversível que o plano não declara: ${firstOf(r.humanBlockerIds)} — o usuário decide`;
101
+ }
102
+ }
98
103
  for (const [role, r] of present) {
99
104
  if ((r.blockers ?? 0) > 0) return `${role} abriu ${r.blockers} bloqueador(es): ${firstOf(r.blockerIds ?? [])}`;
100
105
  }
@@ -162,7 +167,78 @@ export function planChecksPause(status) {
162
167
  return `${what} — rode \`morph-spec plan-check {feature}\` até zero erro antes do gate do plano`;
163
168
  }
164
169
 
170
+ export const PAUSING_RUNGS = ['migrar', 'irreversível'];
171
+ const MIGRATION_PATH_RE = /(^|\/)migrations?\//i;
172
+
173
+ export function pausingRungs(checks) {
174
+ const list = Array.isArray(checks?.checks) ? checks.checks : [];
175
+ return list.filter((c) => PAUSING_RUNGS.includes(c.rung)).map((c) => ({ id: c.id, rung: c.rung }));
176
+ }
177
+
178
+ // Só o arquivo que É o contrato. Webhook e endpoint ficam com o revisor (`irreversivel`): o caminho não diz se quebra quem consome.
179
+ const CONTRACT_PATH_RE = /(^|\/)(openapi|swagger)\.(ya?ml|json)$|\.proto$/i;
180
+
181
+ const touchedBy = (re) => (changedFiles = []) =>
182
+ changedFiles.map((f) => f.replaceAll('\\', '/')).filter((f) => re.test(f) && !isTestOnly(f));
183
+ export const migrationsTouched = touchedBy(MIGRATION_PATH_RE);
184
+ export const contractsTouched = touchedBy(CONTRACT_PATH_RE);
185
+
186
+ export const humanApprover = (approvedBy) => !!approvedBy && approvedBy !== 'auto' && !String(approvedBy).startsWith('policy:');
187
+
188
+ // Regra única do gate-check (pausa) e do approve/advance automáticos do plano (recusa).
189
+ export function rungPause(signals) {
190
+ const rungs = signals.rungs ?? [];
191
+ const named = rungs.map((r) => `${r.id} (${r.rung})`).join(', ');
192
+ if (signals.gate === 'plan') {
193
+ return rungs.length ? { signal: 'rung', reason: `${named} não se desfaz com revert — o usuário confirma antes de implementar` } : null;
194
+ }
195
+ if (signals.gate !== 'review') return null;
196
+ const migrations = signals.migrationsTouched ?? [];
197
+ if (migrations.length > 0 && rungs.length === 0) {
198
+ return { signal: 'rung-undeclared', reason: `a feature toca migration (${firstOf(migrations)}) e nenhum cenário declara \`degrau: migrar\` ou \`irreversível\`` };
199
+ }
200
+ const contracts = signals.contractsTouched ?? [];
201
+ if (contracts.length > 0 && !rungs.some((r) => r.rung === 'irreversível')) {
202
+ return { signal: 'rung-undeclared', reason: `a feature toca contrato externo (${firstOf(contracts)}) e nenhum cenário declara \`degrau: irreversível\`` };
203
+ }
204
+ if (rungs.length > 0 && !signals.planApprovedByHuman) return { signal: 'rung', reason: `${named} não passou por aprovação humana no plano` };
205
+ return null;
206
+ }
207
+
208
+ const PLAN_CRITIQUE_REASONS = {
209
+ missing: 'sem `2-plan/critica.md`',
210
+ 'no-hash': '`2-plan/critica.md` sem `**Plan-Hash:**`',
211
+ stale: '`2-plan/critica.md` é de outra versão do plano e nenhum `**Aplicado-em:**` cobre a atual',
212
+ };
213
+
214
+ // Regra única do gate-check (pausa) e do approve/advance automáticos do plano (recusa).
215
+ export function planCritiquePause(status) {
216
+ const what = PLAN_CRITIQUE_REASONS[status];
217
+ return what ? `${what} — despache a crítica em contexto fresco (morph-plan, references/critique.md) antes do gate do plano` : null;
218
+ }
219
+
220
+ // Pauses the session clears by doing the work the reason names; the rest wait for the human.
221
+ const REPORT_SIGNALS = ['review-reports', 'screen-checklist', 'media-claims'];
222
+ export const SESSION_SIGNALS = ['verify-red', 'verify-missing', 'verify-stale', 'e2e-skip', 'plan-checks', 'plan-critique', 'uiux-checklist', ...REPORT_SIGNALS];
223
+
224
+ const reviewRounds = (review) => Math.max(0, ...Object.values(review?.reports ?? {}).map((r) => r.rounds ?? 0));
225
+ const humanBlockers = (review) => Object.values(review?.reports ?? {}).some((r) => (r.humanBlockerIds?.length ?? 0) > 0);
226
+
227
+ export function pauseResolver(signal, signals = {}) {
228
+ if (!SESSION_SIGNALS.includes(signal)) return 'human';
229
+ if (signal === 'verify-red' && (signals.failStreak ?? 0) >= FIX_ATTEMPTS) return 'human';
230
+ if (signal === 'e2e-skip' && classifyE2eSkip(signals.e2eReason) !== 'requested') return 'human';
231
+ if (REPORT_SIGNALS.includes(signal) && reviewRounds(signals.reviewReports) >= FIX_ATTEMPTS) return 'human';
232
+ if (signal === 'review-reports' && humanBlockers(signals.reviewReports)) return 'human';
233
+ return 'session';
234
+ }
235
+
165
236
  export function shouldPauseGate(config, signals) {
237
+ const decision = pauseDecision(config, signals);
238
+ return decision.pause ? { ...decision, resolver: pauseResolver(decision.signal, signals) } : decision;
239
+ }
240
+
241
+ function pauseDecision(config, signals) {
166
242
  const trust = config?.trust ?? 'auto';
167
243
 
168
244
  if (trust === 'manual') {
@@ -301,15 +377,13 @@ export function shouldPauseGate(config, signals) {
301
377
  if (signals.gate === 'plan') {
302
378
  const reason = planChecksPause(signals.planChecks);
303
379
  if (reason) return { pause: true, signal: 'plan-checks', reason };
380
+ const critique = planCritiquePause(signals.planCritique);
381
+ if (critique) return { pause: true, signal: 'plan-critique', reason: critique };
304
382
  }
305
383
 
306
- if (typeof signals.sliceCount === 'number' && signals.sliceCount > MAX_SLICES) {
307
- return {
308
- pause: true,
309
- signal: 'slice-count',
310
- reason: `${signals.sliceCount} fatias, acima de ${MAX_SLICES} — o plano é grande demais para uma feature`,
311
- };
312
- }
384
+ // Por último: o usuário só é chamado pelo degrau quando a sessão não tem mais nada a corrigir.
385
+ const rung = rungPause(signals);
386
+ if (rung) return { pause: true, ...rung };
313
387
 
314
388
  return { pause: false, signal: null, reason: '' };
315
389
  }
@@ -346,8 +420,13 @@ export function getRiskSignals(signals) {
346
420
  if (signals.gate === 'plan' && signals.planChecks && signals.planChecks !== 'fresh') {
347
421
  out.push(`checks.json ${signals.planChecks}`);
348
422
  }
349
- if (typeof signals.sliceCount === 'number' && signals.sliceCount > MAX_SLICES) {
350
- out.push(`${signals.sliceCount} fatias (acima de ${MAX_SLICES})`);
423
+ if (signals.gate === 'plan' && signals.planCritique) out.push(`crítica do plano: ${signals.planCritique}`);
424
+ if ((signals.rungs?.length ?? 0) > 0) out.push(`degrau: ${signals.rungs.map((r) => `${r.id} ${r.rung}`).join(', ')}`);
425
+ if (signals.gate === 'review' && (signals.migrationsTouched?.length ?? 0) > 0) {
426
+ out.push(`migration tocada: ${firstOf(signals.migrationsTouched)}`);
427
+ }
428
+ if (signals.gate === 'review' && (signals.contractsTouched?.length ?? 0) > 0) {
429
+ out.push(`contrato externo tocado: ${firstOf(signals.contractsTouched)}`);
351
430
  }
352
431
  if (signals.gate === 'review' && signals.mediaClaims?.featureGeneratesMedia === true) {
353
432
  out.push(signals.mediaClaims.section === true