ai-maestro 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +15 -0
  3. package/README.md +727 -0
  4. package/bin/maestro.mjs +246 -0
  5. package/package.json +29 -0
  6. package/src/checkpoint.mjs +102 -0
  7. package/src/codex-diagnostics.mjs +682 -0
  8. package/src/codex-home-inspect.mjs +252 -0
  9. package/src/codex-home.mjs +258 -0
  10. package/src/commands.mjs +809 -0
  11. package/src/config.mjs +11 -0
  12. package/src/debug.mjs +164 -0
  13. package/src/encoding-guard.mjs +125 -0
  14. package/src/engines/ai-router-engine.mjs +37 -0
  15. package/src/engines/codex-engine.mjs +21 -0
  16. package/src/engines/engine.mjs +21 -0
  17. package/src/engines/registry.mjs +29 -0
  18. package/src/files.mjs +65 -0
  19. package/src/format.mjs +132 -0
  20. package/src/lock.mjs +439 -0
  21. package/src/memory-log.mjs +18 -0
  22. package/src/memory.mjs +1 -0
  23. package/src/orchestration/attempt-chain-runtime.mjs +627 -0
  24. package/src/orchestration/budget-manager.mjs +121 -0
  25. package/src/orchestration/capability-registry.mjs +108 -0
  26. package/src/orchestration/consolidator.mjs +772 -0
  27. package/src/orchestration/delegation-executor.mjs +262 -0
  28. package/src/orchestration/delegation-manager.mjs +116 -0
  29. package/src/orchestration/deployment-intent.mjs +36 -0
  30. package/src/orchestration/engine-history.mjs +110 -0
  31. package/src/orchestration/engine-policy.mjs +73 -0
  32. package/src/orchestration/engine-selector.mjs +187 -0
  33. package/src/orchestration/execution-context.mjs +45 -0
  34. package/src/orchestration/failure-classifier.mjs +136 -0
  35. package/src/orchestration/failure-evidence.mjs +237 -0
  36. package/src/orchestration/fallback-chain-lock.mjs +217 -0
  37. package/src/orchestration/fallback-chain-trail.mjs +218 -0
  38. package/src/orchestration/fallback-executor.mjs +146 -0
  39. package/src/orchestration/fallback-graph.mjs +106 -0
  40. package/src/orchestration/fallback-recommendation.mjs +73 -0
  41. package/src/orchestration/file-conflict-detector.mjs +126 -0
  42. package/src/orchestration/host-validation.mjs +241 -0
  43. package/src/orchestration/orchestration-loop.mjs +1971 -0
  44. package/src/orchestration/orchestration-runtime.mjs +1019 -0
  45. package/src/orchestration/orchestration-scheduler.mjs +135 -0
  46. package/src/orchestration/orchestration-trail.mjs +192 -0
  47. package/src/orchestration/preflight.mjs +212 -0
  48. package/src/orchestration/provider-health.mjs +566 -0
  49. package/src/orchestration/provider-router.mjs +352 -0
  50. package/src/orchestration/rc-check-adapters.mjs +817 -0
  51. package/src/orchestration/rc-check.mjs +544 -0
  52. package/src/orchestration/rc-policy.mjs +200 -0
  53. package/src/orchestration/runtime-gate.mjs +146 -0
  54. package/src/orchestration/sector-managers.mjs +215 -0
  55. package/src/orchestration/self-hosting-canary.mjs +231 -0
  56. package/src/orchestration/self-hosting-readiness.mjs +244 -0
  57. package/src/orchestration/spec-planner.mjs +877 -0
  58. package/src/orchestration/subtask-planner.mjs +176 -0
  59. package/src/orchestration/task-classifier.mjs +241 -0
  60. package/src/orchestration/verifier.mjs +1608 -0
  61. package/src/orchestration-commands.mjs +279 -0
  62. package/src/planner.mjs +38 -0
  63. package/src/project.mjs +1 -0
  64. package/src/run-diagnostics.mjs +641 -0
  65. package/src/runner.mjs +243 -0
  66. package/src/safety.mjs +116 -0
  67. package/src/schema.mjs +371 -0
  68. package/src/session-commands.mjs +521 -0
  69. package/src/shell.mjs +93 -0
  70. package/src/smart-planner.mjs +249 -0
  71. package/src/task-commands.mjs +1182 -0
  72. package/src/tasks.mjs +134 -0
  73. package/src/ui/commands.mjs +76 -0
  74. package/src/ui/events.mjs +45 -0
  75. package/src/ui/public/app.js +600 -0
  76. package/src/ui/public/index.html +88 -0
  77. package/src/ui/public/style.css +460 -0
  78. package/src/ui/server.mjs +112 -0
  79. package/src/ui/state.mjs +504 -0
  80. package/src/usage.mjs +178 -0
  81. package/src/workspace-diff.mjs +228 -0
@@ -0,0 +1,262 @@
1
+ // src/orchestration/delegation-executor.mjs
2
+ // Responsabilidade: Criar plano de delegação, não executar ainda.
3
+
4
+ import { selectSectorManager, shouldUseManager } from './sector-managers.mjs';
5
+ import { planSubtasks, validateSubtaskPlan } from './subtask-planner.mjs';
6
+ import { detectFileConflicts } from './file-conflict-detector.mjs';
7
+
8
+ // detectFileConflicts moved to file-conflict-detector.mjs (single source of
9
+ // truth, per spec section 4); re-exported here so existing importers of
10
+ // this module keep working.
11
+ export { detectFileConflicts };
12
+
13
+ /**
14
+ * Plano de delegação para uma tarefa.
15
+ * @param {object} input - Objeto contendo task, classification, projectContext, etc.
16
+ * @returns {object} Plano de delegação estruturado.
17
+ */
18
+ export async function planDelegation(input) {
19
+ const {
20
+ task,
21
+ classification,
22
+ projectContext = {},
23
+ engines = [],
24
+ history = []
25
+ } = input;
26
+
27
+ // Decidir se precisa delegação
28
+ if (!classification || !shouldUseManager(classification)) {
29
+ return {
30
+ decision: 'NO_DELEGATION',
31
+ manager: null,
32
+ subtasks: [],
33
+ assignments: [],
34
+ executionMode: 'serial',
35
+ requiresHuman: false,
36
+ warnings: [`Tarefa não atende critérios para delegação. Complexidade: ${classification?.complexity || 'unknown'}, Risk: ${classification?.risk || 'unknown'}`]
37
+ };
38
+ }
39
+
40
+ // Selecionar gerente
41
+ const manager = selectSectorManager(classification);
42
+ if (!manager) {
43
+ return {
44
+ decision: 'NO_DELEGATION',
45
+ manager: null,
46
+ subtasks: [],
47
+ assignments: [],
48
+ executionMode: 'serial',
49
+ requiresHuman: false,
50
+ warnings: [`Nenhum gerente de setor aplicável para taskKind: ${classification.taskKind}`]
51
+ };
52
+ }
53
+
54
+ // Planejar subtarefas
55
+ const subtaskPlanInput = {
56
+ task,
57
+ classification,
58
+ manager,
59
+ projectContext,
60
+ constraints: {
61
+ maxSubtasks: manager.maxSubtasks || 4,
62
+ maxDepth: 2
63
+ }
64
+ };
65
+
66
+ const subtaskPlan = planSubtasks(subtaskPlanInput);
67
+
68
+ if (!subtaskPlan.canSplit) {
69
+ // Task não será dividida, mas ainda pode ser delegada ao manager
70
+ return {
71
+ decision: 'PLAN_ONLY',
72
+ manager,
73
+ subtasks: [],
74
+ assignments: [
75
+ {
76
+ subtaskId: task.id,
77
+ role: 'worker',
78
+ candidateEngine: selectBestEngine(classification, engines, history),
79
+ reason: [`Delegada ao gerente ${manager.id} para execução direta.`]
80
+ }
81
+ ],
82
+ executionMode: 'serial',
83
+ requiresHuman: classification.risk === 'critical',
84
+ warnings: subtaskPlan.warnings || []
85
+ };
86
+ }
87
+
88
+ // Validar plano de subtarefas
89
+ const validation = validateSubtaskPlan(subtaskPlan);
90
+ if (!validation.isValid) {
91
+ return {
92
+ decision: 'NO_DELEGATION',
93
+ manager,
94
+ subtasks: [],
95
+ assignments: [],
96
+ executionMode: 'serial',
97
+ requiresHuman: true,
98
+ warnings: validation.errors
99
+ };
100
+ }
101
+
102
+ // Detectar conflitos de arquivo
103
+ const fileConflicts = detectFileConflicts(subtaskPlan.subtasks);
104
+ let executionMode = 'serial'; // v0.5.2: sempre serial
105
+ if (fileConflicts.length > 0) {
106
+ // Conflito força serial
107
+ executionMode = 'serial';
108
+ }
109
+
110
+ // Criar assignments
111
+ const assignments = subtaskPlan.subtasks.map(subtask => {
112
+ let role = 'worker';
113
+ if (subtask.verificationRequired && subtask === subtaskPlan.subtasks[subtaskPlan.subtasks.length - 1]) {
114
+ role = 'verifier';
115
+ }
116
+
117
+ const engine = selectBestEngine(subtask, engines, history);
118
+ return {
119
+ subtaskId: subtask.id,
120
+ role,
121
+ candidateEngine: engine,
122
+ reason: [
123
+ `Subtarefa de tipo ${subtask.taskKind}`,
124
+ `Risco: ${subtask.risk}`,
125
+ `Motor candidato: ${engine}`
126
+ ]
127
+ };
128
+ });
129
+
130
+ const requiresHuman =
131
+ classification.risk === 'critical' ||
132
+ (manager.requiresHumanFor || []).some(cond => classification.taskKind === cond);
133
+
134
+ const warnings = subtaskPlan.warnings || [];
135
+ if (fileConflicts.length > 0) {
136
+ warnings.push(`${fileConflicts.length} conflito(s) de arquivo detectado(s). Executar em modo serial.`);
137
+ }
138
+
139
+ return {
140
+ decision: 'READY',
141
+ manager,
142
+ subtasks: subtaskPlan.subtasks,
143
+ assignments,
144
+ executionMode,
145
+ requiresHuman,
146
+ warnings
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Seleciona o melhor motor para uma tarefa/subtarefa.
152
+ * Versão simplificada: retorna o primeiro motor válido.
153
+ */
154
+ function selectBestEngine(classification, engines = [], history = []) {
155
+ if (!engines || engines.length === 0) {
156
+ return 'codex-default';
157
+ }
158
+
159
+ const available = engines.filter(e => e.enabled === true);
160
+ if (available.length === 0) {
161
+ return 'codex-default';
162
+ }
163
+
164
+ const preferred = available.find(e => e.id === 'codex9' || e.id === 'codex8');
165
+ return preferred ? preferred.id : available[0].id;
166
+ }
167
+
168
+ /**
169
+ * Valida um plano de delegação.
170
+ */
171
+ export function validateDelegationPlan(plan) {
172
+ const errors = [];
173
+ const warnings = [];
174
+
175
+ if (!plan || typeof plan !== 'object') {
176
+ errors.push('Plano inválido: nulo ou não é um objeto.');
177
+ return { isValid: false, errors, warnings };
178
+ }
179
+
180
+ const validDecisions = ['NO_DELEGATION', 'PLAN_ONLY', 'READY'];
181
+ if (!validDecisions.includes(plan.decision)) {
182
+ errors.push(`Campo decision inválido. Deve ser um de: ${validDecisions.join(', ')}`);
183
+ }
184
+
185
+ if (plan.decision === 'READY' && !plan.manager) {
186
+ errors.push('Decisão READY requer um gerente válido.');
187
+ }
188
+
189
+ if (!Array.isArray(plan.subtasks)) {
190
+ errors.push('Campo subtasks deve ser um array.');
191
+ }
192
+
193
+ if (!Array.isArray(plan.assignments)) {
194
+ errors.push('Campo assignments deve ser um array.');
195
+ } else {
196
+ plan.assignments.forEach((assignment, idx) => {
197
+ if (!assignment.subtaskId || typeof assignment.subtaskId !== 'string') {
198
+ errors.push(`Assignment ${idx}: subtaskId inválido.`);
199
+ }
200
+ if (!['worker', 'reviewer', 'fixer', 'verifier'].includes(assignment.role)) {
201
+ errors.push(`Assignment ${idx}: role inválida.`);
202
+ }
203
+ if (!assignment.candidateEngine || typeof assignment.candidateEngine !== 'string') {
204
+ errors.push(`Assignment ${idx}: candidateEngine inválido.`);
205
+ }
206
+ if (!Array.isArray(assignment.reason)) {
207
+ errors.push(`Assignment ${idx}: reason deve ser um array.`);
208
+ }
209
+ });
210
+ }
211
+
212
+ if (plan.executionMode !== 'serial') {
213
+ warnings.push(`v0.5.2: executionMode deve ser 'serial', encontrado '${plan.executionMode}'.`);
214
+ }
215
+
216
+ if (typeof plan.requiresHuman !== 'boolean') {
217
+ errors.push('Campo requiresHuman deve ser um booleano.');
218
+ }
219
+
220
+ if (!Array.isArray(plan.warnings)) {
221
+ errors.push('Campo warnings deve ser um array.');
222
+ }
223
+
224
+ return { isValid: errors.length === 0, errors, warnings };
225
+ }
226
+
227
+ /**
228
+ * Gera um resumo legível de um plano de delegação.
229
+ */
230
+ export function summarizeDelegationPlan(plan) {
231
+ if (!plan) {
232
+ return 'Plano de delegação inválido.';
233
+ }
234
+
235
+ let summary = `Plano de Delegação:\n`;
236
+ summary += `Decisão: ${plan.decision}\n`;
237
+
238
+ if (plan.manager) {
239
+ summary += `Gerente: ${plan.manager.displayName || plan.manager.id || 'desconhecido'}\n`;
240
+ }
241
+
242
+ summary += `Subtarefas: ${plan.subtasks?.length || 0}\n`;
243
+ summary += `Assignments: ${plan.assignments?.length || 0}\n`;
244
+ summary += `Modo de Execução: ${plan.executionMode || 'serial'}\n`;
245
+ summary += `Requer Intervenção Humana: ${plan.requiresHuman ? 'Sim' : 'Não'}\n`;
246
+
247
+ if (plan.assignments && plan.assignments.length > 0) {
248
+ summary += `\nAssignments:\n`;
249
+ plan.assignments.forEach((assignment, idx) => {
250
+ summary += ` ${idx + 1}. ${assignment.subtaskId} → ${assignment.candidateEngine} (role: ${assignment.role})\n`;
251
+ });
252
+ }
253
+
254
+ if (plan.warnings && plan.warnings.length > 0) {
255
+ summary += `\nAvisos:\n`;
256
+ plan.warnings.forEach(w => {
257
+ summary += `- ${w}\n`;
258
+ });
259
+ }
260
+
261
+ return summary;
262
+ }
@@ -0,0 +1,116 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { CONFIG } from '../config.mjs';
4
+ import { stampSchemaVersion } from '../schema.mjs';
5
+ import { planSectorWork } from './sector-managers.mjs';
6
+
7
+ const delegationLogPath = path.join(CONFIG.maestroPath, 'delegation-log.jsonl');
8
+
9
+ // Hard limits from the spec (Phase 9). These are enforced here, not just
10
+ // documented — buildDelegationPlan() below refuses to exceed them rather
11
+ // than trusting callers to check first.
12
+ export const DELEGATION_LIMITS = {
13
+ maxDepth: 2,
14
+ maxSubagentsPerManager: 4,
15
+ maxAutoSubtasksPerTask: 8,
16
+ maxEnginesTriedPerTask: 3,
17
+ maxRetryPerEngine: 1
18
+ };
19
+
20
+ export function getDelegationLogPath() {
21
+ return delegationLogPath;
22
+ }
23
+
24
+ export async function logDelegationEvent(entry) {
25
+ await fs.mkdir(CONFIG.maestroPath, { recursive: true });
26
+ const stamped = stampSchemaVersion({
27
+ timestamp: new Date().toISOString(),
28
+ ...entry
29
+ });
30
+ await fs.appendFile(delegationLogPath, JSON.stringify(stamped) + '\n');
31
+ return stamped;
32
+ }
33
+
34
+ // Two subtasks conflict if they declare overlapping `files`. Conflicting
35
+ // subtasks MUST run sequentially (never concurrently) in this release —
36
+ // v0.5.0 does not implement parallel writes at all (see
37
+ // .maestro/SUBAGENT_POLICY.md), so this function currently only exists to
38
+ // make that guarantee inspectable/testable, not to unlock parallelism.
39
+ export function detectFileConflicts(subtasks) {
40
+ const conflicts = [];
41
+ for (let i = 0; i < subtasks.length; i++) {
42
+ for (let j = i + 1; j < subtasks.length; j++) {
43
+ const filesA = new Set(subtasks[i].files || []);
44
+ const filesB = subtasks[j].files || [];
45
+ const overlap = filesB.filter(file => filesA.has(file));
46
+ if (overlap.length > 0) {
47
+ conflicts.push({ a: i, b: j, files: overlap });
48
+ }
49
+ }
50
+ }
51
+ return conflicts;
52
+ }
53
+
54
+ // depth: how many delegation levels already happened above this call
55
+ // (0 = top-level task, 1 = subtask of a sector manager, 2 = would be a
56
+ // sub-subtask — refused, since maxDepth is 2 meaning "at most 2 manager
57
+ // hops from the original task").
58
+ export function buildDelegationPlan(task, classification, { depth = 0 } = {}) {
59
+ if (depth >= DELEGATION_LIMITS.maxDepth) {
60
+ return {
61
+ delegate: false,
62
+ sector: null,
63
+ reason: 'max delegation depth (' + DELEGATION_LIMITS.maxDepth + ') reached at depth=' + depth + '; task must execute directly or be declined.',
64
+ subtasks: []
65
+ };
66
+ }
67
+
68
+ const plan = planSectorWork(task, classification);
69
+ if (plan.decision !== 'decompose') {
70
+ return { delegate: false, sector: plan.sector, reason: plan.reason, subtasks: [] };
71
+ }
72
+
73
+ let subtasks = plan.subtasks;
74
+ if (subtasks.length > DELEGATION_LIMITS.maxSubagentsPerManager) {
75
+ subtasks = subtasks.slice(0, DELEGATION_LIMITS.maxSubagentsPerManager);
76
+ }
77
+ if (subtasks.length > DELEGATION_LIMITS.maxAutoSubtasksPerTask) {
78
+ subtasks = subtasks.slice(0, DELEGATION_LIMITS.maxAutoSubtasksPerTask);
79
+ }
80
+
81
+ const conflicts = detectFileConflicts(subtasks);
82
+
83
+ return {
84
+ delegate: true,
85
+ sector: plan.sector,
86
+ reason: plan.reason,
87
+ subtasks,
88
+ fileConflicts: conflicts,
89
+ executionOrder: 'serial',
90
+ depth: depth + 1
91
+ };
92
+ }
93
+
94
+ export async function recordDelegation({ task, plan, delegatedBy = 'maestro', outcome = null }) {
95
+ return logDelegationEvent({
96
+ taskId: task.id ?? null,
97
+ delegatedBy,
98
+ sector: plan.sector,
99
+ delegate: plan.delegate,
100
+ reason: plan.reason,
101
+ subtaskCount: plan.subtasks ? plan.subtasks.length : 0,
102
+ subtaskTitles: (plan.subtasks || []).map(subtask => subtask.title),
103
+ fileConflicts: plan.fileConflicts || [],
104
+ executionOrder: plan.executionOrder || null,
105
+ depth: plan.depth ?? 0,
106
+ outcome
107
+ });
108
+ }
109
+
110
+ export function withinEngineAttemptLimit(attemptCount) {
111
+ return attemptCount < DELEGATION_LIMITS.maxEnginesTriedPerTask;
112
+ }
113
+
114
+ export function withinRetryLimit(retryCount) {
115
+ return retryCount < DELEGATION_LIMITS.maxRetryPerEngine;
116
+ }
@@ -0,0 +1,36 @@
1
+ import { stripAccents } from '../smart-planner.mjs';
2
+
3
+ // A bare "deploy"-shaped word is not enough on its own: acceptance criteria
4
+ // and task descriptions routinely mention deploy only to forbid it ("sem
5
+ // deploy", "nao fazer deploy", "deployment is out of scope"). This helper
6
+ // only reports an affirmative deploy intent when a deploy-action word
7
+ // appears in a clause that has no negation/exclusion cue anywhere in that
8
+ // same clause. Negation can precede ("nao fazer deploy") or follow ("deploy
9
+ // nao deve ser realizado") the action word, so a fixed look-behind window
10
+ // isn't enough -- splitting the text into clauses and checking each one as
11
+ // a whole handles both orders without depending solely on \bdeploy\b.
12
+ const DEPLOY_ACTION_PATTERN = /\bdeploy(ment)?\b|\bpublicar\b|\bimplantar\b|\bimplanta(c|ç)[aã]o\b|\bpush to prod|\brelease to prod|\bpublish to prod/;
13
+ const NEGATION_PATTERN = /\bnao\b|\bsem\b|\bnunca\b|\bjamais\b|\bnot\b|\bdon'?t\b|\bdo not\b/;
14
+ const EXCLUSION_PATTERN = /fora do escopo|out of scope|nao faz parte|not part of/;
15
+
16
+ function splitClauses(text) {
17
+ const parts = text.split(/[.,;:!?\n]+/).map(part => part.trim()).filter(Boolean);
18
+ return parts.length ? parts : [text];
19
+ }
20
+
21
+ export function detectAffirmativeDeploymentIntent(rawText) {
22
+ const text = stripAccents(String(rawText || '').toLowerCase());
23
+ if (!DEPLOY_ACTION_PATTERN.test(text)) {
24
+ return false;
25
+ }
26
+ for (const clause of splitClauses(text)) {
27
+ if (!DEPLOY_ACTION_PATTERN.test(clause)) {
28
+ continue;
29
+ }
30
+ if (NEGATION_PATTERN.test(clause) || EXCLUSION_PATTERN.test(clause)) {
31
+ continue;
32
+ }
33
+ return true;
34
+ }
35
+ return false;
36
+ }
@@ -0,0 +1,110 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { CONFIG } from '../config.mjs';
4
+ import { pathExists } from '../files.mjs';
5
+ import { stampSchemaVersion } from '../schema.mjs';
6
+
7
+ const historyPath = path.join(CONFIG.maestroPath, 'engine-history.jsonl');
8
+
9
+ export function getEngineHistoryPath() {
10
+ return historyPath;
11
+ }
12
+
13
+ // entry shape (all fields optional except engine/success):
14
+ // { engine, provider, model, taskKind, complexity, success, failureCategory,
15
+ // durationMs, estimatedTokens, estimatedCost, toolsAvailable, retries,
16
+ // fallbackUsed, verifierResult, runKind, taskId }
17
+ export async function recordEngineOutcome(entry) {
18
+ await fs.mkdir(CONFIG.maestroPath, { recursive: true });
19
+ const stamped = stampSchemaVersion({
20
+ timestamp: new Date().toISOString(),
21
+ ...entry
22
+ });
23
+ if (await pathExists(historyPath)) {
24
+ await fs.copyFile(historyPath, historyPath + '.bak');
25
+ }
26
+ await fs.appendFile(historyPath, JSON.stringify(stamped) + '\n');
27
+ return stamped;
28
+ }
29
+
30
+ export async function readEngineHistory(limit = Infinity) {
31
+ try {
32
+ const content = await fs.readFile(historyPath, 'utf-8');
33
+ const rows = content
34
+ .split(/\r?\n/)
35
+ .filter(Boolean)
36
+ .map(line => {
37
+ try {
38
+ return JSON.parse(line);
39
+ } catch (error) {
40
+ return { parseError: error.message, raw: line };
41
+ }
42
+ });
43
+ return Number.isFinite(limit) ? rows.slice(-limit) : rows;
44
+ } catch (error) {
45
+ if (error.code === 'ENOENT') {
46
+ return [];
47
+ }
48
+ throw error;
49
+ }
50
+ }
51
+
52
+ export async function getHistoryForEngine(engineId, { limit = 50 } = {}) {
53
+ const rows = await readEngineHistory(Infinity);
54
+ return rows.filter(row => row.engine === engineId).slice(-limit);
55
+ }
56
+
57
+ export function computeSuccessRate(entries) {
58
+ const valid = entries.filter(entry => typeof entry.success === 'boolean');
59
+ if (valid.length === 0) {
60
+ return null;
61
+ }
62
+ return valid.filter(entry => entry.success === true).length / valid.length;
63
+ }
64
+
65
+ export function toEngineSelectorHistory(entries) {
66
+ return entries
67
+ .filter(entry => typeof entry.success === 'boolean' && entry.engine)
68
+ .map(entry => ({ engine: entry.engine, success: entry.success }));
69
+ }
70
+
71
+ export async function summarizeHistoryByEngine() {
72
+ const rows = await readEngineHistory(Infinity);
73
+ const byEngine = {};
74
+ for (const row of rows) {
75
+ if (!row.engine) continue;
76
+ const bucket = byEngine[row.engine] || (byEngine[row.engine] = {
77
+ engine: row.engine,
78
+ totalRuns: 0,
79
+ successes: 0,
80
+ failures: 0,
81
+ failureCategories: {},
82
+ totalDurationMs: 0,
83
+ durationSamples: 0,
84
+ lastRunAt: null
85
+ });
86
+ bucket.totalRuns += 1;
87
+ if (row.success === true) bucket.successes += 1;
88
+ if (row.success === false) {
89
+ bucket.failures += 1;
90
+ const category = row.failureCategory || 'unknown_failure';
91
+ bucket.failureCategories[category] = (bucket.failureCategories[category] || 0) + 1;
92
+ }
93
+ if (typeof row.durationMs === 'number') {
94
+ bucket.totalDurationMs += row.durationMs;
95
+ bucket.durationSamples += 1;
96
+ }
97
+ if (!bucket.lastRunAt || row.timestamp > bucket.lastRunAt) {
98
+ bucket.lastRunAt = row.timestamp;
99
+ }
100
+ }
101
+ return Object.values(byEngine).map(bucket => ({
102
+ engine: bucket.engine,
103
+ totalRuns: bucket.totalRuns,
104
+ successRate: bucket.totalRuns > 0 ? bucket.successes / bucket.totalRuns : null,
105
+ failures: bucket.failures,
106
+ failureCategories: bucket.failureCategories,
107
+ averageDurationMs: bucket.durationSamples > 0 ? Math.round(bucket.totalDurationMs / bucket.durationSamples) : null,
108
+ lastRunAt: bucket.lastRunAt
109
+ }));
110
+ }
@@ -0,0 +1,73 @@
1
+ // Categorical engine-usage policy (Phase 14), separate from budget-manager.mjs
2
+ // (which enforces numeric limits: runs/subagents/depth/tokens/currency).
3
+ // This module answers "is this engine even allowed to be used this way",
4
+ // independent of how much budget remains.
5
+
6
+ function envAllows(name) {
7
+ return process.env[name] === '1';
8
+ }
9
+
10
+ // Only checks `id`/`provider` — short, structured slugs — not the free-text
11
+ // `model` field. Some fiches (e.g. ai-router) put a whole descriptive
12
+ // sentence in `model` ("delegates to whichever CLI ... fcc-claude"), and a
13
+ // naive substring check there false-positives on any engine that merely
14
+ // mentions Claude as one option among several, not one that IS Claude.
15
+ function looksLikeClaude(engineRecord) {
16
+ const id = String((engineRecord && engineRecord.id) || '').toLowerCase();
17
+ const provider = String((engineRecord && engineRecord.provider) || '').toLowerCase();
18
+ return id.includes('claude') || provider.includes('claude') || provider.includes('anthropic');
19
+ }
20
+
21
+ function looksLikeDirectOpenAI(engineRecord) {
22
+ const provider = String((engineRecord && engineRecord.provider) || '').toLowerCase();
23
+ const id = String((engineRecord && engineRecord.id) || '').toLowerCase();
24
+ // Explicitly excludes 9router-routed models even if the underlying model
25
+ // happens to be a GPT/OpenAI model — 9Router is the allowed path; only a
26
+ // provider that talks to OpenAI's API directly is gated by this rule.
27
+ if (provider === '9router' || id.startsWith('9router')) {
28
+ return false;
29
+ }
30
+ return provider === 'openai' || provider === 'openai-direct' || id.includes('openai-direct');
31
+ }
32
+
33
+ // context: { role: 'worker'|'review'|'manager'|..., isClaudeReview: boolean,
34
+ // allowClaudeReviewFlag: boolean, allowDirectOpenAIFlag: boolean }
35
+ // The `*Flag` fields let a caller pass budget.json's allowClaudeReview /
36
+ // allowDirectOpenAI values in directly; the env vars are checked either way,
37
+ // matching the spec's "bloqueado sem <ENV>=1" language.
38
+ export function checkEnginePolicy(engineRecord, context = {}) {
39
+ const violations = [];
40
+ const warnings = [];
41
+ const role = context.role || 'worker';
42
+
43
+ if (looksLikeClaude(engineRecord)) {
44
+ if (role === 'worker' && context.isClaudeReview !== true) {
45
+ violations.push(
46
+ 'Claude-family engine "' + (engineRecord && engineRecord.id) + '" must not be used as a default worker. ' +
47
+ 'See .maestro/ENGINE_SELECTION_POLICY.md — Claude/premium review is opt-in only, never routine task execution.'
48
+ );
49
+ } else if (!envAllows('MAESTRO_ALLOW_CLAUDE_REVIEW') && context.allowClaudeReviewFlag !== true) {
50
+ violations.push(
51
+ 'Claude review requires MAESTRO_ALLOW_CLAUDE_REVIEW=1 or .maestro/budget.json allowClaudeReview=true.'
52
+ );
53
+ }
54
+ }
55
+
56
+ if (looksLikeDirectOpenAI(engineRecord)) {
57
+ if (!envAllows('MAESTRO_ALLOW_DIRECT_OPENAI') && context.allowDirectOpenAIFlag !== true) {
58
+ violations.push(
59
+ 'Direct OpenAI access ("' + (engineRecord && engineRecord.id) + '", provider="' + (engineRecord && engineRecord.provider) + '") requires ' +
60
+ 'MAESTRO_ALLOW_DIRECT_OPENAI=1 or .maestro/budget.json allowDirectOpenAI=true.'
61
+ );
62
+ }
63
+ }
64
+
65
+ if (engineRecord && engineRecord.validated !== true) {
66
+ warnings.push(
67
+ 'Engine "' + engineRecord.id + '" is not validated (validated=false in .maestro/engines.json); ' +
68
+ 'treat any result with reduced confidence until it is explicitly validated (see "maestro engines validate").'
69
+ );
70
+ }
71
+
72
+ return { ok: violations.length === 0, violations, warnings };
73
+ }