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.
- package/CHANGELOG.md +11 -0
- package/LICENSE +15 -0
- package/README.md +727 -0
- package/bin/maestro.mjs +246 -0
- package/package.json +29 -0
- package/src/checkpoint.mjs +102 -0
- package/src/codex-diagnostics.mjs +682 -0
- package/src/codex-home-inspect.mjs +252 -0
- package/src/codex-home.mjs +258 -0
- package/src/commands.mjs +809 -0
- package/src/config.mjs +11 -0
- package/src/debug.mjs +164 -0
- package/src/encoding-guard.mjs +125 -0
- package/src/engines/ai-router-engine.mjs +37 -0
- package/src/engines/codex-engine.mjs +21 -0
- package/src/engines/engine.mjs +21 -0
- package/src/engines/registry.mjs +29 -0
- package/src/files.mjs +65 -0
- package/src/format.mjs +132 -0
- package/src/lock.mjs +439 -0
- package/src/memory-log.mjs +18 -0
- package/src/memory.mjs +1 -0
- package/src/orchestration/attempt-chain-runtime.mjs +627 -0
- package/src/orchestration/budget-manager.mjs +121 -0
- package/src/orchestration/capability-registry.mjs +108 -0
- package/src/orchestration/consolidator.mjs +772 -0
- package/src/orchestration/delegation-executor.mjs +262 -0
- package/src/orchestration/delegation-manager.mjs +116 -0
- package/src/orchestration/deployment-intent.mjs +36 -0
- package/src/orchestration/engine-history.mjs +110 -0
- package/src/orchestration/engine-policy.mjs +73 -0
- package/src/orchestration/engine-selector.mjs +187 -0
- package/src/orchestration/execution-context.mjs +45 -0
- package/src/orchestration/failure-classifier.mjs +136 -0
- package/src/orchestration/failure-evidence.mjs +237 -0
- package/src/orchestration/fallback-chain-lock.mjs +217 -0
- package/src/orchestration/fallback-chain-trail.mjs +218 -0
- package/src/orchestration/fallback-executor.mjs +146 -0
- package/src/orchestration/fallback-graph.mjs +106 -0
- package/src/orchestration/fallback-recommendation.mjs +73 -0
- package/src/orchestration/file-conflict-detector.mjs +126 -0
- package/src/orchestration/host-validation.mjs +241 -0
- package/src/orchestration/orchestration-loop.mjs +1971 -0
- package/src/orchestration/orchestration-runtime.mjs +1019 -0
- package/src/orchestration/orchestration-scheduler.mjs +135 -0
- package/src/orchestration/orchestration-trail.mjs +192 -0
- package/src/orchestration/preflight.mjs +212 -0
- package/src/orchestration/provider-health.mjs +566 -0
- package/src/orchestration/provider-router.mjs +352 -0
- package/src/orchestration/rc-check-adapters.mjs +817 -0
- package/src/orchestration/rc-check.mjs +544 -0
- package/src/orchestration/rc-policy.mjs +200 -0
- package/src/orchestration/runtime-gate.mjs +146 -0
- package/src/orchestration/sector-managers.mjs +215 -0
- package/src/orchestration/self-hosting-canary.mjs +231 -0
- package/src/orchestration/self-hosting-readiness.mjs +244 -0
- package/src/orchestration/spec-planner.mjs +877 -0
- package/src/orchestration/subtask-planner.mjs +176 -0
- package/src/orchestration/task-classifier.mjs +241 -0
- package/src/orchestration/verifier.mjs +1608 -0
- package/src/orchestration-commands.mjs +279 -0
- package/src/planner.mjs +38 -0
- package/src/project.mjs +1 -0
- package/src/run-diagnostics.mjs +641 -0
- package/src/runner.mjs +243 -0
- package/src/safety.mjs +116 -0
- package/src/schema.mjs +371 -0
- package/src/session-commands.mjs +521 -0
- package/src/shell.mjs +93 -0
- package/src/smart-planner.mjs +249 -0
- package/src/task-commands.mjs +1182 -0
- package/src/tasks.mjs +134 -0
- package/src/ui/commands.mjs +76 -0
- package/src/ui/events.mjs +45 -0
- package/src/ui/public/app.js +600 -0
- package/src/ui/public/index.html +88 -0
- package/src/ui/public/style.css +460 -0
- package/src/ui/server.mjs +112 -0
- package/src/ui/state.mjs +504 -0
- package/src/usage.mjs +178 -0
- package/src/workspace-diff.mjs +228 -0
package/src/ui/state.mjs
ADDED
|
@@ -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
|
+
}
|