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,504 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { CONFIG } from '../config.mjs';
4
+ import { readJsonSafe } from '../files.mjs';
5
+ import { summarizeText } from '../format.mjs';
6
+ import { countTasksByStatus } from '../task-commands.mjs';
7
+ import { sortRunnableTasks } from '../tasks.mjs';
8
+ import { listEngineRecords } from '../orchestration/capability-registry.mjs';
9
+ import { loadBudget } from '../orchestration/budget-manager.mjs';
10
+ import { loadFallbackGraph } from '../orchestration/fallback-graph.mjs';
11
+ import { DELEGATION_LIMITS } from '../orchestration/delegation-manager.mjs';
12
+ import { SECTOR_MANAGERS } from '../orchestration/sector-managers.mjs';
13
+
14
+ const JSONL_RECENT_LIMIT = 100;
15
+ const TIMELINE_LIMIT = 100;
16
+
17
+ const STATUS_META = {
18
+ idle: { key: 'idle', label: 'parado', icon: '⏸', tone: 'idle' },
19
+ running: { key: 'running', label: 'executando', icon: '▶', tone: 'running' },
20
+ in_progress: { key: 'running', label: 'executando', icon: '▶', tone: 'running' },
21
+ thinking: { key: 'thinking', label: 'pensando', icon: '☁', tone: 'thinking' },
22
+ planning: { key: 'thinking', label: 'pensando', icon: '☁', tone: 'thinking' },
23
+ completed: { key: 'completed', label: 'finalizado', icon: '✓', tone: 'success' },
24
+ success: { key: 'completed', label: 'finalizado', icon: '✓', tone: 'success' },
25
+ done: { key: 'completed', label: 'finalizado', icon: '✓', tone: 'success' },
26
+ completed_with_warnings: { key: 'completed_with_warnings', label: 'finalizado com alertas', icon: '⚠', tone: 'warning' },
27
+ blocked: { key: 'blocked', label: 'bloqueado', icon: '⛔', tone: 'blocked' },
28
+ needs_human: { key: 'needs_human', label: 'precisa de decisão humana', icon: '❔', tone: 'blocked' },
29
+ failed: { key: 'failed', label: 'erro', icon: '⚠', tone: 'error' },
30
+ error: { key: 'failed', label: 'erro', icon: '⚠', tone: 'error' },
31
+ warning: { key: 'warning', label: 'atenção', icon: '!', tone: 'warning' },
32
+ stopped: { key: 'idle', label: 'parado', icon: '⏸', tone: 'idle' }
33
+ };
34
+
35
+ const GROUP_META = {
36
+ maestro: { key: 'maestro', label: 'Maestro', icon: '▣' },
37
+ codex: { key: 'codex', label: 'Codex', icon: '⬢' },
38
+ memory: { key: 'memory', label: 'Memory', icon: '◌' },
39
+ safety: { key: 'safety', label: 'Safety', icon: '⚑' },
40
+ engine: { key: 'engine', label: 'Engine', icon: '◈' },
41
+ ui: { key: 'ui', label: 'UI', icon: '▤' },
42
+ issue: { key: 'issue', label: 'Erros/Bloqueios', icon: '⚠' }
43
+ };
44
+
45
+ function readTextSafe(filePath, fallback = '') {
46
+ return fs.readFile(filePath, 'utf-8').catch(error => {
47
+ if (error.code === 'ENOENT') return fallback;
48
+ throw error;
49
+ });
50
+ }
51
+
52
+ export async function readJsonlRecent(filePath, limit = JSONL_RECENT_LIMIT) {
53
+ try {
54
+ const content = await fs.readFile(filePath, 'utf-8');
55
+ return content
56
+ .split(/\r?\n/)
57
+ .filter(Boolean)
58
+ .slice(-limit)
59
+ .map(line => JSON.parse(line));
60
+ } catch (error) {
61
+ if (error.code === 'ENOENT') return [];
62
+ if (error instanceof SyntaxError) return [{ parseError: 'Invalid JSONL in ' + filePath, source: filePath }];
63
+ throw error;
64
+ }
65
+ }
66
+
67
+ export function normalizeStatus(value, fallback = 'idle') {
68
+ const key = String(value || fallback || 'idle').toLowerCase();
69
+ return STATUS_META[key] || STATUS_META[fallback] || STATUS_META.idle;
70
+ }
71
+
72
+ export function normalizeGroup(agent = '', entry = {}) {
73
+ const raw = String(agent || entry.agent || entry.source || '').toLowerCase();
74
+ if (raw.includes('memory') || raw.includes('ai-universal-memory')) return GROUP_META.memory;
75
+ if (raw.includes('ui')) return GROUP_META.ui;
76
+ if (raw.includes('safety')) return GROUP_META.safety;
77
+ if (raw.includes('engine')) return GROUP_META.engine;
78
+ if (raw.includes('codex')) return GROUP_META.codex;
79
+ return GROUP_META.maestro;
80
+ }
81
+
82
+ function parseTimestamp(value, fallback = new Date().toISOString()) {
83
+ if (!value) return fallback;
84
+ const date = new Date(value);
85
+ return Number.isNaN(date.getTime()) ? fallback : date.toISOString();
86
+ }
87
+
88
+ function compactSummary(text, limit = 260) {
89
+ return summarizeText(String(text || ''), limit);
90
+ }
91
+
92
+ function stringifyRaw(entry) {
93
+ try {
94
+ return JSON.stringify(entry, null, 2);
95
+ } catch (error) {
96
+ return String(entry);
97
+ }
98
+ }
99
+
100
+ export function formatRelativeTime(value, now = Date.now()) {
101
+ if (!value) return 'agora';
102
+ const time = new Date(value).getTime();
103
+ if (Number.isNaN(time)) return 'agora';
104
+ const delta = Math.max(0, now - time);
105
+ const minutes = Math.floor(delta / 60000);
106
+ if (minutes < 1) return 'agora';
107
+ if (minutes < 60) return `${minutes}m`;
108
+ const hours = Math.floor(minutes / 60);
109
+ if (hours < 24) return `${hours}h`;
110
+ const days = Math.floor(hours / 24);
111
+ return `${days}d`;
112
+ }
113
+
114
+ function makeTimelineEntry({
115
+ timestamp,
116
+ agent,
117
+ kind,
118
+ status,
119
+ summary,
120
+ source,
121
+ raw,
122
+ taskId,
123
+ commandId,
124
+ issue = false,
125
+ warnings = []
126
+ }) {
127
+ const normalizedStatus = normalizeStatus(status, issue ? 'error' : 'idle');
128
+ const group = normalizeGroup(agent, { agent, status, issue, commandId, taskId, source });
129
+ return {
130
+ timestamp: parseTimestamp(timestamp),
131
+ agent: agent || 'maestro-cli',
132
+ group: group.key,
133
+ groupLabel: group.label,
134
+ icon: issue ? GROUP_META.issue.icon : group.icon,
135
+ kind: kind || 'event',
136
+ status: normalizedStatus.key,
137
+ statusLabel: normalizedStatus.label,
138
+ statusIcon: normalizedStatus.icon,
139
+ tone: normalizedStatus.tone,
140
+ issue,
141
+ warnings,
142
+ summary: compactSummary(summary || kind || 'evento'),
143
+ source: source || 'unknown',
144
+ taskId: taskId === undefined ? null : taskId,
145
+ commandId: commandId || null,
146
+ rawText: stringifyRaw(raw),
147
+ collapsedByDefault: true
148
+ };
149
+ }
150
+
151
+ function fromMemoryEvent(row) {
152
+ return makeTimelineEntry({
153
+ timestamp: row.time || row.timestamp,
154
+ agent: row.agent || 'ai-universal-memory',
155
+ kind: row.action || 'note',
156
+ status: row.status || 'completed',
157
+ summary: row.summary || row.error || row.action || 'memory event',
158
+ source: '.memory/events.jsonl',
159
+ raw: row,
160
+ issue: String(row.status || '').toLowerCase() === 'failed'
161
+ });
162
+ }
163
+
164
+ function fromRunEntry(row) {
165
+ const blockedBySafety = row.blockedBySafety === true;
166
+ const blockedByGate = row.blockedByGate === true;
167
+ const blocked = blockedBySafety || row.status === 'blocked' || row.status === 'needs_human';
168
+ const warningBits = [];
169
+ if (row.status === 'completed_with_warnings') warningBits.push('concluído com alertas');
170
+ if (row.suspectedFalsePositive) warningBits.push('possível falsa conclusão');
171
+ if (row.unsupportedToolCall) warningBits.push('ferramenta não suportada usada pelo worker');
172
+ if (row.windowsShellCommandMismatch) warningBits.push('comando incompatível com PowerShell');
173
+ if (row.syntaxValidationFailed) warningBits.push('arquivo alterado pode estar com sintaxe quebrada');
174
+ if (row.analysisTaskChangedCode) warningBits.push('task de análise alterou código');
175
+ if (row.deletedFiles && row.deletedFiles.length) warningBits.push('worker deletou arquivos: ' + row.deletedFiles.join(', '));
176
+ // v0.5.1 runtime gate fields (present on every run that went through
177
+ // src/task-commands.mjs's runTask after the gate was wired in; absent on
178
+ // older rows, which is fine — this is purely additive summary text).
179
+ if (row.runtimeDecision && row.runtimeDecision !== 'EXECUTE') {
180
+ warningBits.push('gate: ' + row.runtimeDecision + (row.blockedReasonCategory ? ' (' + row.blockedReasonCategory + ')' : ''));
181
+ }
182
+ if (row.forced === true) warningBits.push('executado com --force');
183
+ const gateSuffix = [
184
+ row.selectedManager ? 'gerente=' + row.selectedManager : null,
185
+ row.selectedEngine ? 'engine=' + row.selectedEngine : null,
186
+ row.preflightDecision ? 'preflight=' + row.preflightDecision : null
187
+ ].filter(Boolean).join(' ');
188
+ const baseSummary = 'Task #' + row.taskId + ' ' + (row.status || (row.exitCode === 0 ? 'finalizado' : 'erro')) + (gateSuffix ? ' [' + gateSuffix + ']' : '');
189
+ return makeTimelineEntry({
190
+ timestamp: row.endTime || row.startTime || row.timestamp,
191
+ agent: blockedBySafety ? 'safety' : (blockedByGate ? 'engine' : (row.engine || 'codex9-worker')),
192
+ kind: 'run',
193
+ status: row.status || (row.exitCode === 0 ? 'completed' : 'failed'),
194
+ summary: warningBits.length ? baseSummary + ' — ' + warningBits.join('; ') : baseSummary,
195
+ source: '.maestro/RUNS.jsonl',
196
+ raw: row,
197
+ taskId: row.taskId,
198
+ warnings: row.warnings && row.warnings.length ? row.warnings : warningBits,
199
+ issue: row.status === 'failed' || blocked || row.status === 'completed_with_warnings' || row.unsupportedToolCall === true || row.suspectedFalsePositive === true || row.windowsShellCommandMismatch === true || row.syntaxValidationFailed === true
200
+ });
201
+ }
202
+
203
+ function fromUsageEntry(row) {
204
+ const blocked = row.blockedBySafety === true || row.exitCode === 2;
205
+ const status = blocked ? 'blocked' : (row.exitCode === 0 ? 'completed' : 'failed');
206
+ const summary = [
207
+ row.command || 'usage',
208
+ row.strategy ? 'via ' + row.strategy : null,
209
+ row.taskId ? '#' + row.taskId : null
210
+ ].filter(Boolean).join(' ');
211
+ return makeTimelineEntry({
212
+ timestamp: row.timestamp,
213
+ agent: blocked ? 'safety' : 'engine',
214
+ kind: 'usage',
215
+ status,
216
+ summary: summary || 'usage event',
217
+ source: '.maestro/usage.jsonl',
218
+ raw: row,
219
+ commandId: row.command || null,
220
+ issue: blocked || status === 'failed'
221
+ });
222
+ }
223
+
224
+ function fromUiRunEntry(row) {
225
+ const status = row.exitCode === 0 ? 'completed' : 'failed';
226
+ const summary = [
227
+ 'UI',
228
+ row.commandId || 'command',
229
+ row.taskId ? '#' + row.taskId : null,
230
+ status
231
+ ].filter(Boolean).join(' ');
232
+ return makeTimelineEntry({
233
+ timestamp: row.timestamp,
234
+ agent: 'ui',
235
+ kind: 'ui-command',
236
+ status,
237
+ summary,
238
+ source: '.maestro/ui-runs.jsonl',
239
+ raw: row,
240
+ commandId: row.commandId || null,
241
+ taskId: row.taskId === undefined ? null : row.taskId,
242
+ issue: status === 'failed'
243
+ });
244
+ }
245
+
246
+ export function buildTimelineEntries({ memoryEvents = [], runs = [], usage = [], uiRuns = [] } = {}) {
247
+ return [...memoryEvents.map(fromMemoryEvent), ...runs.map(fromRunEntry), ...usage.map(fromUsageEntry), ...uiRuns.map(fromUiRunEntry)]
248
+ .sort((a, b) => b.timestamp.localeCompare(a.timestamp))
249
+ .slice(0, TIMELINE_LIMIT);
250
+ }
251
+
252
+ export function buildTaskView(task) {
253
+ const statusView = normalizeStatus(task.status);
254
+ const actions = [];
255
+ const taskId = Number(task.id);
256
+ if (Number.isInteger(taskId) && taskId > 0 && ['pending', 'failed', 'in_progress'].includes(String(task.status))) {
257
+ actions.push({
258
+ id: 'run-task',
259
+ command: 'run-task',
260
+ label: 'Run Task',
261
+ taskId,
262
+ tone: 'primary'
263
+ });
264
+ }
265
+ if (Number.isInteger(taskId) && taskId > 0 && ['blocked', 'needs_human'].includes(String(task.status))) {
266
+ actions.push({
267
+ id: 'task-pending',
268
+ command: 'task-pending',
269
+ label: 'Reabrir',
270
+ taskId,
271
+ tone: 'secondary'
272
+ });
273
+ }
274
+ return {
275
+ ...task,
276
+ id: taskId,
277
+ statusView,
278
+ statusLabel: statusView.label,
279
+ statusIcon: statusView.icon,
280
+ statusTone: statusView.tone,
281
+ lastUpdatedAt: task.updatedAt || task.createdAt || null,
282
+ lastUpdatedAgo: formatRelativeTime(task.updatedAt || task.createdAt || null),
283
+ actions
284
+ };
285
+ }
286
+
287
+ export function buildCardViews({ timeline = [], tasks = [], memorySummary = '', taskCounts = {}, lockStatus = {}, currentEngine = 'codex9' } = {}) {
288
+ const latestByGroup = group => timeline.find(entry => entry.group === group) || null;
289
+ const maestroEvent = latestByGroup('maestro');
290
+ const codexEvent = latestByGroup('codex');
291
+ const memoryEvent = latestByGroup('memory');
292
+ const safetyEvent = latestByGroup('safety') || timeline.find(entry => entry.issue) || null;
293
+ const engineEvent = latestByGroup('engine') || latestByGroup('codex');
294
+ const projectEvent = timeline[0] || null;
295
+
296
+ return [
297
+ {
298
+ id: 'maestro',
299
+ title: 'Maestro',
300
+ status: lockStatus.locked ? 'running' : (taskCounts.blocked ? 'blocked' : (tasks.some(task => task.status === 'pending' || task.status === 'in_progress') ? 'thinking' : (safetyEvent ? 'warning' : 'idle'))),
301
+ detail: lockStatus.locked ? 'Lock ativo: ' + (lockStatus.command || 'maestro') : 'Pronto',
302
+ lastEvent: maestroEvent,
303
+ lastEventLabel: maestroEvent ? maestroEvent.summary : 'Sem evento recente'
304
+ },
305
+ {
306
+ id: 'codex',
307
+ title: 'Codex',
308
+ status: tasks.some(task => task.status === 'in_progress' || task.status === 'pending') ? 'thinking' : 'idle',
309
+ detail: 'Engine atual: ' + currentEngine,
310
+ lastEvent: codexEvent,
311
+ lastEventLabel: codexEvent ? codexEvent.summary : 'Sem evento recente'
312
+ },
313
+ {
314
+ id: 'memory',
315
+ title: 'Memory',
316
+ status: memorySummary ? 'completed' : 'warning',
317
+ detail: memorySummary || 'Sem resumo de memória',
318
+ lastEvent: memoryEvent,
319
+ lastEventLabel: memoryEvent ? memoryEvent.summary : 'Sem evento recente'
320
+ },
321
+ {
322
+ id: 'safety',
323
+ title: 'Safety',
324
+ status: taskCounts.blocked ? 'blocked' : 'idle',
325
+ detail: (taskCounts.blocked || 0) + ' bloqueadas',
326
+ lastEvent: safetyEvent,
327
+ lastEventLabel: safetyEvent ? safetyEvent.summary : 'Sem evento recente'
328
+ },
329
+ {
330
+ id: 'router',
331
+ title: 'Engine Router',
332
+ status: 'idle',
333
+ detail: 'Display local',
334
+ lastEvent: engineEvent,
335
+ lastEventLabel: engineEvent ? engineEvent.summary : 'Sem evento recente'
336
+ },
337
+ {
338
+ id: 'project',
339
+ title: 'Projeto',
340
+ status: taskCounts.failed ? 'error' : (tasks.some(task => task.status === 'pending' || task.status === 'in_progress') ? 'running' : 'completed'),
341
+ detail: tasks.length + ' tarefas',
342
+ lastEvent: projectEvent,
343
+ lastEventLabel: projectEvent ? projectEvent.summary : 'Sem evento recente'
344
+ }
345
+ ];
346
+ }
347
+
348
+ export async function readHandoffSnapshot(projectRoot = process.cwd()) {
349
+ const memoryHandoffPath = path.join(projectRoot, CONFIG.memoryPath, 'handoff.md');
350
+ const maestroHandoffPath = path.join(projectRoot, CONFIG.maestroPath, 'HANDOFF.md');
351
+ try {
352
+ const memoryContent = await fs.readFile(memoryHandoffPath, 'utf-8');
353
+ return { content: memoryContent, source: memoryHandoffPath, exists: true };
354
+ } catch (error) {
355
+ if (error.code !== 'ENOENT') throw error;
356
+ }
357
+ try {
358
+ const maestroContent = await fs.readFile(maestroHandoffPath, 'utf-8');
359
+ return { content: maestroContent, source: maestroHandoffPath, exists: true };
360
+ } catch (error) {
361
+ if (error.code !== 'ENOENT') throw error;
362
+ }
363
+ return {
364
+ content: '# Handoff indisponível\n\nNenhum handoff encontrado em .memory/handoff.md ou .maestro/HANDOFF.md.\n',
365
+ source: null,
366
+ exists: false
367
+ };
368
+ }
369
+
370
+ // Read-only orchestra snapshot for the UI (Phase 16). Deliberately exposes
371
+ // no way to edit engines.json/budget.json/fallback-graph.json from here —
372
+ // project rule: "Não permitir editar configurações sensíveis pela UI nessa
373
+ // versão." Callers that omit engines/budget/fallbackGraph (e.g. existing
374
+ // tests built before this field existed) get safe empty defaults instead
375
+ // of a crash.
376
+ export function buildOrchestraView({ engines = [], budget = null, fallbackGraph = null } = {}) {
377
+ const engineSummaries = engines.map(engine => ({
378
+ id: engine.id,
379
+ displayName: engine.displayName || engine.id,
380
+ enabled: engine.enabled === true,
381
+ validated: engine.validated === true,
382
+ provider: engine.provider || 'unknown',
383
+ costClass: engine.cost ? engine.cost.class : 'unknown',
384
+ roles: engine.roles || []
385
+ }));
386
+ return {
387
+ engines: engineSummaries,
388
+ enabledEngineCount: engineSummaries.filter(engine => engine.enabled).length,
389
+ budget: budget ? {
390
+ allowPaid: budget.allowPaid === true,
391
+ allowClaudeReview: budget.allowClaudeReview === true,
392
+ allowDirectOpenAI: budget.allowDirectOpenAI === true,
393
+ preferFree: budget.preferFree === true,
394
+ estimatedTokenBudget: budget.estimatedTokenBudget ?? null,
395
+ maxRunsPerTask: budget.maxRunsPerTask ?? null,
396
+ maxSubagentsPerTask: budget.maxSubagentsPerTask ?? null,
397
+ maxDepth: budget.maxDepth ?? null
398
+ } : null,
399
+ fallbackRuleCount: fallbackGraph && fallbackGraph.rules ? Object.keys(fallbackGraph.rules).length : 0,
400
+ delegationLimits: DELEGATION_LIMITS,
401
+ sectorManagers: SECTOR_MANAGERS.map(sector => sector.id)
402
+ };
403
+ }
404
+
405
+ export function buildUiStateFromFiles({
406
+ projectRoot,
407
+ tasks,
408
+ memoryEvents,
409
+ runs,
410
+ usage,
411
+ uiRuns,
412
+ usageSummary,
413
+ lock,
414
+ memoryBrief,
415
+ checkpoint,
416
+ handoff,
417
+ engines = [],
418
+ budget = null,
419
+ fallbackGraph = null
420
+ }) {
421
+ const taskViews = sortRunnableTasks(tasks).map(buildTaskView);
422
+ const taskCounts = countTasksByStatus(tasks);
423
+ const lockStatus = lock ? { locked: true, ...lock } : { locked: false };
424
+ const timeline = buildTimelineEntries({ memoryEvents, runs, usage, uiRuns });
425
+ const currentEngine = taskViews.find(task => task.status === 'in_progress' || task.status === 'pending')?.engine || 'codex9';
426
+ const memorySummarySource = memoryBrief || handoff || checkpoint || '';
427
+ const memorySummary = summarizeText(memorySummarySource.replace(/^#+\s*/gm, ''), 700);
428
+ const warnings = [];
429
+ if (lockStatus.locked) warnings.push('Lock ativo');
430
+ if (taskCounts.blocked) warnings.push('Tarefas bloqueadas');
431
+ if (taskCounts.needs_human) warnings.push('Tarefas aguardando decisão humana (' + taskCounts.needs_human + ')');
432
+ if (usageSummary && usageSummary.apiOpenAiDetected) warnings.push('api.openai.com detectado');
433
+
434
+ return {
435
+ projectRoot,
436
+ memorySummary,
437
+ handoff,
438
+ tasks: taskViews,
439
+ taskCounts,
440
+ timeline,
441
+ recentEvents: timeline,
442
+ recentRuns: runs.slice(-10),
443
+ recentUsage: usage.slice(-10),
444
+ recentMemoryEvents: memoryEvents.slice(-10),
445
+ recentUiRuns: uiRuns.slice(-10),
446
+ lockStatus,
447
+ currentEngine,
448
+ warnings,
449
+ cards: buildCardViews({ timeline, tasks: taskViews, memorySummary, taskCounts, lockStatus, currentEngine }),
450
+ statusMap: STATUS_META,
451
+ groupMap: GROUP_META,
452
+ orchestra: buildOrchestraView({ engines, budget, fallbackGraph })
453
+ };
454
+ }
455
+
456
+ export async function readUiState(projectRoot = process.cwd()) {
457
+ const memoryPath = path.join(projectRoot, CONFIG.memoryPath);
458
+ const maestroPath = path.join(projectRoot, CONFIG.maestroPath);
459
+ const tasks = await readJsonSafe(path.join(maestroPath, 'TASKS.json'), []);
460
+ const memoryEvents = await readJsonlRecent(path.join(memoryPath, 'events.jsonl'), JSONL_RECENT_LIMIT);
461
+ const runs = await readJsonlRecent(path.join(maestroPath, 'RUNS.jsonl'), JSONL_RECENT_LIMIT);
462
+ const usage = await readJsonlRecent(path.join(maestroPath, 'usage.jsonl'), JSONL_RECENT_LIMIT);
463
+ const uiRuns = await readJsonlRecent(path.join(maestroPath, 'ui-runs.jsonl'), JSONL_RECENT_LIMIT);
464
+ const usageSummary = await readJsonSafe(path.join(maestroPath, 'usage-summary.json'), null);
465
+ const lock = await readJsonSafe(path.join(maestroPath, '.lock.json'), null);
466
+ const memoryBrief = await readTextSafe(path.join(memoryPath, 'BRIEF.md'));
467
+ const checkpoint = await readTextSafe(path.join(maestroPath, 'CHECKPOINT.md'));
468
+ const handoff = await readTextSafe(path.join(maestroPath, 'HANDOFF.md'));
469
+ const engines = await listEngineRecords();
470
+ const budget = await loadBudget();
471
+ const fallbackGraph = await loadFallbackGraph();
472
+ return buildUiStateFromFiles({
473
+ projectRoot,
474
+ tasks,
475
+ memoryEvents,
476
+ runs,
477
+ usage,
478
+ uiRuns,
479
+ usageSummary,
480
+ lock,
481
+ memoryBrief,
482
+ checkpoint,
483
+ handoff,
484
+ engines,
485
+ budget,
486
+ fallbackGraph
487
+ });
488
+ }
489
+
490
+ export function getUiWatchFiles(projectRoot = process.cwd()) {
491
+ return [
492
+ path.join(projectRoot, CONFIG.memoryPath, 'BRIEF.md'),
493
+ path.join(projectRoot, CONFIG.memoryPath, 'handoff.md'),
494
+ path.join(projectRoot, CONFIG.memoryPath, 'events.jsonl'),
495
+ path.join(projectRoot, CONFIG.maestroPath, 'TASKS.json'),
496
+ path.join(projectRoot, CONFIG.maestroPath, 'RUNS.jsonl'),
497
+ path.join(projectRoot, CONFIG.maestroPath, 'usage.jsonl'),
498
+ path.join(projectRoot, CONFIG.maestroPath, 'usage-summary.json'),
499
+ path.join(projectRoot, CONFIG.maestroPath, 'CHECKPOINT.md'),
500
+ path.join(projectRoot, CONFIG.maestroPath, 'HANDOFF.md'),
501
+ path.join(projectRoot, CONFIG.maestroPath, '.lock.json'),
502
+ path.join(projectRoot, CONFIG.maestroPath, 'ui-runs.jsonl')
503
+ ];
504
+ }
package/src/usage.mjs ADDED
@@ -0,0 +1,178 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { CONFIG } from './config.mjs';
4
+ import { stampSchemaVersion, validateUsageSummary, inferRunKind } from './schema.mjs';
5
+ import { listEngineRecords } from './orchestration/capability-registry.mjs';
6
+
7
+ const usagePath = path.join(CONFIG.maestroPath, 'usage.jsonl');
8
+ const summaryPath = path.join(CONFIG.maestroPath, 'usage-summary.json');
9
+
10
+ // Hotfix A.2: safety/cost policy fields the usage-summary schema requires
11
+ // (validateUsageSummary in src/schema.mjs). Old summary files predate these
12
+ // fields; they are migrated automatically by adding the defaults below —
13
+ // history counters are never dropped, and an existing explicit boolean is
14
+ // never overwritten.
15
+ export const USAGE_POLICY_DEFAULTS = Object.freeze({
16
+ preferFree: true,
17
+ allowPaid: false,
18
+ allowClaudeReview: false,
19
+ allowDirectOpenAI: false
20
+ });
21
+
22
+ export function applyUsagePolicyDefaults(summary = {}) {
23
+ const migrated = { ...summary };
24
+ for (const [key, value] of Object.entries(USAGE_POLICY_DEFAULTS)) {
25
+ if (typeof migrated[key] !== 'boolean') {
26
+ migrated[key] = value;
27
+ }
28
+ }
29
+ return migrated;
30
+ }
31
+
32
+ async function readSummaryFileSafe() {
33
+ try {
34
+ return JSON.parse(await fs.readFile(summaryPath, 'utf-8'));
35
+ } catch (error) {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function estimateTokens(text) {
41
+ return Math.ceil((text || '').length / 4);
42
+ }
43
+
44
+ async function readUsageRows() {
45
+ try {
46
+ const content = await fs.readFile(usagePath, 'utf-8');
47
+ return content.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line));
48
+ } catch (error) {
49
+ if (error.code === 'ENOENT') {
50
+ return [];
51
+ }
52
+ throw error;
53
+ }
54
+ }
55
+
56
+ export async function recordUsage(entry) {
57
+ await fs.mkdir(CONFIG.maestroPath, { recursive: true });
58
+ const promptChars = entry.prompt ? entry.prompt.length : (entry.promptChars || 0);
59
+ const estimatedInputTokens = entry.estimatedInputTokens || estimateTokens(entry.prompt || '');
60
+ const estimatedOutputTokens = entry.output ? estimateTokens(entry.output) : (entry.estimatedOutputTokens || 0);
61
+ const row = stampSchemaVersion({
62
+ timestamp: new Date().toISOString(),
63
+ engine: 'codex9',
64
+ profile: CONFIG.codexProfile,
65
+ // runKind separates real task work from diagnostics/preflight/verification/
66
+ // subagent runs so stats don't conflate them (Phase 14). Explicit
67
+ // entry.runKind always wins; otherwise infer from entry.command; a
68
+ // command this project doesn't recognize yet stays 'unknown', never a
69
+ // guessed 'task'.
70
+ runKind: entry.runKind || inferRunKind(entry.command),
71
+ promptChars,
72
+ estimatedInputTokens,
73
+ estimatedOutputTokens,
74
+ estimatedTotalTokens: estimatedInputTokens + estimatedOutputTokens,
75
+ claudeAvoided: true,
76
+ ...entry
77
+ });
78
+ delete row.prompt;
79
+ delete row.output;
80
+ await fs.appendFile(usagePath, JSON.stringify(row) + '\n');
81
+ await writeUsageSummary();
82
+ return row;
83
+ }
84
+
85
+ // Best-effort engine -> cost-class lookup for the summary breakdown below.
86
+ // This is an ESTIMATE, never a real billing figure (project rule: "Nunca
87
+ // chamar a estimativa de billing real"). An engine id not found in
88
+ // .maestro/engines.json (or the registry file missing) counts as 'unknown',
89
+ // not 'free' — absence of data is not evidence of being free.
90
+ async function buildCostClassLookup() {
91
+ const engines = await listEngineRecords();
92
+ const lookup = new Map();
93
+ for (const engine of engines) {
94
+ lookup.set(engine.id, engine.cost ? engine.cost.class : 'unknown');
95
+ }
96
+ return lookup;
97
+ }
98
+
99
+ function groupCounts(rows, keyFn) {
100
+ return rows.reduce((acc, row) => {
101
+ const key = keyFn(row) || 'unknown';
102
+ acc[key] = (acc[key] || 0) + 1;
103
+ return acc;
104
+ }, {});
105
+ }
106
+
107
+ export async function writeUsageSummary() {
108
+ const rows = await readUsageRows();
109
+ const existingSummary = await readSummaryFileSafe();
110
+ // Carry over any policy booleans a previous summary already had; add the
111
+ // defaults for the missing ones (automatic migration of old files).
112
+ const policy = applyUsagePolicyDefaults(existingSummary || {});
113
+ const costLookup = await buildCostClassLookup();
114
+ const executionByCost = rows.reduce((acc, row) => {
115
+ const costClass = costLookup.get(row.engine) || 'unknown';
116
+ const bucket = (costClass === 'free' || costClass === 'local') ? 'free'
117
+ : costClass === 'free-tier' ? 'free'
118
+ : costClass === 'paid' ? 'paid'
119
+ : 'unknown';
120
+ acc[bucket] = (acc[bucket] || 0) + 1;
121
+ return acc;
122
+ }, { free: 0, paid: 0, unknown: 0 });
123
+
124
+ const summary = stampSchemaVersion({
125
+ preferFree: policy.preferFree,
126
+ allowPaid: policy.allowPaid,
127
+ allowClaudeReview: policy.allowClaudeReview,
128
+ allowDirectOpenAI: policy.allowDirectOpenAI,
129
+ totalRuns: rows.length,
130
+ completed: rows.filter(row => row.exitCode === 0).length,
131
+ failed: rows.filter(row => row.exitCode && row.exitCode !== 0).length,
132
+ blocked: rows.filter(row => row.blockedBySafety).length,
133
+ estimatedTotalTokens: rows.reduce((sum, row) => sum + (row.estimatedTotalTokens || 0), 0),
134
+ estimatedClaudeAvoidedTokens: rows.reduce((sum, row) => sum + (row.claudeAvoided ? (row.estimatedTotalTokens || 0) : 0), 0),
135
+ engines: groupCounts(rows, row => row.engine),
136
+ // byRunKind separates real task work from diagnostics/doctor/codex-test/
137
+ // ui-command/release-check/preflight/verification/subagent runs (Phase 14).
138
+ // Rows recorded before this field existed have no `runKind` and land in
139
+ // 'unknown', not 'task' — see inferRunKind() in src/schema.mjs.
140
+ byRunKind: groupCounts(rows, row => row.runKind),
141
+ // ESTIMATED execution split by cost class, never real billing data (see
142
+ // buildCostClassLookup() above). 'unknown' includes both engines absent
143
+ // from .maestro/engines.json and engines whose cost.class is 'unknown'.
144
+ executionByCost,
145
+ // v0.5.1: how the runtime gate (src/orchestration/runtime-gate.mjs)
146
+ // resolved each run that went through it. Rows from before the gate was
147
+ // wired in (or non-task commands) have no runtimeDecision and land in
148
+ // 'unknown' — never assumed to be EXECUTE.
149
+ byGateDecision: groupCounts(rows, row => row.runtimeDecision),
150
+ // Only meaningful for rows where runtimeDecision === 'BLOCK'; answers
151
+ // "blocked by preflight / budget / policy / no-candidate" per project
152
+ // rule that stats must be able to count each separately.
153
+ byBlockReason: groupCounts(rows.filter(row => row.runtimeDecision === 'BLOCK'), row => row.blockedReasonCategory),
154
+ lastRuns: rows.slice(-10)
155
+ });
156
+ const result = validateUsageSummary(summary);
157
+ if (!result.ok) {
158
+ console.warn('Usage summary failed schema validation: ' + result.errors.join(', '));
159
+ }
160
+ await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2));
161
+ return summary;
162
+ }
163
+
164
+ export async function getUsageSummary() {
165
+ try {
166
+ const parsed = JSON.parse(await fs.readFile(summaryPath, 'utf-8'));
167
+ const migrated = applyUsagePolicyDefaults(parsed);
168
+ const needsMigration = Object.keys(USAGE_POLICY_DEFAULTS).some(key => parsed[key] !== migrated[key]);
169
+ if (needsMigration) {
170
+ // Automatic in-place migration of an old summary file: only the
171
+ // missing policy defaults are added; every historical counter stays.
172
+ await fs.writeFile(summaryPath, JSON.stringify(migrated, null, 2));
173
+ }
174
+ return migrated;
175
+ } catch (error) {
176
+ return writeUsageSummary();
177
+ }
178
+ }