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