atlas-workflow 0.9.1 → 0.9.3

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 (169) hide show
  1. package/README.md +5 -2
  2. package/VERSION +1 -1
  3. package/build/bump-version.mjs +6 -21
  4. package/build/cli/atlas-init.mjs +92 -5
  5. package/build/tests/classify-findings.test.mjs +20 -0
  6. package/build/tests/etapa3.test.mjs +161 -0
  7. package/build/tests/test_classify_findings.py +79 -0
  8. package/hosts/opencode/.opencode/agents/atlas-findings-repair.md +4 -0
  9. package/hosts/opencode/.opencode/agents/atlas-task-validator.md +18 -1
  10. package/hosts/opencode/.opencode/atlas/VERSION +1 -1
  11. package/hosts/opencode/.opencode/atlas/orchestrator/README.md +7 -5
  12. package/hosts/opencode/.opencode/atlas/orchestrator/commands/workflow.md +1 -1
  13. package/hosts/opencode/.opencode/atlas/orchestrator/references/host-adapters.md +13 -12
  14. package/hosts/opencode/.opencode/atlas/orchestrator/skills/atlas-workflow-orchestrator/SKILL.md +24 -17
  15. package/hosts/opencode/.opencode/atlas/packages/mcp-server/README.md +1 -1
  16. package/hosts/opencode/.opencode/atlas/packages/mcp-server/package.json +1 -1
  17. package/hosts/opencode/.opencode/atlas/packages/mcp-server/server.js +514 -20
  18. package/hosts/opencode/.opencode/atlas/packages/templates/BACKLOG_MESTRE_TEMPLATE.md +14 -3
  19. package/hosts/opencode/.opencode/atlas/packages/templates/PRD_TEMPLATE.md +2 -1
  20. package/hosts/opencode/.opencode/atlas/packages/templates/STATE_FILE_SCHEMA.md +25 -1
  21. package/hosts/opencode/.opencode/skills/_shared/references/stack-profiles.md +36 -0
  22. package/hosts/opencode/.opencode/skills/_shared/scripts/document_quality.mjs +252 -0
  23. package/hosts/opencode/.opencode/skills/atlas-backlog-generator/SKILL.md +7 -2
  24. package/hosts/opencode/.opencode/skills/atlas-direct-execute/SKILL.md +6 -2
  25. package/hosts/opencode/.opencode/skills/atlas-findings-repair/SKILL.md +11 -1
  26. package/hosts/opencode/.opencode/skills/atlas-plan-execute/SKILL.md +16 -2
  27. package/hosts/opencode/.opencode/skills/atlas-plan-handoff/SKILL.md +6 -4
  28. package/hosts/opencode/.opencode/skills/atlas-prd-interview/SKILL.md +7 -2
  29. package/hosts/opencode/.opencode/skills/atlas-slice-review/SKILL.md +37 -2
  30. package/hosts/opencode/.opencode/skills/atlas-slice-review/references/scenario-lenses.md +8 -0
  31. package/hosts/opencode/.opencode/skills/atlas-slice-review/scripts/classify_findings.mjs +60 -0
  32. package/hosts/opencode/.opencode/skills/atlas-slice-review/scripts/classify_findings.py +9 -41
  33. package/hosts/opencode/.opencode/skills/atlas-sprint-prd-generator/SKILL.md +7 -4
  34. package/hosts/opencode/.opencode/skills/atlas-task-validator/SKILL.md +29 -14
  35. package/hosts/opencode/.opencode/skills/atlas-workflow-orchestrator/SKILL.md +24 -17
  36. package/hosts/pi/.pi/agents/atlas-direct-execute.md +6 -2
  37. package/hosts/pi/.pi/agents/atlas-findings-repair.md +15 -1
  38. package/hosts/pi/.pi/agents/atlas-plan-execute.md +16 -2
  39. package/hosts/pi/.pi/agents/atlas-slice-review.md +37 -2
  40. package/hosts/pi/.pi/agents/atlas-task-validator.md +18 -1
  41. package/hosts/pi/atlas/VERSION +1 -1
  42. package/hosts/pi/atlas/orchestrator/README.md +7 -5
  43. package/hosts/pi/atlas/orchestrator/commands/workflow.md +1 -1
  44. package/hosts/pi/atlas/orchestrator/references/host-adapters.md +13 -12
  45. package/hosts/pi/atlas/orchestrator/skills/atlas-workflow-orchestrator/SKILL.md +24 -17
  46. package/hosts/pi/atlas/packages/mcp-server/README.md +1 -1
  47. package/hosts/pi/atlas/packages/mcp-server/package.json +1 -1
  48. package/hosts/pi/atlas/packages/mcp-server/server.js +514 -20
  49. package/hosts/pi/atlas/packages/templates/BACKLOG_MESTRE_TEMPLATE.md +14 -3
  50. package/hosts/pi/atlas/packages/templates/PRD_TEMPLATE.md +2 -1
  51. package/hosts/pi/atlas/packages/templates/STATE_FILE_SCHEMA.md +25 -1
  52. package/hosts/pi/skills/_shared/references/stack-profiles.md +36 -0
  53. package/hosts/pi/skills/_shared/scripts/document_quality.mjs +252 -0
  54. package/hosts/pi/skills/atlas-backlog-generator/SKILL.md +7 -2
  55. package/hosts/pi/skills/atlas-direct-execute/SKILL.md +6 -2
  56. package/hosts/pi/skills/atlas-findings-repair/SKILL.md +11 -1
  57. package/hosts/pi/skills/atlas-plan-execute/SKILL.md +16 -2
  58. package/hosts/pi/skills/atlas-plan-handoff/SKILL.md +6 -4
  59. package/hosts/pi/skills/atlas-prd-interview/SKILL.md +7 -2
  60. package/hosts/pi/skills/atlas-slice-review/SKILL.md +37 -2
  61. package/hosts/pi/skills/atlas-slice-review/references/scenario-lenses.md +8 -0
  62. package/hosts/pi/skills/atlas-slice-review/scripts/classify_findings.mjs +60 -0
  63. package/hosts/pi/skills/atlas-slice-review/scripts/classify_findings.py +9 -41
  64. package/hosts/pi/skills/atlas-sprint-prd-generator/SKILL.md +7 -4
  65. package/hosts/pi/skills/atlas-task-validator/SKILL.md +29 -14
  66. package/hosts/pi/skills/atlas-workflow-orchestrator/SKILL.md +24 -17
  67. package/hosts/zcode/.zcode-plugin/plugin.json +27 -0
  68. package/hosts/zcode/agents/atlas-direct-execute.md +31 -0
  69. package/hosts/zcode/agents/atlas-findings-repair.md +39 -0
  70. package/hosts/zcode/agents/atlas-plan-execute.md +33 -0
  71. package/hosts/zcode/agents/atlas-slice-review.md +27 -0
  72. package/hosts/zcode/agents/atlas-task-validator.md +138 -0
  73. package/hosts/zcode/packages/mcp-server/README.md +29 -0
  74. package/hosts/zcode/packages/mcp-server/VERSION +1 -0
  75. package/hosts/zcode/packages/mcp-server/package.json +15 -0
  76. package/hosts/zcode/packages/mcp-server/server.js +3897 -0
  77. package/hosts/zcode/packages/orchestrator/README.md +270 -0
  78. package/hosts/zcode/packages/orchestrator/commands/workflow.md +37 -0
  79. package/hosts/zcode/packages/orchestrator/defaults/paths.md +21 -0
  80. package/hosts/zcode/packages/orchestrator/references/host-adapters.md +106 -0
  81. package/hosts/zcode/packages/orchestrator/references/qa_s13_matrix.md +141 -0
  82. package/hosts/zcode/packages/orchestrator/references/subagent_dispatch.md +42 -0
  83. package/hosts/zcode/packages/orchestrator/skills/atlas-workflow-orchestrator/SKILL.md +391 -0
  84. package/hosts/zcode/packages/templates/BACKLOG_MESTRE_TEMPLATE.md +855 -0
  85. package/hosts/zcode/packages/templates/BOUNDARY_PRD_PLAN.md +93 -0
  86. package/hosts/zcode/packages/templates/PERGUNTAS_EM_ABERTO_TEMPLATE.md +139 -0
  87. package/hosts/zcode/packages/templates/PLAN_TEMPLATE.md +146 -0
  88. package/hosts/zcode/packages/templates/PRD_TEMPLATE.md +150 -0
  89. package/hosts/zcode/packages/templates/STATE_FILE_SCHEMA.md +56 -0
  90. package/hosts/zcode/skills/_shared/references/stack-profiles.md +36 -0
  91. package/hosts/zcode/skills/_shared/scripts/document_quality.mjs +252 -0
  92. package/hosts/zcode/skills/atlas-backlog-generator/SKILL.md +93 -0
  93. package/hosts/zcode/skills/atlas-backlog-generator/agents/openai.yaml +4 -0
  94. package/hosts/zcode/skills/atlas-direct-execute/SKILL.md +221 -0
  95. package/hosts/zcode/skills/atlas-direct-execute/agents/openai.yaml +7 -0
  96. package/hosts/zcode/skills/atlas-findings-repair/SKILL.md +158 -0
  97. package/hosts/zcode/skills/atlas-findings-repair/agents/openai.yaml +7 -0
  98. package/hosts/zcode/skills/atlas-plan-execute/SKILL.md +175 -0
  99. package/hosts/zcode/skills/atlas-plan-execute/agents/openai.yaml +7 -0
  100. package/hosts/zcode/skills/atlas-plan-execute/references/plan-contract.md +88 -0
  101. package/hosts/zcode/skills/atlas-plan-execute/references/quality-gates.md +60 -0
  102. package/hosts/zcode/skills/atlas-plan-execute/scripts/check_budget_state.py +96 -0
  103. package/hosts/zcode/skills/atlas-plan-execute/scripts/extract_plan_contract.py +191 -0
  104. package/hosts/zcode/skills/atlas-plan-execute/scripts/validate_gate_result.py +56 -0
  105. package/hosts/zcode/skills/atlas-plan-handoff/SKILL.md +183 -0
  106. package/hosts/zcode/skills/atlas-plan-handoff/agents/openai.yaml +7 -0
  107. package/hosts/zcode/skills/atlas-prd-interview/SKILL.md +82 -0
  108. package/hosts/zcode/skills/atlas-prd-interview/agents/openai.yaml +7 -0
  109. package/hosts/zcode/skills/atlas-slice-review/SKILL.md +156 -0
  110. package/hosts/zcode/skills/atlas-slice-review/agents/openai.yaml +4 -0
  111. package/hosts/zcode/skills/atlas-slice-review/references/review-contract.md +58 -0
  112. package/hosts/zcode/skills/atlas-slice-review/references/scenario-lenses.md +57 -0
  113. package/hosts/zcode/skills/atlas-slice-review/scripts/classify_findings.mjs +60 -0
  114. package/hosts/zcode/skills/atlas-slice-review/scripts/classify_findings.py +24 -0
  115. package/hosts/zcode/skills/atlas-slice-review/scripts/extract_review_slice.py +158 -0
  116. package/hosts/zcode/skills/atlas-sprint-prd-generator/SKILL.md +77 -0
  117. package/hosts/zcode/skills/atlas-sprint-prd-generator/agents/openai.yaml +7 -0
  118. package/hosts/zcode/skills/atlas-task-validator/SKILL.md +173 -0
  119. package/hosts/zcode/skills/atlas-task-validator/agents/openai.yaml +7 -0
  120. package/hosts/zcode/skills/atlas-workflow-orchestrator/SKILL.md +391 -0
  121. package/package.json +1 -1
  122. package/plugins/atlas-workflow-orchestrator/.codex/agents/atlas-findings-repair.toml +1 -1
  123. package/plugins/atlas-workflow-orchestrator/.codex/agents/atlas-task-validator.toml +1 -1
  124. package/plugins/atlas-workflow-orchestrator/.codex-plugin/plugin.json +1 -1
  125. package/plugins/atlas-workflow-orchestrator/VERSION +1 -1
  126. package/plugins/atlas-workflow-orchestrator/agents/atlas-findings-repair.md +4 -0
  127. package/plugins/atlas-workflow-orchestrator/agents/atlas-task-validator.md +18 -1
  128. package/plugins/atlas-workflow-orchestrator/orchestrator/README.md +7 -5
  129. package/plugins/atlas-workflow-orchestrator/orchestrator/commands/workflow.md +1 -1
  130. package/plugins/atlas-workflow-orchestrator/orchestrator/references/host-adapters.md +13 -12
  131. package/plugins/atlas-workflow-orchestrator/orchestrator/skills/atlas-workflow-orchestrator/SKILL.md +24 -17
  132. package/plugins/atlas-workflow-orchestrator/packages/mcp-server/README.md +1 -1
  133. package/plugins/atlas-workflow-orchestrator/packages/mcp-server/package.json +1 -1
  134. package/plugins/atlas-workflow-orchestrator/packages/mcp-server/server.js +514 -20
  135. package/plugins/atlas-workflow-orchestrator/packages/skills/_shared/references/stack-profiles.md +36 -0
  136. package/plugins/atlas-workflow-orchestrator/packages/skills/_shared/scripts/document_quality.mjs +252 -0
  137. package/plugins/atlas-workflow-orchestrator/packages/skills/atlas-backlog-generator/SKILL.md +7 -2
  138. package/plugins/atlas-workflow-orchestrator/packages/skills/atlas-direct-execute/SKILL.md +6 -2
  139. package/plugins/atlas-workflow-orchestrator/packages/skills/atlas-findings-repair/SKILL.md +11 -1
  140. package/plugins/atlas-workflow-orchestrator/packages/skills/atlas-plan-execute/SKILL.md +16 -2
  141. package/plugins/atlas-workflow-orchestrator/packages/skills/atlas-plan-handoff/SKILL.md +6 -4
  142. package/plugins/atlas-workflow-orchestrator/packages/skills/atlas-prd-interview/SKILL.md +7 -2
  143. package/plugins/atlas-workflow-orchestrator/packages/skills/atlas-slice-review/SKILL.md +37 -2
  144. package/plugins/atlas-workflow-orchestrator/packages/skills/atlas-slice-review/references/scenario-lenses.md +8 -0
  145. package/plugins/atlas-workflow-orchestrator/packages/skills/atlas-slice-review/scripts/classify_findings.mjs +60 -0
  146. package/plugins/atlas-workflow-orchestrator/packages/skills/atlas-slice-review/scripts/classify_findings.py +9 -41
  147. package/plugins/atlas-workflow-orchestrator/packages/skills/atlas-sprint-prd-generator/SKILL.md +7 -4
  148. package/plugins/atlas-workflow-orchestrator/packages/skills/atlas-task-validator/SKILL.md +29 -14
  149. package/plugins/atlas-workflow-orchestrator/packages/templates/BACKLOG_MESTRE_TEMPLATE.md +14 -3
  150. package/plugins/atlas-workflow-orchestrator/packages/templates/PRD_TEMPLATE.md +2 -1
  151. package/plugins/atlas-workflow-orchestrator/packages/templates/STATE_FILE_SCHEMA.md +25 -1
  152. package/plugins/atlas-workflow-orchestrator/skills/_shared/references/stack-profiles.md +36 -0
  153. package/plugins/atlas-workflow-orchestrator/skills/_shared/scripts/document_quality.mjs +252 -0
  154. package/plugins/atlas-workflow-orchestrator/skills/atlas-backlog-generator/SKILL.md +7 -2
  155. package/plugins/atlas-workflow-orchestrator/skills/atlas-direct-execute/SKILL.md +6 -2
  156. package/plugins/atlas-workflow-orchestrator/skills/atlas-findings-repair/SKILL.md +11 -1
  157. package/plugins/atlas-workflow-orchestrator/skills/atlas-plan-execute/SKILL.md +16 -2
  158. package/plugins/atlas-workflow-orchestrator/skills/atlas-plan-handoff/SKILL.md +6 -4
  159. package/plugins/atlas-workflow-orchestrator/skills/atlas-prd-interview/SKILL.md +7 -2
  160. package/plugins/atlas-workflow-orchestrator/skills/atlas-slice-review/SKILL.md +37 -2
  161. package/plugins/atlas-workflow-orchestrator/skills/atlas-slice-review/references/scenario-lenses.md +8 -0
  162. package/plugins/atlas-workflow-orchestrator/skills/atlas-slice-review/scripts/classify_findings.mjs +60 -0
  163. package/plugins/atlas-workflow-orchestrator/skills/atlas-slice-review/scripts/classify_findings.py +9 -41
  164. package/plugins/atlas-workflow-orchestrator/skills/atlas-sprint-prd-generator/SKILL.md +7 -4
  165. package/plugins/atlas-workflow-orchestrator/skills/atlas-task-validator/SKILL.md +29 -14
  166. package/plugins/atlas-workflow-orchestrator/skills/atlas-workflow-orchestrator/SKILL.md +24 -17
  167. package/plugins/atlas-workflow-orchestrator/templates/BACKLOG_MESTRE_TEMPLATE.md +14 -3
  168. package/plugins/atlas-workflow-orchestrator/templates/PRD_TEMPLATE.md +2 -1
  169. package/plugins/atlas-workflow-orchestrator/templates/STATE_FILE_SCHEMA.md +25 -1
@@ -0,0 +1,3897 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import process from 'node:process';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const SERVER_NAME = 'atlas-workflow-orchestrator';
10
+ const RUN_DIR = path.join('.atlas', 'state');
11
+ const SENSITIVE_KEY = /(authorization|credential|password|secret|token|api[_-]?key)/i;
12
+ // S04: chaves cujo nome casa com SENSITIVE_KEY (substring `token`) mas NÃO são
13
+ // segredo/PII — são contadores monotônicos do slot de validação que PRECISAM
14
+ // persistir e sobreviver a re-spun. Allowlist exata (não substring).
15
+ // APENAS `dispatch_token` (nome interno específico); a chave genérica `token`
16
+ // permanece sujeita a redação para não expor segredos de payloads de usuário.
17
+ const NON_SENSITIVE_KEYS = new Set(['dispatch_token']);
18
+ const SERVER_DIR = path.dirname(fileURLToPath(import.meta.url));
19
+ const PRD_PATTERNS = {
20
+ section_1_context: ['TBD', 'a confirmar', 'talvez', 'não definido'],
21
+ section_2_scope: ['pode ser', 'depende de', 'ainda não', 'incompleto'],
22
+ section_3_decisions: ['vago'],
23
+ section_4_experience: ['a definir', 'gap', 'depende de'],
24
+ section_5_contracts: ['ainda não definido', 'mock apenas', 'a confirmar'],
25
+ };
26
+ const SECTION_LABELS = {
27
+ section_1_context: '§1 Contexto e objetivo',
28
+ section_2_scope: '§2 Escopo',
29
+ section_3_decisions: '§3 Decisões de produto',
30
+ section_4_experience: '§4 Fluxos e cenários UX',
31
+ section_5_contracts: '§5 Contrato funcional e invariantes',
32
+ };
33
+ const SECTION_HEADING = {
34
+ section_1_context: /^##\s+1\.\s+/,
35
+ section_2_scope: /^##\s+2\.\s+/,
36
+ section_3_decisions: /^##\s+3\.\s+/,
37
+ section_4_experience: /^##\s+4\.\s+/,
38
+ section_5_contracts: /^##\s+5\.\s+/,
39
+ };
40
+ const REQUIRED_PRD_SECTIONS = [
41
+ ['1', 'Contexto e objetivo'],
42
+ ['2', 'Escopo'],
43
+ ['3', 'Decisões de produto'],
44
+ ['4', 'Fluxos e cenários UX'],
45
+ ['5', 'Contrato funcional e invariantes'],
46
+ ['6', 'Critérios de aceite'],
47
+ ];
48
+ const REQUIRED_PLAN_SECTIONS = [
49
+ ['1', 'Tradução executiva'],
50
+ ['2', 'Invariantes de execução'],
51
+ ['3', 'Pitfalls'],
52
+ ['4', 'Estado na abertura da sprint'],
53
+ ['5', 'Tarefas de execução'],
54
+ ['6', 'Contratos técnicos'],
55
+ ['7', 'Slices'],
56
+ ['8', 'Validação e checklist'],
57
+ ];
58
+ const WORKFLOW_CONFIG = {
59
+ path: 'builtin:atlas-workflow',
60
+ skills: {
61
+ backlog_generator: 'atlas-backlog-generator',
62
+ prd_generator: 'atlas-sprint-prd-generator',
63
+ prd_interview: 'atlas-prd-interview',
64
+ plan_handoff: 'atlas-plan-handoff',
65
+ plan_execute: 'atlas-plan-execute',
66
+ direct_execute: 'atlas-direct-execute',
67
+ findings_repair: 'atlas-findings-repair',
68
+ slice_review: 'atlas-slice-review',
69
+ task_validator: 'atlas-task-validator',
70
+ },
71
+ modes: ['full', 'direct', 'execute', 'interview-only', 'interview_only'],
72
+ };
73
+
74
+ const VALIDATOR_MAX_ATTEMPTS = 2;
75
+ // P2-1: teto de falhas de proof-of-work POR attempt. challenge_failed não consome
76
+ // attempt nem fecha o slot (re-despacha o mesmo validador), mas sem limite um
77
+ // mismatch sistemático (ex.: validador resolve o path do challenge com CWD
78
+ // diferente do consumer_root do MCP) loopa pra sempre. Após este teto de falhas
79
+ // para o mesmo validator_run_id, o slot fecha terminal (fail-closed): a slice
80
+ // bloqueia com causa explícita em vez de re-despachar indefinidamente.
81
+ const VALIDATOR_CHALLENGE_MAX_FAILURES = 2;
82
+ const VALIDATOR_PASSED_STATUSES = new Set(['passed', 'passed_with_observations']);
83
+ const EXECUTOR_BOOTSTRAP_TIMEOUT_MS = 120_000;
84
+ const EXECUTOR_PROGRESS_TIMEOUT_MS = 300_000;
85
+ const EXECUTOR_CHECKPOINT_EVENTS = new Set([
86
+ 'executor_started',
87
+ 'skill_loaded',
88
+ 'plan_loaded',
89
+ 'handoff_accepted',
90
+ 'task_started',
91
+ 'first_write',
92
+ 'state_path_created',
93
+ ]);
94
+
95
+ function validatorRunId(runId, attempt, timestamp) {
96
+ return `${runId}:validator:${attempt}:${timestamp}`;
97
+ }
98
+
99
+ function repairRunId(runId, attempt, timestamp) {
100
+ return `${runId}:repair:${attempt}:${timestamp}`;
101
+ }
102
+
103
+ // Nível de garantia declarado no routing/output (PRD D12). Enum fechado:
104
+ // pipelines completas (full/direct/execute) declaram full_pipeline; uso avulso
105
+ // documental/leitura declara reduced_standalone (fora do escopo desta camada).
106
+ // Data-driven: rota → nível, sem ramo solto. Modos sem execução de código
107
+ // (interview-only) NÃO declaram guarantee_level (não há execução a garantir):
108
+ // guaranteeLevelForMode devolve null e o campo é OMITIDO do output (PRD D2/D12).
109
+ const GUARANTEE_LEVELS = ['full_pipeline', 'reduced_standalone'];
110
+ const MODE_GUARANTEE_LEVEL = {
111
+ full: 'full_pipeline',
112
+ direct: 'full_pipeline',
113
+ execute: 'full_pipeline',
114
+ };
115
+ const MODE_EXECUTOR_SKILL = {
116
+ full: WORKFLOW_CONFIG.skills.plan_execute,
117
+ direct: WORKFLOW_CONFIG.skills.direct_execute,
118
+ execute: WORKFLOW_CONFIG.skills.plan_execute,
119
+ };
120
+ function guaranteeLevelForMode(mode) {
121
+ return MODE_GUARANTEE_LEVEL[mode] ?? null;
122
+ }
123
+
124
+ function expectedExecutorSkill(mode) {
125
+ return MODE_EXECUTOR_SKILL[mode] ?? null;
126
+ }
127
+
128
+ // Banco canônico de templates de banner de fase (PRD §4 Fluxos / D*, PLAN §6.2).
129
+ // Fonte única na camada determinística: o orquestrador apenas ECOA a string
130
+ // pronta — nunca monta texto livre. Data-driven como HOST_ADAPTERS: tabela única
131
+ // `event → template`, sem string de banner inline espalhada pelos gates.
132
+ // Símbolo fixo `▸`, idioma pt-BR, exatamente uma linha por evento. Os 11 eventos
133
+ // fechados do PRD §4. Slots no formato {nome} são preenchidos por renderBanner.
134
+ const BANNER_TEMPLATES = {
135
+ roteia: '▸ atlas: roteamento · input={tipo} → modo={modo}',
136
+ roteia_troca: '▸ atlas: roteamento · pediu={x} mas input={y} → modo={z}',
137
+ preflight_ok: '▸ atlas: preflight · ok ({caps})',
138
+ preflight_fail: '▸ atlas: preflight · BLOCK · {motivo}',
139
+ prd_lacunas: '▸ atlas: prd · {n} lacunas',
140
+ prd_ok: '▸ atlas: prd · ok',
141
+ entrevista: '▸ atlas: entrevista · {n} perguntas',
142
+ plano: '▸ atlas: plano · validado (TC pass)',
143
+ exec: '▸ atlas: exec · slice {i}/{n}',
144
+ validacao: '▸ atlas: validação · {status}',
145
+ review: '▸ atlas: review · {status}',
146
+ done: '▸ atlas: done · {resumo}',
147
+ };
148
+ const BANNER_EVENTS = Object.keys(BANNER_TEMPLATES);
149
+
150
+ // Modo-alvo do roteamento por tipo de input (PRD D3/D6): o tipo de fato manda
151
+ // sobre o modo pedido. plan → execute (executa plano pronto); prd/backlog → full
152
+ // (gera/usa plano). Data-driven: alimenta o slot {modo} do banner `roteia`.
153
+ const ROUTED_MODE_BY_TYPE = {
154
+ plan: 'execute',
155
+ prd: 'full',
156
+ backlog: 'full',
157
+ // idea = descrição livre (não é arquivo). Roteia para `direct` (implementa a partir da
158
+ // descrição/spec, sem artefato de plano separado). Não é "input ilegível".
159
+ idea: 'direct',
160
+ };
161
+
162
+ // Preenche os slots {nome} do template do evento com `slots` e devolve a string
163
+ // pt-BR pronta. Evento desconhecido é erro de programação (lança). Slot ausente
164
+ // não é silenciado: deixa o marcador visível para o defeito não passar batido.
165
+ function renderBanner(event, slots = {}) {
166
+ const template = BANNER_TEMPLATES[event];
167
+ if (template === undefined) {
168
+ throw rpcError(-32603, `Evento de banner desconhecido: ${event}`);
169
+ }
170
+ return template.replace(/\{(\w+)\}/g, (match, key) => (
171
+ Object.prototype.hasOwnProperty.call(slots, key) ? String(slots[key]) : match
172
+ ));
173
+ }
174
+ // Camada de adapter: conhecimento host-específico centralizado em código.
175
+ // Skills consultam atlas_capabilities e usam o descritor retornado em vez de
176
+ // hardcodar nome de host. Adicionar host novo = adicionar entrada aqui.
177
+ // Contrato HostAdapter (DEC-007): entrada runtime data-driven. Campos:
178
+ // subagent_dispatch, question_prompt, todo_tool, hooks, capabilities_flags. plan_paths/state são
179
+ // portáveis (iguais a todos os hosts) e vivem em capabilities(). Adicionar host =
180
+ // adicionar entrada aqui; nenhum ramo `if host==` em outro lugar.
181
+ // capabilities_flags: pré-requisitos essenciais (subagent_available, mcp_available)
182
+ // são hard-fail no preflight (DEC-004); todo_available é não-essencial.
183
+ const HOST_ADAPTERS = {
184
+ claude: {
185
+ label: 'Claude Code',
186
+ subagent_dispatch: {
187
+ mechanism: 'Agent(subagent_type)',
188
+ example: 'Agent(subagent_type: "atlas-task-validator", prompt: "<state_path>")',
189
+ registration: 'agents/<name>.md na raiz do plugin',
190
+ },
191
+ validator_dispatch: {
192
+ dispatcher: 'orchestrator',
193
+ join: {
194
+ sync: 'self_evident',
195
+ confidence: 'presumed',
196
+ mechanism: 'Agent(subagent_type) bloqueante por design do host',
197
+ },
198
+ },
199
+ question_prompt: { mechanism: 'AskUserQuestion', mode: 'structured', max_questions: 4, options_per_question: 3, persistence: 'prd_after_each_round' },
200
+ todo_tool: 'TodoWrite',
201
+ hooks: { supported: true, mechanism: 'hooks/claude/settings.snippet.json' },
202
+ capabilities_flags: { subagent_available: true, mcp_available: true, todo_available: true },
203
+ },
204
+ codex: {
205
+ label: 'Codex App',
206
+ subagent_dispatch: {
207
+ mechanism: 'spawn_agent(agent_type)',
208
+ example: 'spawn_agent(agent_type: "atlas-task-validator", items: [{ type: "text", text: "<state_path>" }])',
209
+ registration: 'CODEX_HOME/agents/<name>.toml via `npx github:pauloborini/atlas-workflow init codex` (custom agent nativo; developer_instructions carrega o SKILL.md; atlas-task-validator pinado em model=gpt-5.4, model_reasoning_effort=high)',
210
+ },
211
+ validator_dispatch: {
212
+ dispatcher: 'orchestrator',
213
+ required_agent_type: 'atlas-task-validator',
214
+ required_codex_model: 'gpt-5.4',
215
+ required_codex_model_reasoning_effort: 'high',
216
+ join: {
217
+ sync: 'self_evident',
218
+ confidence: 'confirmed',
219
+ mechanism: 'spawn_agent bloqueante; retorno via state_path + veredito; no Codex deve usar explicitamente agent_type="atlas-task-validator"',
220
+ },
221
+ },
222
+ question_prompt: { mechanism: 'request_user_input', mode: 'structured', max_questions: 3, options_per_question: 3, persistence: 'prd_after_each_round' },
223
+ todo_tool: 'tasks',
224
+ hooks: { supported: false, mechanism: null },
225
+ // Codex subagents are native, but spawned agents do not receive spawn_agent in
226
+ // the current host tool surface. Validator runs as an isolated sibling
227
+ // dispatched by the orchestrator after the executor writes state_path.
228
+ capabilities_flags: { subagent_available: true, mcp_available: true, todo_available: true },
229
+ },
230
+ opencode: {
231
+ label: 'opencode',
232
+ subagent_dispatch: {
233
+ mechanism: '@<name> (ou auto por description)',
234
+ example: 'invocar @atlas-task-validator passando <state_path>',
235
+ registration: '.opencode/agents/<name>.md (frontmatter description + mode: subagent)',
236
+ },
237
+ validator_dispatch: {
238
+ dispatcher: 'orchestrator',
239
+ join: {
240
+ sync: 'self_evident',
241
+ confidence: 'presumed',
242
+ mechanism: '@<name> bloqueante presumido',
243
+ },
244
+ },
245
+ question_prompt: { mechanism: 'question', mode: 'structured', max_questions: 4, options_per_question: 3, persistence: 'prd_after_each_round' },
246
+ // opencode expõe `todowrite` nativo ao agente primário (orquestrador). O `todoread`
247
+ // foi fundido em `todowrite` (mar/2026): a tool retorna a lista atual no output.
248
+ // Subagentes têm `todowrite` desabilitado por padrão, mas o todo é usado pelo
249
+ // orquestrador (primário), não pelos validadores — então a flag descreve o nível certo.
250
+ todo_tool: 'todowrite',
251
+ hooks: { supported: true, mechanism: '.opencode/plugins/' },
252
+ // Nativo compatível: subagente (.opencode/agents) + MCP local (opencode.json) + todo (todowrite).
253
+ capabilities_flags: { subagent_available: true, mcp_available: true, todo_available: true },
254
+ },
255
+ pi: {
256
+ label: 'pi cli',
257
+ subagent_dispatch: {
258
+ // pi-subagents dispara pela tool `subagent({agent, task})` — NÃO por @name nem via MCP.
259
+ // As tools MCP do Atlas chegam proxiadas/prefixadas pelo pi-mcp-adapter (atlas_workflow_<tool>).
260
+ mechanism: 'subagent({ agent, task }) — tool do pi-subagents',
261
+ example: 'subagent({ agent: "atlas-task-validator", task: "<state_path>", context: "fresh" })',
262
+ registration: '.pi/agents/<name>.md (pi-subagents; frontmatter name + description + tools)',
263
+ },
264
+ validator_dispatch: {
265
+ dispatcher: 'orchestrator',
266
+ join: {
267
+ sync: 'must_report',
268
+ confidence: 'reported_required',
269
+ mechanism: 'subagent({agent,task}) via pi-subagents; join depende de dep externa',
270
+ },
271
+ },
272
+ question_prompt: { mechanism: 'interactive_prompt', mode: 'structured', max_questions: 4, options_per_question: 3, persistence: 'prd_after_each_round' },
273
+ todo_tool: null,
274
+ hooks: { supported: false, mechanism: null },
275
+ // pi exige 2 deps externas obrigatórias (DEC-005): pi-mcp-adapter (MCP) e
276
+ // pi-subagents (subagente). O perfil declara a expectativa; a disponibilidade
277
+ // real é reportada em host_capabilities no preflight — ausente => hard-fail.
278
+ capabilities_flags: { subagent_available: true, mcp_available: true, todo_available: false },
279
+ required_deps: ['pi-mcp-adapter', 'pi-subagents'],
280
+ // must_report: essenciais dependem de deps externas não-sondáveis pelo servidor.
281
+ // Fail-closed — só passam se o caller reportar disponibilidade real (não otimismo do perfil).
282
+ prereq_policy: 'must_report',
283
+ },
284
+ antigravity: {
285
+ label: 'Antigravity',
286
+ // Antigravity não tem skill loader nativo em subagentes — o SKILL.md completo
287
+ // DEVE ser embutido no Prompt de cada invoke_subagent (via define_subagent como
288
+ // system_prompt ou diretamente no Prompt). Nunca despachar subagente sem SKILL.md
289
+ // injetado, pois o subagente não carregará o contrato e o pipeline vai impasse.
290
+ //
291
+ // Fluxo para fases de execução/validação (executor, validator, repair, review):
292
+ // 1. define_subagent(name: "<atlas-exec>", system_prompt: "<SKILL.MD completo>")
293
+ // 2. invoke_subagent(Subagents: [{TypeName: "<atlas-exec>", Role: "<papel>",
294
+ // Prompt: "<state_path ou plan_path>",
295
+ // Workspace: "branch"}])
296
+ // — invoke_subagent é BLOQUEANTE por design: não polling, não background.
297
+ // — Workspace: "branch" garante isolamento de contexto (fronteira G4/G9).
298
+ //
299
+ // Fases documentais (PRD, entrevista, plano) NÃO usam subagente — o orquestrador
300
+ // conduz no fio principal; define_subagent não é chamado para essas fases.
301
+ subagent_dispatch: {
302
+ mechanism: 'define_subagent(name, system_prompt) + invoke_subagent(Subagents: [{TypeName, Role, Prompt, Workspace}])',
303
+ example: 'define_subagent(name: "atlas-task-validator", system_prompt: "<SKILL.MD completo do atlas-task-validator>") seguido de invoke_subagent(Subagents: [{TypeName: "atlas-task-validator", Role: "Validador frio", Prompt: "<state_path>", Workspace: "branch"}])',
304
+ registration: 'define_subagent dinâmico por sessão — o SKILL.md canônico é passado como system_prompt; sem pré-registro persistente',
305
+ // Sem loader nativo: o SKILL.md DEVE ser embutido no system_prompt do define_subagent.
306
+ // Não usar TypeName: "self" sem injetar o SKILL.md — o subagente herdaria o contexto
307
+ // do orquestrador e violaria o isolamento frio (G4/G9).
308
+ skill_loading: 'embed_in_system_prompt',
309
+ },
310
+ validator_dispatch: {
311
+ dispatcher: 'orchestrator',
312
+ join: {
313
+ sync: 'self_evident',
314
+ confidence: 'high',
315
+ mechanism: 'invoke_subagent bloqueante por design do host — sem polling, sem callback',
316
+ },
317
+ },
318
+ // question_prompt: usado pela atlas-prd-interview para fazer perguntas ao usuário.
319
+ // No Antigravity, usar ask_question (ferramenta nativa de perguntas interativas).
320
+ // IMPORTANTE — resume_after_interview: após receber respostas via ask_question,
321
+ // persistir no PRD e RETOMAR O PIPELINE IMEDIATAMENTE sem nova confirmação.
322
+ // Nunca aguardar input adicional do usuário entre fases — viola fire-and-continue.
323
+ question_prompt: {
324
+ mechanism: 'ask_question',
325
+ mode: 'structured',
326
+ max_questions: 4,
327
+ options_per_question: 3,
328
+ persistence: 'prd_after_each_round',
329
+ resume_after_interview: 'automatic',
330
+ },
331
+ todo_tool: null,
332
+ hooks: { supported: false, mechanism: null },
333
+ capabilities_flags: { subagent_available: true, mcp_available: true, todo_available: false },
334
+ // self_evident: MCP nativo + invoke_subagent bloqueante provados pelo boot do host.
335
+ // Não exige host_capabilities report (igual claude/codex/opencode).
336
+ prereq_policy: 'self_evident',
337
+ },
338
+ zcode: {
339
+ label: 'ZCode',
340
+ subagent_dispatch: {
341
+ // ZCode roda no Claude Agent SDK: Agent(subagent_type) nativo e bloqueante.
342
+ // Skills/agents do plugin vivem no bundle (.zcode-plugin) carregado pelo host.
343
+ mechanism: 'Agent(subagent_type)',
344
+ example: 'Agent(subagent_type: "atlas-task-validator", prompt: "<state_path>")',
345
+ registration: 'agents/<name>.md na raiz do plugin (descoberto via .zcode-plugin/plugin.json)',
346
+ },
347
+ validator_dispatch: {
348
+ dispatcher: 'orchestrator',
349
+ join: {
350
+ sync: 'self_evident',
351
+ confidence: 'presumed',
352
+ mechanism: 'Agent(subagent_type) bloqueante por design do host (Claude Agent SDK)',
353
+ },
354
+ },
355
+ question_prompt: { mechanism: 'AskUserQuestion', mode: 'structured', max_questions: 4, options_per_question: 3, persistence: 'prd_after_each_round' },
356
+ todo_tool: 'TodoWrite',
357
+ hooks: { supported: true, mechanism: '.zcode-plugin/plugin.json (hooks)' },
358
+ // ZCode é clone estrutural do Claude Code (Claude Agent SDK): subagente +
359
+ // MCP-local + TodoWrite nativos. Perfil self_evident — passa PREREQ/JOIN sem report.
360
+ capabilities_flags: { subagent_available: true, mcp_available: true, todo_available: true },
361
+ },
362
+ generic: {
363
+ label: 'Host genérico',
364
+ subagent_dispatch: {
365
+ mechanism: 'subagente nativo do host',
366
+ example: 'despachar o subagente atlas-task-validator passando apenas <state_path>',
367
+ registration: 'mecanismo nativo equivalente do host',
368
+ },
369
+ validator_dispatch: {
370
+ dispatcher: 'orchestrator',
371
+ join: {
372
+ sync: 'must_report',
373
+ confidence: 'reported_required',
374
+ mechanism: 'indeterminado; host deve reportar',
375
+ },
376
+ },
377
+ question_prompt: { mechanism: 'native_structured_question', mode: 'structured', max_questions: 4, options_per_question: 3, persistence: 'prd_after_each_round' },
378
+ todo_tool: null,
379
+ hooks: { supported: false, mechanism: null },
380
+ // generic EXIGE subagente+MCP do host (DEC-004); host MCP-only sem subagente
381
+ // fica fora de escopo e é rejeitado no preflight, não degradado.
382
+ capabilities_flags: { subagent_available: true, mcp_available: true, todo_available: false },
383
+ // must_report: host desconhecido — o servidor não pode presumir subagente+MCP.
384
+ // Fail-closed — exige report afirmativo de disponibilidade.
385
+ prereq_policy: 'must_report',
386
+ },
387
+ };
388
+
389
+ // Pré-requisitos de determinismo (DEC-004): essenciais → hard-fail no preflight;
390
+ // não-essenciais → seguem sem o recurso, registrando. Contrato consumido por S09.
391
+ const PREREQUISITES = {
392
+ essential: ['subagent_available', 'mcp_available'],
393
+ non_essential: ['todo_available'],
394
+ };
395
+ const PREREQUISITE_FLAGS = [...PREREQUISITES.essential, ...PREREQUISITES.non_essential];
396
+
397
+ // Versão do contrato atlas_capabilities. Política: incremento aditivo (campos novos
398
+ // opcionais) mantém compat — consumidores DEVEM ignorar campos desconhecidos.
399
+ // Remoção/renomeação de campo ou mudança de semântica exige bump e nota de migração.
400
+ // v1 → v2: adiciona capabilities_flags, hooks, prerequisites, known_hosts,
401
+ // required_deps, prereq_policy (aditivo).
402
+ // v2 → v3: adiciona validator_dispatch (quem despacha o validador frio G4 e como).
403
+ // v3 → v4 (DEC-SIB-001/003): sibling é a única topologia. validator_dispatch
404
+ // colapsa para `{ dispatcher: 'orchestrator' }` em todos os hosts; os campos de
405
+ // topologia legada (dispatcher por executor) foram REMOVIDOS do contrato
406
+ // (mudança de semântica → bump consciente). Consumidores que liam o antigo
407
+ // validator_dispatch.topology devem assumir sibling incondicionalmente. Estado antigo em disco com esses
408
+ // campos é rollback-safe: campos extras são ignorados pelo spread/normalize.
409
+ // v4 → v5 (DEC-SIB-003, S06, SPEC_JOIN_CAPABILITY_S03 §2.2): adiciona
410
+ // validator_dispatch.join { sync, confidence, mechanism } por host. Aditivo
411
+ // (campo novo; nenhum campo removido nesta etapa). O input do preflight ganha
412
+ // host_capabilities.join_sync_available (opcional, gate JOIN separado — NÃO é
413
+ // flag de prereq). Consumidores que ignoram campos desconhecidos seguem compatíveis.
414
+ const CAPABILITIES_SCHEMA_VERSION = 5;
415
+
416
+ // Nomes de host derivados do registry — única fonte de verdade para enums de schema.
417
+ // Adicionar host em HOST_ADAPTERS propaga automaticamente (sem enum hardcoded).
418
+ const HOST_NAMES = Object.keys(HOST_ADAPTERS);
419
+
420
+ // Registry de detecção de host, data-driven e ordenado por precedência (DEC-003).
421
+ // Adicionar host = adicionar um detector aqui (env próprio/arquivo); sem ramo solto.
422
+ // `arg host` e `ATLAS_HOST` (override explícito) têm prioridade sobre sinais de env.
423
+ // Cada detector retorna o nome do host se casar, ou null. Só hosts presentes em
424
+ // HOST_ADAPTERS são aceitos (perfil desconhecido cai em generic).
425
+ const HOST_DETECTORS = [
426
+ { via: 'env:CLAUDE_PLUGIN_ROOT', detect: (env) => (env.CLAUDE_PLUGIN_ROOT ? 'claude' : null) },
427
+ { via: 'env:CODEX', detect: (env) => (env.CODEX_HOME || env.CODEX_PLUGIN_ROOT ? 'codex' : null) },
428
+ // ZCode (app Electron no Claude Agent SDK) injeta ZCODE_PLUGIN_ROOT ao spawnar o
429
+ // subprocesso MCP do plugin (comprovado no bundle zcode.cjs: interpolação análoga a
430
+ // CLAUDE_PLUGIN_ROOT). Sinal próprio e determinístico — precedência sobre ATLAS_HOST.
431
+ { via: 'env:ZCODE_PLUGIN_ROOT', detect: (env) => (env.ZCODE_PLUGIN_ROOT ? 'zcode' : null) },
432
+ // opencode/pi não expõem env distintivo garantido no subprocesso MCP (S01).
433
+ // Detecção determinística: o packaging injeta ATLAS_HOST no env do MCP —
434
+ // opencode: opencode.json → mcp.<name>.environment.ATLAS_HOST = "opencode"
435
+ // pi: mcp.json (pi-mcp-adapter) → env.ATLAS_HOST = "pi"
436
+ // Tratado pela branch ATLAS_HOST acima; sem file-detection frágil.
437
+ ];
438
+
439
+ function detectHost(args = {}, env = process.env) {
440
+ if (args.host && HOST_ADAPTERS[args.host]) return { host: args.host, detected_via: 'arg' };
441
+ const override = env.ATLAS_HOST;
442
+ if (override && HOST_ADAPTERS[override]) return { host: override, detected_via: 'env:ATLAS_HOST' };
443
+ for (const detector of HOST_DETECTORS) {
444
+ const host = detector.detect(env);
445
+ if (host && HOST_ADAPTERS[host]) return { host, detected_via: detector.via };
446
+ }
447
+ return { host: 'generic', detected_via: 'default' };
448
+ }
449
+
450
+ function capabilities(args = {}) {
451
+ const { host, detected_via } = detectHost(args);
452
+ const adapter = HOST_ADAPTERS[host];
453
+ return {
454
+ host,
455
+ host_label: adapter.label,
456
+ detected_via,
457
+ schema_version: CAPABILITIES_SCHEMA_VERSION,
458
+ subagent_dispatch: adapter.subagent_dispatch,
459
+ validator_dispatch: adapter.validator_dispatch,
460
+ question_prompt: adapter.question_prompt,
461
+ todo_tool: adapter.todo_tool,
462
+ hooks: adapter.hooks,
463
+ capabilities_flags: adapter.capabilities_flags,
464
+ required_deps: adapter.required_deps ?? [],
465
+ prerequisites: PREREQUISITES,
466
+ // 'must_report' avisa o orquestrador que DEVE apurar e reportar host_capabilities
467
+ // (subagente/MCP reais) no preflight — sem isso, o gate PREREQ falha-fechado.
468
+ prereq_policy: adapter.prereq_policy ?? 'self_evident',
469
+ plan_paths: {
470
+ write: '.atlas/plans/',
471
+ read_order: ['.atlas/plans/', '.cursor/plans/', '.codex/plans/'],
472
+ deprecated_read: ['.cursor/plans/', '.codex/plans/'],
473
+ },
474
+ state_backend: 'atlas_run_state',
475
+ state_dir: RUN_DIR,
476
+ known_hosts: Object.keys(HOST_ADAPTERS),
477
+ };
478
+ }
479
+
480
+ // Hard-fail de pré-requisitos de determinismo (DEC-004). Mescla as flags do perfil
481
+ // do host com a disponibilidade real reportada pelo caller (`host_capabilities`).
482
+ //
483
+ // Política por host (`prereq_policy`):
484
+ // - 'self_evident' (claude/codex/opencode/zcode, default): runtime nativo. Flag essencial
485
+ // vem do report quando presente, senão do perfil (otimista justificado: MCP-vivo
486
+ // prova-se no boot; subagente é nativo do host/plugin instalado).
487
+ // - 'must_report' (pi/generic): essencial depende de dep externa (pi) ou de host
488
+ // desconhecido (generic) — NÃO sondável pelo servidor. Fail-closed: a flag só
489
+ // conta como true se reportada explicitamente true; ausente/não-bool ⇒ false ⇒
490
+ // blocked. Converte a garantia de prosa do orquestrador em contrato.
491
+ //
492
+ // O merge é delimitado a PREREQUISITE_FLAGS (chave desconhecida no override é ignorada;
493
+ // o additionalProperties:false do schema é enforçado na camada do client MCP, este
494
+ // loop é a defesa server-side). Capability não-essencial (todo) nunca bloqueia.
495
+ function checkPrerequisites(args = {}) {
496
+ const { host } = detectHost(args);
497
+ const adapter = HOST_ADAPTERS[host];
498
+ const mustReport = adapter.prereq_policy === 'must_report';
499
+ const reported = args.host_capabilities && typeof args.host_capabilities === 'object'
500
+ ? args.host_capabilities
501
+ : {};
502
+ const flags = {};
503
+ for (const key of PREREQUISITE_FLAGS) {
504
+ const reportedVal = typeof reported[key] === 'boolean' ? reported[key] : undefined;
505
+ if (mustReport && PREREQUISITES.essential.includes(key)) {
506
+ flags[key] = reportedVal === true;
507
+ } else {
508
+ flags[key] = reportedVal !== undefined ? reportedVal : adapter.capabilities_flags[key];
509
+ }
510
+ }
511
+ const missing = PREREQUISITES.essential.filter((key) => flags[key] !== true);
512
+ if (missing.length === 0) {
513
+ return { status: 'passed', host, effective_flags: flags, missing: [] };
514
+ }
515
+ const unreported = mustReport && PREREQUISITES.essential.every(
516
+ (key) => typeof reported[key] !== 'boolean',
517
+ );
518
+ return {
519
+ status: 'blocked',
520
+ host,
521
+ effective_flags: flags,
522
+ missing,
523
+ error: `Pré-requisito de determinismo ausente no host '${host}': ${missing.join(', ')}`,
524
+ cause: unreported ? 'host_nao_reportou_disponibilidade' : 'host_sem_prerequisito_essencial',
525
+ impact: 'sem_isolamento_de_contexto_o_validator_perde_determinismo_em_tarefa_grande',
526
+ next_action: host === 'pi'
527
+ ? 'instalar_pi-mcp-adapter_e_pi-subagents_e_reportar_host_capabilities'
528
+ : 'usar_host_com_subagente_e_mcp_nativos_ou_reportar_host_capabilities',
529
+ };
530
+ }
531
+
532
+ // Gate JOIN (DEC-SIB-003, SPEC_JOIN_CAPABILITY_S03 §3/§5). Espelha checkPrerequisites:
533
+ // lê validator_dispatch.join do adapter e decide hard-fail por política.
534
+ // - join.sync === 'self_evident' (claude/codex/opencode/zcode): host nativo conhecido;
535
+ // o runtime presume join disponível e NÃO exige report. confidence 'presumed'
536
+ // (claude/opencode) passa, mas é registrado para observabilidade (smoke S13).
537
+ // - join.sync === 'must_report' (pi/generic): fail-closed. Só passa se o caller
538
+ // reportar host_capabilities.join_sync_available === true. Ausente/não-bool ⇒ blocked.
539
+ // join_sync_available é gate SEPARADO — não entra em PREREQUISITE_FLAGS nem polui
540
+ // effective_flags de checkPrerequisites (o merge daquele loop ignora chaves desconhecidas).
541
+ function checkJoinCapability(args = {}) {
542
+ const { host } = detectHost(args);
543
+ const adapter = HOST_ADAPTERS[host];
544
+ const join = adapter.validator_dispatch?.join ?? {};
545
+ const reported = args.host_capabilities && typeof args.host_capabilities === 'object'
546
+ ? args.host_capabilities
547
+ : {};
548
+ if (join.sync === 'must_report') {
549
+ if (reported.join_sync_available === true) {
550
+ return { status: 'passed', host, confidence: join.confidence };
551
+ }
552
+ return {
553
+ status: 'blocked',
554
+ host,
555
+ error: `host '${host}' não reportou join síncrono; sibling exige join (DEC-SIB-003)`,
556
+ cause: 'host_nao_reportou_join_sincrono',
557
+ impact: 'sem_join_sincrono_o_slot_de_validacao_vaza_em_fire_and_forget',
558
+ next_action: 'instalar_deps_de_subagente_sincrono_ou_usar_host_nativo_e_reportar_join_sync_available',
559
+ };
560
+ }
561
+ // self_evident: passa sem report (host nativo). confidence preservado p/ observabilidade.
562
+ return { status: 'passed', host, confidence: join.confidence };
563
+ }
564
+
565
+ const LEGACY_ROUTE_KEY = ['fam', 'ily'].join('');
566
+ const VERSION_CANDIDATES = [
567
+ path.resolve(SERVER_DIR, '../../VERSION'),
568
+ ];
569
+ const PACKAGE_VERSION_CANDIDATES = [
570
+ path.resolve(SERVER_DIR, 'package.json'),
571
+ ];
572
+
573
+ function readVersion() {
574
+ const info = readVersionInfo();
575
+ return info.version;
576
+ }
577
+
578
+ function readVersionInfo() {
579
+ let rootVersion = null;
580
+ let packageVersion = null;
581
+ const errors = [];
582
+
583
+ for (const candidate of VERSION_CANDIDATES) {
584
+ try {
585
+ if (!fs.existsSync(candidate)) continue;
586
+ const value = fs.readFileSync(candidate, 'utf8').trim();
587
+ if (value) {
588
+ rootVersion = value;
589
+ break;
590
+ }
591
+ } catch (error) {
592
+ errors.push({ path: candidate, cause: error.message });
593
+ }
594
+ }
595
+
596
+ for (const candidate of PACKAGE_VERSION_CANDIDATES) {
597
+ try {
598
+ if (!fs.existsSync(candidate)) continue;
599
+ packageVersion = JSON.parse(fs.readFileSync(candidate, 'utf8')).version || null;
600
+ if (packageVersion) break;
601
+ } catch (error) {
602
+ errors.push({ path: candidate, cause: error.message });
603
+ }
604
+ }
605
+
606
+ const version = rootVersion || packageVersion || 'unknown';
607
+ const mismatch = rootVersion && packageVersion && rootVersion !== packageVersion;
608
+ return {
609
+ version,
610
+ root_version: rootVersion,
611
+ package_version: packageVersion,
612
+ status: mismatch ? 'blocked' : 'passed',
613
+ error: mismatch ? `Drift de versão: VERSION=${rootVersion}, package.json=${packageVersion}` : null,
614
+ errors,
615
+ next_action: mismatch ? 'alinhar_versoes_do_plugin' : 'avançar',
616
+ };
617
+ }
618
+
619
+ function parseWorkflowConfig() {
620
+ return WORKFLOW_CONFIG;
621
+ }
622
+
623
+ function consumerRoot(args = {}) {
624
+ const explicitRoot = optionalString(args, 'project_root');
625
+ if (explicitRoot && explicitRoot.trim() !== '') {
626
+ return path.resolve(explicitRoot);
627
+ }
628
+ const cwd = process.cwd();
629
+ if (cwd === '/' || cwd === '/var/folders') {
630
+ const home = process.env.HOME || process.env.USERPROFILE;
631
+ if (home) return path.resolve(home);
632
+ }
633
+ return path.resolve(cwd);
634
+ }
635
+
636
+ function runRoot(args = {}) {
637
+ return path.join(consumerRoot(args), RUN_DIR);
638
+ }
639
+
640
+ function ensureRunDir(args = {}) {
641
+ const dir = runRoot(args);
642
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
643
+ return dir;
644
+ }
645
+
646
+ function validateRunId(runId) {
647
+ if (typeof runId !== 'string' || runId.trim() === '') {
648
+ throw rpcError(-32602, 'run_id obrigatório');
649
+ }
650
+ if (!/^[A-Za-z0-9._-]+$/.test(runId)) {
651
+ throw rpcError(-32602, 'run_id inválido: use apenas letras, números, ponto, hífen ou underscore');
652
+ }
653
+ return runId;
654
+ }
655
+
656
+ function optionalString(args, key) {
657
+ const value = args[key];
658
+ if (value === undefined || value === null) return undefined;
659
+ if (typeof value !== 'string') {
660
+ throw rpcError(-32602, `Campo inválido: ${key} deve ser string`);
661
+ }
662
+ return value;
663
+ }
664
+
665
+ function optionalData(args) {
666
+ if (args.data === undefined || args.data === null) return undefined;
667
+ if (typeof args.data !== 'object' || Array.isArray(args.data)) {
668
+ throw rpcError(-32602, 'Campo inválido: data deve ser objeto');
669
+ }
670
+ return args.data;
671
+ }
672
+
673
+ function requiredString(args, key) {
674
+ const value = optionalString(args, key);
675
+ if (!value || value.trim() === '') {
676
+ throw rpcError(-32602, `${key} obrigatório`);
677
+ }
678
+ return value;
679
+ }
680
+
681
+ function optionalInteger(args, key) {
682
+ const value = args[key];
683
+ if (value === undefined || value === null) return undefined;
684
+ if (!Number.isInteger(value)) {
685
+ throw rpcError(-32602, `Campo inválido: ${key} deve ser inteiro`);
686
+ }
687
+ return value;
688
+ }
689
+
690
+ function resolveConsumerPath(inputPath, args = {}) {
691
+ const value = requiredString({ value: inputPath }, 'value');
692
+ return path.resolve(consumerRoot(args), value);
693
+ }
694
+
695
+ function statePath(runId, args = {}) {
696
+ const runDir = path.join(ensureRunDir(args), validateRunId(runId));
697
+ fs.mkdirSync(runDir, { recursive: true, mode: 0o700 });
698
+ return path.join(runDir, 'run.json');
699
+ }
700
+
701
+ function nowIso() {
702
+ return new Date().toISOString();
703
+ }
704
+
705
+ function isoPlusMs(iso, ms) {
706
+ const base = Date.parse(iso);
707
+ return new Date((Number.isFinite(base) ? base : Date.now()) + ms).toISOString();
708
+ }
709
+
710
+ function redact(value) {
711
+ if (Array.isArray(value)) return value.map(redact);
712
+ if (!value || typeof value !== 'object') return value;
713
+
714
+ return Object.fromEntries(
715
+ Object.entries(value).map(([key, nested]) => [
716
+ key,
717
+ SENSITIVE_KEY.test(key) && !NON_SENSITIVE_KEYS.has(key) ? '[REDACTED]' : redact(nested),
718
+ ]),
719
+ );
720
+ }
721
+
722
+ function logCall(entry, args = {}) {
723
+ try {
724
+ const line = JSON.stringify({ timestamp: nowIso(), ...entry }) + '\n';
725
+ fs.appendFileSync(path.join(ensureRunDir(args), 'mcp.log'), line, { mode: 0o600 });
726
+ } catch (error) {
727
+ // Ignora silenciosamente falhas de gravação de log (ex: diretório somente-leitura)
728
+ }
729
+ }
730
+
731
+ function rpcError(code, message, data) {
732
+ const error = new Error(message);
733
+ error.code = code;
734
+ error.data = data;
735
+ return error;
736
+ }
737
+
738
+ function ping() {
739
+ const version = readVersionInfo();
740
+ return {
741
+ status: version.status === 'passed' ? 'alive' : 'blocked',
742
+ name: SERVER_NAME,
743
+ version: version.version,
744
+ version_check: version,
745
+ transport: 'stdio',
746
+ // Fonte única da superfície de tools: derivado de toolsList() para nunca
747
+ // divergir do dispatcher/schema. Lista manual paralela já omitiu
748
+ // atlas_classify_input no passado (drift silencioso) — o orquestrador
749
+ // (Fase 0) aborta se uma capability exigida pelo modo não aparece aqui,
750
+ // então a divergência travava run válida. Guard cruzado em server.test.js.
751
+ capabilities: toolsList().tools.map((tool) => tool.name),
752
+ state_dir: RUN_DIR,
753
+ };
754
+ }
755
+
756
+ function stateInvalid(message, cause, extra = {}) {
757
+ return {
758
+ status: 'blocked',
759
+ error: message,
760
+ cause,
761
+ impact: 'ledger_nao_confiavel_fase_bloqueada',
762
+ next_action: 'recuperar_ou_remover_estado_invalido_com_decisao_explicita',
763
+ ...extra,
764
+ };
765
+ }
766
+
767
+ function validateStateShape(state, source) {
768
+ if (!state || typeof state !== 'object' || Array.isArray(state)) {
769
+ return stateInvalid(`Estado local incompatível: ${source}`, 'state_nao_e_objeto');
770
+ }
771
+ if (typeof state.run_id !== 'string' || state.run_id.trim() === '') {
772
+ return stateInvalid(`Estado local parcial: ${source}`, 'run_id_ausente_ou_invalido');
773
+ }
774
+ if (typeof state.phase !== 'string' || state.phase.trim() === '') {
775
+ return stateInvalid(`Estado local parcial: ${source}`, 'phase_ausente_ou_invalida', { run_id: state.run_id });
776
+ }
777
+ if (typeof state.status !== 'string' || state.status.trim() === '') {
778
+ return stateInvalid(`Estado local parcial: ${source}`, 'status_ausente_ou_invalido', { run_id: state.run_id });
779
+ }
780
+ if (!state.data || typeof state.data !== 'object' || Array.isArray(state.data)) {
781
+ return stateInvalid(`Estado local parcial: ${source}`, 'data_ausente_ou_invalida', { run_id: state.run_id });
782
+ }
783
+ const stateVersion = state.data?.routing?.version;
784
+ const currentVersion = readVersionInfo().version;
785
+ if (stateVersion && stateVersion !== currentVersion) {
786
+ return stateInvalid(
787
+ `Estado local incompatível: ${source}`,
788
+ `routing.version=${stateVersion}, current=${currentVersion}`,
789
+ {
790
+ run_id: state.run_id,
791
+ impact: 'pipeline_hibrido_poderia_gerar_ledger_falso',
792
+ next_action: 'reiniciar_run_apos_alinhar_versao_do_plugin',
793
+ },
794
+ );
795
+ }
796
+ return { status: 'passed', state };
797
+ }
798
+
799
+ function inspectRunStateFile(file) {
800
+ try {
801
+ const state = JSON.parse(fs.readFileSync(file, 'utf8'));
802
+ return validateStateShape(state, path.basename(file));
803
+ } catch (error) {
804
+ return stateInvalid(
805
+ `Estado local corrompido: ${file}`,
806
+ error.message,
807
+ { next_action: 'recuperar_ou_remover_estado_corrompido_com_decisao_explicita' },
808
+ );
809
+ }
810
+ }
811
+
812
+ function findActiveRunConflict(runId, args = {}) {
813
+ const dir = ensureRunDir(args);
814
+ for (const name of fs.readdirSync(dir)) {
815
+ const file = path.join(dir, name, 'run.json');
816
+ if (!fs.existsSync(file)) continue;
817
+ // Atualização-simples: um run ANTIGO inativo (inclusive de versão anterior do
818
+ // plugin) não pode travar um run NOVO. A varredura de conflito só importa pra
819
+ // runs de OUTRO run_id que estejam com dispatch ATIVO. Por isso parseamos de
820
+ // forma tolerante e ignoramos runs inativos/corrompidos/de versão velha — a
821
+ // validação estrita de versão/shape do PRÓPRIO run continua em readState
822
+ // (validateStateShape) no caminho que de fato opera sobre aquele estado.
823
+ let state;
824
+ try {
825
+ state = JSON.parse(fs.readFileSync(file, 'utf8'));
826
+ } catch {
827
+ continue; // run alheio corrompido não é nosso conflito
828
+ }
829
+ if (!state || typeof state !== 'object' || state.run_id === runId) continue;
830
+ const active = state.data?.dispatch?.active;
831
+ if (active?.phase) {
832
+ // Conflito real só quando o OUTRO run ativo é da versão atual; um run ativo
833
+ // de versão antiga é resíduo de processo morto, não lock vivo.
834
+ const stateVersion = state.data?.routing?.version;
835
+ const currentVersion = readVersionInfo().version;
836
+ if (stateVersion && stateVersion !== currentVersion) continue;
837
+ return {
838
+ status: 'blocked',
839
+ error: `Lock conflict: run ativa ${state.run_id} na fase ${active.phase}`,
840
+ cause: 'dispatch_ativo_em_outra_run',
841
+ impact: 'segunda_run_poderia_corromper_estado_ou_ledger',
842
+ conflicting_run_id: state.run_id,
843
+ active_phase: active.phase,
844
+ next_action: 'aguardar_ou_liberar_lock_com_decisao_explicita',
845
+ };
846
+ }
847
+ }
848
+ return { status: 'passed' };
849
+ }
850
+
851
+ function readState(runId, args = {}) {
852
+ const file = statePath(runId, args);
853
+ if (!fs.existsSync(file)) {
854
+ throw rpcError(-32004, `Run inexistente: ${runId}`, { run_id: runId });
855
+ }
856
+ try {
857
+ const state = JSON.parse(fs.readFileSync(file, 'utf8'));
858
+ const inspected = validateStateShape(state, `${runId}.json`);
859
+ if (inspected.status === 'blocked') {
860
+ throw rpcError(-32003, `Estado inválido para run: ${runId}`, {
861
+ run_id: runId,
862
+ cause: inspected.cause,
863
+ impact: inspected.impact,
864
+ next_action: inspected.next_action,
865
+ });
866
+ }
867
+ return state;
868
+ } catch (cause) {
869
+ if (cause.code) throw cause;
870
+ throw rpcError(-32003, `Estado inválido para run: ${runId}`, {
871
+ run_id: runId,
872
+ cause: cause.message,
873
+ impact: 'ledger_nao_confiavel_fase_bloqueada',
874
+ next_action: 'recuperar_ou_remover_estado_corrompido_com_decisao_explicita',
875
+ });
876
+ }
877
+ }
878
+
879
+ function upsertState(args) {
880
+ const runId = validateRunId(args.run_id);
881
+ const phase = optionalString(args, 'phase');
882
+ const status = optionalString(args, 'status');
883
+ const summary = optionalString(args, 'summary');
884
+ const data = optionalData(args);
885
+ const timestamp = nowIso();
886
+ let previous = null;
887
+
888
+ try {
889
+ previous = readState(runId, args);
890
+ } catch (error) {
891
+ if (error.code !== -32004) throw error;
892
+ }
893
+
894
+ const next = {
895
+ run_id: runId,
896
+ phase: phase ?? previous?.phase ?? 'unknown',
897
+ status: status ?? previous?.status ?? 'unknown',
898
+ summary: summary ?? previous?.summary ?? null,
899
+ // P2/S22: upsert parcial DEVE preservar chaves irmãs do estado (dispatch,
900
+ // validator_cycle, routing, gates). O executor escreve o handoff via
901
+ // atlas_run_state(upsert) com um `data` parcial; um replace cego apagava
902
+ // `data.dispatch.active={plan_execute}`, fazendo o lock_validator(start)
903
+ // seguinte bloquear ("current_phase null"). Merge top-level: o caller adiciona
904
+ // chaves novas sem derrubar as existentes. Sem `data` no payload → mantém o
905
+ // estado anterior intacto (comportamento de no-op preservado).
906
+ data: redact(data !== undefined ? { ...(previous?.data ?? {}), ...data } : (previous?.data ?? {})),
907
+ created_at: previous?.created_at ?? timestamp,
908
+ updated_at: timestamp,
909
+ last_call: {
910
+ tool: 'atlas_run_state',
911
+ action: 'upsert',
912
+ timestamp,
913
+ },
914
+ };
915
+
916
+ const target = statePath(runId, args);
917
+ const tmp = `${target}.${process.pid}.tmp`;
918
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
919
+ fs.renameSync(tmp, target);
920
+ return next;
921
+ }
922
+
923
+ function patchGateResult(runId, gate, result, args = {}) {
924
+ let previous = null;
925
+ try {
926
+ previous = readState(runId, args);
927
+ } catch (error) {
928
+ if (error.code !== -32004) throw error;
929
+ }
930
+
931
+ const data = {
932
+ ...(previous?.data ?? {}),
933
+ gates: {
934
+ ...(previous?.data?.gates ?? {}),
935
+ [gate]: redact(result),
936
+ },
937
+ };
938
+
939
+ return upsertState({
940
+ run_id: runId,
941
+ project_root: args.project_root,
942
+ phase: previous?.phase ?? 'gates',
943
+ status: result.status === 'passed' ? 'gate_passed' : 'gate_blocked',
944
+ summary: `${gate}: ${result.status}`,
945
+ data,
946
+ });
947
+ }
948
+
949
+ function patchTemplateConformanceResult(runId, result, args = {}) {
950
+ let previous = null;
951
+ try {
952
+ previous = readState(runId, args);
953
+ } catch (error) {
954
+ if (error.code !== -32004) throw error;
955
+ }
956
+
957
+ const data = {
958
+ ...(previous?.data ?? {}),
959
+ template_conformance: redact(result),
960
+ gates: {
961
+ ...(previous?.data?.gates ?? {}),
962
+ template_conformance: redact(result),
963
+ },
964
+ };
965
+
966
+ return upsertState({
967
+ run_id: runId,
968
+ project_root: args.project_root,
969
+ phase: previous?.phase ?? 'template_conformance',
970
+ status: result.status === 'passed' ? 'template_conformance_passed' : 'template_conformance_blocked',
971
+ summary: `template_conformance: ${result.status}`,
972
+ data,
973
+ });
974
+ }
975
+
976
+ function patchRoutingResult(runId, result, args = {}) {
977
+ let previous = null;
978
+ try {
979
+ previous = readState(runId, args);
980
+ } catch (error) {
981
+ if (error.code !== -32004) throw error;
982
+ }
983
+
984
+ const data = {
985
+ ...(previous?.data ?? {}),
986
+ routing: result.routing ?? previous?.data?.routing ?? null,
987
+ gates: {
988
+ ...(previous?.data?.gates ?? {}),
989
+ [result.gate ?? 'G10']: redact(result),
990
+ },
991
+ };
992
+
993
+ return upsertState({
994
+ run_id: runId,
995
+ project_root: args.project_root,
996
+ phase: previous?.phase ?? 'preflight',
997
+ status: result.status === 'passed' ? 'preflight_passed' : 'preflight_blocked',
998
+ summary: `G10: ${result.status}`,
999
+ data,
1000
+ });
1001
+ }
1002
+
1003
+ function patchDispatchResult(runId, result, args = {}) {
1004
+ const previous = readState(runId, args);
1005
+ const currentDispatch = previous.data?.dispatch ?? {};
1006
+ const history = [
1007
+ ...(currentDispatch.history ?? []),
1008
+ {
1009
+ timestamp: result.timestamp,
1010
+ phase: result.phase ?? null,
1011
+ action: result.action ?? null,
1012
+ event: result.event ?? null,
1013
+ status: result.status,
1014
+ next_action: result.next_action ?? null,
1015
+ error: result.error ?? null,
1016
+ },
1017
+ ];
1018
+ const data = {
1019
+ ...(previous.data ?? {}),
1020
+ dispatch: {
1021
+ ...currentDispatch,
1022
+ ...(result.dispatch ?? {}),
1023
+ history,
1024
+ },
1025
+ gates: {
1026
+ ...(previous.data?.gates ?? {}),
1027
+ [result.gate ?? 'G7']: redact(result),
1028
+ },
1029
+ };
1030
+
1031
+ return upsertState({
1032
+ run_id: runId,
1033
+ project_root: args.project_root,
1034
+ phase: previous.phase ?? 'dispatch',
1035
+ status: result.status === 'passed' ? 'dispatch_ok' : 'dispatch_blocked',
1036
+ summary: `${result.gate ?? 'G7'}: ${result.status}`,
1037
+ data,
1038
+ });
1039
+ }
1040
+
1041
+ function normalizeValidatorCycle(cycle = {}) {
1042
+ return {
1043
+ // S04: token de dispatch monotônico explícito do slot de validação. Inteiro,
1044
+ // sempre crescente, nunca reusado; persiste no run.json e sobrevive a re-spun.
1045
+ dispatch_token: Number.isInteger(cycle.dispatch_token) ? cycle.dispatch_token : 0,
1046
+ // DEC-SIB-002: o teto de attempts é invariante de CONTRATO (enforcement por
1047
+ // tool MCP, não por estado/prosa). VALIDATOR_MAX_ATTEMPTS é o teto canônico e
1048
+ // NÃO é configurável via estado persistido. Um run.json adulterado/corrompido
1049
+ // com max_attempts inflado (ex.: 99) não pode elevar o teto e liberar um 3º+
1050
+ // validator. Por isso clampamos max_attempts: inteiro válido e ≥1 vira
1051
+ // min(estado, teto); qualquer 0/negativo/ausente/não-inteiro cai no default
1052
+ // canônico. O valor efetivo nunca excede VALIDATOR_MAX_ATTEMPTS, e como todos
1053
+ // os fluxos (start/complete/fail) leem o cycle por esta normalização, os
1054
+ // retornos que ecoam cycle.max_attempts já recebem o valor clampado.
1055
+ // Pela mesma razão, attempts_used recebe piso ≥0: um valor negativo adulterado
1056
+ // (ex.: -5) inflaria o teto efetivo (max_attempts - attempts_used) e permitiria
1057
+ // dispatches além do limite. Valor float/string/null cai em 0. Teto superior
1058
+ // NÃO é necessário — attempts_used=99 já cai no lado seguro (99>=2 → blocked).
1059
+ max_attempts: Number.isInteger(cycle.max_attempts) && cycle.max_attempts >= 1
1060
+ ? Math.min(cycle.max_attempts, VALIDATOR_MAX_ATTEMPTS)
1061
+ : VALIDATOR_MAX_ATTEMPTS,
1062
+ attempts_used: Number.isInteger(cycle.attempts_used) && cycle.attempts_used >= 0
1063
+ ? cycle.attempts_used
1064
+ : 0,
1065
+ status: typeof cycle.status === 'string' ? cycle.status : 'idle',
1066
+ active: cycle.active && typeof cycle.active === 'object' ? cycle.active : null,
1067
+ last_state_path: typeof cycle.last_state_path === 'string' ? cycle.last_state_path : null,
1068
+ last_verdict: typeof cycle.last_verdict === 'string' ? cycle.last_verdict : null,
1069
+ findings_packet: cycle.findings_packet && typeof cycle.findings_packet === 'object' ? cycle.findings_packet : null,
1070
+ repair: cycle.repair && typeof cycle.repair === 'object'
1071
+ ? {
1072
+ skill: typeof cycle.repair.skill === 'string' ? cycle.repair.skill : WORKFLOW_CONFIG.skills.findings_repair,
1073
+ status: typeof cycle.repair.status === 'string' ? cycle.repair.status : 'not_needed',
1074
+ required_from_attempt: Number.isInteger(cycle.repair.required_from_attempt) ? cycle.repair.required_from_attempt : null,
1075
+ requested_at: typeof cycle.repair.requested_at === 'string' ? cycle.repair.requested_at : null,
1076
+ completed_at: typeof cycle.repair.completed_at === 'string' ? cycle.repair.completed_at : null,
1077
+ active: cycle.repair.active && typeof cycle.repair.active === 'object' ? cycle.repair.active : null,
1078
+ }
1079
+ : {
1080
+ skill: WORKFLOW_CONFIG.skills.findings_repair,
1081
+ status: 'not_needed',
1082
+ required_from_attempt: null,
1083
+ requested_at: null,
1084
+ completed_at: null,
1085
+ active: null,
1086
+ },
1087
+ history: Array.isArray(cycle.history) ? cycle.history : [],
1088
+ };
1089
+ }
1090
+
1091
+ function patchValidatorResult(runId, result, args = {}) {
1092
+ const previous = readState(runId, args);
1093
+ const current = normalizeValidatorCycle(previous.data?.validator_cycle ?? {});
1094
+ const history = [
1095
+ ...current.history,
1096
+ {
1097
+ timestamp: result.timestamp,
1098
+ action: result.action,
1099
+ status: result.status,
1100
+ validator_status: result.validator_status ?? null,
1101
+ validator_attempt: result.validator_attempt ?? null,
1102
+ validator_run_id: result.validator_run_id ?? null,
1103
+ repair_run_id: result.repair_run_id ?? null,
1104
+ state_path: result.state_path ?? null,
1105
+ next_action: result.next_action ?? null,
1106
+ error: result.error ?? null,
1107
+ },
1108
+ ];
1109
+ const data = {
1110
+ ...(previous.data ?? {}),
1111
+ validator_cycle: {
1112
+ ...current,
1113
+ ...(result.validator_cycle ?? {}),
1114
+ history,
1115
+ },
1116
+ gates: {
1117
+ ...(previous.data?.gates ?? {}),
1118
+ [result.gate ?? 'G4']: redact(result),
1119
+ },
1120
+ };
1121
+
1122
+ return upsertState({
1123
+ run_id: runId,
1124
+ project_root: args.project_root,
1125
+ phase: previous.phase ?? 'dispatch',
1126
+ status: result.status === 'passed' ? 'validator_gate_ok' : 'validator_gate_blocked',
1127
+ summary: `${result.gate ?? 'G4'}: ${result.status}`,
1128
+ data,
1129
+ });
1130
+ }
1131
+
1132
+ // S10: deriva o slot de recovery para um orquestrador re-spun reconhecer de forma
1133
+ // determinística qual retorno aceitar. Aditivo, não-quebrante: campo top-level
1134
+ // `validator_recovery` no retorno de leitura (null quando não há slot ativo).
1135
+ // Construído na leitura (fora do caminho de persistência/redact), então
1136
+ // `expected_dispatch_token` não é redigido — não reabre a chave genérica `token`.
1137
+ function deriveValidatorRecovery(state) {
1138
+ const cycle = state?.data?.validator_cycle;
1139
+ const active = cycle && typeof cycle === 'object' ? cycle.active : null;
1140
+ if (!active || typeof active !== 'object') return null;
1141
+ return {
1142
+ expected_validator_run_id: typeof active.run_id === 'string' ? active.run_id : null,
1143
+ expected_dispatch_token: Number.isInteger(active.dispatch_token) ? active.dispatch_token : null,
1144
+ expected_state_path: typeof active.state_path === 'string' ? active.state_path : null,
1145
+ attempt: Number.isInteger(active.attempt) ? active.attempt : null,
1146
+ status: typeof cycle.status === 'string' ? cycle.status : null,
1147
+ // P1.1: challenge de proof-of-work do slot ativo (null se não emitido). O
1148
+ // validador irmão lê isto, computa sha256 do arquivo e devolve em challenge_response.
1149
+ challenge: active.challenge && typeof active.challenge === 'object' ? active.challenge : null,
1150
+ };
1151
+ }
1152
+
1153
+ function runState(args = {}) {
1154
+ const action = args.action ?? 'get';
1155
+ if (action === 'get') {
1156
+ const state = readState(validateRunId(args.run_id), args);
1157
+ return { ...state, validator_recovery: deriveValidatorRecovery(state) };
1158
+ }
1159
+ if (action === 'upsert') return upsertState(args);
1160
+ throw rpcError(-32602, `Ação inválida para atlas_run_state: ${action}`);
1161
+ }
1162
+
1163
+ function verifyArtifact(args = {}) {
1164
+ const runId = validateRunId(args.run_id);
1165
+ const artifactPath = requiredString(args, 'artifact_path');
1166
+ const absolutePath = resolveConsumerPath(artifactPath, args);
1167
+ const timestamp = nowIso();
1168
+ // Banner correto por tipo de artefato: verificar um PRD não pode ecoar
1169
+ // "plano · validado". `artifact_kind` é opcional e aditivo — `prd` → banner de
1170
+ // PRD; ausente/`plan` mantém o banner de plano (compat com callers antigos que
1171
+ // só verificavam plano).
1172
+ const artifactKind = optionalString(args, 'artifact_kind');
1173
+ const okBanner = artifactKind === 'prd'
1174
+ ? renderBanner('prd_ok', {})
1175
+ : renderBanner('plano', {});
1176
+ let result;
1177
+
1178
+ try {
1179
+ const stat = fs.statSync(absolutePath);
1180
+ if (!stat.isFile()) {
1181
+ result = {
1182
+ gate: 'G1',
1183
+ status: 'blocked',
1184
+ artifact_path: artifactPath,
1185
+ timestamp,
1186
+ banner: renderBanner('preflight_fail', { motivo: `artefato inválido: ${artifactPath}` }),
1187
+ error: `Artefato não é arquivo legível: ${artifactPath}`,
1188
+ next_action: 'corrigir_artefato',
1189
+ };
1190
+ } else {
1191
+ fs.accessSync(absolutePath, fs.constants.R_OK);
1192
+ result = {
1193
+ gate: 'G1',
1194
+ status: 'passed',
1195
+ artifact_path: artifactPath,
1196
+ bytes: stat.size,
1197
+ timestamp,
1198
+ banner: okBanner,
1199
+ next_action: 'avançar',
1200
+ };
1201
+ }
1202
+ } catch (error) {
1203
+ result = {
1204
+ gate: 'G1',
1205
+ status: 'blocked',
1206
+ artifact_path: artifactPath,
1207
+ timestamp,
1208
+ banner: renderBanner('preflight_fail', { motivo: `artefato ausente: ${artifactPath}` }),
1209
+ error: `Artefato ausente ou ilegível: ${artifactPath}`,
1210
+ cause: error.message,
1211
+ next_action: 'corrigir_artefato',
1212
+ };
1213
+ }
1214
+
1215
+ patchGateResult(runId, 'G1', result, args);
1216
+ return result;
1217
+ }
1218
+
1219
+ function splitPrdSections(content) {
1220
+ const sections = {};
1221
+ let current = null;
1222
+ const lines = content.split(/\r?\n/);
1223
+
1224
+ for (let index = 0; index < lines.length; index += 1) {
1225
+ const line = lines[index];
1226
+ const matched = Object.entries(SECTION_HEADING).find(([, regex]) => regex.test(line));
1227
+ if (matched) current = matched[0];
1228
+ if (current) {
1229
+ sections[current] ??= [];
1230
+ sections[current].push({ line: index + 1, text: line });
1231
+ }
1232
+ }
1233
+
1234
+ return sections;
1235
+ }
1236
+
1237
+ function lineIsExcluded(line) {
1238
+ return line.toLowerCase().includes('depende de plano');
1239
+ }
1240
+
1241
+ function scanSectionPatterns(sections) {
1242
+ const matches = [];
1243
+
1244
+ for (const [sectionKey, patterns] of Object.entries(PRD_PATTERNS)) {
1245
+ const lines = sections[sectionKey] ?? [];
1246
+ const sectionText = lines.map((line) => line.text).join('\n').trim();
1247
+
1248
+ if (sectionKey === 'section_3_decisions') {
1249
+ const hasDecisionRows = /\|\s*D\d+\s*\|/.test(sectionText);
1250
+ if (!hasDecisionRows) {
1251
+ matches.push({
1252
+ section: SECTION_LABELS[sectionKey],
1253
+ pattern: '(empty or minimal content)',
1254
+ line: lines[0]?.line ?? null,
1255
+ excerpt: 'Seção sem decisão D* fechada.',
1256
+ reason: 'Decisões de produto vazias ou mínimas bloqueiam planejamento.',
1257
+ });
1258
+ }
1259
+ }
1260
+
1261
+ for (const { line, text } of lines) {
1262
+ if (lineIsExcluded(text)) continue;
1263
+ const lower = text.toLowerCase();
1264
+ for (const pattern of patterns) {
1265
+ if (lower.includes(pattern.toLowerCase())) {
1266
+ matches.push({
1267
+ section: SECTION_LABELS[sectionKey],
1268
+ pattern,
1269
+ line,
1270
+ excerpt: text.trim().slice(0, 240),
1271
+ reason: 'Padrão de ambiguidade bloqueante detectado.',
1272
+ });
1273
+ }
1274
+ }
1275
+ }
1276
+ }
1277
+
1278
+ return matches;
1279
+ }
1280
+
1281
+ function scanPrd(args = {}) {
1282
+ const runId = validateRunId(args.run_id);
1283
+ const prdPath = requiredString(args, 'prd_path');
1284
+ const absolutePath = resolveConsumerPath(prdPath, args);
1285
+ const timestamp = nowIso();
1286
+ let result;
1287
+
1288
+ try {
1289
+ const content = fs.readFileSync(absolutePath, 'utf8');
1290
+ if (content.trim() === '') {
1291
+ result = {
1292
+ gate: 'G5',
1293
+ status: 'blocked',
1294
+ prd_path: prdPath,
1295
+ timestamp,
1296
+ blocking_count: 1,
1297
+ banner: renderBanner('prd_lacunas', { n: 1 }),
1298
+ blocking_matches: [{
1299
+ section: 'documento',
1300
+ pattern: '(empty file)',
1301
+ line: null,
1302
+ excerpt: '',
1303
+ reason: 'PRD vazio não pode avançar como documento pronto.',
1304
+ }],
1305
+ next_action: 'entrevista',
1306
+ };
1307
+ } else {
1308
+ const blockingMatches = scanSectionPatterns(splitPrdSections(content));
1309
+ result = {
1310
+ gate: 'G5',
1311
+ status: blockingMatches.length === 0 ? 'passed' : 'blocked',
1312
+ prd_path: prdPath,
1313
+ timestamp,
1314
+ blocking_count: blockingMatches.length,
1315
+ banner: blockingMatches.length === 0
1316
+ ? renderBanner('prd_ok', {})
1317
+ : renderBanner('prd_lacunas', { n: blockingMatches.length }),
1318
+ blocking_matches: blockingMatches,
1319
+ next_action: blockingMatches.length === 0 ? 'avançar' : 'entrevista',
1320
+ message: blockingMatches.length === 0
1321
+ ? 'Ambiguity scan: 0 padrões bloqueantes — entrevista pulada'
1322
+ : 'Ambiguity scan: padrões bloqueantes encontrados — entrevista obrigatória',
1323
+ };
1324
+ }
1325
+ } catch (error) {
1326
+ result = {
1327
+ gate: 'G5',
1328
+ status: 'blocked',
1329
+ prd_path: prdPath,
1330
+ timestamp,
1331
+ blocking_count: 1,
1332
+ banner: renderBanner('prd_lacunas', { n: 1 }),
1333
+ blocking_matches: [{
1334
+ section: 'documento',
1335
+ pattern: '(read error)',
1336
+ line: null,
1337
+ excerpt: '',
1338
+ reason: `PRD ilegível: ${prdPath}`,
1339
+ }],
1340
+ error: `PRD ausente ou ilegível: ${prdPath}`,
1341
+ cause: error.message,
1342
+ next_action: 'entrevista',
1343
+ };
1344
+ }
1345
+
1346
+ patchGateResult(runId, 'G5', result, args);
1347
+ return result;
1348
+ }
1349
+
1350
+ function collectHeadings(content) {
1351
+ const headings = new Map();
1352
+ for (const [index, line] of content.split(/\r?\n/).entries()) {
1353
+ const match = /^##\s+(\d+)\.\s+(.+?)\s*$/.exec(line);
1354
+ if (match) headings.set(match[1], { title: match[2], line: index + 1 });
1355
+ }
1356
+ return headings;
1357
+ }
1358
+
1359
+ function hasRequiredStatus(content, requiredStatus) {
1360
+ const regex = new RegExp(`\\|\\s*\\*\\*Status\\*\\*\\s*\\|\\s*${requiredStatus.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\|`, 'i');
1361
+ return regex.test(content);
1362
+ }
1363
+
1364
+ function conformancePending(category, item, line, message, nextAction = 'corrigir_artefato') {
1365
+ return { category, item, line, message, next_action: nextAction };
1366
+ }
1367
+
1368
+ function verifyRequiredSections(headings, requiredSections) {
1369
+ return requiredSections
1370
+ .filter(([number]) => !headings.has(number))
1371
+ .map(([number, title]) => conformancePending(
1372
+ 'seção_obrigatória',
1373
+ `§${number} ${title}`,
1374
+ null,
1375
+ `Seção obrigatória ausente: §${number} ${title}`,
1376
+ ));
1377
+ }
1378
+
1379
+ function verifyPrdConformance(content, requiredStatus) {
1380
+ const pendencies = verifyRequiredSections(collectHeadings(content), REQUIRED_PRD_SECTIONS);
1381
+
1382
+ if (requiredStatus && !hasRequiredStatus(content, requiredStatus)) {
1383
+ pendencies.push(conformancePending(
1384
+ 'status',
1385
+ requiredStatus,
1386
+ null,
1387
+ `Status documental requerido ausente: ${requiredStatus}`,
1388
+ 'ajustar_status_documental',
1389
+ ));
1390
+ }
1391
+
1392
+ if (!/\|\s*D\d+\s*\|/.test(content)) {
1393
+ pendencies.push(conformancePending(
1394
+ 'decisões',
1395
+ 'D*',
1396
+ null,
1397
+ 'PRD sem decisões D* fechadas.',
1398
+ 'registrar_decisoes_fechadas',
1399
+ ));
1400
+ }
1401
+
1402
+ for (const group of ['Produto', 'UX', 'Dados', 'Regressão de produto']) {
1403
+ if (!new RegExp(`\\*\\*${group}\\*\\*`, 'i').test(content)) {
1404
+ pendencies.push(conformancePending(
1405
+ 'critérios_de_aceite',
1406
+ group,
1407
+ null,
1408
+ `Grupo de critérios ausente: ${group}`,
1409
+ 'completar_criterios_de_aceite',
1410
+ ));
1411
+ }
1412
+ }
1413
+
1414
+ const checkboxCount = (content.match(/^- \[[ xX]\]\s+\S/gm) ?? []).length;
1415
+ if (checkboxCount === 0) {
1416
+ pendencies.push(conformancePending(
1417
+ 'critérios_de_aceite',
1418
+ 'checkboxes',
1419
+ null,
1420
+ 'Critérios de aceite observáveis não encontrados.',
1421
+ 'completar_criterios_de_aceite',
1422
+ ));
1423
+ }
1424
+
1425
+ return pendencies;
1426
+ }
1427
+
1428
+ function verifyPlanConformance(content) {
1429
+ // §7 Slices só é obrigatória em `execution_mode: orchestrated-per-slice` (template).
1430
+ // Em `sequencial` a seção é dispensável — não force "§7 Não aplicável" só para passar
1431
+ // o gate (S1). Verdade forte = presença do literal orchestrated-per-slice no cabeçalho.
1432
+ const orchestratedPerSlice = /orchestrated-per-slice/i.test(content);
1433
+ const requiredSections = orchestratedPerSlice
1434
+ ? REQUIRED_PLAN_SECTIONS
1435
+ : REQUIRED_PLAN_SECTIONS.filter(([number]) => number !== '7');
1436
+ const pendencies = verifyRequiredSections(collectHeadings(content), requiredSections);
1437
+
1438
+ if (!/\|\s*\*\*PRD\*\*\s*\|/.test(content)) {
1439
+ pendencies.push(conformancePending(
1440
+ 'referência_prd',
1441
+ 'PRD',
1442
+ null,
1443
+ 'Plano sem link/campo PRD no cabeçalho.',
1444
+ 'vincular_prd',
1445
+ ));
1446
+ }
1447
+
1448
+ if (!/####\s+T\d+\./.test(content)) {
1449
+ pendencies.push(conformancePending(
1450
+ 'tarefas',
1451
+ 'T01..Tn',
1452
+ null,
1453
+ 'Plano sem tarefas numeradas T01..Tn.',
1454
+ 'criar_tarefas_numeradas',
1455
+ ));
1456
+ }
1457
+
1458
+ if (!/BOUNDARY_PRD_PLAN\.md/.test(content)) {
1459
+ pendencies.push(conformancePending(
1460
+ 'boundary',
1461
+ 'BOUNDARY_PRD_PLAN.md',
1462
+ null,
1463
+ 'Plano sem referência à fronteira PRD/PLAN.',
1464
+ 'vincular_boundary',
1465
+ ));
1466
+ }
1467
+
1468
+ return pendencies;
1469
+ }
1470
+
1471
+ function verifyTemplateConformance(args = {}) {
1472
+ const runId = validateRunId(args.run_id);
1473
+ const artifactPath = requiredString(args, 'artifact_path');
1474
+ const artifactType = requiredString(args, 'artifact_type');
1475
+ if (!['prd', 'plan'].includes(artifactType)) {
1476
+ throw rpcError(-32602, 'artifact_type inválido: use prd ou plan');
1477
+ }
1478
+
1479
+ const requiredStatus = optionalString(args, 'required_status');
1480
+ const absolutePath = resolveConsumerPath(artifactPath, args);
1481
+ const timestamp = nowIso();
1482
+ let result;
1483
+
1484
+ try {
1485
+ const content = fs.readFileSync(absolutePath, 'utf8');
1486
+ if (content.trim() === '') {
1487
+ result = {
1488
+ gate: 'template_conformance',
1489
+ status: 'blocked',
1490
+ artifact_type: artifactType,
1491
+ artifact_path: artifactPath,
1492
+ timestamp,
1493
+ pending_count: 1,
1494
+ banner: renderBanner('preflight_fail', { motivo: `TC ${artifactType}: arquivo vazio` }),
1495
+ pendencies: [conformancePending(
1496
+ 'documento',
1497
+ 'arquivo_vazio',
1498
+ null,
1499
+ 'Artefato vazio não pode passar em conformidade.',
1500
+ )],
1501
+ next_action: 'corrigir_artefato',
1502
+ };
1503
+ } else {
1504
+ const pendencies = artifactType === 'prd'
1505
+ ? verifyPrdConformance(content, requiredStatus)
1506
+ : verifyPlanConformance(content);
1507
+ result = {
1508
+ gate: 'template_conformance',
1509
+ status: pendencies.length === 0 ? 'passed' : 'blocked',
1510
+ artifact_type: artifactType,
1511
+ artifact_path: artifactPath,
1512
+ required_status: requiredStatus ?? null,
1513
+ timestamp,
1514
+ pending_count: pendencies.length,
1515
+ banner: pendencies.length === 0
1516
+ ? renderBanner('plano', {})
1517
+ : renderBanner('preflight_fail', { motivo: `TC ${artifactType}: ${pendencies.length} pendências` }),
1518
+ pendencies,
1519
+ next_action: pendencies.length === 0 ? 'avançar' : pendencies[0].next_action,
1520
+ };
1521
+ }
1522
+ } catch (error) {
1523
+ result = {
1524
+ gate: 'template_conformance',
1525
+ status: 'blocked',
1526
+ artifact_type: artifactType,
1527
+ artifact_path: artifactPath,
1528
+ timestamp,
1529
+ pending_count: 1,
1530
+ banner: renderBanner('preflight_fail', { motivo: `TC ${artifactType}: artefato ilegível` }),
1531
+ pendencies: [conformancePending(
1532
+ 'leitura',
1533
+ artifactPath,
1534
+ null,
1535
+ `Artefato ausente ou ilegível: ${artifactPath}`,
1536
+ )],
1537
+ error: `Artefato ausente ou ilegível: ${artifactPath}`,
1538
+ cause: error.message,
1539
+ next_action: 'corrigir_artefato',
1540
+ };
1541
+ }
1542
+
1543
+ patchTemplateConformanceResult(runId, result, args);
1544
+ return result;
1545
+ }
1546
+
1547
+ // Detecta tipo de input para roteamento (PRD D4/D5). Hierarquia de confiança:
1548
+ // (1) verdade forte: conformidade de template de plano passa → 'plan';
1549
+ // (2) dica: cabeçalho/frontmatter canônico de plano → 'plan';
1550
+ // (3) dica fraca: nome casando PLAN_*.md → 'plan';
1551
+ // PRD/backlog por marcadores de template; senão 'unknown'.
1552
+ // Nome de arquivo nunca basta sozinho nem engana (PRD §5 Contrato): só conta como dica
1553
+ // fraca e cede para a verdade forte. Reusa verifyPlanConformance para (1).
1554
+ function classifyArtifactContent(content, fileName = '') {
1555
+ const text = content ?? '';
1556
+
1557
+ // (1) Verdade forte: plano conforme o template canônico (zero pendências).
1558
+ if (text.trim() !== '' && verifyPlanConformance(text).length === 0) {
1559
+ return { artifact_type: 'plan', signal: 'template_conformance' };
1560
+ }
1561
+
1562
+ // (2) Dica de cabeçalho/frontmatter canônico de plano.
1563
+ const planHeaderHint = /\|\s*\*\*PRD\*\*\s*\|/.test(text)
1564
+ || /^#\s+PLAN[\s_]/im.test(text)
1565
+ || /\bexecution_mode\b/.test(text);
1566
+ if (planHeaderHint && /####\s+T\d+\./.test(text)) {
1567
+ return { artifact_type: 'plan', signal: 'header_hint' };
1568
+ }
1569
+
1570
+ // PRD: marcadores do template canônico de PRD.
1571
+ const prdHint = /^#\s+PRD[:\s]/im.test(text)
1572
+ || /\|\s*D\d+\s*\|/.test(text)
1573
+ || /Decisões de produto/i.test(text);
1574
+ if (prdHint) {
1575
+ return { artifact_type: 'prd', signal: 'prd_markers' };
1576
+ }
1577
+
1578
+ // Backlog: marcadores do template canônico de backlog/roadmap.
1579
+ const backlogHint = /\bBACKLOG[\s_]/i.test(text)
1580
+ || /\bSprint\s+S\d+/i.test(text)
1581
+ || /\bRoadmap\b/i.test(text);
1582
+ if (backlogHint) {
1583
+ return { artifact_type: 'backlog', signal: 'backlog_markers' };
1584
+ }
1585
+
1586
+ // (3) Dica fraca: nome PLAN_*.md, só se nada mais classificou.
1587
+ if (/(^|\/)PLAN_[^/]*\.md$/i.test(fileName)) {
1588
+ return { artifact_type: 'plan', signal: 'weak_name_hint' };
1589
+ }
1590
+
1591
+ return { artifact_type: 'unknown', signal: 'no_match' };
1592
+ }
1593
+
1594
+ function classifyInput(args = {}) {
1595
+ const runId = validateRunId(args.run_id);
1596
+ const inputPath = requiredString(args, 'input_path');
1597
+ const absolutePath = resolveConsumerPath(inputPath, args);
1598
+ const timestamp = nowIso();
1599
+ let result;
1600
+
1601
+ // Input que não é arquivo existente = descrição livre (idea), não path. Heurística
1602
+ // determinística: parece path só se terminar em extensão de arquivo E não tiver espaço.
1603
+ // Idea NÃO é "input ilegível" — roteia para `direct` sem BLOCK falso-positivo (A6).
1604
+ // Path com cara de arquivo mas ausente/ilegível continua caindo no catch (erro real).
1605
+ const trimmedInput = inputPath.trim();
1606
+ const looksLikePath = /\.[a-z0-9]{1,6}$/i.test(trimmedInput) && !/\s/.test(trimmedInput);
1607
+ if (!looksLikePath && !fs.existsSync(absolutePath)) {
1608
+ return {
1609
+ gate: 'classify_input',
1610
+ status: 'not_a_file',
1611
+ input_path: inputPath,
1612
+ artifact_type: 'idea',
1613
+ routed_mode: ROUTED_MODE_BY_TYPE.idea,
1614
+ detection_signal: 'free_text_idea',
1615
+ timestamp,
1616
+ banner: renderBanner('roteia', { tipo: 'idea', modo: ROUTED_MODE_BY_TYPE.idea }),
1617
+ next_action: 'rotear_idea_para_direct',
1618
+ };
1619
+ }
1620
+
1621
+ try {
1622
+ const content = fs.readFileSync(absolutePath, 'utf8');
1623
+ const { artifact_type, signal } = classifyArtifactContent(content, inputPath);
1624
+ // Modo-alvo por tipo de input (PRD D3/D6): o fato manda. plan → execute;
1625
+ // prd/backlog → full (gera/usa plano). Data-driven; sem ramo solto.
1626
+ const routedMode = ROUTED_MODE_BY_TYPE[artifact_type] ?? null;
1627
+ result = {
1628
+ gate: 'classify_input',
1629
+ status: artifact_type === 'unknown' ? 'unknown' : 'classified',
1630
+ input_path: inputPath,
1631
+ artifact_type,
1632
+ routed_mode: routedMode,
1633
+ detection_signal: signal,
1634
+ timestamp,
1635
+ // Banner canônico do banco (T06/T07): roteamento por tipo de input.
1636
+ banner: artifact_type === 'unknown'
1637
+ ? renderBanner('preflight_fail', { motivo: `input não classificado: ${inputPath}` })
1638
+ : renderBanner('roteia', { tipo: artifact_type, modo: routedMode }),
1639
+ next_action: artifact_type === 'unknown' ? 'pedir_esclarecimento' : 'rotear_por_tipo',
1640
+ };
1641
+ } catch (error) {
1642
+ result = {
1643
+ gate: 'classify_input',
1644
+ status: 'blocked',
1645
+ input_path: inputPath,
1646
+ artifact_type: 'unknown',
1647
+ timestamp,
1648
+ banner: renderBanner('preflight_fail', { motivo: `input ilegível: ${inputPath}` }),
1649
+ error: `Input ausente ou ilegível: ${inputPath}`,
1650
+ cause: error.message,
1651
+ next_action: 'corrigir_input',
1652
+ };
1653
+ }
1654
+
1655
+ return result;
1656
+ }
1657
+
1658
+ function preflight(args = {}) {
1659
+ const runId = validateRunId(args.run_id);
1660
+ if (Object.prototype.hasOwnProperty.call(args, LEGACY_ROUTE_KEY)) {
1661
+ throw rpcError(-32602, `unknown_property: ${LEGACY_ROUTE_KEY}`);
1662
+ }
1663
+ const mode = requiredString(args, 'mode');
1664
+ const expectedVersion = optionalString(args, 'expected_version');
1665
+ const config = parseWorkflowConfig();
1666
+ const version = readVersionInfo();
1667
+ const activeConflict = findActiveRunConflict(runId, args);
1668
+ const timestamp = nowIso();
1669
+ let previous = null;
1670
+
1671
+ try {
1672
+ previous = readState(runId, args);
1673
+ } catch (error) {
1674
+ if (error.code !== -32004) throw error;
1675
+ }
1676
+
1677
+ const currentRouting = previous?.data?.routing;
1678
+ let result;
1679
+
1680
+ const prereq = checkPrerequisites(args);
1681
+ const join = checkJoinCapability(args);
1682
+ if (prereq.status === 'blocked') {
1683
+ result = {
1684
+ gate: 'PREREQ',
1685
+ status: 'blocked',
1686
+ timestamp,
1687
+ mode,
1688
+ host: prereq.host,
1689
+ missing_prerequisites: prereq.missing,
1690
+ effective_flags: prereq.effective_flags,
1691
+ error: prereq.error,
1692
+ cause: prereq.cause,
1693
+ impact: prereq.impact,
1694
+ next_action: prereq.next_action,
1695
+ };
1696
+ } else if (join.status === 'blocked') {
1697
+ // Gate JOIN após PREREQ passar (ordem determinística: prereq → join → versão/lock).
1698
+ result = {
1699
+ gate: 'JOIN',
1700
+ status: 'blocked',
1701
+ timestamp,
1702
+ mode,
1703
+ host: join.host,
1704
+ error: join.error,
1705
+ cause: join.cause,
1706
+ impact: join.impact,
1707
+ next_action: join.next_action,
1708
+ };
1709
+ } else if (version.status === 'blocked') {
1710
+ result = {
1711
+ gate: 'VERSION_DRIFT',
1712
+ status: 'blocked',
1713
+ timestamp,
1714
+ mode,
1715
+ version,
1716
+ error: version.error,
1717
+ cause: version.error,
1718
+ impact: 'pipeline_hibrido_poderia_gerar_artefato_invalido',
1719
+ next_action: version.next_action,
1720
+ };
1721
+ } else if (expectedVersion && expectedVersion !== version.version) {
1722
+ result = {
1723
+ gate: 'VERSION_DRIFT',
1724
+ status: 'blocked',
1725
+ timestamp,
1726
+ mode,
1727
+ expected_version: expectedVersion,
1728
+ received_version: version.version,
1729
+ error: `Drift de versão: esperado ${expectedVersion}, MCP reportou ${version.version}`,
1730
+ cause: 'expected_version_diverge_do_mcp',
1731
+ impact: 'pipeline_hibrido_poderia_gerar_artefato_invalido',
1732
+ next_action: 'alinhar_versao_do_host_ou_reinstalar_plugin',
1733
+ };
1734
+ } else if (activeConflict.status === 'blocked') {
1735
+ result = {
1736
+ gate: 'LOCK_CONFLICT',
1737
+ status: 'blocked',
1738
+ timestamp,
1739
+ mode,
1740
+ error: activeConflict.error,
1741
+ cause: activeConflict.cause ?? null,
1742
+ impact: activeConflict.impact ?? 'workflow_bloqueado_para_preservar_integridade_do_ledger',
1743
+ conflicting_run_id: activeConflict.conflicting_run_id ?? null,
1744
+ active_phase: activeConflict.active_phase ?? null,
1745
+ next_action: activeConflict.next_action,
1746
+ };
1747
+ } else if (!config.modes.includes(mode)) {
1748
+ result = {
1749
+ gate: 'G10',
1750
+ status: 'blocked',
1751
+ timestamp,
1752
+ mode,
1753
+ error: `Modo inválido: ${mode}`,
1754
+ supported_modes: config.modes,
1755
+ next_action: 'corrigir_rota',
1756
+ };
1757
+ } else if (currentRouting && currentRouting.mode !== mode) {
1758
+ result = {
1759
+ gate: 'G10',
1760
+ status: 'blocked',
1761
+ timestamp,
1762
+ mode,
1763
+ locked_mode: currentRouting.mode,
1764
+ error: `Troca de modo bloqueada: ${currentRouting.mode} -> ${mode}`,
1765
+ next_action: 'encerrar_run_ou_usar_modo_travado',
1766
+ };
1767
+ } else {
1768
+ const guaranteeLevel = guaranteeLevelForMode(mode);
1769
+ // Campo OMITIDO quando o modo não declara garantia (interview-only → null).
1770
+ result = {
1771
+ gate: 'G10',
1772
+ status: 'passed',
1773
+ timestamp,
1774
+ mode,
1775
+ ...(guaranteeLevel ? { guarantee_level: guaranteeLevel } : {}),
1776
+ routing: {
1777
+ mode,
1778
+ ...(expectedExecutorSkill(mode) ? { executor_skill: expectedExecutorSkill(mode) } : {}),
1779
+ ...(guaranteeLevel ? { guarantee_level: guaranteeLevel } : {}),
1780
+ skills: config.skills,
1781
+ version: version.version,
1782
+ locked_at: currentRouting?.locked_at ?? timestamp,
1783
+ config_path: config.path,
1784
+ supported_modes: config.modes,
1785
+ },
1786
+ next_action: 'avançar',
1787
+ };
1788
+ }
1789
+
1790
+ // Banner canônico do preflight (T07): passed → preflight_ok com caps efetivas;
1791
+ // qualquer block → preflight_fail com motivo derivado do gate/erro. Derivado do
1792
+ // status final (não espalha string por branch) — fonte única no banco BANNER_TEMPLATES.
1793
+ if (result.status === 'passed') {
1794
+ result.banner = renderBanner('preflight_ok', { caps: 'subagent+mcp' });
1795
+ } else {
1796
+ const motivo = result.error
1797
+ ? String(result.error).slice(0, 80)
1798
+ : `${result.gate} bloqueado`;
1799
+ result.banner = renderBanner('preflight_fail', { motivo });
1800
+ }
1801
+
1802
+ patchRoutingResult(runId, result, args);
1803
+ return result;
1804
+ }
1805
+
1806
+ function getDispatchState(runId, args = {}) {
1807
+ const state = readState(runId, args);
1808
+ const routing = state.data?.routing;
1809
+ if (!routing) {
1810
+ throw rpcError(-32011, 'Preflight não executado: execute atlas_preflight antes do dispatch', {
1811
+ run_id: runId,
1812
+ });
1813
+ }
1814
+ return { state, routing, dispatch: state.data?.dispatch ?? {} };
1815
+ }
1816
+
1817
+ function expectedNextPhase(routing, dispatch) {
1818
+ if (dispatch.next_phase) return dispatch.next_phase;
1819
+ if (routing.mode === 'full') return 'plan_handoff';
1820
+ if (routing.mode === 'direct') return 'plan_execute';
1821
+ if (routing.mode === 'execute') return 'plan_execute';
1822
+ return 'prd_interview';
1823
+ }
1824
+
1825
+ function initialExecutorLiveness(timestamp) {
1826
+ return {
1827
+ status: 'spawned',
1828
+ bootstrap_timeout_ms: EXECUTOR_BOOTSTRAP_TIMEOUT_MS,
1829
+ progress_timeout_ms: EXECUTOR_PROGRESS_TIMEOUT_MS,
1830
+ bootstrap_deadline_at: isoPlusMs(timestamp, EXECUTOR_BOOTSTRAP_TIMEOUT_MS),
1831
+ next_progress_deadline_at: null,
1832
+ required_first_checkpoint: 'executor_started',
1833
+ last_checkpoint: null,
1834
+ last_progress_at: null,
1835
+ checkpoints: [],
1836
+ };
1837
+ }
1838
+
1839
+ function checkpointStatus(event) {
1840
+ if (event === 'state_path_created') return 'handoff_ready';
1841
+ if (event === 'task_started' || event === 'first_write') return 'executing';
1842
+ if (event === 'plan_loaded' || event === 'handoff_accepted') return 'ready';
1843
+ return 'booting';
1844
+ }
1845
+
1846
+ function startDispatch(args, context) {
1847
+ const phase = requiredString(args, 'phase');
1848
+ if (Object.prototype.hasOwnProperty.call(args, LEGACY_ROUTE_KEY)) {
1849
+ throw rpcError(-32602, `unknown_property: ${LEGACY_ROUTE_KEY}`);
1850
+ }
1851
+ const timestamp = nowIso();
1852
+
1853
+ if (context.dispatch.active) {
1854
+ return {
1855
+ gate: 'G7',
1856
+ action: 'start',
1857
+ phase,
1858
+ status: 'blocked',
1859
+ timestamp,
1860
+ error: `Dispatch paralelo bloqueado: fase ativa ${context.dispatch.active.phase}`,
1861
+ current_phase: context.dispatch.active.phase,
1862
+ expected_phase: context.dispatch.active.phase,
1863
+ next_action: 'aguardar_fase_ativa',
1864
+ };
1865
+ }
1866
+
1867
+ if (phase === 'slice_review' && !context.dispatch.execution_completed) {
1868
+ return {
1869
+ gate: 'G8',
1870
+ action: 'start',
1871
+ phase,
1872
+ status: 'blocked',
1873
+ timestamp,
1874
+ error: 'Review bloqueado: execução ainda não concluída com validator',
1875
+ current_phase: context.dispatch.previous_phase ?? null,
1876
+ expected_phase: 'plan_execute',
1877
+ next_action: 'dispatch_plan_execute_blocking',
1878
+ };
1879
+ }
1880
+
1881
+ const expected = expectedNextPhase(context.routing, context.dispatch);
1882
+ if (phase !== expected && phase !== 'slice_review') {
1883
+ return {
1884
+ gate: 'G7',
1885
+ action: 'start',
1886
+ phase,
1887
+ status: 'blocked',
1888
+ timestamp,
1889
+ error: `Fase fora de ordem: esperado ${expected}, recebido ${phase}`,
1890
+ current_phase: context.dispatch.previous_phase ?? null,
1891
+ expected_phase: expected,
1892
+ next_action: `dispatch_${expected}`,
1893
+ };
1894
+ }
1895
+
1896
+ return {
1897
+ gate: 'G7',
1898
+ action: 'start',
1899
+ phase,
1900
+ status: 'passed',
1901
+ timestamp,
1902
+ current_phase: phase,
1903
+ expected_phase: expected,
1904
+ dispatch: {
1905
+ active: {
1906
+ phase,
1907
+ started_at: timestamp,
1908
+ ...(phase === 'plan_execute' ? { liveness: initialExecutorLiveness(timestamp) } : {}),
1909
+ },
1910
+ previous_phase: context.dispatch.previous_phase ?? null,
1911
+ next_phase: null,
1912
+ next_action: `complete_${phase}`,
1913
+ },
1914
+ next_action: `complete_${phase}`,
1915
+ };
1916
+ }
1917
+
1918
+ function checkpointDispatch(args, context) {
1919
+ const phase = requiredString(args, 'phase');
1920
+ const event = requiredString(args, 'event');
1921
+ const timestamp = nowIso();
1922
+ const active = context.dispatch.active;
1923
+
1924
+ if (phase !== 'plan_execute') {
1925
+ return {
1926
+ gate: 'G12',
1927
+ action: 'checkpoint',
1928
+ phase,
1929
+ event,
1930
+ status: 'blocked',
1931
+ timestamp,
1932
+ error: 'Checkpoint de liveness só se aplica a plan_execute',
1933
+ current_phase: active?.phase ?? null,
1934
+ expected_phase: 'plan_execute',
1935
+ next_action: 'corrigir_fase_do_checkpoint',
1936
+ };
1937
+ }
1938
+ if (!active || active.phase !== phase) {
1939
+ return {
1940
+ gate: 'G12',
1941
+ action: 'checkpoint',
1942
+ phase,
1943
+ event,
1944
+ status: 'blocked',
1945
+ timestamp,
1946
+ error: `Checkpoint fora de ordem: fase ativa ${active?.phase ?? 'nenhuma'}, recebido ${phase}`,
1947
+ current_phase: active?.phase ?? null,
1948
+ expected_phase: active?.phase ?? expectedNextPhase(context.routing, context.dispatch),
1949
+ next_action: active ? `checkpoint_${active.phase}` : `dispatch_${expectedNextPhase(context.routing, context.dispatch)}`,
1950
+ };
1951
+ }
1952
+ if (!EXECUTOR_CHECKPOINT_EVENTS.has(event)) {
1953
+ return {
1954
+ gate: 'G12',
1955
+ action: 'checkpoint',
1956
+ phase,
1957
+ event,
1958
+ status: 'blocked',
1959
+ timestamp,
1960
+ error: `Checkpoint desconhecido: ${event}`,
1961
+ current_phase: phase,
1962
+ expected_phase: phase,
1963
+ next_action: 'emitir_checkpoint_executor_valido',
1964
+ };
1965
+ }
1966
+
1967
+ const planPath = optionalString(args, 'plan_path');
1968
+ const statePathValue = optionalString(args, 'state_path');
1969
+ const detail = optionalString(args, 'detail');
1970
+ if (event === 'state_path_created') {
1971
+ if (!statePathValue || statePathValue.trim() === '') {
1972
+ return {
1973
+ gate: 'G12',
1974
+ action: 'checkpoint',
1975
+ phase,
1976
+ event,
1977
+ status: 'blocked',
1978
+ timestamp,
1979
+ error: 'state_path obrigatório para checkpoint state_path_created',
1980
+ current_phase: phase,
1981
+ expected_phase: phase,
1982
+ next_action: 'emitir_state_path_created_com_state_path',
1983
+ };
1984
+ }
1985
+ try {
1986
+ JSON.parse(fs.readFileSync(resolveConsumerPath(statePathValue, args), 'utf8'));
1987
+ } catch (error) {
1988
+ return {
1989
+ gate: 'G12',
1990
+ action: 'checkpoint',
1991
+ phase,
1992
+ event,
1993
+ status: 'blocked',
1994
+ timestamp,
1995
+ error: `state_path inválido ou ilegível para checkpoint state_path_created: ${error.message}`,
1996
+ current_phase: phase,
1997
+ expected_phase: phase,
1998
+ next_action: 'corrigir_state_path_antes_do_handoff',
1999
+ };
2000
+ }
2001
+ }
2002
+ const liveness = active.liveness && typeof active.liveness === 'object'
2003
+ ? active.liveness
2004
+ : initialExecutorLiveness(active.started_at ?? timestamp);
2005
+ const checkpoint = {
2006
+ event,
2007
+ timestamp,
2008
+ ...(planPath ? { plan_path: planPath } : {}),
2009
+ ...(statePathValue ? { state_path: statePathValue } : {}),
2010
+ ...(detail ? { detail } : {}),
2011
+ };
2012
+ const nextLiveness = {
2013
+ ...liveness,
2014
+ status: checkpointStatus(event),
2015
+ last_checkpoint: event,
2016
+ last_progress_at: timestamp,
2017
+ next_progress_deadline_at: isoPlusMs(timestamp, EXECUTOR_PROGRESS_TIMEOUT_MS),
2018
+ checkpoints: [
2019
+ ...(Array.isArray(liveness.checkpoints) ? liveness.checkpoints : []),
2020
+ checkpoint,
2021
+ ],
2022
+ };
2023
+
2024
+ return {
2025
+ gate: 'G12',
2026
+ action: 'checkpoint',
2027
+ phase,
2028
+ event,
2029
+ status: 'passed',
2030
+ timestamp,
2031
+ executor_liveness: nextLiveness.status,
2032
+ current_phase: phase,
2033
+ expected_phase: phase,
2034
+ dispatch: {
2035
+ active: {
2036
+ ...active,
2037
+ liveness: nextLiveness,
2038
+ },
2039
+ executor_liveness: nextLiveness,
2040
+ next_action: `complete_${phase}`,
2041
+ },
2042
+ next_action: `continue_${phase}`,
2043
+ };
2044
+ }
2045
+
2046
+ function statusDispatch(args, context) {
2047
+ const phase = requiredString(args, 'phase');
2048
+ const timestamp = nowIso();
2049
+ const active = context.dispatch.active;
2050
+
2051
+ if (!active || active.phase !== phase) {
2052
+ return {
2053
+ gate: 'G12',
2054
+ action: 'status',
2055
+ phase,
2056
+ status: 'blocked',
2057
+ timestamp,
2058
+ error: `Status fora de ordem: fase ativa ${active?.phase ?? 'nenhuma'}, recebido ${phase}`,
2059
+ current_phase: active?.phase ?? null,
2060
+ expected_phase: active?.phase ?? expectedNextPhase(context.routing, context.dispatch),
2061
+ next_action: active ? `status_${active.phase}` : `dispatch_${expectedNextPhase(context.routing, context.dispatch)}`,
2062
+ };
2063
+ }
2064
+
2065
+ const liveness = active.liveness && typeof active.liveness === 'object'
2066
+ ? active.liveness
2067
+ : (phase === 'plan_execute' ? initialExecutorLiveness(active.started_at ?? timestamp) : null);
2068
+ const checkpoints = Array.isArray(liveness?.checkpoints) ? liveness.checkpoints : [];
2069
+ const deadline = Date.parse(liveness?.bootstrap_deadline_at ?? '');
2070
+ const now = Date.parse(timestamp);
2071
+ const bootstrapExpired = phase === 'plan_execute'
2072
+ && checkpoints.length === 0
2073
+ && Number.isFinite(deadline)
2074
+ && Number.isFinite(now)
2075
+ && now > deadline;
2076
+ const progressDeadline = Date.parse(liveness?.next_progress_deadline_at ?? '');
2077
+ const progressExpired = phase === 'plan_execute'
2078
+ && checkpoints.length > 0
2079
+ && Number.isFinite(progressDeadline)
2080
+ && Number.isFinite(now)
2081
+ && now > progressDeadline;
2082
+
2083
+ if (bootstrapExpired || progressExpired) {
2084
+ const cause = bootstrapExpired ? 'executor_bootstrap_timeout' : 'executor_progress_timeout';
2085
+ const stalledLiveness = {
2086
+ ...liveness,
2087
+ status: 'stalled',
2088
+ stalled_at: timestamp,
2089
+ cause,
2090
+ };
2091
+ return {
2092
+ gate: 'G12',
2093
+ action: 'status',
2094
+ phase,
2095
+ status: 'blocked',
2096
+ timestamp,
2097
+ cause,
2098
+ error: bootstrapExpired
2099
+ ? `Executor sem checkpoint até ${liveness.bootstrap_deadline_at}`
2100
+ : `Executor sem progresso desde ${liveness.last_progress_at}`,
2101
+ current_phase: phase,
2102
+ expected_phase: phase,
2103
+ dispatch: {
2104
+ active: null,
2105
+ previous_phase: phase,
2106
+ next_phase: phase,
2107
+ next_action: `retry_${phase}`,
2108
+ executor_liveness: stalledLiveness,
2109
+ },
2110
+ next_action: `retry_${phase}`,
2111
+ };
2112
+ }
2113
+
2114
+ return {
2115
+ gate: 'G12',
2116
+ action: 'status',
2117
+ phase,
2118
+ status: 'passed',
2119
+ timestamp,
2120
+ executor_liveness: liveness?.status ?? 'not_tracked',
2121
+ current_phase: phase,
2122
+ expected_phase: phase,
2123
+ dispatch: {
2124
+ active: liveness ? { ...active, liveness } : active,
2125
+ executor_liveness: liveness,
2126
+ next_action: `complete_${phase}`,
2127
+ },
2128
+ next_action: `complete_${phase}`,
2129
+ };
2130
+ }
2131
+
2132
+ function completeDispatch(args, context) {
2133
+ const phase = requiredString(args, 'phase');
2134
+ const timestamp = nowIso();
2135
+ const active = context.dispatch.active;
2136
+
2137
+ if (!active || active.phase !== phase) {
2138
+ return {
2139
+ gate: 'G7',
2140
+ action: 'complete',
2141
+ phase,
2142
+ status: 'blocked',
2143
+ timestamp,
2144
+ error: `Conclusão fora de ordem: fase ativa ${active?.phase ?? 'nenhuma'}, recebido ${phase}`,
2145
+ current_phase: active?.phase ?? null,
2146
+ expected_phase: active?.phase ?? expectedNextPhase(context.routing, context.dispatch),
2147
+ next_action: active ? `complete_${active.phase}` : `dispatch_${expectedNextPhase(context.routing, context.dispatch)}`,
2148
+ };
2149
+ }
2150
+
2151
+ if (phase === 'plan_handoff' && context.routing.mode === 'full') {
2152
+ return {
2153
+ gate: 'G11',
2154
+ action: 'complete',
2155
+ phase,
2156
+ status: 'passed',
2157
+ timestamp,
2158
+ dispatch: {
2159
+ active: null,
2160
+ previous_phase: phase,
2161
+ plan_validated: true,
2162
+ next_phase: 'plan_execute',
2163
+ next_action: 'dispatch_plan_execute_blocking',
2164
+ },
2165
+ next_action: 'dispatch_plan_execute_blocking',
2166
+ };
2167
+ }
2168
+
2169
+ if (phase === 'plan_execute') {
2170
+ const validatorStatus = requiredString(args, 'validator_status');
2171
+ if (!VALIDATOR_PASSED_STATUSES.has(validatorStatus)) {
2172
+ return {
2173
+ gate: 'G8',
2174
+ action: 'complete',
2175
+ phase,
2176
+ status: 'blocked',
2177
+ timestamp,
2178
+ error: `Execução não pode concluir sem validator terminal aprovado; recebido ${validatorStatus}`,
2179
+ current_phase: phase,
2180
+ expected_phase: 'task_validator',
2181
+ next_action: 'rodar_task_validator_antes_do_review',
2182
+ };
2183
+ }
2184
+ return {
2185
+ gate: 'G8',
2186
+ action: 'complete',
2187
+ phase,
2188
+ status: 'passed',
2189
+ timestamp,
2190
+ validator_status: validatorStatus,
2191
+ dispatch: {
2192
+ active: null,
2193
+ previous_phase: phase,
2194
+ execution_completed: true,
2195
+ validator_status: validatorStatus,
2196
+ next_phase: 'slice_review',
2197
+ next_action: 'review_optional_or_complete',
2198
+ },
2199
+ next_action: 'review_optional_or_complete',
2200
+ };
2201
+ }
2202
+
2203
+ if (phase === 'slice_review') {
2204
+ return {
2205
+ gate: 'G8',
2206
+ action: 'complete',
2207
+ phase,
2208
+ status: 'passed',
2209
+ timestamp,
2210
+ dispatch: {
2211
+ active: null,
2212
+ previous_phase: phase,
2213
+ review_completed: true,
2214
+ next_phase: null,
2215
+ next_action: 'complete_allowed',
2216
+ },
2217
+ next_action: 'complete_allowed',
2218
+ };
2219
+ }
2220
+
2221
+ return {
2222
+ gate: 'G7',
2223
+ action: 'complete',
2224
+ phase,
2225
+ status: 'passed',
2226
+ timestamp,
2227
+ dispatch: {
2228
+ active: null,
2229
+ previous_phase: phase,
2230
+ next_phase: expectedNextPhase(context.routing, context.dispatch),
2231
+ next_action: `dispatch_${expectedNextPhase(context.routing, context.dispatch)}`,
2232
+ },
2233
+ next_action: `dispatch_${expectedNextPhase(context.routing, context.dispatch)}`,
2234
+ };
2235
+ }
2236
+
2237
+ function abortDispatch(args, context) {
2238
+ const phase = requiredString(args, 'phase');
2239
+ const timestamp = nowIso();
2240
+ const active = context.dispatch.active;
2241
+ const result = {
2242
+ gate: 'G7',
2243
+ action: 'abort',
2244
+ phase,
2245
+ status: active?.phase === phase ? 'passed' : 'blocked',
2246
+ timestamp,
2247
+ error: active?.phase === phase ? null : `Abort fora de ordem: fase ativa ${active?.phase ?? 'nenhuma'}, recebido ${phase}`,
2248
+ current_phase: active?.phase ?? null,
2249
+ expected_phase: active?.phase ?? null,
2250
+ dispatch: active?.phase === phase ? {
2251
+ active: null,
2252
+ previous_phase: phase,
2253
+ next_phase: phase,
2254
+ next_action: `retry_${phase}`,
2255
+ } : {},
2256
+ next_action: active?.phase === phase ? `retry_${phase}` : `complete_${active?.phase ?? expectedNextPhase(context.routing, context.dispatch)}`,
2257
+ };
2258
+ return result;
2259
+ }
2260
+
2261
+ function lockDispatch(args = {}) {
2262
+ const runId = validateRunId(args.run_id);
2263
+ if (Object.prototype.hasOwnProperty.call(args, LEGACY_ROUTE_KEY)) {
2264
+ throw rpcError(-32602, `unknown_property: ${LEGACY_ROUTE_KEY}`);
2265
+ }
2266
+ const action = args.action ?? 'start';
2267
+ if (!['start', 'checkpoint', 'status', 'complete', 'abort'].includes(action)) {
2268
+ throw rpcError(-32602, `Ação inválida para atlas_lock_dispatch: ${action}`);
2269
+ }
2270
+
2271
+ const context = getDispatchState(runId, args);
2272
+ const result =
2273
+ action === 'start' ? startDispatch(args, context) :
2274
+ action === 'checkpoint' ? checkpointDispatch(args, context) :
2275
+ action === 'status' ? statusDispatch(args, context) :
2276
+ action === 'complete' ? completeDispatch(args, context) :
2277
+ abortDispatch(args, context);
2278
+
2279
+ result.banner = dispatchBanner(result);
2280
+ patchDispatchResult(runId, result, args);
2281
+ return result;
2282
+ }
2283
+
2284
+ // Proof-of-work do validador irmão (P1.1 camada 1). Escopo HONESTO: não é prova
2285
+ // criptográfica de isolamento (o MCP fala stdio com um único caller e não distingue
2286
+ // orquestrador de subagente). É atestação mecânica de que o veredito tocou bytes
2287
+ // reais do boundary — eleva o piso do atalho preguiçoso (afirmar `pass` sem ler
2288
+ // código) e dá rastro de auditoria. Pegar 1 arquivo do `files_changed` do
2289
+ // `state_path`; o validador reporta o sha256 dele; o MCP RECOMPUTA do disco no
2290
+ // complete (nunca armazena o hash esperado em estado legível — senão o orquestrador
2291
+ // só copiaria). Best-effort: sem arquivo legível ⇒ challenge null ⇒ sem enforcement
2292
+ // (não quebra run válida). Seleção determinística por dispatch_token (reproduzível).
2293
+ function pickValidatorChallenge(statePathValue, args, dispatchToken) {
2294
+ try {
2295
+ const sliceState = JSON.parse(fs.readFileSync(resolveConsumerPath(statePathValue, args), 'utf8'));
2296
+ const files = Array.isArray(sliceState.files_changed)
2297
+ ? sliceState.files_changed.filter((f) => typeof f === 'string' && f.trim() !== '')
2298
+ : [];
2299
+ if (files.length === 0) return null;
2300
+ const offset = ((dispatchToken % files.length) + files.length) % files.length;
2301
+ for (let i = 0; i < files.length; i += 1) {
2302
+ const rel = files[(offset + i) % files.length];
2303
+ try {
2304
+ const fabs = resolveConsumerPath(rel, args);
2305
+ if (!fs.statSync(fabs).isFile()) continue;
2306
+ fs.accessSync(fabs, fs.constants.R_OK);
2307
+ return { file: rel, algo: 'sha256' };
2308
+ } catch {
2309
+ // arquivo do boundary inexistente/ilegível (ex.: deletado na slice) — tenta o próximo.
2310
+ }
2311
+ }
2312
+ return null;
2313
+ } catch {
2314
+ // state_path ilegível aqui não bloqueia o start (o validador falha do lado dele
2315
+ // se não conseguir ler o boundary); proof-of-work é aditivo.
2316
+ return null;
2317
+ }
2318
+ }
2319
+
2320
+ // Verifica o challenge_response no complete recomputando o hash do disco.
2321
+ // { ok: true } — sem challenge emitido OU hash confere
2322
+ // { ok: false, reason } — resposta ausente, arquivo ilegível ou hash divergente
2323
+ function verifyValidatorChallenge(challenge, response, args) {
2324
+ if (!challenge || typeof challenge.file !== 'string') return { ok: true };
2325
+ if (typeof response !== 'string' || response.trim() === '') {
2326
+ return { ok: false, reason: 'challenge_response_ausente' };
2327
+ }
2328
+ let actual;
2329
+ try {
2330
+ actual = crypto.createHash('sha256')
2331
+ .update(fs.readFileSync(resolveConsumerPath(challenge.file, args)))
2332
+ .digest('hex');
2333
+ } catch {
2334
+ return { ok: false, reason: 'challenge_file_unreadable' };
2335
+ }
2336
+ // Aceita hex puro ou saída de `shasum` (`<hash> <arquivo>`): primeiro token.
2337
+ const submitted = response.trim().toLowerCase().split(/\s+/)[0];
2338
+ return submitted === actual ? { ok: true } : { ok: false, reason: 'challenge_hash_divergente' };
2339
+ }
2340
+
2341
+ const STATE_REQUIRED_FIELDS = [
2342
+ 'run_id', 'slice', 'tasks', 'files_changed', 'diff_stat', 'plan_path',
2343
+ 'boundary_refs', 'executed_at', 'executor_skill',
2344
+ ];
2345
+ const STATE_EXTENSION_ARRAYS = [
2346
+ 'obligations', 'invariants', 'scenario_probes', 'risk_probes',
2347
+ 'validation_map', 'task_evidence', 'worktree_baseline', 'worktree_final',
2348
+ ];
2349
+
2350
+ function gitOutput(root, gitArgs) {
2351
+ return execFileSync('git', ['-C', root, ...gitArgs], {
2352
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
2353
+ }).trim();
2354
+ }
2355
+
2356
+ function gitLines(root, gitArgs) {
2357
+ const output = gitOutput(root, gitArgs);
2358
+ return output ? output.split(/\r?\n/).filter(Boolean) : [];
2359
+ }
2360
+
2361
+ function stateEvidenceFiles(state) {
2362
+ const result = [];
2363
+ for (const item of [...(state.task_evidence ?? []), ...(state.repair_evidence ?? [])]) {
2364
+ if (!item || typeof item !== 'object') continue;
2365
+ for (const key of ['files', 'files_touched']) {
2366
+ if (Array.isArray(item[key])) result.push(...item[key]);
2367
+ }
2368
+ if (typeof item.file === 'string') result.push(item.file);
2369
+ }
2370
+ return [...new Set(result.filter((item) => typeof item === 'string' && item.trim()))].sort();
2371
+ }
2372
+
2373
+ function snapshotStatus(xy) {
2374
+ if (xy === '??') return 'A';
2375
+ for (const status of ['U', 'D', 'R', 'C', 'A', 'T', 'M']) {
2376
+ if (xy.includes(status)) return status;
2377
+ }
2378
+ return 'M';
2379
+ }
2380
+
2381
+ function normalizeSnapshotPath(input) {
2382
+ if (typeof input !== 'string' || !input.trim()) throw new Error('path vazio');
2383
+ const normalized = path.posix.normalize(input.replaceAll('\\', '/'));
2384
+ if (path.posix.isAbsolute(normalized) || normalized === '..' || normalized.startsWith('../')) {
2385
+ throw new Error('path fora do projeto');
2386
+ }
2387
+ return normalized;
2388
+ }
2389
+
2390
+ function snapshotHash(root, rel) {
2391
+ try {
2392
+ const normalized = normalizeSnapshotPath(rel);
2393
+ const abs = path.resolve(root, normalized);
2394
+ if (abs !== root && !abs.startsWith(`${root}${path.sep}`)) throw new Error('path fora do projeto');
2395
+ const stat = fs.lstatSync(abs);
2396
+ const content = stat.isSymbolicLink()
2397
+ ? Buffer.from(fs.readlinkSync(abs))
2398
+ : fs.readFileSync(abs);
2399
+ return crypto.createHash('sha256').update(content).digest('hex');
2400
+ } catch {
2401
+ return null;
2402
+ }
2403
+ }
2404
+
2405
+ function captureWorktreeSnapshot(root) {
2406
+ const raw = execFileSync('git', [
2407
+ '-C', root, 'status', '--porcelain=v1', '-z', '--untracked-files=all',
2408
+ ]);
2409
+ const records = raw.toString('utf8').split('\0').filter(Boolean);
2410
+ const snapshot = [];
2411
+ for (let index = 0; index < records.length; index += 1) {
2412
+ const record = records[index];
2413
+ const xy = record.slice(0, 2);
2414
+ const rel = normalizeSnapshotPath(record.slice(3));
2415
+ const status = snapshotStatus(xy);
2416
+ if (status === 'R' || status === 'C') {
2417
+ const previous = normalizeSnapshotPath(records[index + 1]);
2418
+ index += 1;
2419
+ if (!previous.startsWith('.atlas/state/')) {
2420
+ snapshot.push({ path: previous, status: 'D', sha256: null });
2421
+ }
2422
+ }
2423
+ if (!rel.startsWith('.atlas/state/')) {
2424
+ snapshot.push({ path: rel, status, sha256: status === 'D' ? null : snapshotHash(root, rel) });
2425
+ }
2426
+ }
2427
+ return snapshot.sort((a, b) => a.path.localeCompare(b.path) || a.status.localeCompare(b.status));
2428
+ }
2429
+
2430
+ function validateSnapshot(snapshot, field, violations) {
2431
+ if (!Array.isArray(snapshot)) return;
2432
+ const paths = new Set();
2433
+ const normalized = [];
2434
+ for (const [index, entry] of snapshot.entries()) {
2435
+ const label = `${field}[${index}]`;
2436
+ if (!entry || typeof entry !== 'object') {
2437
+ violations.push(`${label} deve ser objeto`);
2438
+ continue;
2439
+ }
2440
+ let rel;
2441
+ try {
2442
+ rel = normalizeSnapshotPath(entry.path);
2443
+ } catch {
2444
+ violations.push(`${label}.path inválido`);
2445
+ continue;
2446
+ }
2447
+ if (paths.has(rel)) violations.push(`${field} contém path duplicado: ${rel}`);
2448
+ paths.add(rel);
2449
+ if (!['A', 'M', 'D', 'R', 'C', 'T', 'U'].includes(entry.status)) {
2450
+ violations.push(`${label}.status inválido`);
2451
+ }
2452
+ if (entry.status === 'D') {
2453
+ if (entry.sha256 !== null) violations.push(`${label}.sha256 deve ser null para delete`);
2454
+ } else if (typeof entry.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(entry.sha256)) {
2455
+ violations.push(`${label}.sha256 inválido`);
2456
+ }
2457
+ normalized.push({ path: rel, status: entry.status, sha256: entry.sha256 });
2458
+ }
2459
+ const sorted = [...normalized].sort((a, b) => a.path.localeCompare(b.path) || a.status.localeCompare(b.status));
2460
+ if (JSON.stringify(normalized) !== JSON.stringify(sorted)) violations.push(`${field} deve estar ordenado por path/status`);
2461
+ }
2462
+
2463
+ function snapshotDeltaFiles(baseline, finalSnapshot) {
2464
+ const before = new Map(baseline.map((entry) => [entry.path, JSON.stringify(entry)]));
2465
+ const after = new Map(finalSnapshot.map((entry) => [entry.path, JSON.stringify(entry)]));
2466
+ return [...new Set([...before.keys(), ...after.keys()])]
2467
+ .filter((rel) => before.get(rel) !== after.get(rel))
2468
+ .sort();
2469
+ }
2470
+
2471
+ function validateStateBoundary(statePathValue, args = {}) {
2472
+ let state;
2473
+ try {
2474
+ state = JSON.parse(fs.readFileSync(resolveConsumerPath(statePathValue, args), 'utf8'));
2475
+ } catch (error) {
2476
+ return { ok: false, violations: [`state_path inválido: ${error.message}`] };
2477
+ }
2478
+ const violations = [];
2479
+ for (const field of STATE_REQUIRED_FIELDS) {
2480
+ if (state[field] === undefined || state[field] === null) violations.push(`campo obrigatório ausente: ${field}`);
2481
+ }
2482
+ for (const field of ['tasks', 'files_changed', 'boundary_refs']) {
2483
+ if (!Array.isArray(state[field])) violations.push(`${field} deve ser array`);
2484
+ }
2485
+ const isDirect = state.executor_skill === 'atlas-direct-execute';
2486
+ const hasExtension = state.contract_kind !== undefined;
2487
+ if (!hasExtension && state.executor_skill !== 'atlas-plan-execute') violations.push('schema legado permitido somente para atlas-plan-execute');
2488
+ if (isDirect && state.contract_kind !== 'direct') violations.push('atlas-direct-execute exige contract_kind=direct');
2489
+ if (hasExtension && state.executor_skill === 'atlas-plan-execute' && state.contract_kind !== 'plan') violations.push('atlas-plan-execute exige contract_kind=plan');
2490
+ if (hasExtension && !['plan', 'direct'].includes(state.contract_kind)) violations.push('contract_kind deve ser plan ou direct');
2491
+ if (hasExtension || isDirect) {
2492
+ for (const field of ['base_sha', 'head_sha']) {
2493
+ if (typeof state[field] !== 'string' || !state[field].trim()) violations.push(`${field} obrigatório na extensão`);
2494
+ }
2495
+ for (const field of STATE_EXTENSION_ARRAYS) {
2496
+ if (!Array.isArray(state[field])) violations.push(`${field} deve ser array na extensão`);
2497
+ }
2498
+ if (isDirect && Array.isArray(state.obligations) && state.obligations.length === 0) violations.push('direct exige obligations não vazio');
2499
+ validateSnapshot(state.worktree_baseline, 'worktree_baseline', violations);
2500
+ validateSnapshot(state.worktree_final, 'worktree_final', violations);
2501
+ }
2502
+ if (violations.length > 0 || !hasExtension) {
2503
+ return { ok: violations.length === 0, legacy: !hasExtension, state, violations };
2504
+ }
2505
+ const root = consumerRoot(args);
2506
+ try {
2507
+ gitOutput(root, ['rev-parse', '--verify', `${state.base_sha}^{commit}`]);
2508
+ gitOutput(root, ['rev-parse', '--verify', `${state.head_sha}^{commit}`]);
2509
+ const currentHead = gitOutput(root, ['rev-parse', 'HEAD']);
2510
+ if (currentHead !== state.head_sha) violations.push(`head_sha stale: state=${state.head_sha}, real=${currentHead}`);
2511
+ const committed = gitLines(root, ['diff', '--name-only', `${state.base_sha}...${state.head_sha}`]);
2512
+ const actualFinal = captureWorktreeSnapshot(root);
2513
+ if (JSON.stringify(actualFinal) !== JSON.stringify(state.worktree_final)) {
2514
+ violations.push('worktree_final stale: snapshot diverge do working tree atual');
2515
+ }
2516
+ const worktreeDelta = snapshotDeltaFiles(state.worktree_baseline, state.worktree_final);
2517
+ const claimedEvidence = stateEvidenceFiles(state);
2518
+ const expectedFiles = [...new Set([...committed, ...worktreeDelta])].sort();
2519
+ const declaredFiles = [...new Set((state.files_changed ?? []).filter((f) => typeof f === 'string'))].sort();
2520
+ if (JSON.stringify(expectedFiles) !== JSON.stringify(declaredFiles)) {
2521
+ violations.push(`files_changed diverge do boundary real: esperado=${JSON.stringify(expectedFiles)} recebido=${JSON.stringify(declaredFiles)}`);
2522
+ }
2523
+ if (JSON.stringify(expectedFiles) !== JSON.stringify(claimedEvidence)) {
2524
+ violations.push(`evidência diverge do boundary real: esperado=${JSON.stringify(expectedFiles)} recebido=${JSON.stringify(claimedEvidence)}`);
2525
+ }
2526
+ const statMatch = /^(\d+)\s+files?\b/.exec(String(state.diff_stat).trim());
2527
+ if (!statMatch || Number(statMatch[1]) !== expectedFiles.length) {
2528
+ violations.push(`diff_stat stale: esperado ${expectedFiles.length} files, recebido=${JSON.stringify(state.diff_stat)}`);
2529
+ }
2530
+ } catch (error) {
2531
+ violations.push(`boundary Git inválido: ${error.message}`);
2532
+ }
2533
+ return { ok: violations.length === 0, legacy: false, state, violations };
2534
+ }
2535
+
2536
+ function structuredBlockingFindings(packet) {
2537
+ const findings = Array.isArray(packet?.findings) ? packet.findings : [];
2538
+ return findings.filter((finding) => finding && ['P0', 'P1'].includes(finding.severity));
2539
+ }
2540
+
2541
+ function normalizeFindingsPacket(packet) {
2542
+ if (!packet || typeof packet !== 'object') {
2543
+ return { packet, violations: ['finding packet obrigatório'] };
2544
+ }
2545
+ if (!Array.isArray(packet.findings)) {
2546
+ return { packet, violations: ['findings deve ser array'] };
2547
+ }
2548
+ const violations = [];
2549
+ const ids = new Set();
2550
+ const findings = packet.findings.map((finding, index) => {
2551
+ if (!finding || typeof finding !== 'object') {
2552
+ violations.push(`finding ${index} deve ser objeto`);
2553
+ return finding;
2554
+ }
2555
+ const label = typeof finding.id === 'string' && finding.id.trim()
2556
+ ? finding.id.trim()
2557
+ : `finding ${index}`;
2558
+ if (typeof finding.id !== 'string' || !/^F-\d{3}$/.test(finding.id.trim())) {
2559
+ violations.push(`${label}: id obrigatório no formato F-NNN`);
2560
+ } else if (ids.has(finding.id.trim())) {
2561
+ violations.push(`${label}: id duplicado`);
2562
+ } else {
2563
+ ids.add(finding.id.trim());
2564
+ }
2565
+ if (!['P0', 'P1', 'P2', 'P3'].includes(finding.severity)) {
2566
+ violations.push(`${label}: severity deve ser P0|P1|P2|P3`);
2567
+ }
2568
+ for (const field of ['file', 'failure_mode', 'evidence', 'recommendation', 'fix_validation']) {
2569
+ if (typeof finding[field] !== 'string' || !finding[field].trim()) violations.push(`${label}: ${field} obrigatório`);
2570
+ }
2571
+ if (!Number.isInteger(finding.line) || finding.line < 1) violations.push(`${label}: line inválida`);
2572
+ return { ...finding, msg: `${finding.failure_mode ?? ''}: ${finding.evidence ?? ''}`.trim() };
2573
+ });
2574
+ return { packet: { ...packet, findings }, violations };
2575
+ }
2576
+
2577
+ function validatorStart(args, context) {
2578
+ const runId = validateRunId(args.run_id);
2579
+ const statePathValue = requiredString(args, 'state_path');
2580
+ const timestamp = nowIso();
2581
+ const cycle = normalizeValidatorCycle(context.state.data?.validator_cycle ?? {});
2582
+
2583
+ if (context.dispatch.active?.phase !== 'plan_execute') {
2584
+ return {
2585
+ gate: 'G4',
2586
+ action: 'start',
2587
+ status: 'blocked',
2588
+ timestamp,
2589
+ error: 'Validator só pode iniciar com plan_execute ativo',
2590
+ current_phase: context.dispatch.active?.phase ?? null,
2591
+ next_action: 'manter_plan_execute_ativo_antes_da_validacao',
2592
+ };
2593
+ }
2594
+
2595
+ const liveness = context.dispatch.active.liveness && typeof context.dispatch.active.liveness === 'object'
2596
+ ? context.dispatch.active.liveness
2597
+ : null;
2598
+ const checkpoints = Array.isArray(liveness?.checkpoints) ? liveness.checkpoints : [];
2599
+ const lastCheckpoint = checkpoints.at(-1);
2600
+ if (
2601
+ liveness?.status !== 'handoff_ready'
2602
+ || liveness.last_checkpoint !== 'state_path_created'
2603
+ || lastCheckpoint?.event !== 'state_path_created'
2604
+ || lastCheckpoint?.state_path !== statePathValue
2605
+ ) {
2606
+ return {
2607
+ gate: 'G12',
2608
+ action: 'start',
2609
+ status: 'blocked',
2610
+ timestamp,
2611
+ error: 'Validator bloqueado: executor não comprovou state_path_created para este state_path',
2612
+ current_phase: 'plan_execute',
2613
+ executor_liveness: liveness?.status ?? 'not_tracked',
2614
+ expected_checkpoint: 'state_path_created',
2615
+ state_path: statePathValue,
2616
+ last_checkpoint: liveness?.last_checkpoint ?? null,
2617
+ last_state_path: lastCheckpoint?.state_path ?? null,
2618
+ next_action: 'aguardar_state_path_created_antes_do_validator',
2619
+ };
2620
+ }
2621
+
2622
+ const boundaryValidation = validateStateBoundary(statePathValue, args);
2623
+ if (!boundaryValidation.ok) {
2624
+ return {
2625
+ gate: 'G4', action: 'start', status: 'blocked', timestamp,
2626
+ state_path: statePathValue,
2627
+ boundary_violations: boundaryValidation.violations,
2628
+ error: `State/boundary inválido: ${boundaryValidation.violations.join('; ')}`,
2629
+ next_action: 'regerar_state_path_com_boundary_real',
2630
+ };
2631
+ }
2632
+
2633
+ if (cycle.active) {
2634
+ return {
2635
+ gate: 'G4',
2636
+ action: 'start',
2637
+ status: 'blocked',
2638
+ timestamp,
2639
+ error: `Validator já está ativo (attempt ${cycle.active.attempt})`,
2640
+ validator_attempt: cycle.active.attempt,
2641
+ next_action: 'aguardar_validator_ativo',
2642
+ };
2643
+ }
2644
+
2645
+ // SPEC_FSM_SIBLING_S02 §1.3 / D-S02-2: `passed` e `passed_with_observations`
2646
+ // são terminais SEM transição de saída. A slice fechou com sucesso; um novo
2647
+ // validatorStart não pode reabri-la. Este guard DEVE preceder o HF-05
2648
+ // (attempts_used >= max_attempts) porque quando o terminal é atingido no
2649
+ // attempt 2 (último), attempts_used==max_attempts==2 — sem a prioridade
2650
+ // correta, HF-05 dispararia primeiro retornando causa enganosa
2651
+ // ("Terceiro validator proibido") em vez da causa real ("terminal não reabre").
2652
+ if (VALIDATOR_PASSED_STATUSES.has(cycle.status)) {
2653
+ return {
2654
+ gate: 'G4',
2655
+ action: 'start',
2656
+ status: 'blocked',
2657
+ timestamp,
2658
+ error: `Ciclo do validator já concluído (${cycle.status}); terminal não reabre para novo dispatch`,
2659
+ validator_status: cycle.status,
2660
+ next_action: 'encerrar_slice_terminal_aprovada',
2661
+ };
2662
+ }
2663
+
2664
+ // HF-05: teto de max_attempts atingido. Só chega aqui se o ciclo NÃO está
2665
+ // em estado terminal aprovado (guard acima já descartou esse caso).
2666
+ if (cycle.attempts_used >= cycle.max_attempts) {
2667
+ return {
2668
+ gate: 'G4',
2669
+ action: 'start',
2670
+ status: 'blocked',
2671
+ timestamp,
2672
+ error: `Terceiro validator proibido: attempts=${cycle.attempts_used}, máximo=${cycle.max_attempts}`,
2673
+ validator_attempt: cycle.attempts_used,
2674
+ next_action: 'tratar_como_blocked_final_validator_failed',
2675
+ };
2676
+ }
2677
+
2678
+ if (cycle.status === 'blocked') {
2679
+ return {
2680
+ gate: 'G4',
2681
+ action: 'start',
2682
+ status: 'blocked',
2683
+ timestamp,
2684
+ error: 'Ciclo do validator já está bloqueado para esta slice',
2685
+ next_action: 'encerrar_run_ou_reiniciar_slice_com_decisao_explicita',
2686
+ };
2687
+ }
2688
+
2689
+ if (cycle.status === 'repair_required' || cycle.status === 'repair_running') {
2690
+ return {
2691
+ gate: 'G4',
2692
+ action: 'start',
2693
+ status: 'blocked',
2694
+ timestamp,
2695
+ error: cycle.status === 'repair_running'
2696
+ ? 'Retry do validator exige o término do repair ativo antes de novo dispatch'
2697
+ : 'Retry do validator exige conclusão explícita do repair antes de novo dispatch',
2698
+ validator_attempt: cycle.attempts_used,
2699
+ next_action: 'complete_findings_repair',
2700
+ };
2701
+ }
2702
+
2703
+ const attempt = cycle.attempts_used + 1;
2704
+ const activeValidatorRunId = validatorRunId(runId, attempt, timestamp);
2705
+ // S04: token de dispatch monotônico — incrementa a cada dispatch aceito
2706
+ // (status passed). Nunca decrementado nem reusado dentro da slice.
2707
+ const dispatchToken = cycle.dispatch_token + 1;
2708
+ // P1.1: challenge de proof-of-work amarrado a este attempt. null se o boundary
2709
+ // não tem arquivo legível (best-effort, não bloqueia). Vai ao validador via
2710
+ // validator_recovery.challenge (canal canônico) e também ecoa aqui pro log.
2711
+ const challenge = pickValidatorChallenge(statePathValue, args, dispatchToken);
2712
+ return {
2713
+ gate: 'G4',
2714
+ action: 'start',
2715
+ status: 'passed',
2716
+ timestamp,
2717
+ state_path: statePathValue,
2718
+ validator_attempt: attempt,
2719
+ validator_run_id: activeValidatorRunId,
2720
+ validator_status: 'running',
2721
+ dispatch_token: dispatchToken,
2722
+ challenge,
2723
+ next_action: 'await_validator_verdict',
2724
+ banner: renderBanner('validacao', { status: `running ${attempt}/${cycle.max_attempts}` }),
2725
+ validator_cycle: {
2726
+ dispatch_token: dispatchToken,
2727
+ max_attempts: cycle.max_attempts,
2728
+ attempts_used: attempt,
2729
+ status: 'running',
2730
+ active: {
2731
+ attempt,
2732
+ run_id: activeValidatorRunId,
2733
+ state_path: statePathValue,
2734
+ dispatch_token: dispatchToken,
2735
+ challenge,
2736
+ started_at: timestamp,
2737
+ },
2738
+ last_state_path: statePathValue,
2739
+ repair: {
2740
+ skill: WORKFLOW_CONFIG.skills.findings_repair,
2741
+ status: cycle.repair.status === 'completed' ? 'completed' : 'not_needed',
2742
+ required_from_attempt: cycle.repair.required_from_attempt,
2743
+ requested_at: cycle.repair.requested_at,
2744
+ completed_at: cycle.repair.completed_at,
2745
+ active: null,
2746
+ },
2747
+ // O retry precisa manter os findings originais: o complete do attempt 2
2748
+ // correlaciona `repaired_finding_ids` contra exatamente esse packet.
2749
+ findings_packet: attempt === 2 ? cycle.findings_packet : null,
2750
+ },
2751
+ };
2752
+ }
2753
+
2754
+ function validatorComplete(args, context) {
2755
+ const timestamp = nowIso();
2756
+ const cycle = normalizeValidatorCycle(context.state.data?.validator_cycle ?? {});
2757
+ const statePathValue = requiredString(args, 'state_path');
2758
+ const activeValidatorRunId = requiredString(args, 'validator_run_id');
2759
+ const verdict = requiredString(args, 'verdict');
2760
+ const packetResult = normalizeFindingsPacket(optionalData(args));
2761
+ const packet = packetResult.packet;
2762
+ // S04/S16: token de dispatch é obrigatório para fechar o slot ativo. Ele vem
2763
+ // do validator_recovery lido pela folha fria e volta no output estruturado do
2764
+ // validator. Sem token não existe garantia anti-stale completa.
2765
+ const dispatchToken = optionalInteger(args, 'dispatch_token');
2766
+ const challengeResponse = optionalString(args, 'challenge_response');
2767
+
2768
+ if (!cycle.active) {
2769
+ // S10: slot já fechado. Distinguir retorno duplicado já aplicado (idempotente
2770
+ // reconhecível) de payload sem nenhum slot conhecido. NUNCA reabrir o ciclo.
2771
+ // Um complete já aplicado para este run_id aparece no history como evento
2772
+ // `action: 'complete'`/`status: 'passed'` com o mesmo validator_run_id.
2773
+ const appliedCompleteEvent = cycle.history.find(
2774
+ (event) =>
2775
+ event.action === 'complete' &&
2776
+ event.status === 'passed' &&
2777
+ event.validator_run_id === activeValidatorRunId,
2778
+ );
2779
+ if (appliedCompleteEvent) {
2780
+ return {
2781
+ gate: 'G4',
2782
+ action: 'complete',
2783
+ status: 'blocked',
2784
+ timestamp,
2785
+ validator_run_id: activeValidatorRunId,
2786
+ state_path: statePathValue,
2787
+ stale_discarded: true,
2788
+ reason: 'stale_duplicate_already_applied',
2789
+ last_verdict: cycle.last_verdict,
2790
+ // S10/P3-2: ecoa o veredito real que o complete casado produziu no history
2791
+ // (repair_required, passed, passed_with_observations,
2792
+ // blocked_final_validator_failed). last_verdict reflete só o ciclo atual e
2793
+ // pode divergir do estado real daquele evento — applied_validator_status
2794
+ // evita que o consumidor leia um fail→repair como conclusão bem-sucedida.
2795
+ applied_validator_status: appliedCompleteEvent.validator_status ?? null,
2796
+ error: `Retorno duplicado do validator já aplicado (run_id ${activeValidatorRunId}); descartado de forma idempotente`,
2797
+ next_action: 'descartar_retorno_duplicado_idempotente',
2798
+ };
2799
+ }
2800
+ return {
2801
+ gate: 'G4',
2802
+ action: 'complete',
2803
+ status: 'blocked',
2804
+ timestamp,
2805
+ stale_discarded: true,
2806
+ error: 'Nenhum validator ativo para concluir',
2807
+ next_action: 'start_validator_primeiro',
2808
+ };
2809
+ }
2810
+
2811
+ if (cycle.active.run_id !== activeValidatorRunId) {
2812
+ return {
2813
+ gate: 'G4',
2814
+ action: 'complete',
2815
+ status: 'blocked',
2816
+ timestamp,
2817
+ validator_attempt: cycle.active.attempt,
2818
+ validator_run_id: activeValidatorRunId,
2819
+ stale_discarded: true,
2820
+ error: `validator_run_id não corresponde ao validator ativo: recebido ${activeValidatorRunId}`,
2821
+ next_action: 'aguardar_ou_descartar_retorno_stale_do_validator',
2822
+ };
2823
+ }
2824
+
2825
+ if (cycle.active.state_path !== statePathValue) {
2826
+ return {
2827
+ gate: 'G4',
2828
+ action: 'complete',
2829
+ status: 'blocked',
2830
+ timestamp,
2831
+ validator_attempt: cycle.active.attempt,
2832
+ validator_run_id: activeValidatorRunId,
2833
+ state_path: statePathValue,
2834
+ stale_discarded: true,
2835
+ error: `state_path do validator ativo diverge: esperado ${cycle.active.state_path}, recebido ${statePathValue}`,
2836
+ next_action: 'corrigir_payload_do_validator',
2837
+ };
2838
+ }
2839
+
2840
+ if (dispatchToken === undefined) {
2841
+ return {
2842
+ gate: 'G4',
2843
+ action: 'complete',
2844
+ status: 'blocked',
2845
+ timestamp,
2846
+ validator_attempt: cycle.active.attempt,
2847
+ validator_run_id: activeValidatorRunId,
2848
+ state_path: statePathValue,
2849
+ stale_discarded: true,
2850
+ error: 'dispatch_token obrigatório para concluir validator ativo',
2851
+ next_action: 'reler_validator_recovery_e_reenviar_token',
2852
+ };
2853
+ }
2854
+
2855
+ // S04: verificação idempotente do token de dispatch (anti-stale).
2856
+ // Divergência → blocked SEM fechar o slot (não retorna validator_cycle, então
2857
+ // active é preservado pelo merge).
2858
+ // S10: marca stale_discarded para o orquestrador distinguir stale de erro real.
2859
+ if (cycle.active.dispatch_token !== dispatchToken) {
2860
+ return {
2861
+ gate: 'G4',
2862
+ action: 'complete',
2863
+ status: 'blocked',
2864
+ timestamp,
2865
+ validator_attempt: cycle.active.attempt,
2866
+ validator_run_id: activeValidatorRunId,
2867
+ state_path: statePathValue,
2868
+ stale_discarded: true,
2869
+ error: `token de dispatch divergente: esperado ${cycle.active.dispatch_token}, recebido ${dispatchToken}`,
2870
+ next_action: 'aguardar_ou_descartar_retorno_stale_do_validator',
2871
+ };
2872
+ }
2873
+
2874
+ // P1.1: proof-of-work. Só vale se o start emitiu challenge (cycle.active.challenge).
2875
+ // Falha (resposta ausente/hash divergente) NÃO fecha o slot — igual stale: active é
2876
+ // preservado (não retornamos validator_cycle), o orquestrador re-despacha o MESMO
2877
+ // validador (mesmo attempt) que lê o boundary e reenvia o hash correto. Não consome
2878
+ // attempt nem reabre terminal. Arquivo sumido/ilegível consome o mesmo orçamento bounded.
2879
+ const challengeCheck = verifyValidatorChallenge(cycle.active.challenge, challengeResponse, args);
2880
+ if (!challengeCheck.ok) {
2881
+ // P2-1: falhas de challenge são bounded por attempt. As anteriores ficam no
2882
+ // history (patchValidatorResult registra cada challenge_failed). Esgotado o
2883
+ // teto, o slot fecha terminal (fail-closed) em vez de re-despachar pra sempre.
2884
+ const priorChallengeFailures = cycle.history.filter(
2885
+ (event) =>
2886
+ event.action === 'complete' &&
2887
+ event.validator_status === 'challenge_failed' &&
2888
+ event.validator_run_id === activeValidatorRunId,
2889
+ ).length;
2890
+ if (priorChallengeFailures >= VALIDATOR_CHALLENGE_MAX_FAILURES) {
2891
+ return {
2892
+ gate: 'G4',
2893
+ action: 'complete',
2894
+ status: 'blocked',
2895
+ timestamp,
2896
+ validator_attempt: cycle.active.attempt,
2897
+ validator_run_id: activeValidatorRunId,
2898
+ state_path: statePathValue,
2899
+ validator_status: 'challenge_exhausted',
2900
+ challenge_file: cycle.active.challenge.file,
2901
+ error: `Proof-of-work do validador falhou ${priorChallengeFailures + 1}x (máximo=${VALIDATOR_CHALLENGE_MAX_FAILURES}) para ${cycle.active.challenge.file}; re-dispatch encerrado`,
2902
+ cause: 'validator_proof_of_work_exhausted',
2903
+ impact: 'validador_nao_comprovou_leitura_do_boundary_apos_teto_de_tentativas',
2904
+ next_action: 'encerrar_com_blocked_e_investigar_resolucao_de_path_do_challenge_no_host',
2905
+ banner: renderBanner('validacao', { status: 'blocked_challenge_exhausted' }),
2906
+ validator_cycle: {
2907
+ dispatch_token: cycle.dispatch_token,
2908
+ status: 'blocked',
2909
+ active: null,
2910
+ last_state_path: statePathValue,
2911
+ last_verdict: cycle.last_verdict,
2912
+ findings_packet: cycle.findings_packet,
2913
+ repair: cycle.repair,
2914
+ },
2915
+ };
2916
+ }
2917
+ return {
2918
+ gate: 'G4',
2919
+ action: 'complete',
2920
+ status: 'blocked',
2921
+ timestamp,
2922
+ validator_attempt: cycle.active.attempt,
2923
+ validator_run_id: activeValidatorRunId,
2924
+ state_path: statePathValue,
2925
+ validator_status: 'challenge_failed',
2926
+ challenge_file: cycle.active.challenge.file,
2927
+ challenge_failures: priorChallengeFailures + 1,
2928
+ challenge_failures_max: VALIDATOR_CHALLENGE_MAX_FAILURES,
2929
+ error: `Proof-of-work do validador falhou (${challengeCheck.reason}): o veredito não comprovou leitura de ${cycle.active.challenge.file}`,
2930
+ cause: 'validator_proof_of_work_failed',
2931
+ impact: 'sem_prova_de_leitura_do_boundary_o_veredito_pode_nao_ter_lido_o_codigo',
2932
+ next_action: 'redespachar_o_mesmo_validador_irmao_que_le_o_boundary_e_reenvia_challenge_response',
2933
+ };
2934
+ }
2935
+ const challengeVerified = !cycle.active.challenge ? 'no_challenge' : 'verified';
2936
+
2937
+ if (packetResult.violations.length > 0) {
2938
+ return {
2939
+ gate: 'G4', action: 'complete', status: 'blocked', timestamp,
2940
+ validator_attempt: cycle.active.attempt, validator_run_id: activeValidatorRunId,
2941
+ state_path: statePathValue, validator_status: 'invalid_finding_shape',
2942
+ error: `Finding estruturado inválido: ${packetResult.violations.join('; ')}`,
2943
+ next_action: 'corrigir_shape_estruturado_do_finding',
2944
+ };
2945
+ }
2946
+
2947
+ const normalizedVerdict = verdict === 'pass'
2948
+ ? 'passed'
2949
+ : verdict === 'pass_with_observations'
2950
+ ? 'passed_with_observations'
2951
+ : verdict;
2952
+
2953
+ const blockingFindings = structuredBlockingFindings(packet);
2954
+ if (VALIDATOR_PASSED_STATUSES.has(normalizedVerdict) && blockingFindings.length > 0) {
2955
+ return {
2956
+ gate: 'G4', action: 'complete', status: 'blocked', timestamp,
2957
+ validator_attempt: cycle.active.attempt,
2958
+ validator_run_id: activeValidatorRunId,
2959
+ state_path: statePathValue,
2960
+ validator_status: 'invalid_verdict_severity',
2961
+ finding_ids: blockingFindings.map((finding) => finding.id ?? null),
2962
+ error: `${blockingFindings[0].severity} exige verdict fail`,
2963
+ next_action: 'corrigir_veredito_estruturado_do_validator',
2964
+ };
2965
+ }
2966
+
2967
+ if (cycle.active.attempt === 2
2968
+ && cycle.repair.status === 'completed'
2969
+ && VALIDATOR_PASSED_STATUSES.has(normalizedVerdict)) {
2970
+ const targetIds = structuredBlockingFindings(cycle.findings_packet)
2971
+ .map((finding) => finding.id)
2972
+ .filter((id) => typeof id === 'string' && id.trim());
2973
+ const correlated = new Set(Array.isArray(packet?.repaired_finding_ids) ? packet.repaired_finding_ids : []);
2974
+ const missingIds = targetIds.filter((id) => !correlated.has(id));
2975
+ if (missingIds.length > 0) {
2976
+ return {
2977
+ gate: 'G4', action: 'complete', status: 'blocked', timestamp,
2978
+ validator_attempt: cycle.active.attempt, validator_run_id: activeValidatorRunId,
2979
+ state_path: statePathValue, validator_status: 'repair_correlation_missing',
2980
+ missing_finding_ids: missingIds,
2981
+ error: 'Segundo validator não correlacionou todos os findings reparados',
2982
+ next_action: 'revalidar_repair_e_retornar_repaired_finding_ids',
2983
+ };
2984
+ }
2985
+ }
2986
+
2987
+ if (VALIDATOR_PASSED_STATUSES.has(normalizedVerdict)) {
2988
+ return {
2989
+ gate: 'G4',
2990
+ action: 'complete',
2991
+ status: 'passed',
2992
+ timestamp,
2993
+ validator_attempt: cycle.active.attempt,
2994
+ validator_run_id: activeValidatorRunId,
2995
+ state_path: statePathValue,
2996
+ validator_status: normalizedVerdict,
2997
+ challenge_verified: challengeVerified,
2998
+ next_action: 'complete_plan_execute',
2999
+ banner: renderBanner('validacao', { status: normalizedVerdict }),
3000
+ validator_cycle: {
3001
+ dispatch_token: cycle.dispatch_token,
3002
+ status: normalizedVerdict,
3003
+ active: null,
3004
+ last_state_path: statePathValue,
3005
+ last_verdict: normalizedVerdict,
3006
+ findings_packet: packet ?? null,
3007
+ repair: {
3008
+ skill: WORKFLOW_CONFIG.skills.findings_repair,
3009
+ status: cycle.repair.status === 'completed' ? 'completed' : 'not_needed',
3010
+ required_from_attempt: cycle.repair.required_from_attempt,
3011
+ requested_at: cycle.repair.requested_at,
3012
+ completed_at: cycle.repair.completed_at,
3013
+ active: null,
3014
+ },
3015
+ },
3016
+ };
3017
+ }
3018
+
3019
+ if (normalizedVerdict !== 'fail') {
3020
+ return {
3021
+ gate: 'G4',
3022
+ action: 'complete',
3023
+ status: 'blocked',
3024
+ timestamp,
3025
+ error: `Veredito inválido do validator: ${verdict}`,
3026
+ validator_attempt: cycle.active.attempt,
3027
+ next_action: 'corrigir_output_do_validator',
3028
+ };
3029
+ }
3030
+
3031
+ if (cycle.active.attempt >= cycle.max_attempts) {
3032
+ return {
3033
+ gate: 'G4',
3034
+ action: 'complete',
3035
+ status: 'blocked',
3036
+ timestamp,
3037
+ validator_attempt: cycle.active.attempt,
3038
+ validator_run_id: activeValidatorRunId,
3039
+ state_path: statePathValue,
3040
+ validator_status: 'blocked_final_validator_failed',
3041
+ challenge_verified: challengeVerified,
3042
+ error: `Segundo validator falhou; terceiro validator é proibido (máximo=${cycle.max_attempts})`,
3043
+ next_action: 'encerrar_com_blocked_final_validator_failed',
3044
+ banner: renderBanner('validacao', { status: 'blocked_final_validator_failed' }),
3045
+ validator_cycle: {
3046
+ dispatch_token: cycle.dispatch_token,
3047
+ status: 'blocked',
3048
+ active: null,
3049
+ last_state_path: statePathValue,
3050
+ last_verdict: 'fail',
3051
+ findings_packet: packet ?? null,
3052
+ repair: {
3053
+ skill: WORKFLOW_CONFIG.skills.findings_repair,
3054
+ status: 'exhausted',
3055
+ required_from_attempt: cycle.active.attempt,
3056
+ requested_at: cycle.repair.requested_at,
3057
+ completed_at: cycle.repair.completed_at,
3058
+ active: null,
3059
+ },
3060
+ },
3061
+ };
3062
+ }
3063
+
3064
+ return {
3065
+ gate: 'G4',
3066
+ action: 'complete',
3067
+ status: 'passed',
3068
+ timestamp,
3069
+ validator_attempt: cycle.active.attempt,
3070
+ validator_run_id: activeValidatorRunId,
3071
+ state_path: statePathValue,
3072
+ validator_status: 'repair_required',
3073
+ challenge_verified: challengeVerified,
3074
+ next_action: 'start_findings_repair_lock',
3075
+ banner: renderBanner('validacao', { status: 'repair_required' }),
3076
+ validator_cycle: {
3077
+ dispatch_token: cycle.dispatch_token,
3078
+ status: 'repair_required',
3079
+ active: null,
3080
+ last_state_path: statePathValue,
3081
+ last_verdict: 'fail',
3082
+ findings_packet: packet ?? null,
3083
+ repair: {
3084
+ skill: WORKFLOW_CONFIG.skills.findings_repair,
3085
+ status: 'required',
3086
+ required_from_attempt: cycle.active.attempt,
3087
+ requested_at: timestamp,
3088
+ completed_at: null,
3089
+ active: null,
3090
+ },
3091
+ },
3092
+ };
3093
+ }
3094
+
3095
+ function validatorRepairStart(args, context) {
3096
+ const runId = validateRunId(args.run_id);
3097
+ const timestamp = nowIso();
3098
+ const cycle = normalizeValidatorCycle(context.state.data?.validator_cycle ?? {});
3099
+ const statePathValue = requiredString(args, 'state_path');
3100
+
3101
+ if (cycle.active) {
3102
+ return {
3103
+ gate: 'G4',
3104
+ action: 'repair_start',
3105
+ status: 'blocked',
3106
+ timestamp,
3107
+ error: 'Repair não pode iniciar enquanto há validator ativo',
3108
+ validator_attempt: cycle.active.attempt,
3109
+ next_action: 'aguardar_validator_ativo',
3110
+ };
3111
+ }
3112
+
3113
+ if (cycle.repair.active) {
3114
+ return {
3115
+ gate: 'G4',
3116
+ action: 'repair_start',
3117
+ status: 'blocked',
3118
+ timestamp,
3119
+ validator_attempt: cycle.attempts_used,
3120
+ repair_run_id: cycle.repair.active.run_id ?? null,
3121
+ error: `Repair já está ativo para attempt ${cycle.attempts_used}`,
3122
+ next_action: 'aguardar_findings_repair_ativo',
3123
+ };
3124
+ }
3125
+
3126
+ if (cycle.status !== 'repair_required') {
3127
+ return {
3128
+ gate: 'G4',
3129
+ action: 'repair_start',
3130
+ status: 'blocked',
3131
+ timestamp,
3132
+ error: `Repair fora de ordem: status atual ${cycle.status}`,
3133
+ next_action: 'completar_validator_fail_antes_do_repair',
3134
+ };
3135
+ }
3136
+
3137
+ if (cycle.last_state_path && cycle.last_state_path !== statePathValue) {
3138
+ return {
3139
+ gate: 'G4',
3140
+ action: 'repair_start',
3141
+ status: 'blocked',
3142
+ timestamp,
3143
+ validator_attempt: cycle.attempts_used,
3144
+ state_path: statePathValue,
3145
+ error: `Repair deve partir do state_path do fail: esperado ${cycle.last_state_path}, recebido ${statePathValue}`,
3146
+ next_action: 'corrigir_state_path_do_repair',
3147
+ };
3148
+ }
3149
+
3150
+
3151
+ const boundaryBefore = validateStateBoundary(statePathValue, args);
3152
+ if (!boundaryBefore.ok) {
3153
+ return {
3154
+ gate: 'G4', action: 'repair_start', status: 'blocked', timestamp,
3155
+ state_path: statePathValue,
3156
+ boundary_violations: boundaryBefore.violations,
3157
+ error: `Repair bloqueado por state/boundary inválido: ${boundaryBefore.violations.join('; ')}`,
3158
+ next_action: 'corrigir_state_path_antes_do_repair',
3159
+ };
3160
+ }
3161
+
3162
+ const activeRepairRunId = repairRunId(runId, cycle.attempts_used, timestamp);
3163
+ return {
3164
+ gate: 'G4',
3165
+ action: 'repair_start',
3166
+ status: 'passed',
3167
+ timestamp,
3168
+ validator_attempt: cycle.attempts_used,
3169
+ repair_run_id: activeRepairRunId,
3170
+ repair_budget: 1,
3171
+ findings: cycle.findings_packet?.findings ?? [],
3172
+ state_path: statePathValue,
3173
+ validator_status: 'repair_running',
3174
+ next_action: `dispatch_${WORKFLOW_CONFIG.skills.findings_repair}`,
3175
+ banner: renderBanner('validacao', { status: 'repair_running' }),
3176
+ validator_cycle: {
3177
+ status: 'repair_running',
3178
+ repair: {
3179
+ skill: WORKFLOW_CONFIG.skills.findings_repair,
3180
+ status: 'running',
3181
+ required_from_attempt: cycle.repair.required_from_attempt ?? cycle.attempts_used,
3182
+ requested_at: cycle.repair.requested_at ?? timestamp,
3183
+ completed_at: null,
3184
+ active: {
3185
+ run_id: activeRepairRunId,
3186
+ state_path: statePathValue,
3187
+ started_at: timestamp,
3188
+ boundary_before: {
3189
+ head_sha: boundaryBefore.state.head_sha ?? null,
3190
+ diff_stat: boundaryBefore.state.diff_stat,
3191
+ files_changed: boundaryBefore.state.files_changed,
3192
+ worktree_final: boundaryBefore.state.worktree_final ?? null,
3193
+ },
3194
+ },
3195
+ },
3196
+ },
3197
+ };
3198
+ }
3199
+
3200
+ function validatorRepairComplete(args, context) {
3201
+ const timestamp = nowIso();
3202
+ const cycle = normalizeValidatorCycle(context.state.data?.validator_cycle ?? {});
3203
+ const statePathValue = requiredString(args, 'state_path');
3204
+ const activeRepairRunId = requiredString(args, 'repair_run_id');
3205
+ const repairData = optionalData(args);
3206
+
3207
+ if (cycle.active) {
3208
+ return {
3209
+ gate: 'G4',
3210
+ action: 'repair_complete',
3211
+ status: 'blocked',
3212
+ timestamp,
3213
+ error: 'Repair não pode fechar enquanto há validator ativo',
3214
+ validator_attempt: cycle.active.attempt,
3215
+ next_action: 'aguardar_validator_ativo',
3216
+ };
3217
+ }
3218
+
3219
+ // S10: idempotência reconhecível — um repair_complete já aplicado para este
3220
+ // repair_run_id aparece no history como evento `repair_complete`/`passed`.
3221
+ // Distingue duplicado/fora de ordem após repair concluído de erro real.
3222
+ const appliedRepairEvent = cycle.history.find(
3223
+ (event) =>
3224
+ event.action === 'repair_complete' &&
3225
+ event.status === 'passed' &&
3226
+ event.repair_run_id === activeRepairRunId,
3227
+ );
3228
+
3229
+ if (cycle.status !== 'repair_running') {
3230
+ return {
3231
+ gate: 'G4',
3232
+ action: 'repair_complete',
3233
+ status: 'blocked',
3234
+ timestamp,
3235
+ repair_run_id: activeRepairRunId,
3236
+ stale_discarded: true,
3237
+ ...(appliedRepairEvent ? { reason: 'repair_duplicate_already_applied' } : {}),
3238
+ error: appliedRepairEvent
3239
+ ? `Repair duplicado já aplicado (run_id ${activeRepairRunId}); descartado de forma idempotente`
3240
+ : `Repair fora de ordem: status atual ${cycle.status}`,
3241
+ next_action: appliedRepairEvent
3242
+ ? 'descartar_retorno_duplicado_idempotente'
3243
+ : 'iniciar_findings_repair_antes_de_concluir',
3244
+ };
3245
+ }
3246
+
3247
+ if (!cycle.repair.active) {
3248
+ return {
3249
+ gate: 'G4',
3250
+ action: 'repair_complete',
3251
+ status: 'blocked',
3252
+ timestamp,
3253
+ repair_run_id: activeRepairRunId,
3254
+ stale_discarded: true,
3255
+ ...(appliedRepairEvent ? { reason: 'repair_duplicate_already_applied' } : {}),
3256
+ error: appliedRepairEvent
3257
+ ? `Repair duplicado já aplicado (run_id ${activeRepairRunId}); descartado de forma idempotente`
3258
+ : 'Nenhum repair ativo para concluir',
3259
+ next_action: appliedRepairEvent
3260
+ ? 'descartar_retorno_duplicado_idempotente'
3261
+ : 'start_findings_repair_primeiro',
3262
+ };
3263
+ }
3264
+
3265
+ if (cycle.repair.active.run_id !== activeRepairRunId) {
3266
+ return {
3267
+ gate: 'G4',
3268
+ action: 'repair_complete',
3269
+ status: 'blocked',
3270
+ timestamp,
3271
+ validator_attempt: cycle.attempts_used,
3272
+ repair_run_id: activeRepairRunId,
3273
+ stale_discarded: true,
3274
+ ...(appliedRepairEvent ? { reason: 'repair_duplicate_already_applied' } : {}),
3275
+ error: `repair_run_id não corresponde ao repair ativo: recebido ${activeRepairRunId}`,
3276
+ next_action: 'aguardar_ou_descartar_retorno_stale_do_repair',
3277
+ };
3278
+ }
3279
+
3280
+ if (cycle.repair.active.state_path !== statePathValue) {
3281
+ return {
3282
+ gate: 'G4',
3283
+ action: 'repair_complete',
3284
+ status: 'blocked',
3285
+ timestamp,
3286
+ validator_attempt: cycle.attempts_used,
3287
+ repair_run_id: activeRepairRunId,
3288
+ state_path: statePathValue,
3289
+ stale_discarded: true,
3290
+ error: `state_path do repair ativo diverge: esperado ${cycle.repair.active.state_path}, recebido ${statePathValue}`,
3291
+ next_action: 'atualizar_o_state_path_original_sem_redirecionar_boundary',
3292
+ };
3293
+ }
3294
+
3295
+
3296
+ const boundaryAfter = validateStateBoundary(statePathValue, args);
3297
+ if (!boundaryAfter.ok) {
3298
+ return {
3299
+ gate: 'G4', action: 'repair_complete', status: 'blocked', timestamp,
3300
+ repair_run_id: activeRepairRunId, state_path: statePathValue,
3301
+ boundary_violations: boundaryAfter.violations,
3302
+ error: `Repair não atualizou state/boundary completo: ${boundaryAfter.violations.join('; ')}`,
3303
+ next_action: 'atualizar_head_stat_snapshots_files_e_evidencias_no_state_original',
3304
+ };
3305
+ }
3306
+
3307
+ const targets = structuredBlockingFindings(cycle.findings_packet).filter(
3308
+ (finding) => typeof finding.id === 'string' && finding.id.trim(),
3309
+ );
3310
+ const findings = Array.isArray(cycle.findings_packet?.findings) ? cycle.findings_packet.findings : [];
3311
+ const receivedIds = new Set(findings.map((finding) => finding?.id).filter(Boolean));
3312
+ const repairs = Array.isArray(repairData?.repairs) ? repairData.repairs : [];
3313
+ const stateRepairs = Array.isArray(boundaryAfter.state.repair_evidence)
3314
+ ? boundaryAfter.state.repair_evidence
3315
+ : [];
3316
+ const repairViolations = [];
3317
+ for (const [label, entries] of [['output', repairs], ['state', stateRepairs]]) {
3318
+ const seen = new Set();
3319
+ for (const repair of entries) {
3320
+ const id = repair?.finding_id;
3321
+ if (!receivedIds.has(id)) repairViolations.push(`${label}: repair ID desconhecido ${id ?? '<ausente>'}`);
3322
+ if (seen.has(id)) repairViolations.push(`${label}: repair ID duplicado ${id}`);
3323
+ seen.add(id);
3324
+ if (!Array.isArray(repair?.files_touched) || repair.files_touched.length === 0) {
3325
+ repairViolations.push(`${label}: ${id ?? '<ausente>'} sem files_touched`);
3326
+ }
3327
+ }
3328
+ }
3329
+ const normalizeRepair = (repair) => ({
3330
+ finding_id: repair.finding_id,
3331
+ files_touched: [...repair.files_touched].sort(),
3332
+ checks_run: [...(repair.checks_run ?? [])].sort(),
3333
+ status: repair.status,
3334
+ });
3335
+ if (repairViolations.length === 0) {
3336
+ const outputNormalized = repairs.map(normalizeRepair).sort((a, b) => a.finding_id.localeCompare(b.finding_id));
3337
+ const stateNormalized = stateRepairs.map(normalizeRepair).sort((a, b) => a.finding_id.localeCompare(b.finding_id));
3338
+ if (JSON.stringify(outputNormalized) !== JSON.stringify(stateNormalized)) {
3339
+ repairViolations.push('output do repair diverge de repair_evidence persistido');
3340
+ }
3341
+ }
3342
+ const before = cycle.repair.active.boundary_before;
3343
+ if (Array.isArray(before?.worktree_final) && Array.isArray(boundaryAfter.state.worktree_final)) {
3344
+ let committedDuringRepair = [];
3345
+ if (before.head_sha && before.head_sha !== boundaryAfter.state.head_sha) {
3346
+ try {
3347
+ committedDuringRepair = gitLines(consumerRoot(args), [
3348
+ 'diff', '--name-only', `${before.head_sha}...${boundaryAfter.state.head_sha}`,
3349
+ ]);
3350
+ } catch (error) {
3351
+ repairViolations.push(`não foi possível derivar commits do repair: ${error.message}`);
3352
+ }
3353
+ }
3354
+ const touchedReal = [...new Set([
3355
+ ...snapshotDeltaFiles(before.worktree_final, boundaryAfter.state.worktree_final),
3356
+ ...committedDuringRepair,
3357
+ ])].sort();
3358
+ const touchedClaimed = [...new Set(repairs.flatMap((repair) => repair?.files_touched ?? []))].sort();
3359
+ if (JSON.stringify(touchedReal) !== JSON.stringify(touchedClaimed)) {
3360
+ repairViolations.push(`arquivos do repair divergem do delta real: esperado=${JSON.stringify(touchedReal)} recebido=${JSON.stringify(touchedClaimed)}`);
3361
+ }
3362
+ }
3363
+ if (repairViolations.length > 0) {
3364
+ return {
3365
+ gate: 'G4', action: 'repair_complete', status: 'blocked', timestamp,
3366
+ repair_run_id: activeRepairRunId, state_path: statePathValue,
3367
+ repair_violations: repairViolations,
3368
+ error: `Repair fora do boundary recebido: ${repairViolations.join('; ')}`,
3369
+ next_action: 'corrigir_correlacao_ids_arquivos_e_state_do_repair',
3370
+ };
3371
+ }
3372
+ if (targets.length > 0) {
3373
+ const missing = targets.filter((target) => {
3374
+ const output = repairs.find((repair) => repair?.finding_id === target.id);
3375
+ const persisted = stateRepairs.find((repair) => repair?.finding_id === target.id);
3376
+ return output?.status !== 'resolved'
3377
+ || !Array.isArray(output.files_touched) || output.files_touched.length === 0
3378
+ || !Array.isArray(output.checks_run) || output.checks_run.length === 0
3379
+ || persisted?.status !== 'resolved'
3380
+ || !Array.isArray(persisted.files_touched) || persisted.files_touched.length === 0
3381
+ || !Array.isArray(persisted.checks_run) || persisted.checks_run.length === 0;
3382
+ });
3383
+ if (missing.length > 0) {
3384
+ return {
3385
+ gate: 'G4', action: 'repair_complete', status: 'blocked', timestamp,
3386
+ repair_run_id: activeRepairRunId, state_path: statePathValue,
3387
+ unresolved_finding_ids: missing.map((finding) => finding.id),
3388
+ error: 'Repair sem evidência de resolução para finding P0/P1 alvo',
3389
+ next_action: 'persistir_correlacao_finding_arquivo_check_status',
3390
+ };
3391
+ }
3392
+ }
3393
+
3394
+ return {
3395
+ gate: 'G4',
3396
+ action: 'repair_complete',
3397
+ status: 'passed',
3398
+ timestamp,
3399
+ validator_attempt: cycle.attempts_used,
3400
+ repair_run_id: activeRepairRunId,
3401
+ validator_status: 'ready_for_retry',
3402
+ state_path: statePathValue,
3403
+ next_action: 'dispatch_task_validator_retry',
3404
+ banner: renderBanner('validacao', { status: 'ready_for_retry' }),
3405
+ validator_cycle: {
3406
+ status: 'ready_for_retry',
3407
+ active: null,
3408
+ last_state_path: statePathValue,
3409
+ repair: {
3410
+ skill: WORKFLOW_CONFIG.skills.findings_repair,
3411
+ status: 'completed',
3412
+ required_from_attempt: cycle.repair.required_from_attempt ?? cycle.attempts_used,
3413
+ requested_at: cycle.repair.requested_at,
3414
+ completed_at: timestamp,
3415
+ active: null,
3416
+ },
3417
+ },
3418
+ };
3419
+ }
3420
+
3421
+ function lockValidator(args = {}) {
3422
+ const runId = validateRunId(args.run_id);
3423
+ const action = args.action ?? 'start';
3424
+ if (!['start', 'complete', 'repair_start', 'repair_complete'].includes(action)) {
3425
+ throw rpcError(-32602, `Ação inválida para atlas_lock_validator: ${action}`);
3426
+ }
3427
+ const context = getDispatchState(runId, args);
3428
+ const result = action === 'start'
3429
+ ? validatorStart(args, context)
3430
+ : action === 'complete'
3431
+ ? validatorComplete(args, context)
3432
+ : action === 'repair_start'
3433
+ ? validatorRepairStart(args, context)
3434
+ : validatorRepairComplete(args, context);
3435
+ patchValidatorResult(runId, result, args);
3436
+ return result;
3437
+ }
3438
+
3439
+ // Banner canônico do lock_dispatch (T07): mapeia (fase, status) ao evento do
3440
+ // banco. Fase de execução → `exec`/`validação`; review → `review`; conclusão de
3441
+ // plano → `plano`; bloqueio → `preflight_fail` (BLOCK genérico com motivo).
3442
+ // Tabela data-driven; nenhuma string de banner montada inline no gate.
3443
+ function dispatchBanner(result) {
3444
+ if (result.status === 'blocked') {
3445
+ const motivo = result.error ? String(result.error).slice(0, 80) : `${result.phase} bloqueado`;
3446
+ return renderBanner('preflight_fail', { motivo });
3447
+ }
3448
+ if (result.phase === 'slice_review') {
3449
+ return renderBanner('review', { status: result.action === 'complete' ? 'ok' : 'iniciado' });
3450
+ }
3451
+ if (result.phase === 'plan_execute') {
3452
+ // complete carrega validator_status → evento de validação; start → exec da slice.
3453
+ return result.action === 'complete'
3454
+ ? renderBanner('validacao', { status: result.validator_status ?? 'ok' })
3455
+ : renderBanner('exec', { i: 1, n: 1 });
3456
+ }
3457
+ if (result.phase === 'plan_handoff') {
3458
+ return renderBanner('plano', {});
3459
+ }
3460
+ // demais fases (prd_interview etc.): exec genérico da fase em andamento.
3461
+ return renderBanner('exec', { i: 1, n: 1 });
3462
+ }
3463
+
3464
+ function assertAfterPlan(args = {}) {
3465
+ const runId = validateRunId(args.run_id);
3466
+ const attemptedAction = requiredString(args, 'attempted_action');
3467
+ const { routing, dispatch } = getDispatchState(runId, args);
3468
+ const timestamp = nowIso();
3469
+ let result;
3470
+
3471
+ if (routing.mode === 'execute') {
3472
+ // PRD D13: o gate de bloqueio pós-plano é próprio do full e NÃO se aplica a
3473
+ // execute — o plano já é o input inicial. Aqui não se exige fase de plano;
3474
+ // o equivalente é a re-verificação do plano antes de despachar a execução.
3475
+ result = {
3476
+ gate: 'G11',
3477
+ action: 'assert_after_plan',
3478
+ phase: 'after_plan',
3479
+ status: 'passed',
3480
+ mode: 'execute',
3481
+ applicable: false,
3482
+ timestamp,
3483
+ current_phase: dispatch.previous_phase ?? null,
3484
+ expected_phase: 'plan_execute',
3485
+ note: 'assert_after_plan não se aplica em execute (PRD D13): plano é o input; re-verifique o plano antes do dispatch.',
3486
+ next_action: 'reverificar_plano_e_dispatch_plan_execute',
3487
+ };
3488
+ } else if (routing.mode === 'full' && dispatch.plan_validated && !dispatch.execution_completed) {
3489
+ if (attemptedAction === 'dispatch_plan_execute') {
3490
+ result = {
3491
+ gate: 'G11',
3492
+ action: 'assert_after_plan',
3493
+ phase: 'after_plan',
3494
+ status: 'passed',
3495
+ timestamp,
3496
+ current_phase: dispatch.previous_phase ?? null,
3497
+ expected_phase: 'plan_execute',
3498
+ next_action: 'dispatch_plan_execute_blocking',
3499
+ };
3500
+ } else {
3501
+ result = {
3502
+ gate: 'G11',
3503
+ action: 'assert_after_plan',
3504
+ phase: 'after_plan',
3505
+ status: 'blocked',
3506
+ // block_kind diferencia "gate funcionando" de "erro de pipeline": este bloqueio é
3507
+ // o guard de conclusão prematura disparando como projetado (S3). Não é falha do
3508
+ // MCP nem estado corrompido — a ação correta é despachar plan_execute e seguir.
3509
+ block_kind: 'premature_completion_guard',
3510
+ timestamp,
3511
+ error: `Conclusão prematura bloqueada no full: ${attemptedAction}`,
3512
+ note: 'Gate G11 funcionando: o full não conclui só com handoff. Não é erro de pipeline — despache plan_execute (blocking) e prossiga.',
3513
+ current_phase: dispatch.previous_phase ?? null,
3514
+ expected_phase: 'plan_execute',
3515
+ next_action: 'dispatch_plan_execute_blocking',
3516
+ };
3517
+ }
3518
+ } else {
3519
+ result = {
3520
+ gate: 'G11',
3521
+ action: 'assert_after_plan',
3522
+ phase: 'after_plan',
3523
+ status: 'passed',
3524
+ timestamp,
3525
+ current_phase: dispatch.previous_phase ?? null,
3526
+ expected_phase: dispatch.next_phase ?? null,
3527
+ next_action: dispatch.next_action ?? 'avançar',
3528
+ };
3529
+ }
3530
+
3531
+ // Banner canônico do assert_after_plan (T07): pós-plano coerente com o evento
3532
+ // `plano` (plano validado / re-verificação) quando passa; BLOCK com motivo quando
3533
+ // bloqueia. Fonte única no banco BANNER_TEMPLATES.
3534
+ result.banner = result.status === 'blocked'
3535
+ ? renderBanner('preflight_fail', { motivo: result.error ? String(result.error).slice(0, 80) : 'pós-plano bloqueado' })
3536
+ : renderBanner('plano', {});
3537
+
3538
+ patchDispatchResult(runId, result, args);
3539
+ return result;
3540
+ }
3541
+
3542
+ function toolResult(value) {
3543
+ // JSON compacto (sem indentação): o consumidor é o LLM orquestrador, que parseia
3544
+ // igual com ou sem whitespace. Pretty-print só gastava tokens em toda resposta MCP
3545
+ // (~10-13 por run). Mesmos campos/valores — zero impacto em determinismo/contrato.
3546
+ return {
3547
+ content: [
3548
+ {
3549
+ type: 'text',
3550
+ text: JSON.stringify(value),
3551
+ },
3552
+ ],
3553
+ };
3554
+ }
3555
+
3556
+ function toolsList() {
3557
+ return {
3558
+ tools: [
3559
+ {
3560
+ name: 'atlas_ping',
3561
+ description: 'Retorna saúde, identidade, versão e capacidades mínimas do MCP Atlas Workflow.',
3562
+ inputSchema: {
3563
+ type: 'object',
3564
+ additionalProperties: false,
3565
+ properties: {},
3566
+ },
3567
+ },
3568
+ {
3569
+ name: 'atlas_capabilities',
3570
+ description: 'Adapter de host: detecta o host (Claude/Codex/genérico) e retorna descritores canônicos de disparo de subagente, todo nativo e paths de plano. Skills consultam isto em vez de hardcodar nome de host.',
3571
+ inputSchema: {
3572
+ type: 'object',
3573
+ additionalProperties: false,
3574
+ properties: {
3575
+ host: { type: 'string', enum: HOST_NAMES },
3576
+ },
3577
+ },
3578
+ },
3579
+ {
3580
+ name: 'atlas_run_state',
3581
+ description: 'Cria, atualiza ou consulta estado de run em .atlas/state/ no cwd do projeto consumidor.',
3582
+ inputSchema: {
3583
+ type: 'object',
3584
+ additionalProperties: false,
3585
+ required: ['run_id'],
3586
+ properties: {
3587
+ action: { type: 'string', enum: ['get', 'upsert'], default: 'get' },
3588
+ run_id: { type: 'string', minLength: 1 },
3589
+ project_root: { type: 'string', minLength: 1 },
3590
+ phase: { type: 'string' },
3591
+ status: { type: 'string' },
3592
+ summary: { type: 'string' },
3593
+ data: { type: 'object' },
3594
+ },
3595
+ },
3596
+ },
3597
+ {
3598
+ name: 'atlas_verify_artifact',
3599
+ description: 'Gate G1: verifica se artefato obrigatório existe em disco e é legível.',
3600
+ inputSchema: {
3601
+ type: 'object',
3602
+ additionalProperties: false,
3603
+ required: ['run_id', 'artifact_path'],
3604
+ properties: {
3605
+ run_id: { type: 'string', minLength: 1 },
3606
+ project_root: { type: 'string', minLength: 1 },
3607
+ artifact_path: { type: 'string', minLength: 1 },
3608
+ artifact_kind: { enum: ['prd', 'plan'] },
3609
+ },
3610
+ },
3611
+ },
3612
+ {
3613
+ name: 'atlas_scan_prd',
3614
+ description: 'Gate G5: escaneia PRD por padrões determinísticos de ambiguidade bloqueante.',
3615
+ inputSchema: {
3616
+ type: 'object',
3617
+ additionalProperties: false,
3618
+ required: ['run_id', 'prd_path'],
3619
+ properties: {
3620
+ run_id: { type: 'string', minLength: 1 },
3621
+ project_root: { type: 'string', minLength: 1 },
3622
+ prd_path: { type: 'string', minLength: 1 },
3623
+ },
3624
+ },
3625
+ },
3626
+ {
3627
+ name: 'atlas_verify_template_conformance',
3628
+ description: 'Gate de conformidade: valida PRD ou plano contra o template canônico aplicável e registra pendências acionáveis.',
3629
+ inputSchema: {
3630
+ type: 'object',
3631
+ additionalProperties: false,
3632
+ required: ['run_id', 'artifact_path', 'artifact_type'],
3633
+ properties: {
3634
+ run_id: { type: 'string', minLength: 1 },
3635
+ project_root: { type: 'string', minLength: 1 },
3636
+ artifact_path: { type: 'string', minLength: 1 },
3637
+ artifact_type: { type: 'string', enum: ['prd', 'plan'] },
3638
+ required_status: { type: 'string' },
3639
+ },
3640
+ },
3641
+ },
3642
+ {
3643
+ name: 'atlas_classify_input',
3644
+ description: 'Classifica o input em backlog|prd|plan|unknown (PRD D4/D5). Verdade forte = conformidade de template de plano passa; depois cabeçalho canônico; nome PLAN_*.md é só dica fraca. Devolve artifact_type + banner de roteamento. Alimenta o guardrail anti plano-de-plano.',
3645
+ inputSchema: {
3646
+ type: 'object',
3647
+ additionalProperties: false,
3648
+ required: ['run_id', 'input_path'],
3649
+ properties: {
3650
+ run_id: { type: 'string', minLength: 1 },
3651
+ project_root: { type: 'string', minLength: 1 },
3652
+ input_path: { type: 'string', minLength: 1 },
3653
+ },
3654
+ },
3655
+ },
3656
+ {
3657
+ name: 'atlas_preflight',
3658
+ description: 'Gate PREREQ+G10: hard-fail de pré-requisitos de determinismo (subagente/MCP do host, DEC-004), depois valida modo, versão e lock ativo, travando a rota da run. Output declara guarantee_level (enum full_pipeline|reduced_standalone).',
3659
+ inputSchema: {
3660
+ type: 'object',
3661
+ additionalProperties: false,
3662
+ required: ['run_id', 'mode'],
3663
+ properties: {
3664
+ run_id: { type: 'string', minLength: 1 },
3665
+ project_root: { type: 'string', minLength: 1 },
3666
+ mode: { type: 'string', enum: WORKFLOW_CONFIG.modes },
3667
+ expected_version: { type: 'string' },
3668
+ host: { type: 'string', enum: HOST_NAMES },
3669
+ // additionalProperties:false é enforçado pelo client MCP; o servidor ainda
3670
+ // delimita defensivamente o override a PREREQUISITE_FLAGS em checkPrerequisites.
3671
+ host_capabilities: {
3672
+ type: 'object',
3673
+ description: 'Disponibilidade real reportada pelo host (override das flags do perfil). Ex.: pi sem deps → {"subagent_available":false}.',
3674
+ additionalProperties: false,
3675
+ properties: {
3676
+ subagent_available: { type: 'boolean' },
3677
+ mcp_available: { type: 'boolean' },
3678
+ todo_available: { type: 'boolean' },
3679
+ // Gate JOIN separado (DEC-SIB-003): report afirmativo de join síncrono
3680
+ // para hosts must_report (pi/generic). NÃO entra em PREREQUISITE_FLAGS.
3681
+ join_sync_available: { type: 'boolean' },
3682
+ },
3683
+ },
3684
+ },
3685
+ },
3686
+ },
3687
+ {
3688
+ name: 'atlas_lock_dispatch',
3689
+ description: 'Gates G7/G8/G12: controla fase ativa, checkpoints de liveness do executor, transições de dispatch, validator antes de review e concorrência 1.',
3690
+ inputSchema: {
3691
+ type: 'object',
3692
+ additionalProperties: false,
3693
+ required: ['run_id', 'phase'],
3694
+ properties: {
3695
+ run_id: { type: 'string', minLength: 1 },
3696
+ project_root: { type: 'string', minLength: 1 },
3697
+ action: { type: 'string', enum: ['start', 'checkpoint', 'status', 'complete', 'abort'], default: 'start' },
3698
+ phase: { type: 'string', enum: ['plan_handoff', 'plan_execute', 'slice_review'] },
3699
+ event: {
3700
+ type: 'string',
3701
+ enum: [...EXECUTOR_CHECKPOINT_EVENTS],
3702
+ description: 'Checkpoint G12 emitido pelo executor durante plan_execute.',
3703
+ },
3704
+ plan_path: { type: 'string' },
3705
+ state_path: { type: 'string' },
3706
+ detail: { type: 'string' },
3707
+ validator_status: { type: 'string' },
3708
+ },
3709
+ },
3710
+ },
3711
+ {
3712
+ name: 'atlas_lock_validator',
3713
+ description: 'Gate G4/G8 sibling: enforça um validator por vez em todos os hosts, dispatch_token obrigatório no retorno, proof-of-work (challenge sha256 do boundary recomputado no complete), máximo de 2 attempts, repair obrigatório entre fail e retry e bloqueio explícito do terceiro validator.',
3714
+ inputSchema: {
3715
+ type: 'object',
3716
+ additionalProperties: false,
3717
+ required: ['run_id', 'action'],
3718
+ properties: {
3719
+ run_id: { type: 'string', minLength: 1 },
3720
+ project_root: { type: 'string', minLength: 1 },
3721
+ action: { type: 'string', enum: ['start', 'complete', 'repair_start', 'repair_complete'] },
3722
+ state_path: { type: 'string' },
3723
+ validator_run_id: { type: 'string' },
3724
+ repair_run_id: { type: 'string' },
3725
+ dispatch_token: { type: 'integer' },
3726
+ challenge_response: { type: 'string' },
3727
+ verdict: { type: 'string', enum: ['pass', 'pass_with_observations', 'fail'] },
3728
+ data: { type: 'object', additionalProperties: true },
3729
+ host: { type: 'string', enum: HOST_NAMES },
3730
+ },
3731
+ },
3732
+ },
3733
+ {
3734
+ name: 'atlas_assert_after_plan',
3735
+ description: 'Gate G11: bloqueia encerramento prematuro do modo full após plano validado e antes da execução.',
3736
+ inputSchema: {
3737
+ type: 'object',
3738
+ additionalProperties: false,
3739
+ required: ['run_id', 'attempted_action'],
3740
+ properties: {
3741
+ run_id: { type: 'string', minLength: 1 },
3742
+ project_root: { type: 'string', minLength: 1 },
3743
+ attempted_action: { type: 'string' },
3744
+ },
3745
+ },
3746
+ },
3747
+ ],
3748
+ };
3749
+ }
3750
+
3751
+ function handleRequest(message) {
3752
+ const { id, method, params = {} } = message;
3753
+ if (method === 'initialize') {
3754
+ return {
3755
+ id,
3756
+ result: {
3757
+ protocolVersion: params.protocolVersion ?? '2024-11-05',
3758
+ serverInfo: { name: SERVER_NAME, version: readVersion() },
3759
+ capabilities: { tools: {} },
3760
+ },
3761
+ };
3762
+ }
3763
+ if (method === 'tools/list') return { id, result: toolsList() };
3764
+ if (method === 'tools/call') {
3765
+ const name = params.name;
3766
+ const args = params.arguments ?? {};
3767
+ try {
3768
+ const value =
3769
+ name === 'atlas_ping' ? ping() :
3770
+ name === 'atlas_capabilities' ? capabilities(args) :
3771
+ name === 'atlas_run_state' ? runState(args) :
3772
+ name === 'atlas_verify_artifact' ? verifyArtifact(args) :
3773
+ name === 'atlas_scan_prd' ? scanPrd(args) :
3774
+ name === 'atlas_verify_template_conformance' ? verifyTemplateConformance(args) :
3775
+ name === 'atlas_classify_input' ? classifyInput(args) :
3776
+ name === 'atlas_preflight' ? preflight(args) :
3777
+ name === 'atlas_lock_dispatch' ? lockDispatch(args) :
3778
+ name === 'atlas_lock_validator' ? lockValidator(args) :
3779
+ name === 'atlas_assert_after_plan' ? assertAfterPlan(args) :
3780
+ (() => { throw rpcError(-32601, `Tool desconhecida: ${name}`); })();
3781
+ logCall({ tool: name, run: args.run_id ?? null, status: 'ok' }, args);
3782
+ return { id, result: toolResult(value) };
3783
+ } catch (error) {
3784
+ logCall({ tool: name, run: args.run_id ?? null, status: 'error', error: error.message }, args);
3785
+ throw error;
3786
+ }
3787
+ }
3788
+ if (method === 'notifications/initialized') return null;
3789
+ throw rpcError(-32601, `Método desconhecido: ${method}`);
3790
+ }
3791
+
3792
+ function send(message) {
3793
+ if (message === null || message.id === undefined) return;
3794
+ const body = JSON.stringify({ jsonrpc: '2.0', ...message });
3795
+ process.stdout.write(`${body}\n`);
3796
+ }
3797
+
3798
+ function parseMessages(buffer) {
3799
+ const messages = [];
3800
+ let rest = buffer;
3801
+
3802
+ while (true) {
3803
+ const crlfHeaderEnd = rest.indexOf('\r\n\r\n');
3804
+ const lfHeaderEnd = rest.indexOf('\n\n');
3805
+ const hasCrlfHeader = crlfHeaderEnd !== -1 && (lfHeaderEnd === -1 || crlfHeaderEnd <= lfHeaderEnd);
3806
+ const headerEnd = hasCrlfHeader ? crlfHeaderEnd : lfHeaderEnd;
3807
+ if (headerEnd === -1) break;
3808
+ const header = rest.slice(0, headerEnd);
3809
+ const match = /^Content-Length:\s*(\d+)$/im.exec(header);
3810
+ if (!match) break;
3811
+ const length = Number(match[1]);
3812
+ const bodyStart = headerEnd + (hasCrlfHeader ? 4 : 2);
3813
+ const bodyEnd = bodyStart + length;
3814
+ if (rest.length < bodyEnd) return { messages, rest };
3815
+ messages.push(JSON.parse(rest.slice(bodyStart, bodyEnd)));
3816
+ rest = rest.slice(bodyEnd);
3817
+ }
3818
+
3819
+ if (messages.length === 0 && /^Content-Length:/i.test(rest) && !/\r?\n\r?\n/.test(rest)) {
3820
+ return { messages, rest };
3821
+ }
3822
+
3823
+ if (messages.length === 0 && rest.includes('\n')) {
3824
+ const lines = rest.split(/\r?\n/);
3825
+ rest = lines.pop() ?? '';
3826
+ for (const line of lines) {
3827
+ if (line.trim()) messages.push(JSON.parse(line));
3828
+ }
3829
+ }
3830
+
3831
+ return { messages, rest };
3832
+ }
3833
+
3834
+ function startStdioLoop() {
3835
+ let pending = '';
3836
+ process.stdin.setEncoding('utf8');
3837
+ process.stdin.on('data', (chunk) => {
3838
+ try {
3839
+ const parsed = parseMessages(pending + chunk);
3840
+ pending = parsed.rest;
3841
+ for (const message of parsed.messages) {
3842
+ try {
3843
+ send(handleRequest(message));
3844
+ } catch (error) {
3845
+ send({
3846
+ id: message.id,
3847
+ error: {
3848
+ code: Number.isInteger(error.code) ? error.code : -32603,
3849
+ message: error.message,
3850
+ data: error.data || { original_code: error.code },
3851
+ },
3852
+ });
3853
+ }
3854
+ }
3855
+ } catch (error) {
3856
+ send({ id: null, error: { code: -32700, message: `JSON inválido: ${error.message}` } });
3857
+ }
3858
+ });
3859
+ }
3860
+
3861
+ // Só inicia o loop stdio quando executado como entrypoint (node server.js).
3862
+ // Importado por testes (node --test), o módulo expõe funções puras sem bootar I/O.
3863
+ const isEntrypoint = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
3864
+ if (isEntrypoint) startStdioLoop();
3865
+
3866
+ export {
3867
+ HOST_ADAPTERS,
3868
+ HOST_NAMES,
3869
+ PREREQUISITES,
3870
+ CAPABILITIES_SCHEMA_VERSION,
3871
+ WORKFLOW_CONFIG,
3872
+ GUARANTEE_LEVELS,
3873
+ detectHost,
3874
+ capabilities,
3875
+ checkPrerequisites,
3876
+ checkJoinCapability,
3877
+ expectedNextPhase,
3878
+ expectedExecutorSkill,
3879
+ guaranteeLevelForMode,
3880
+ classifyArtifactContent,
3881
+ BANNER_TEMPLATES,
3882
+ BANNER_EVENTS,
3883
+ renderBanner,
3884
+ verifyArtifact,
3885
+ scanPrd,
3886
+ verifyTemplateConformance,
3887
+ classifyInput,
3888
+ preflight,
3889
+ lockDispatch,
3890
+ lockValidator,
3891
+ captureWorktreeSnapshot,
3892
+ validateStateBoundary,
3893
+ assertAfterPlan,
3894
+ runState,
3895
+ ping,
3896
+ toolsList,
3897
+ };