ninja-terminals 2.3.1 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { spawn, execSync } = require('child_process');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+ const {
9
+ SESSION_FILE,
10
+ SESSION_DIR,
11
+ readRuntimeSession,
12
+ healthCheckSession,
13
+ writeRuntimeSession,
14
+ requestJson,
15
+ } = require('./lib/runtime-session');
16
+
17
+ const LOG_FILE = path.join(SESSION_DIR, 'ninja-server.log');
18
+ const PROJECT_ROOT = __dirname;
19
+
20
+ const PROBE_PORTS = [3300, 3301, 3302, 3303, 3304, 3305, 3306, 3307, 3308, 3309, 3310];
21
+ const AUTH_WAIT_MS = 15000;
22
+ const AUTH_POLL_MS = 500;
23
+
24
+ const USAGE = `
25
+ ninja-ensure — Start, discover, or recover a dispatch-ready Ninja Terminal runtime
26
+
27
+ Usage:
28
+ ninja-ensure Start/discover/recover, open browser, wait for auth
29
+ ninja-ensure --no-open Start/discover/recover, don't open browser
30
+ ninja-ensure --allow-no-auth Allow success without authToken (dispatch will fail)
31
+ ninja-ensure --dry-run Report what would happen, no action
32
+ ninja-ensure --help Show this help
33
+
34
+ Dispatch-ready means:
35
+ - Ninja Terminal server is running and healthy
36
+ - Session file exists with correct URL
37
+ - authToken is available for CLI dispatch (ninja-dispatch, agent-send)
38
+
39
+ If authToken is missing, run: ninja-login
40
+
41
+ Probed ports: ${PROBE_PORTS.join(', ')}
42
+ Also probes NINJA_PORT if set.
43
+
44
+ Session file: ${SESSION_FILE}
45
+ `;
46
+
47
+ function log(msg) {
48
+ console.log(msg);
49
+ }
50
+
51
+ function chromeHasUrl(url) {
52
+ if (process.platform !== 'darwin') return false;
53
+ try {
54
+ const script = `
55
+ tell application "Google Chrome"
56
+ repeat with w in windows
57
+ repeat with t in tabs of w
58
+ if URL of t contains "${url}" then return "found"
59
+ end repeat
60
+ end repeat
61
+ end tell
62
+ return "not_found"
63
+ `;
64
+ const result = execSync(`osascript -e '${script}'`, { encoding: 'utf8', timeout: 3000 }).trim();
65
+ return result === 'found';
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ function openBrowser(url) {
72
+ if (process.platform === 'darwin') {
73
+ if (chromeHasUrl(url)) {
74
+ log('Browser already has Ninja Terminal open');
75
+ return true;
76
+ }
77
+ try {
78
+ execSync(`open -a "Google Chrome" "${url}"`, { stdio: 'ignore' });
79
+ return true;
80
+ } catch {
81
+ try {
82
+ execSync(`open "${url}"`, { stdio: 'ignore' });
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+ }
89
+ const cmd = process.platform === 'win32' ? 'start' : 'xdg-open';
90
+ try {
91
+ execSync(`${cmd} "${url}"`, { stdio: 'ignore', shell: true });
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ function getTokenFromSources(session) {
99
+ if (process.env.NINJA_AUTH_TOKEN) return process.env.NINJA_AUTH_TOKEN;
100
+ if (session?.authToken) return session.authToken;
101
+ const tokenPath = path.join(os.homedir(), '.ninja', 'token');
102
+ if (fs.existsSync(tokenPath)) {
103
+ return fs.readFileSync(tokenPath, 'utf8').trim();
104
+ }
105
+ return null;
106
+ }
107
+
108
+ async function waitForAuthToken(timeoutMs = AUTH_WAIT_MS, pollMs = AUTH_POLL_MS) {
109
+ const deadline = Date.now() + timeoutMs;
110
+ while (Date.now() < deadline) {
111
+ const session = readRuntimeSession();
112
+ const token = getTokenFromSources(session);
113
+ if (token) return token;
114
+ await new Promise(r => setTimeout(r, pollMs));
115
+ }
116
+ return null;
117
+ }
118
+
119
+ async function printTerminalStatus(session, token) {
120
+ if (!token) return;
121
+ try {
122
+ const res = await requestJson({
123
+ host: session.host || 'localhost',
124
+ port: session.port,
125
+ path: '/api/terminals',
126
+ token,
127
+ });
128
+ if (res.statusCode === 200 && Array.isArray(res.body)) {
129
+ for (const t of res.body) {
130
+ const parts = [`T${t.id}`, t.label || '', t.status || 'unknown'];
131
+ if (t.taskName) parts.push(`— ${t.taskName}`);
132
+ log(parts.filter(Boolean).join(' '));
133
+ }
134
+ }
135
+ } catch {
136
+ // Ignore — summary not critical
137
+ }
138
+ }
139
+
140
+ async function probePort(port, host = 'localhost') {
141
+ const session = { host, port };
142
+ const health = await healthCheckSession(session, 2000);
143
+ if (health.ok && health.response?.status === 'ok' && typeof health.response?.terminals === 'number') {
144
+ return { port, host, terminals: health.response.terminals };
145
+ }
146
+ return null;
147
+ }
148
+
149
+ async function probeForOrphanedServer() {
150
+ const portsToProbe = [...PROBE_PORTS];
151
+
152
+ if (process.env.NINJA_PORT) {
153
+ const envPort = parseInt(process.env.NINJA_PORT, 10);
154
+ if (!isNaN(envPort) && !portsToProbe.includes(envPort)) {
155
+ portsToProbe.unshift(envPort);
156
+ }
157
+ }
158
+
159
+ const results = await Promise.all(portsToProbe.map(port => probePort(port)));
160
+ const found = results.find(r => r !== null);
161
+ return found || null;
162
+ }
163
+
164
+ function recoverSessionFile(port, host = 'localhost') {
165
+ const url = `http://${host}:${port}`;
166
+ const session = writeRuntimeSession({
167
+ port,
168
+ url,
169
+ cwd: process.cwd(),
170
+ terminals: 4,
171
+ command: 'ninja-ensure',
172
+ recovered: true,
173
+ recoveredAt: new Date().toISOString(),
174
+ pid: null, // Don't claim a pid we don't own
175
+ });
176
+ return session;
177
+ }
178
+
179
+ async function waitForSession(timeoutMs = 20000, pollMs = 300) {
180
+ const deadline = Date.now() + timeoutMs;
181
+ while (Date.now() < deadline) {
182
+ const session = readRuntimeSession();
183
+ if (session?.port) {
184
+ const health = await healthCheckSession(session);
185
+ if (health.ok) return session;
186
+ }
187
+ await new Promise(r => setTimeout(r, pollMs));
188
+ }
189
+ return null;
190
+ }
191
+
192
+ async function startServer() {
193
+ fs.mkdirSync(SESSION_DIR, { recursive: true });
194
+ const logStream = fs.openSync(LOG_FILE, 'a');
195
+
196
+ const child = spawn('node', ['server.js'], {
197
+ cwd: PROJECT_ROOT,
198
+ env: {
199
+ ...process.env,
200
+ NINJA_OPEN_BROWSER: 'false',
201
+ },
202
+ detached: true,
203
+ stdio: ['ignore', logStream, logStream],
204
+ });
205
+
206
+ child.unref();
207
+ fs.closeSync(logStream);
208
+
209
+ log(`Started server (pid ${child.pid}), waiting for ready...`);
210
+ log(`Logs: ${LOG_FILE}`);
211
+
212
+ const session = await waitForSession(20000);
213
+ if (!session) {
214
+ throw new Error('Server failed to start within 20 seconds. Check logs: ' + LOG_FILE);
215
+ }
216
+
217
+ return session;
218
+ }
219
+
220
+ async function main() {
221
+ const args = process.argv.slice(2);
222
+
223
+ if (args.includes('--help') || args.includes('-h')) {
224
+ console.log(USAGE);
225
+ process.exit(0);
226
+ }
227
+
228
+ const noOpen = args.includes('--no-open');
229
+ const dryRun = args.includes('--dry-run');
230
+ const allowNoAuth = args.includes('--allow-no-auth');
231
+
232
+ // Step 1: Check existing session file
233
+ const existingSession = readRuntimeSession();
234
+ let session = null;
235
+ let action = 'none';
236
+
237
+ if (existingSession?.port) {
238
+ const health = await healthCheckSession(existingSession);
239
+ if (health.ok) {
240
+ session = existingSession;
241
+ action = 'reuse';
242
+ }
243
+ }
244
+
245
+ // Step 2: If no healthy session, probe for orphaned server
246
+ if (!session) {
247
+ log('No healthy session file. Probing for orphaned Ninja server...');
248
+ const orphaned = await probeForOrphanedServer();
249
+ if (orphaned) {
250
+ action = 'recover';
251
+ if (!dryRun) {
252
+ session = recoverSessionFile(orphaned.port, orphaned.host);
253
+ log(`Recovered orphaned runtime: http://${orphaned.host}:${orphaned.port}`);
254
+ } else {
255
+ session = { port: orphaned.port, host: orphaned.host, url: `http://${orphaned.host}:${orphaned.port}` };
256
+ }
257
+ } else {
258
+ action = 'start';
259
+ }
260
+ }
261
+
262
+ // Dry run reporting
263
+ if (dryRun) {
264
+ const existingToken = getTokenFromSources(existingSession);
265
+ if (action === 'reuse') {
266
+ log(`[dry-run] Would reuse existing runtime at ${existingSession.url}`);
267
+ log(`[dry-run] authToken present: ${!!existingToken}`);
268
+ } else if (action === 'recover') {
269
+ log(`[dry-run] Would recover orphaned runtime at ${session.url}`);
270
+ log(`[dry-run] Would repair session file: ${SESSION_FILE}`);
271
+ log(`[dry-run] authToken will be missing (browser sync required)`);
272
+ } else {
273
+ log(`[dry-run] No runtime found on probed ports`);
274
+ log(`[dry-run] Would start new server from ${PROJECT_ROOT}`);
275
+ }
276
+ log(`[dry-run] Would open browser: ${!noOpen}`);
277
+ process.exit(0);
278
+ }
279
+
280
+ // Step 3: Start new server if needed
281
+ if (action === 'start') {
282
+ session = await startServer();
283
+ log(`Server ready on ${session.url}`);
284
+ } else if (action === 'reuse') {
285
+ log(`Runtime already active: ${session.url}`);
286
+ }
287
+ // action === 'recover' already logged above
288
+
289
+ // Step 4: Open browser
290
+ if (!noOpen) {
291
+ const opened = openBrowser(session.url);
292
+ if (opened) {
293
+ log(`Opened: ${session.url}`);
294
+ } else {
295
+ log(`Browser open failed — navigate manually to ${session.url}`);
296
+ }
297
+ }
298
+
299
+ log(`Session file: ${SESSION_FILE}`);
300
+
301
+ // Step 5: Check dispatch readiness (authToken)
302
+ let token = getTokenFromSources(session);
303
+
304
+ if (!token && !noOpen) {
305
+ log('Waiting for auth token... (run ninja-login if this hangs)');
306
+ token = await waitForAuthToken(AUTH_WAIT_MS, AUTH_POLL_MS);
307
+ if (token) {
308
+ session = readRuntimeSession(); // Re-read with updated token
309
+ }
310
+ }
311
+
312
+ if (token) {
313
+ log('Dispatch ready');
314
+ await printTerminalStatus(session, token);
315
+ process.exit(0);
316
+ } else {
317
+ if (allowNoAuth) {
318
+ log('Warning: authToken missing. Run: ninja-login');
319
+ log('Runtime is running but CLI dispatch is NOT ready');
320
+ process.exit(0);
321
+ } else {
322
+ log('Error: authToken missing — dispatch will fail');
323
+ log('Open browser and login, then rerun ninja-ensure');
324
+ log('Or use --allow-no-auth to skip this check');
325
+ process.exit(1);
326
+ }
327
+ }
328
+ }
329
+
330
+ main().catch((err) => {
331
+ console.error(`Error: ${err.message}`);
332
+ process.exit(1);
333
+ });
package/ninja-gate.js ADDED
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const {
6
+ SESSION_FILE,
7
+ LEDGER_FILE,
8
+ VERIFICATION_LEDGER_FILE,
9
+ VISUAL_LEDGER_FILE,
10
+ readRuntimeSession,
11
+ readAuthToken,
12
+ healthCheckSession,
13
+ requestJson,
14
+ } = require('./lib/runtime-session');
15
+
16
+ const USAGE = `
17
+ Usage:
18
+ ninja-gate [--since <duration|iso>] [--json]
19
+
20
+ Validates Ninja Terminal evidence without relying on Claude hooks.
21
+
22
+ Checks:
23
+ - Runtime session exists and /health passes
24
+ - At least one successful dispatch exists after --since
25
+ - pre-dispatch visual exists between --since and latest dispatch
26
+ - verification exists after latest dispatch
27
+ - post-output/final-visual exists after latest verification
28
+ - dispatched terminals have terminal taskStatus done/blocked/error when API is available
29
+
30
+ Examples:
31
+ ninja-gate --since 20m
32
+ ninja-gate --since 2026-05-03T17:00:00.000Z --json
33
+ `;
34
+
35
+ const COMPLETE_TASK_STATES = ['done', 'blocked', 'error'];
36
+ const POST_VISUAL_STAGES = ['post-output', 'final-visual'];
37
+
38
+ function readNdjson(file) {
39
+ try {
40
+ const raw = fs.readFileSync(file, 'utf8');
41
+ return raw.trim().split('\n').filter(Boolean).map((line) => {
42
+ try {
43
+ return JSON.parse(line);
44
+ } catch {
45
+ return null;
46
+ }
47
+ }).filter(Boolean);
48
+ } catch {
49
+ return [];
50
+ }
51
+ }
52
+
53
+ function parseSinceArg(value, now = Date.now()) {
54
+ if (!value) return new Date(now - 20 * 60 * 1000).toISOString();
55
+
56
+ const duration = String(value).trim().match(/^(\d+)(ms|s|m|h|d)$/i);
57
+ if (duration) {
58
+ const amount = Number.parseInt(duration[1], 10);
59
+ const unit = duration[2].toLowerCase();
60
+ const factors = {
61
+ ms: 1,
62
+ s: 1000,
63
+ m: 60 * 1000,
64
+ h: 60 * 60 * 1000,
65
+ d: 24 * 60 * 60 * 1000,
66
+ };
67
+ return new Date(now - amount * factors[unit]).toISOString();
68
+ }
69
+
70
+ const parsed = Date.parse(value);
71
+ if (!Number.isNaN(parsed)) return new Date(parsed).toISOString();
72
+
73
+ throw new Error(`Invalid --since value: ${value}`);
74
+ }
75
+
76
+ function isAfter(timestamp, afterTs) {
77
+ return Boolean(timestamp && timestamp > afterTs);
78
+ }
79
+
80
+ function isBetween(timestamp, startTs, endTs) {
81
+ return Boolean(timestamp && timestamp > startTs && timestamp < endTs);
82
+ }
83
+
84
+ function getTaskStatusTimestamp(status) {
85
+ return status?.updatedAt || status?.taskStatusUpdatedAt || status?.timestamp || null;
86
+ }
87
+
88
+ function evaluateEvidence({ sinceTs, dispatchEntries, verificationEntries, visualEntries, taskStatuses }) {
89
+ const failures = [];
90
+ const warnings = [];
91
+
92
+ const successfulDispatches = dispatchEntries
93
+ .filter(e => e.success === true && isAfter(e.timestamp, sinceTs))
94
+ .sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
95
+
96
+ if (successfulDispatches.length === 0) {
97
+ failures.push(`No successful dispatch found after ${sinceTs}`);
98
+ }
99
+
100
+ const latestDispatch = successfulDispatches[successfulDispatches.length - 1] || null;
101
+ const dispatchedTerminalIds = [...new Set(successfulDispatches.map(e => String(e.terminalId)).filter(Boolean))];
102
+ const latestDispatchByTerminalId = new Map();
103
+ for (const dispatch of successfulDispatches) {
104
+ const terminalId = String(dispatch.terminalId);
105
+ const previous = latestDispatchByTerminalId.get(terminalId);
106
+ if (!previous || String(dispatch.timestamp).localeCompare(String(previous.timestamp)) > 0) {
107
+ latestDispatchByTerminalId.set(terminalId, dispatch);
108
+ }
109
+ }
110
+
111
+ let preVisual = null;
112
+ let latestVerification = null;
113
+ let postVisual = null;
114
+
115
+ if (latestDispatch) {
116
+ preVisual = visualEntries
117
+ .filter(e => e.stage === 'pre-dispatch' && isBetween(e.timestamp, sinceTs, latestDispatch.timestamp))
118
+ .sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)))
119
+ .at(-1) || null;
120
+
121
+ if (!preVisual) {
122
+ failures.push('Missing pre-dispatch visual entry between --since and latest dispatch');
123
+ }
124
+
125
+ latestVerification = verificationEntries
126
+ .filter(e => isAfter(e.timestamp, latestDispatch.timestamp))
127
+ .sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)))
128
+ .at(-1) || null;
129
+
130
+ if (!latestVerification) {
131
+ failures.push('Missing verification entry after latest dispatch');
132
+ }
133
+ }
134
+
135
+ if (latestVerification) {
136
+ postVisual = visualEntries
137
+ .filter(e => POST_VISUAL_STAGES.includes(e.stage) && isAfter(e.timestamp, latestVerification.timestamp))
138
+ .sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)))
139
+ .at(-1) || null;
140
+
141
+ if (!postVisual) {
142
+ failures.push('Missing post-output/final-visual entry after latest verification');
143
+ }
144
+ }
145
+
146
+ const taskStatusById = new Map();
147
+ for (const status of taskStatuses || []) {
148
+ if (status && status.id !== undefined) taskStatusById.set(String(status.id), status);
149
+ }
150
+
151
+ const incompleteTaskStatuses = [];
152
+ if (taskStatuses === null) {
153
+ warnings.push('Task status API unavailable; skipped semantic task status check');
154
+ } else {
155
+ for (const terminalId of dispatchedTerminalIds) {
156
+ const status = taskStatusById.get(String(terminalId));
157
+ if (!status) {
158
+ incompleteTaskStatuses.push({ terminalId, taskStatus: 'missing' });
159
+ continue;
160
+ }
161
+
162
+ const state = status.taskStatus || status.state || 'unknown';
163
+ if (!COMPLETE_TASK_STATES.includes(state)) {
164
+ incompleteTaskStatuses.push({ terminalId, taskStatus: state });
165
+ continue;
166
+ }
167
+
168
+ const taskUpdatedAt = getTaskStatusTimestamp(status);
169
+ const terminalDispatch = latestDispatchByTerminalId.get(String(terminalId)) || latestDispatch;
170
+ if (terminalDispatch && (!taskUpdatedAt || taskUpdatedAt <= terminalDispatch.timestamp)) {
171
+ incompleteTaskStatuses.push({ terminalId, taskStatus: `${state} (stale)` });
172
+ }
173
+ }
174
+
175
+ if (incompleteTaskStatuses.length > 0) {
176
+ failures.push(`Incomplete task status for terminal(s): ${
177
+ incompleteTaskStatuses.map(t => `T${t.terminalId}=${t.taskStatus}`).join(', ')
178
+ }`);
179
+ }
180
+ }
181
+
182
+ return {
183
+ ok: failures.length === 0,
184
+ sinceTs,
185
+ failures,
186
+ warnings,
187
+ summary: {
188
+ successfulDispatches: successfulDispatches.length,
189
+ latestDispatch,
190
+ latestVerification,
191
+ preVisual,
192
+ postVisual,
193
+ dispatchedTerminalIds,
194
+ latestDispatchByTerminalId: Object.fromEntries(latestDispatchByTerminalId),
195
+ incompleteTaskStatuses,
196
+ },
197
+ };
198
+ }
199
+
200
+ function parseArgs(argv) {
201
+ const opts = {
202
+ since: null,
203
+ json: false,
204
+ };
205
+
206
+ for (let i = 0; i < argv.length; i++) {
207
+ const arg = argv[i];
208
+ if (arg === '--help' || arg === '-h') {
209
+ opts.help = true;
210
+ } else if (arg === '--json') {
211
+ opts.json = true;
212
+ } else if (arg === '--since') {
213
+ opts.since = argv[++i];
214
+ if (!opts.since) throw new Error('--since requires a value');
215
+ } else {
216
+ throw new Error(`Unknown argument: ${arg}`);
217
+ }
218
+ }
219
+
220
+ return opts;
221
+ }
222
+
223
+ async function fetchTaskStatuses(session, token) {
224
+ if (!token) return null;
225
+ const res = await requestJson({
226
+ host: session.host || 'localhost',
227
+ port: session.port,
228
+ path: '/api/terminals/task-status',
229
+ token,
230
+ timeoutMs: 3000,
231
+ });
232
+ if (res.statusCode !== 200 || !Array.isArray(res.body)) return null;
233
+ return res.body;
234
+ }
235
+
236
+ function printHuman(result, runtime) {
237
+ if (result.ok) {
238
+ console.log('Ninja gate: PASS');
239
+ } else {
240
+ console.log('Ninja gate: FAIL');
241
+ }
242
+
243
+ console.log(`Since: ${result.sinceTs}`);
244
+ if (runtime?.url) console.log(`Runtime: ${runtime.url}`);
245
+
246
+ const latestDispatch = result.summary.latestDispatch;
247
+ if (latestDispatch) {
248
+ console.log(`Latest dispatch: ${latestDispatch.timestamp} T${latestDispatch.terminalId}`);
249
+ }
250
+
251
+ const latestVerification = result.summary.latestVerification;
252
+ if (latestVerification) {
253
+ console.log(`Latest verification: ${latestVerification.timestamp} ${latestVerification.type || ''}`.trim());
254
+ }
255
+
256
+ if (result.summary.preVisual) {
257
+ console.log(`Pre visual: ${result.summary.preVisual.timestamp} ${result.summary.preVisual.stage}`);
258
+ }
259
+ if (result.summary.postVisual) {
260
+ console.log(`Post visual: ${result.summary.postVisual.timestamp} ${result.summary.postVisual.stage}`);
261
+ }
262
+
263
+ if (result.warnings.length > 0) {
264
+ console.log('\nWarnings:');
265
+ for (const warning of result.warnings) console.log(`- ${warning}`);
266
+ }
267
+
268
+ if (result.failures.length > 0) {
269
+ console.log('\nMissing evidence:');
270
+ for (const failure of result.failures) console.log(`- ${failure}`);
271
+ }
272
+ }
273
+
274
+ async function runCli(argv = process.argv.slice(2)) {
275
+ const opts = parseArgs(argv);
276
+ if (opts.help) {
277
+ console.log(USAGE);
278
+ return 0;
279
+ }
280
+
281
+ const sinceTs = parseSinceArg(opts.since);
282
+ const session = readRuntimeSession();
283
+ const token = process.env.NINJA_AUTH_TOKEN || session?.authToken || readAuthToken();
284
+ const runtimeFailures = [];
285
+ let health = null;
286
+ let taskStatuses = null;
287
+
288
+ if (!session || !session.port) {
289
+ runtimeFailures.push(`No runtime session found at ${SESSION_FILE}`);
290
+ } else {
291
+ health = await healthCheckSession(session);
292
+ if (!health.ok) {
293
+ runtimeFailures.push(`Runtime health check failed: ${health.error}`);
294
+ } else {
295
+ taskStatuses = await fetchTaskStatuses(session, token);
296
+ }
297
+ }
298
+
299
+ const result = evaluateEvidence({
300
+ sinceTs,
301
+ dispatchEntries: readNdjson(LEDGER_FILE),
302
+ verificationEntries: readNdjson(VERIFICATION_LEDGER_FILE),
303
+ visualEntries: readNdjson(VISUAL_LEDGER_FILE),
304
+ taskStatuses,
305
+ });
306
+
307
+ result.failures.unshift(...runtimeFailures);
308
+ result.ok = result.failures.length === 0;
309
+ result.runtime = {
310
+ sessionFile: SESSION_FILE,
311
+ url: session?.url || null,
312
+ health: health?.ok === true ? 'ok' : 'failed',
313
+ };
314
+
315
+ if (opts.json) {
316
+ console.log(JSON.stringify(result, null, 2));
317
+ } else {
318
+ printHuman(result, session);
319
+ }
320
+
321
+ return result.ok ? 0 : 1;
322
+ }
323
+
324
+ if (require.main === module) {
325
+ runCli().then((code) => {
326
+ process.exit(code);
327
+ }).catch((err) => {
328
+ console.error(`Error: ${err.message}`);
329
+ process.exit(1);
330
+ });
331
+ }
332
+
333
+ module.exports = {
334
+ COMPLETE_TASK_STATES,
335
+ POST_VISUAL_STAGES,
336
+ evaluateEvidence,
337
+ getTaskStatusTimestamp,
338
+ parseSinceArg,
339
+ runCli,
340
+ };