@polymorphism-tech/morph-spec 8.47.0 → 9.0.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 (222) hide show
  1. package/CLAUDE.md +48 -39
  2. package/README.md +3 -4
  3. package/bin/morph-spec.js +37 -51
  4. package/bin/validate.js +3 -111
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +41 -101
  9. package/framework/MORPH.md +95 -252
  10. package/framework/agents.json +75 -18
  11. package/framework/commands/morph-apply.md +5 -28
  12. package/framework/commands/morph-archive.md +1 -1
  13. package/framework/commands/morph-hotfix.md +8 -8
  14. package/framework/commands/morph-infra.md +1 -1
  15. package/framework/commands/morph-preflight.md +2 -3
  16. package/framework/commands/morph-proposal.md +2 -50
  17. package/framework/commands/morph-status.md +3 -3
  18. package/framework/docs/CLI.md +585 -286
  19. package/framework/evals/review-checklist.md +82 -41
  20. package/framework/hooks/README.md +10 -11
  21. package/framework/hooks/claude-code/core/comment-guard.js +1 -1
  22. package/framework/hooks/claude-code/core/denylist.json +33 -3
  23. package/framework/hooks/claude-code/core/enforce-phase-writes.js +16 -20
  24. package/framework/hooks/claude-code/core/gate-guard.js +2 -11
  25. package/framework/hooks/claude-code/core/pre-bash-denylist.js +38 -0
  26. package/framework/hooks/claude-code/core/protect-spec-files.js +30 -242
  27. package/framework/hooks/claude-code/core/state-sync.js +7 -21
  28. package/framework/hooks/claude-code/core/telemetry-log.js +3 -3
  29. package/framework/hooks/claude-code/core/validate-completion.js +33 -97
  30. package/framework/hooks/claude-code/core/warn-bash-source-write.js +31 -0
  31. package/framework/hooks/claude-code/core/worktree-context.js +5 -4
  32. package/framework/hooks/claude-code/statusline.py +55 -41
  33. package/framework/hooks/package.json +3 -0
  34. package/framework/hooks/shared/activity-logger.js +1 -1
  35. package/framework/hooks/shared/build-check.js +10 -6
  36. package/framework/hooks/shared/command-tokens.js +169 -0
  37. package/framework/hooks/shared/comment-rules.js +1 -1
  38. package/framework/hooks/shared/comment-scanner.js +10 -23
  39. package/framework/hooks/shared/comment-stripper.js +1 -1
  40. package/framework/hooks/shared/e2e-check.js +64 -27
  41. package/framework/hooks/shared/errata.js +72 -0
  42. package/framework/hooks/shared/feature-locator.js +1 -1
  43. package/framework/hooks/shared/frontend-locations.js +6 -0
  44. package/framework/hooks/shared/gate-question.js +18 -0
  45. package/framework/hooks/shared/phase-utils.js +15 -32
  46. package/framework/hooks/shared/slice-progress.js +118 -0
  47. package/framework/hooks/shared/spawn-command.js +23 -0
  48. package/framework/hooks/shared/state-reader.js +61 -87
  49. package/framework/hooks/shared/state-writer.js +2 -3
  50. package/framework/mcp/registry.json +3 -4
  51. package/framework/phases.json +23 -39
  52. package/framework/schemas/config.schema.json +14 -9
  53. package/framework/schemas/feature.schema.json +15 -50
  54. package/framework/skills/README.md +3 -4
  55. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  56. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  57. package/framework/skills/core/morph-apply/SKILL.md +149 -222
  58. package/framework/skills/core/morph-hotfix/SKILL.md +33 -42
  59. package/framework/skills/core/morph-machine/SKILL.md +2 -2
  60. package/framework/skills/core/morph-media/SKILL.md +1 -1
  61. package/framework/skills/core/morph-plan/SKILL.md +137 -299
  62. package/framework/skills/core/morph-review/SKILL.md +111 -136
  63. package/framework/skills/core/morph-standards/SKILL.md +27 -39
  64. package/framework/skills/core/morph-uiux/SKILL.md +77 -152
  65. package/framework/standards/STANDARDS.json +86 -1218
  66. package/framework/standards/STANDARDS.meta.json +1305 -0
  67. package/framework/standards/ai-agents/evals-with-cache.md +1 -2
  68. package/framework/standards/ai-agents/setup.md +1 -1
  69. package/framework/standards/architecture/code-comments.md +6 -6
  70. package/framework/standards/integration/mcp/mcp-tools.md +7 -7
  71. package/framework/templates/README.md +2 -3
  72. package/framework/templates/REGISTRY.json +6 -170
  73. package/framework/templates/docs/plan.md +72 -0
  74. package/framework/templates/dotnet/ai-kit/README.md +1 -9
  75. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +0 -10
  76. package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +1 -1
  77. package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +1 -1
  78. package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +1 -1
  79. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +3 -4
  80. package/package.json +1 -1
  81. package/src/commands/e2e/e2e.js +2 -2
  82. package/src/commands/observability/cost.js +18 -60
  83. package/src/commands/project/doctor-procs.js +1 -1
  84. package/src/commands/project/doctor.js +9 -9
  85. package/src/commands/project/fleet.js +4 -4
  86. package/src/commands/project/heavy.js +3 -3
  87. package/src/commands/project/init.js +4 -0
  88. package/src/commands/project/setup-infra-cmd.js +2 -1
  89. package/src/commands/project/stack-scan.js +58 -0
  90. package/src/commands/project/standards.js +79 -25
  91. package/src/commands/project/status.js +29 -69
  92. package/src/commands/project/update.js +17 -1
  93. package/src/commands/project/worktree.js +1 -1
  94. package/src/commands/state/advance.js +62 -46
  95. package/src/commands/state/approve.js +81 -141
  96. package/src/commands/state/archive.js +20 -84
  97. package/src/commands/state/auto-approval.js +68 -0
  98. package/src/commands/state/create.js +28 -1
  99. package/src/commands/state/delete.js +1 -1
  100. package/src/commands/state/finish.js +11 -23
  101. package/src/commands/state/gate-check.js +85 -84
  102. package/src/commands/state/gate-decision.js +6 -4
  103. package/src/commands/state/handoff.js +1 -1
  104. package/src/commands/state/list.js +9 -9
  105. package/src/commands/state/report-check.js +157 -311
  106. package/src/commands/state/retype.js +9 -3
  107. package/src/commands/validation/mutate.js +77 -14
  108. package/src/commands/validation/plan-check.js +81 -0
  109. package/src/commands/validation/validate-feature.js +60 -135
  110. package/src/commands/validation/verify.js +40 -72
  111. package/src/core/paths/output-schema.js +19 -66
  112. package/src/core/state/gate-order.js +5 -6
  113. package/src/core/state/lifecycle-audit.js +2 -2
  114. package/src/core/state/state-manager.js +17 -98
  115. package/src/core/templates/template-registry.js +2 -2
  116. package/src/core/templates/template-seed.js +22 -0
  117. package/src/core/work-types.js +10 -10
  118. package/src/dashboard/app/components/ActivityFeed.jsx +2 -3
  119. package/src/dashboard/app/components/CommandPalette.jsx +5 -5
  120. package/src/dashboard/app/components/EntityLink.jsx +1 -1
  121. package/src/dashboard/app/hooks/useMorphQuery.js +1 -1
  122. package/src/dashboard/app/lib/phases.js +2 -4
  123. package/src/dashboard/app/pages/Activity.jsx +1 -1
  124. package/src/dashboard/app/pages/FeatureDetail.jsx +139 -129
  125. package/src/dashboard/app/pages/Features.jsx +9 -9
  126. package/src/dashboard/app/pages/Guide.jsx +2 -2
  127. package/src/dashboard/app/pages/PhasePipeline.jsx +3 -4
  128. package/src/dashboard/app/pages/Standards.jsx +1 -1
  129. package/src/dashboard/scanner/activity-scanner.js +5 -15
  130. package/src/dashboard/scanner/agents-scanner.js +1 -1
  131. package/src/dashboard/scanner/docs-scanner.js +2 -2
  132. package/src/dashboard/scanner/features-scanner.js +95 -60
  133. package/src/dashboard/scanner/project-api.js +1 -1
  134. package/src/dashboard/scanner/standards-scanner.js +2 -1
  135. package/src/lib/ci/ci-verify.js +17 -1
  136. package/src/lib/ci/github-ci.js +1 -1
  137. package/src/lib/detectors/index.js +3 -8
  138. package/src/lib/generators/handoff-generator.js +34 -65
  139. package/src/lib/generators/settings-generator.js +11 -89
  140. package/src/lib/git/evidence-staleness.js +1 -1
  141. package/src/lib/git/tree-fingerprint.js +19 -7
  142. package/src/lib/git/tree-state.js +2 -2
  143. package/src/lib/heavy/cleanup.js +1 -5
  144. package/src/lib/installers/memory-installer.js +6 -8
  145. package/src/lib/mutation/commands.js +3 -2
  146. package/src/lib/{tasks/dotnet-test-introspection.js → mutation/dotnet-introspection.js} +17 -282
  147. package/src/lib/mutation/plan.js +14 -6
  148. package/src/lib/mutation/runner.js +1 -1
  149. package/src/lib/mutation/subset.js +46 -9
  150. package/src/lib/plan/artifact-vocabulary.js +60 -0
  151. package/src/lib/plan/checks.js +234 -0
  152. package/src/lib/plan/plan-parser.js +209 -0
  153. package/src/lib/plan/plan-template.js +18 -0
  154. package/src/lib/review-reports.js +222 -0
  155. package/src/lib/risk-detector.js +131 -140
  156. package/src/lib/specs/adr-index.js +50 -69
  157. package/src/lib/specs/design-seed.js +18 -0
  158. package/src/lib/specs/frontmatter.js +44 -0
  159. package/src/lib/specs/gfm-table.js +17 -0
  160. package/src/lib/specs/old-format.js +68 -0
  161. package/src/lib/specs/revocations.js +37 -95
  162. package/src/lib/specs/screen-checklist.js +18 -17
  163. package/src/lib/stack/dotnet-build-ignores.js +47 -0
  164. package/src/lib/stack/stack-scan.js +267 -0
  165. package/src/lib/standards/registry.js +43 -0
  166. package/src/lib/standards/sections.js +58 -0
  167. package/src/lib/tasks/eval-node.js +4 -12
  168. package/src/lib/tasks/test-nodes.js +1 -74
  169. package/src/lib/tasks/test-runner.js +75 -117
  170. package/src/lib/telemetry/metrics.js +8 -69
  171. package/src/lib/telemetry/telemetry-reader.js +5 -118
  172. package/src/lib/telemetry/verify-event.js +5 -7
  173. package/src/lib/validators/code-budget/code-budget-validator.js +1 -1
  174. package/src/lib/validators/contracts/contract-compliance-validator.js +2 -2
  175. package/src/lib/validators/reuse/reuse-validator.js +22 -45
  176. package/src/lib/validators/traceability-check.js +85 -129
  177. package/src/lib/validators/verify-runner.js +118 -235
  178. package/src/lib/worktree/docker-audit.js +1 -2
  179. package/src/lib/worktree/fleet-scanner.js +25 -38
  180. package/src/scripts/setup-infra.js +9 -0
  181. package/src/utils/agents-installer.js +76 -9
  182. package/src/utils/claude-settings-manager.js +0 -35
  183. package/src/utils/file-copier.js +27 -1
  184. package/src/utils/hooks-installer.js +16 -9
  185. package/src/utils/skills-installer.js +1 -2
  186. package/framework/evals/trace-schema.md +0 -67
  187. package/framework/hooks/claude-code/core/tasks-json-guard.js +0 -83
  188. package/framework/hooks/claude-code/core/trace-autogen.js +0 -277
  189. package/framework/hooks/git/pre-commit/specs.sh +0 -50
  190. package/framework/hooks/shared/tasks-validator.js +0 -111
  191. package/framework/rules/morph-task-tracking.md +0 -54
  192. package/framework/rules/morph-workflow.md +0 -244
  193. package/framework/schemas/tasks.schema.json +0 -79
  194. package/framework/skills/core/morph-brainstorming/SKILL.md +0 -161
  195. package/framework/skills/core/morph-implement/SKILL.md +0 -194
  196. package/framework/skills/core/morph-proposal/SKILL.md +0 -152
  197. package/framework/skills/core/morph-stack-scan/SKILL.md +0 -145
  198. package/framework/templates/docs/proposal.md +0 -192
  199. package/framework/templates/docs/spec.md +0 -57
  200. package/framework/templates/docs/ui-components.md +0 -52
  201. package/framework/templates/docs/ui-design-system.md +0 -76
  202. package/framework/templates/docs/ui-flows.md +0 -167
  203. package/framework/templates/examples/spec-examples.md +0 -91
  204. package/framework/templates/feature/decisions.md +0 -124
  205. package/framework/templates/feature/recap.md +0 -145
  206. package/framework/templates/tasks.template.json +0 -17
  207. package/src/commands/state/usecase.js +0 -143
  208. package/src/commands/tasks/dag.js +0 -181
  209. package/src/lib/agents/output-collector.js +0 -76
  210. package/src/lib/evaluator-report.js +0 -104
  211. package/src/lib/specs/acervo-format.js +0 -70
  212. package/src/lib/specs/acervo-lifecycle.js +0 -264
  213. package/src/lib/specs/done-criteria-check.js +0 -200
  214. package/src/lib/specs/usecase-check.js +0 -301
  215. package/src/lib/specs/usecase-promoter.js +0 -301
  216. package/src/lib/specs/usecase-registry.js +0 -237
  217. package/src/lib/tasks/dispatch-planner.js +0 -319
  218. package/src/lib/tasks/mermaid-renderer.js +0 -132
  219. package/src/lib/tasks/task-classifier.js +0 -30
  220. package/src/lib/tasks/task-parser.js +0 -209
  221. package/src/lib/tasks/tasks-json-parser.js +0 -296
  222. package/src/lib/tasks/test-scope.js +0 -597
@@ -0,0 +1,234 @@
1
+ import { createHash } from 'crypto';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
4
+ import { frozenBody } from '../../../framework/hooks/shared/errata.js';
5
+ import { readAgentsJson } from '../specs/screen-checklist.js';
6
+ import { findArtifactTerms } from './artifact-vocabulary.js';
7
+ import { PROOF_KINDS, REUSE_VERDICTS, ADR_SCOPES, SECTIONS, CONFLICTS_HEADING } from './plan-parser.js';
8
+
9
+ export const CHECKS_VERSION = 1;
10
+ export const MAX_SLICE_FILES = 8;
11
+ export const MAX_SLICES = 12;
12
+ export const MAX_PARALLEL = 4;
13
+
14
+ /** sha256 of the plan without its errata: appending a correction never invalidates checks.json. */
15
+ export function planHash(text) {
16
+ return createHash('sha256').update(frozenBody(text), 'utf8').digest('hex');
17
+ }
18
+
19
+ /**
20
+ * Persona ids the project can dispatch: the installed agents.json, else the package's.
21
+ *
22
+ * @param {string} cwd
23
+ * @returns {string[]|null} null when no agents.json is readable — personas then go unchecked
24
+ */
25
+ export function knownPersonas(cwd) {
26
+ const agents = readAgentsJson(cwd)?.agents;
27
+ return agents && typeof agents === 'object' ? Object.keys(agents) : null;
28
+ }
29
+
30
+ /** The ids on a dependency cycle, or null. */
31
+ export function dependencyCycle(slices) {
32
+ const deps = new Map(slices.map((s) => [s.id, s.depends ?? []]));
33
+ const state = new Map();
34
+ const stack = [];
35
+ const visit = (id) => {
36
+ if (state.get(id) === 'done' || !deps.has(id)) return null;
37
+ if (state.get(id) === 'open') return stack.slice(stack.indexOf(id)).concat(id);
38
+ state.set(id, 'open');
39
+ stack.push(id);
40
+ for (const dep of deps.get(id)) {
41
+ const cycle = visit(dep);
42
+ if (cycle) return cycle;
43
+ }
44
+ stack.pop();
45
+ state.set(id, 'done');
46
+ return null;
47
+ };
48
+ for (const id of deps.keys()) {
49
+ const cycle = visit(id);
50
+ if (cycle) return cycle;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ const fileKey = (f) => f.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
56
+
57
+ /**
58
+ * Waves of slices that may run at the same time: a slice joins a wave only
59
+ * after every slice it depends on ran in an earlier one, and never with a
60
+ * slice that touches one of its files. At most MAX_PARALLEL per wave.
61
+ *
62
+ * @param {{ id: string, depends?: string[], files?: string[] }[]} slices
63
+ * @returns {string[][]} empty when the dependencies have a cycle
64
+ */
65
+ export function parallelGroups(slices) {
66
+ if (dependencyCycle(slices)) return [];
67
+ const ids = new Set(slices.map((s) => s.id));
68
+ const depth = new Map();
69
+ const depthOf = (slice) => {
70
+ if (depth.has(slice.id)) return depth.get(slice.id);
71
+ const parents = (slice.depends ?? []).filter((d) => ids.has(d)).map((d) => slices.find((s) => s.id === d));
72
+ const value = parents.length === 0 ? 0 : 1 + Math.max(...parents.map(depthOf));
73
+ depth.set(slice.id, value);
74
+ return value;
75
+ };
76
+ const layers = [];
77
+ for (const slice of slices) (layers[depthOf(slice)] ??= []).push(slice);
78
+
79
+ const groups = [];
80
+ for (const layer of layers.filter(Boolean)) {
81
+ const open = [];
82
+ for (const slice of layer) {
83
+ const files = new Set((slice.files ?? []).map(fileKey));
84
+ const group = open.find((g) => g.ids.length < MAX_PARALLEL && ![...files].some((f) => g.files.has(f)));
85
+ if (group) {
86
+ group.ids.push(slice.id);
87
+ files.forEach((f) => group.files.add(f));
88
+ } else {
89
+ open.push({ ids: [slice.id], files });
90
+ }
91
+ }
92
+ groups.push(...open.map((g) => g.ids));
93
+ }
94
+ return groups;
95
+ }
96
+
97
+ /**
98
+ * Judge a parsed plan.
99
+ *
100
+ * @param {ReturnType<import('./plan-parser.js').parsePlan>} plan
101
+ * @param {{ personas?: string[]|null, surfaces?: string[]|null }} [known]
102
+ * @returns {{ errors: string[], warnings: string[] }}
103
+ */
104
+ export function validatePlan(plan, { personas = null, surfaces = null } = {}) {
105
+ const errors = [];
106
+ const warnings = [];
107
+ const at = (line) => (line ? ` (linha ${line})` : '');
108
+
109
+ for (const key of ['problem', 'scenarios', 'decisions', 'slices']) {
110
+ if (!plan.present[key]) errors.push(`seção obrigatória ausente: ## ${SECTIONS[key]}`);
111
+ }
112
+ if (plan.present.decisions && !plan.conflicts) {
113
+ errors.push(`### ${CONFLICTS_HEADING} ausente em ## ${SECTIONS.decisions} — escreva "nenhum" quando não houver`);
114
+ } else if (plan.conflicts && !plan.conflicts.text) {
115
+ errors.push(`### ${CONFLICTS_HEADING} está vazio${at(plan.conflicts.line)} — escreva "nenhum" quando não houver`);
116
+ }
117
+ if (plan.sectionsAfterErrata.length > 0) {
118
+ errors.push(`## ${SECTIONS.errata} precisa ser a última seção — depois dela: ${plan.sectionsAfterErrata.join(', ')}`);
119
+ }
120
+
121
+ const duplicated = (items) => items.map((i) => i.id).filter((id, i, all) => all.indexOf(id) !== i);
122
+ for (const [label, items] of [['cenário', plan.scenarios], ['fatia', plan.slices], ['ADR', plan.adrs]]) {
123
+ for (const id of new Set(duplicated(items))) errors.push(`${label} duplicado: ${id}`);
124
+ }
125
+
126
+ if (plan.present.scenarios && plan.scenarios.length === 0) {
127
+ errors.push(`## ${SECTIONS.scenarios} sem nenhum cenário no formato "- **C1** — Dado …, quando …, então …"`);
128
+ }
129
+ for (const c of plan.scenarios) {
130
+ if (c.proof === null) {
131
+ errors.push(`${c.id} sem "prova:" (${PROOF_KINDS.join(' | ')})${at(c.line)}`);
132
+ } else if (!PROOF_KINDS.includes(c.proof.toLowerCase())) {
133
+ errors.push(`${c.id}: prova "${c.proof}" fora de ${PROOF_KINDS.join(' | ')}${at(c.line)}`);
134
+ }
135
+ const terms = findArtifactTerms(c.text);
136
+ if (terms.length > 0 && !c.artifactWhy) {
137
+ errors.push(`${c.id} descreve artefato ("${terms.join(', ')}") sem "> Por que artefato:" — reescreva como efeito observável ou justifique${at(c.line)}`);
138
+ }
139
+ }
140
+
141
+ const scenarioIds = new Set(plan.scenarios.map((c) => c.id));
142
+ const sliceIds = new Set(plan.slices.map((s) => s.id));
143
+ const covered = new Set();
144
+ for (const s of plan.slices) {
145
+ if (!s.persona) {
146
+ errors.push(`${s.id} sem "- persona:"${at(s.line)}`);
147
+ } else if (personas && !personas.includes(s.persona)) {
148
+ errors.push(`${s.id}: persona "${s.persona}" não existe em agents.json (${personas.join(', ')})${at(s.line)}`);
149
+ }
150
+ if (s.files.length > MAX_SLICE_FILES) {
151
+ errors.push(`${s.id} toca ${s.files.length} arquivos — o teto é ${MAX_SLICE_FILES}; divida a fatia${at(s.line)}`);
152
+ }
153
+ for (const c of s.covers) {
154
+ if (scenarioIds.has(c)) covered.add(c);
155
+ else errors.push(`${s.id} cobre ${c}, que não existe em ## ${SECTIONS.scenarios}${at(s.line)}`);
156
+ }
157
+ for (const d of s.depends) {
158
+ if (!sliceIds.has(d)) errors.push(`${s.id} depende de ${d}, que não existe${at(s.line)}`);
159
+ }
160
+ if (s.covers.length === 0) warnings.push(`${s.id} não cobre nenhum cenário${at(s.line)}`);
161
+ if (s.unknownKeys.length > 0) warnings.push(`${s.id}: chave desconhecida ${s.unknownKeys.join(', ')}${at(s.line)}`);
162
+ if (s.surface && surfaces && !surfaces.includes(s.surface)) {
163
+ warnings.push(`${s.id}: superfície "${s.surface}" não é uma de ${surfaces.join(', ')}${at(s.line)}`);
164
+ }
165
+ }
166
+ for (const c of plan.scenarios) {
167
+ if (!covered.has(c.id)) errors.push(`${c.id} não é coberto por nenhuma fatia ("- cobre: ${c.id}")`);
168
+ }
169
+ const cycle = dependencyCycle(plan.slices);
170
+ if (cycle) errors.push(`ciclo em "depende": ${cycle.join(' → ')}`);
171
+ if (plan.slices.length > MAX_SLICES) warnings.push(`${plan.slices.length} fatias — acima de ${MAX_SLICES}, considere dividir a feature`);
172
+
173
+ for (const r of plan.reuse) {
174
+ if (!REUSE_VERDICTS.includes(r.verdict)) {
175
+ errors.push(`## ${SECTIONS.reuse}: veredito "${r.verdict}" de "${r.concept}" fora de ${REUSE_VERDICTS.join(' | ')}${at(r.line)}`);
176
+ } else if (r.verdict === 'CRIAR' && !r.why) {
177
+ warnings.push(`## ${SECTIONS.reuse}: CRIAR "${r.concept}" sem why${at(r.line)}`);
178
+ }
179
+ }
180
+ for (const a of plan.adrs) {
181
+ if (!ADR_SCOPES.includes(a.scope)) warnings.push(`${a.id} sem **Alcance:** ${ADR_SCOPES.join(' | ')}${at(a.line)}`);
182
+ }
183
+
184
+ return { errors, warnings };
185
+ }
186
+
187
+ /** The checks.json document for a parsed plan. */
188
+ export function buildChecks(feature, text, plan) {
189
+ return {
190
+ version: CHECKS_VERSION,
191
+ feature,
192
+ planHash: planHash(text),
193
+ checks: plan.scenarios.map((c) => ({
194
+ id: c.id, text: c.text, proof: c.proof ? c.proof.toLowerCase() : null, artifactWhy: c.artifactWhy || null,
195
+ })),
196
+ slices: plan.slices.map((s) => ({
197
+ id: s.id, title: s.title, persona: s.persona, surface: s.surface,
198
+ standards: s.standards, files: s.files, depends: s.depends, covers: s.covers,
199
+ })),
200
+ reuse: plan.reuse.map(({ concept, verdict, target, why }) => ({ concept, verdict, target, why })),
201
+ adrs: plan.adrs.map((a) => ({ id: a.id, title: a.title, scope: ADR_SCOPES.includes(a.scope) ? a.scope : null })),
202
+ };
203
+ }
204
+
205
+ /**
206
+ * The feature's extracted checks and whether they still describe the plan on
207
+ * disk. plan-check writes them only with zero errors, so `fresh` also means
208
+ * the plan passed every plan-check rule (no slice over MAX_SLICE_FILES, every
209
+ * scenario covered).
210
+ *
211
+ * @returns {{ status: 'fresh'|'no-plan'|'no-checks'|'unreadable'|'stale', checks: object|null }}
212
+ */
213
+ export function readPlanChecks(cwd, featureName) {
214
+ const planPath = getAbsoluteOutputPath(cwd, featureName, 'plan');
215
+ const checksPath = getAbsoluteOutputPath(cwd, featureName, 'checks');
216
+ if (!existsSync(planPath)) return { status: 'no-plan', checks: null };
217
+ if (!existsSync(checksPath)) return { status: 'no-checks', checks: null };
218
+ let checks;
219
+ try {
220
+ checks = JSON.parse(readFileSync(checksPath, 'utf8'));
221
+ } catch {
222
+ return { status: 'unreadable', checks: null };
223
+ }
224
+ if (checks?.version !== CHECKS_VERSION || !Array.isArray(checks.slices) || !Array.isArray(checks.checks)) {
225
+ return { status: 'unreadable', checks: null };
226
+ }
227
+ let plan;
228
+ try {
229
+ plan = readFileSync(planPath, 'utf8');
230
+ } catch {
231
+ return { status: 'no-plan', checks };
232
+ }
233
+ return { status: checks.planHash === planHash(plan) ? 'fresh' : 'stale', checks };
234
+ }
@@ -0,0 +1,209 @@
1
+ import { tableCells, isTableLine, SEPARATOR_ROW_RE } from '../specs/gfm-table.js';
2
+ import { ARTIFACT_MARKER_RE } from './artifact-vocabulary.js';
3
+ import { markdownLines } from '../../../framework/hooks/shared/errata.js';
4
+
5
+ export const PROOF_KINDS = ['teste', 'e2e', 'eval', 'manual'];
6
+ export const REUSE_VERDICTS = ['REUSAR', 'ESTENDER', 'CRIAR'];
7
+ export const ADR_SCOPES = ['feature', 'projeto'];
8
+
9
+ export const SECTIONS = {
10
+ problem: 'Problema',
11
+ outOfScope: 'Fora de escopo',
12
+ assumptions: 'Suposições não confirmadas',
13
+ scenarios: 'Cenários',
14
+ decisions: 'Decisões',
15
+ architecture: 'Arquitetura',
16
+ reuse: 'Reuso',
17
+ slices: 'Fatias',
18
+ contract: 'Contrato de implementação',
19
+ errata: 'Errata',
20
+ };
21
+
22
+ export const CONFLICTS_HEADING = 'Conflitos com o acervo';
23
+
24
+ const SLICE_KEYS = {
25
+ persona: 'persona',
26
+ 'superfície': 'surface',
27
+ superficie: 'surface',
28
+ standards: 'standards',
29
+ arquivos: 'files',
30
+ depende: 'depends',
31
+ cobre: 'covers',
32
+ };
33
+
34
+ const DASH = String.raw`\s+[—–-]\s+`;
35
+ const SCENARIO_RE = new RegExp(String.raw`^[-*]\s+\*\*(C\d+)\*\*${DASH}(.+)$`);
36
+ const ADR_RE = new RegExp(String.raw`^###\s+(ADR-\d+)${DASH}(.+?)\s*$`);
37
+ const SLICE_RE = new RegExp(String.raw`^###\s+(F\d+)${DASH}(.+?)\s*$`);
38
+ const PROOF_RE = /^\s*prova\s*:\s*(.*?)\s*$/i;
39
+ const WHY_RE = new RegExp(String.raw`^\s*>\s*` + ARTIFACT_MARKER_RE.source + String.raw`\s*(.*?)\s*$`, 'i');
40
+ const SCOPE_RE = /^\s*\*\*Alcance:\*\*\s*(.*?)\s*$/i;
41
+ const SLICE_LINE_RE = /^\s*[-*]\s+([\p{L}]+)\s*:\s*(.*?)\s*$/u;
42
+
43
+ const fold = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim();
44
+
45
+ const listOf = (value) => value
46
+ .split(',')
47
+ .map((v) => v.replace(/`/g, '').trim())
48
+ .filter((v) => v && !/^[—–-]$/.test(v) && fold(v) !== 'nenhum' && fold(v) !== 'nenhuma');
49
+
50
+ function splitSections(lines) {
51
+ const sections = [];
52
+ let current = null;
53
+ for (const line of lines) {
54
+ const h2 = !line.fenced && /^##\s+(.+?)\s*$/.exec(line.raw);
55
+ if (h2 && !line.raw.startsWith('###')) {
56
+ current = { name: h2[1], line: line.n, lines: [] };
57
+ sections.push(current);
58
+ continue;
59
+ }
60
+ if (current) current.lines.push(line);
61
+ }
62
+ return sections;
63
+ }
64
+
65
+ function parseScenarios(lines) {
66
+ const scenarios = [];
67
+ let current = null;
68
+ for (const line of lines) {
69
+ if (line.fenced) { current = null; continue; }
70
+ const m = SCENARIO_RE.exec(line.raw);
71
+ if (m) {
72
+ current = { id: m[1], text: m[2].trim(), proof: null, artifactWhy: null, line: line.n };
73
+ scenarios.push(current);
74
+ continue;
75
+ }
76
+ if (!current) continue;
77
+ if (/^\s*$/.test(line.raw) || /^#/.test(line.raw) || /^[-*]\s/.test(line.raw)) { current = null; continue; }
78
+ const why = WHY_RE.exec(line.raw);
79
+ if (why) { current.artifactWhy = why[1] || ''; continue; }
80
+ const proof = PROOF_RE.exec(line.raw);
81
+ if (proof && current.proof === null) { current.proof = proof[1]; continue; }
82
+ if (/^\s*>/.test(line.raw)) continue;
83
+ current.text = `${current.text} ${line.raw.trim()}`;
84
+ }
85
+ return scenarios;
86
+ }
87
+
88
+ function parseDecisions(lines) {
89
+ const adrs = [];
90
+ let conflicts = null;
91
+ let current = null;
92
+ for (const line of lines) {
93
+ if (line.fenced) {
94
+ if (current?.kind === 'conflicts') conflicts.body.push(line.raw);
95
+ else if (current?.kind === 'adr') current.entry.body.push(line.raw);
96
+ continue;
97
+ }
98
+ const adr = ADR_RE.exec(line.raw);
99
+ if (adr) {
100
+ current = { kind: 'adr', entry: { id: adr[1], title: adr[2], scope: null, body: [], line: line.n } };
101
+ adrs.push(current.entry);
102
+ continue;
103
+ }
104
+ const h3 = /^###\s+(.+?)\s*$/.exec(line.raw);
105
+ if (h3) {
106
+ if (fold(h3[1]) === fold(CONFLICTS_HEADING)) {
107
+ conflicts = { line: line.n, body: [] };
108
+ current = { kind: 'conflicts' };
109
+ } else {
110
+ current = null;
111
+ }
112
+ continue;
113
+ }
114
+ if (current?.kind === 'adr') {
115
+ current.entry.body.push(line.raw);
116
+ const scope = current.entry.scope === null && SCOPE_RE.exec(line.raw);
117
+ if (scope) current.entry.scope = scope[1].replace(/`/g, '').trim().toLowerCase();
118
+ } else if (current?.kind === 'conflicts') {
119
+ conflicts.body.push(line.raw);
120
+ }
121
+ }
122
+ for (const a of adrs) a.body = a.body.join('\n').trim();
123
+ return {
124
+ adrs,
125
+ conflicts: conflicts && { line: conflicts.line, text: conflicts.body.join('\n').trim() },
126
+ };
127
+ }
128
+
129
+ function parseReuse(lines) {
130
+ const rows = [];
131
+ let header = null;
132
+ for (const line of lines) {
133
+ if (line.fenced || !isTableLine(line.raw) || SEPARATOR_ROW_RE.test(line.raw)) continue;
134
+ const cells = tableCells(line.raw);
135
+ if (!header) { header = cells.map(fold); continue; }
136
+ const at = (name) => cells[header.indexOf(name)] ?? '';
137
+ rows.push({
138
+ concept: at('conceito'),
139
+ verdict: at('veredito').replace(/`|\*/g, '').trim().toUpperCase(),
140
+ target: at('alvo'),
141
+ why: at('why'),
142
+ line: line.n,
143
+ });
144
+ }
145
+ return { header, rows };
146
+ }
147
+
148
+ function parseSlices(lines) {
149
+ const slices = [];
150
+ let current = null;
151
+ for (const line of lines) {
152
+ if (line.fenced) continue;
153
+ const m = SLICE_RE.exec(line.raw);
154
+ if (m) {
155
+ current = {
156
+ id: m[1], title: m[2], persona: null, surface: null,
157
+ standards: [], files: [], depends: [], covers: [], unknownKeys: [], line: line.n,
158
+ };
159
+ slices.push(current);
160
+ continue;
161
+ }
162
+ if (/^###\s/.test(line.raw)) { current = null; continue; }
163
+ if (!current) continue;
164
+ const kv = SLICE_LINE_RE.exec(line.raw);
165
+ if (!kv) continue;
166
+ const key = SLICE_KEYS[fold(kv[1])] ?? SLICE_KEYS[kv[1].toLowerCase()];
167
+ if (!key) { current.unknownKeys.push(kv[1]); continue; }
168
+ if (key === 'persona' || key === 'surface') {
169
+ const value = kv[2].replace(/`/g, '').trim();
170
+ current[key] = value && !/^[—–-]$/.test(value) ? value : null;
171
+ } else {
172
+ current[key] = listOf(kv[2]);
173
+ }
174
+ }
175
+ return slices;
176
+ }
177
+
178
+ /**
179
+ * Parse `2-plan/plan.md` into its structure. Pure: no validation, no I/O —
180
+ * `checks.js` judges what this returns.
181
+ *
182
+ * @param {string} text
183
+ */
184
+ export function parsePlan(text) {
185
+ const lines = markdownLines(text);
186
+ const sections = splitSections(lines);
187
+ const byName = new Map();
188
+ for (const s of sections) {
189
+ const key = Object.keys(SECTIONS).find((k) => fold(SECTIONS[k]) === fold(s.name));
190
+ if (key && !byName.has(key)) byName.set(key, s);
191
+ }
192
+ const body = (key) => byName.get(key)?.lines ?? [];
193
+ const decisions = parseDecisions(body('decisions'));
194
+ const reuse = parseReuse(body('reuse'));
195
+ const errata = byName.get('errata');
196
+
197
+ return {
198
+ sections: sections.map((s) => ({ name: s.name, line: s.line, key: [...byName].find(([, v]) => v === s)?.[0] ?? null })),
199
+ present: Object.fromEntries(Object.keys(SECTIONS).map((k) => [k, byName.has(k)])),
200
+ scenarios: parseScenarios(body('scenarios')),
201
+ adrs: decisions.adrs,
202
+ conflicts: decisions.conflicts,
203
+ reuse: reuse.rows,
204
+ reuseHeader: reuse.header,
205
+ slices: parseSlices(body('slices')),
206
+ errata: errata ? errata.lines.map((l) => l.raw).join('\n').trim() : null,
207
+ sectionsAfterErrata: errata ? sections.filter((s) => s.line > errata.line).map((s) => s.name) : [],
208
+ };
209
+ }
@@ -0,0 +1,18 @@
1
+ import { readFileSync } from 'fs';
2
+ import { templateSourcePath, writeIfAbsent } from '../../core/templates/template-seed.js';
3
+ import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
4
+
5
+ const FEATURE_PLACEHOLDER = /\{\{FEATURE_NAME\}\}/g;
6
+
7
+ export function planTemplatePath(cwd = process.cwd()) {
8
+ return templateSourcePath('plan', cwd);
9
+ }
10
+
11
+ export function renderPlanSkeleton(featureName, cwd = process.cwd()) {
12
+ return readFileSync(planTemplatePath(cwd), 'utf8').replace(FEATURE_PLACEHOLDER, featureName);
13
+ }
14
+
15
+ /** @returns {{ path: string, written: boolean }} `written: false` when the file already existed. */
16
+ export function writePlanSkeleton(featureName, cwd = process.cwd()) {
17
+ return writeIfAbsent(getAbsoluteOutputPath(cwd, featureName, 'plan'), renderPlanSkeleton(featureName, cwd));
18
+ }
@@ -0,0 +1,222 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { OUTPUT_SCHEMA, getAbsoluteOutputPath } from '../core/paths/output-schema.js';
4
+ import { PARTIAL_MARKER } from '../../framework/hooks/shared/slice-progress.js';
5
+
6
+ export const VERDICT_TOKEN = '**Verdict:**';
7
+ export const REVIEWED_SHA_TOKEN = '**Reviewed-SHA:**';
8
+ export const VERDICTS = ['SHIP', 'FIX'];
9
+ export const BLOCKER_TYPES = ['check', 'teste', 'regressao', 'seguranca', 'escopo', 'arquitetura', 'performance'];
10
+ export const CHECK_STATUSES = ['PROVADO', 'NÃO PROVADO', 'FALHA'];
11
+
12
+ export const REVIEW_ROLES = {
13
+ verifier: { output: 'verifierReport', persona: 'morph-verifier' },
14
+ reviewer: { output: 'reviewerReport', persona: 'morph-reviewer' },
15
+ security: { output: 'securityReport', persona: 'morph-security' },
16
+ };
17
+ const ROLE_ORDER = Object.keys(REVIEW_ROLES);
18
+
19
+ export const reviewFileName = (role) => OUTPUT_SCHEMA[REVIEW_ROLES[role].output].filename;
20
+
21
+ // Append-only: uma reavaliação acrescenta ao fim, e vale o ÚLTIMO token/seção.
22
+ const VERDICT_RE = /\*\*Verdict:\*\*\s*([A-Za-z]+)/g;
23
+ const REVIEWED_SHA_RE = /\*\*Reviewed-SHA:\*\*\s*`?([0-9a-f]{7,40})/gi;
24
+ const sectionRe = (title) => new RegExp(`^##\\s+${title}\\s*$`, 'gim');
25
+ const BLOCKERS_SECTION_RE = sectionRe('Bloqueadores');
26
+ const CHECKS_SECTION_RE = sectionRe('Checks');
27
+ const MEDIA_SECTION_RE = /^##\s+M[íi]dia\s+gerada\s*$/m;
28
+ const SCREEN_CHECKLIST_SECTION_RE = sectionRe('Checklist\\s+de\\s+tela');
29
+
30
+ /** Hotfix: só o verificador, escrito pelo orquestrador sem dispatch. */
31
+ export function requiredReviewRoles({ workType, securityRequired = false } = {}) {
32
+ if (workType === 'hotfix') return ['verifier'];
33
+ return securityRequired ? ['verifier', 'reviewer', 'security'] : ['verifier', 'reviewer'];
34
+ }
35
+
36
+ function sectionBodies(text, re) {
37
+ return [...text.matchAll(re)].map((m) => {
38
+ const rest = text.slice(m.index + m[0].length);
39
+ const next = rest.search(/^#{1,2}\s/m);
40
+ return next === -1 ? rest : rest.slice(0, next);
41
+ });
42
+ }
43
+
44
+ function lastSectionBody(text, re) {
45
+ const bodies = sectionBodies(text, re);
46
+ return bodies.length ? bodies[bodies.length - 1] : null;
47
+ }
48
+
49
+ const normalize = (s) => String(s ?? '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/\s+/g, ' ').trim();
50
+ const cleanId = (s) => String(s ?? '').replace(/[*`_]/g, '').trim().toUpperCase();
51
+
52
+ function tableRows(body) {
53
+ const rows = [];
54
+ for (const line of body.split(/\r?\n/)) {
55
+ if (!/^\s*\|/.test(line)) continue;
56
+ const cells = line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
57
+ if (cells.every((c) => /^:?-{2,}:?$/.test(c) || c === '')) continue;
58
+ rows.push(cells);
59
+ }
60
+ return rows;
61
+ }
62
+
63
+ /** `| id | tipo | onde | evidência |`; `untyped` = tipo fora de {@link BLOCKER_TYPES}. */
64
+ function parseBlockers(body) {
65
+ const blockers = [];
66
+ const untyped = [];
67
+ for (const cells of tableRows(body)) {
68
+ if (/^#$|^id$/i.test(cells[0]) && /^tipo$/i.test(cells[1] ?? '')) continue;
69
+ const row = { id: cleanId(cells[0]), type: normalize(cells[1]), where: cells[2] ?? '', evidence: cells[3] ?? '' };
70
+ (BLOCKER_TYPES.includes(row.type) ? blockers : untyped).push(row);
71
+ }
72
+ return { blockers, untyped };
73
+ }
74
+
75
+ const STATUS_BY_KEY = Object.fromEntries(CHECK_STATUSES.map((s) => [normalize(s), s]));
76
+
77
+ /**
78
+ * `| Cn | PROVADO|NÃO PROVADO|FALHA | evidência |` de TODAS as seções: uma confirmação
79
+ * traz só os Cn que mudou, então vale a última linha de cada Cn. Status por igualdade exata.
80
+ */
81
+ function parseChecks(bodies) {
82
+ const byId = new Map();
83
+ for (const cells of bodies.flatMap(tableRows)) {
84
+ const id = cleanId(cells[0]);
85
+ if (!/^C\d+$/.test(id)) continue;
86
+ const raw = String(cells[1] ?? '').replace(/[*`]/g, '').trim();
87
+ byId.delete(id);
88
+ byId.set(id, { id, status: STATUS_BY_KEY[normalize(raw)] ?? raw, evidence: cells[2] ?? '' });
89
+ }
90
+ return [...byId.values()];
91
+ }
92
+
93
+ /** Um relatório de review lido do texto. `checks` é `null` sem a seção `## Checks`. */
94
+ export function parseReviewReport(text) {
95
+ if (typeof text !== 'string') {
96
+ return { partial: false, verdict: undefined, blockers: [], untyped: [], superseded: [], checks: null, reviewedSha: null };
97
+ }
98
+ const firstLine = text.split(/\r?\n/).find((l) => l.trim() !== '') ?? '';
99
+ const verdicts = [...text.matchAll(VERDICT_RE)].map((m) => m[1].toUpperCase());
100
+ const blockersBody = lastSectionBody(text, BLOCKERS_SECTION_RE);
101
+ const checksBodies = sectionBodies(text, CHECKS_SECTION_RE);
102
+ const shas = [...text.matchAll(REVIEWED_SHA_RE)].map((m) => m[1]);
103
+ return {
104
+ partial: PARTIAL_MARKER.test(firstLine),
105
+ verdict: verdicts.length ? verdicts[verdicts.length - 1] : undefined,
106
+ ...(blockersBody === null ? { blockers: [], untyped: [] } : parseBlockers(blockersBody)),
107
+ superseded: verdicts.slice(0, -1),
108
+ checks: checksBodies.length === 0 ? null : parseChecks(checksBodies),
109
+ reviewedSha: shas.length ? shas[shas.length - 1] : null,
110
+ };
111
+ }
112
+
113
+ /**
114
+ * `missing`: sem arquivo. `partial`: `PARCIAL` na 1ª linha, com ou sem veredito.
115
+ * `unverdicted`: sem `**Verdict:** SHIP|FIX`. `ok`: legível — quem decide é o risk-detector.
116
+ */
117
+ function stateOf(path, text) {
118
+ if (text === null) return { status: 'missing', path };
119
+ const parsed = parseReviewReport(text);
120
+ if (parsed.partial) return { status: 'partial', path, ...parsed };
121
+ if (!VERDICTS.includes(parsed.verdict)) return { status: 'unverdicted', path, ...parsed };
122
+ return { status: 'ok', path, ...parsed };
123
+ }
124
+
125
+ function readText(path) {
126
+ try {
127
+ return existsSync(path) ? readFileSync(path, 'utf8') : null;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /** Os três relatórios a partir do diretório da feature — sem git; o dashboard lê árvores arquivadas. */
134
+ export function reviewReportsFromDir(featureDir) {
135
+ const out = {};
136
+ for (const role of ROLE_ORDER) {
137
+ const path = join(featureDir, '4-review', reviewFileName(role));
138
+ out[role] = stateOf(path, readText(path));
139
+ }
140
+ return out;
141
+ }
142
+
143
+ export function reviewReportPath(cwd, featureName, role) {
144
+ return getAbsoluteOutputPath(cwd, featureName, REVIEW_ROLES[role].output);
145
+ }
146
+
147
+ export function reviewReports(cwd, featureName) {
148
+ return reviewReportsFromDir(join(cwd, '.morph', 'features', featureName));
149
+ }
150
+
151
+ /**
152
+ * Todo Cn precisa de uma linha, e só `PROVADO` cobre.
153
+ *
154
+ * @param {string[]} checkIds
155
+ * @param {{id: string, status: string}[]|null} rows - `parseReviewReport(...).checks`
156
+ * @returns {{ missing: string[], notProven: {id: string, status: string}[] }}
157
+ */
158
+ export function checksCoverage(checkIds, rows) {
159
+ const byId = new Map((rows ?? []).map((r) => [r.id, r]));
160
+ const missing = [];
161
+ const notProven = [];
162
+ for (const id of checkIds.map(cleanId)) {
163
+ const row = byId.get(id);
164
+ if (!row) missing.push(id);
165
+ else if (row.status !== 'PROVADO') notProven.push({ id, status: row.status || 'vazio' });
166
+ }
167
+ return { missing, notProven };
168
+ }
169
+
170
+ /**
171
+ * O sinal que `reviewPause` (risk-detector) lê; `checkIds: null` = cobertura não conferível.
172
+ *
173
+ * @param {ReturnType<typeof reviewReportsFromDir>} reports
174
+ * @param {{ required: string[], checkIds: string[]|null }} opts
175
+ */
176
+ export function reviewSignal(reports, { required, checkIds }) {
177
+ const out = { required, reports: {} };
178
+ for (const role of ROLE_ORDER) {
179
+ const r = reports[role];
180
+ if (!required.includes(role) && r.status === 'missing') continue;
181
+ out.reports[role] = r.status === 'missing'
182
+ ? { status: 'missing' }
183
+ : {
184
+ status: r.status,
185
+ verdict: r.verdict ?? null,
186
+ blockers: r.blockers.length,
187
+ blockerIds: r.blockers.map((b) => b.id),
188
+ untyped: r.untyped.length,
189
+ superseded: r.superseded,
190
+ };
191
+ }
192
+ out.checks = checkIds === null
193
+ ? { status: 'no-checks', missing: [], notProven: [] }
194
+ : { status: 'ok', total: checkIds.length, ...checksCoverage(checkIds, reports.verifier.checks) };
195
+ return out;
196
+ }
197
+
198
+ /**
199
+ * Veredito combinado para leitura (dashboard, list): FIX se qualquer relatório
200
+ * presente diz FIX ou tem bloqueador; SHIP só com todo exigido em SHIP.
201
+ */
202
+ export function combinedVerdict(reports, required) {
203
+ const present = ROLE_ORDER.filter((r) => reports[r].status !== 'missing');
204
+ if (present.some((r) => reports[r].verdict === 'FIX' || reports[r].blockers.length > 0)) return 'FIX';
205
+ return required.every((r) => reports[r].status === 'ok' && reports[r].verdict === 'SHIP') ? 'SHIP' : null;
206
+ }
207
+
208
+ /** Todos os bloqueadores tipados dos relatórios presentes, com o papel que os abriu. */
209
+ export function allBlockers(reports) {
210
+ return ROLE_ORDER.flatMap((role) => (reports[role].blockers ?? []).map((b) => ({ ...b, role })));
211
+ }
212
+
213
+ export function verifierMediaSection(cwd, featureName) {
214
+ const text = readText(reviewReportPath(cwd, featureName, 'verifier'));
215
+ return text !== null && MEDIA_SECTION_RE.test(text);
216
+ }
217
+
218
+ export function verifierScreenChecklist(cwd, featureName) {
219
+ const text = readText(reviewReportPath(cwd, featureName, 'verifier'));
220
+ const body = text === null ? null : lastSectionBody(text, SCREEN_CHECKLIST_SECTION_RE);
221
+ return body === null ? { section: false, body: '' } : { section: true, body };
222
+ }