job-forge 2.14.10 → 2.14.11
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/bin/create-job-forge.mjs +4 -0
- package/bin/job-forge.mjs +29 -0
- package/docs/ARCHITECTURE.md +1 -0
- package/docs/CUSTOMIZATION.md +13 -0
- package/docs/SETUP.md +3 -0
- package/package.json +5 -1
- package/scripts/telemetry.mjs +643 -0
package/bin/create-job-forge.mjs
CHANGED
|
@@ -113,6 +113,10 @@ const consumerPkg = {
|
|
|
113
113
|
'trace:list': 'job-forge trace:list',
|
|
114
114
|
'trace:stats': 'job-forge trace:stats',
|
|
115
115
|
'trace:show': 'job-forge trace:show',
|
|
116
|
+
'telemetry:list': 'job-forge telemetry:list',
|
|
117
|
+
'telemetry:status': 'job-forge telemetry:status',
|
|
118
|
+
'telemetry:show': 'job-forge telemetry:show',
|
|
119
|
+
'telemetry:watch': 'job-forge telemetry:watch',
|
|
116
120
|
// One command to pull the latest harness, companion plugin, and any
|
|
117
121
|
// locally-pinned MCP packages. npm update is a no-op on packages not
|
|
118
122
|
// in package.json, so listing @razroo/gmail-mcp + @geometra/mcp is
|
package/bin/job-forge.mjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* sync-check Run cv-sync-check.mjs
|
|
19
19
|
* tokens Run scripts/token-usage-report.mjs
|
|
20
20
|
* trace:* Inspect local agent transcripts via iso-trace
|
|
21
|
+
* telemetry:* Summarize JobForge pipeline status from traces + tracker files
|
|
21
22
|
* sync Re-run the harness symlink sync (bin/sync.mjs)
|
|
22
23
|
* help, --help Show this message
|
|
23
24
|
*/
|
|
@@ -60,6 +61,13 @@ const traceAliases = {
|
|
|
60
61
|
'trace:show': 'show',
|
|
61
62
|
};
|
|
62
63
|
|
|
64
|
+
const telemetryAliases = {
|
|
65
|
+
'telemetry:list': 'list',
|
|
66
|
+
'telemetry:status': 'status',
|
|
67
|
+
'telemetry:show': 'show',
|
|
68
|
+
'telemetry:watch': 'watch',
|
|
69
|
+
};
|
|
70
|
+
|
|
63
71
|
const [, , cmd, ...rest] = process.argv;
|
|
64
72
|
|
|
65
73
|
function printHelp() {
|
|
@@ -80,6 +88,10 @@ Commands:
|
|
|
80
88
|
trace:list List recent local agent sessions (defaults: --since 7d --cwd project)
|
|
81
89
|
trace:stats Show trace stats (defaults: --since 7d --cwd project)
|
|
82
90
|
trace:show ID Show one trace by id or prefix
|
|
91
|
+
telemetry:list List recent JobForge runs with tasks/outcomes/issues
|
|
92
|
+
telemetry:status Show latest JobForge run + pending tracker state
|
|
93
|
+
telemetry:show ID Show one run with child sessions, provider errors, next actions
|
|
94
|
+
telemetry:watch Watch latest run status
|
|
83
95
|
sync Re-create harness symlinks in the current project
|
|
84
96
|
|
|
85
97
|
Deterministic helpers (prefer these over LLM-derived values):
|
|
@@ -104,6 +116,8 @@ Pass --help after a command to see its own flags, e.g.:
|
|
|
104
116
|
job-forge slugify "Anthropic, PBC"
|
|
105
117
|
job-forge trace:list --since 24h
|
|
106
118
|
job-forge trace:show ses_...
|
|
119
|
+
job-forge telemetry:status
|
|
120
|
+
job-forge telemetry:show ses_...
|
|
107
121
|
|
|
108
122
|
Project directory resolves to $JOB_FORGE_PROJECT or cwd.`);
|
|
109
123
|
}
|
|
@@ -128,6 +142,21 @@ if (cmd === 'trace' || traceAliases[cmd]) {
|
|
|
128
142
|
process.exit(result.status ?? 1);
|
|
129
143
|
}
|
|
130
144
|
|
|
145
|
+
if (cmd === 'telemetry' || telemetryAliases[cmd]) {
|
|
146
|
+
const telemetryArgs = cmd === 'telemetry'
|
|
147
|
+
? (rest.length === 0 ? ['help'] : rest)
|
|
148
|
+
: [telemetryAliases[cmd], ...rest];
|
|
149
|
+
|
|
150
|
+
const scriptPath = join(PKG_ROOT, 'scripts/telemetry.mjs');
|
|
151
|
+
const result = spawnSync(process.execPath, [scriptPath, ...telemetryArgs], {
|
|
152
|
+
stdio: 'inherit',
|
|
153
|
+
cwd: PROJECT_DIR,
|
|
154
|
+
env: process.env,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
process.exit(result.status ?? 1);
|
|
158
|
+
}
|
|
159
|
+
|
|
131
160
|
const rel = commands[cmd];
|
|
132
161
|
if (!rel) {
|
|
133
162
|
console.error(`Unknown command: ${cmd}\n`);
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -204,6 +204,7 @@ Scripts maintain data consistency. In a consumer project they're invoked via the
|
|
|
204
204
|
| `cv-sync-check.mjs` | `npx job-forge sync-check` | Setup lint: `cv.md` + `config/profile.yml`, hardcoded-metric scan on `modes/_shared.md` and `batch/batch-prompt.md`, optional `article-digest.md` freshness |
|
|
205
205
|
| `scripts/token-usage-report.mjs` | `npx job-forge tokens` | Per-session opencode token/cost report from the SQLite DB |
|
|
206
206
|
| `scripts/trace.mjs` | `npx job-forge trace:list` / `trace:stats` / `trace:show` | Local transcript observability via `@razroo/iso-trace`; common commands default to OpenCode sessions for the consumer project |
|
|
207
|
+
| `scripts/telemetry.mjs` | `npx job-forge telemetry:status` / `telemetry:show` | JobForge operational telemetry derived from OpenCode traces plus tracker TSV state |
|
|
207
208
|
| `tracker-lib.mjs` | _(library)_ | Shared helpers for reading/writing day-based tracker files — imported by merge/dedup/verify/normalize |
|
|
208
209
|
| `bin/sync.mjs` | `npx job-forge sync` | Creates the harness symlinks in a consumer project (also runs as `postinstall`) |
|
|
209
210
|
| `bin/create-job-forge.mjs` | `npx create-job-forge <dir>` | Scaffolds a new personal project |
|
package/docs/CUSTOMIZATION.md
CHANGED
|
@@ -112,6 +112,19 @@ Scaffolded projects also include npm aliases: `npm run trace:list`, `npm run tra
|
|
|
112
112
|
|
|
113
113
|
For raw iso-trace commands, use `npx job-forge trace sources`, `npx job-forge trace where`, or any other `iso-trace` subcommand after `trace`.
|
|
114
114
|
|
|
115
|
+
## JobForge telemetry
|
|
116
|
+
|
|
117
|
+
Trace is the raw transcript view. Telemetry is the JobForge operational view: it summarizes task dispatches, child session outcomes, provider errors, policy issues, and pending tracker TSVs.
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
npx job-forge telemetry:list
|
|
121
|
+
npx job-forge telemetry:status
|
|
122
|
+
npx job-forge telemetry:show <session-id-or-prefix>
|
|
123
|
+
npx job-forge telemetry:watch
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Telemetry is also local-only and passive. It reads OpenCode's SQLite DB and files under `batch/tracker-additions/`; agents do not need to remember to emit custom events.
|
|
127
|
+
|
|
115
128
|
**Where Claude Code writes JSONL:** `~/.claude/projects/<encoded-cwd>/*.jsonl`.
|
|
116
129
|
|
|
117
130
|
**Direct CLI fallback:** `npx -y @razroo/iso-trace@latest stats --source "$HOME/.claude/projects/<encoded-dir>/<session>.jsonl"`
|
package/docs/SETUP.md
CHANGED
|
@@ -133,6 +133,9 @@ From your project root, these commands maintain the tracker and pipeline checks.
|
|
|
133
133
|
| List recent OpenCode traces for this project | `npx job-forge trace:list` | `npm run trace:list` |
|
|
134
134
|
| Summarize trace tool/file/token usage | `npx job-forge trace:stats` | `npm run trace:stats` |
|
|
135
135
|
| Show one trace by session id/prefix | `npx job-forge trace:show <id>` | `npm run trace:show -- <id>` |
|
|
136
|
+
| List recent JobForge runs with outcomes/issues | `npx job-forge telemetry:list` | `npm run telemetry:list` |
|
|
137
|
+
| Show latest run status + pending TSVs | `npx job-forge telemetry:status` | `npm run telemetry:status` |
|
|
138
|
+
| Show one JobForge run by session id/prefix | `npx job-forge telemetry:show <id>` | `npm run telemetry:show -- <id>` |
|
|
136
139
|
| Re-create harness symlinks | `npx job-forge sync` | `npm run sync` |
|
|
137
140
|
| Build optional dashboard TUI (Go on `PATH`) | `(cd node_modules/job-forge/dashboard && go build .)` | `npm run build:dashboard` (harness repo only) |
|
|
138
141
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "job-forge",
|
|
3
|
-
"version": "2.14.
|
|
3
|
+
"version": "2.14.11",
|
|
4
4
|
"description": "AI-powered job search pipeline built on opencode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
"trace:list": "node bin/job-forge.mjs trace:list",
|
|
22
22
|
"trace:stats": "node bin/job-forge.mjs trace:stats",
|
|
23
23
|
"trace:show": "node bin/job-forge.mjs trace:show",
|
|
24
|
+
"telemetry:list": "node bin/job-forge.mjs telemetry:list",
|
|
25
|
+
"telemetry:status": "node bin/job-forge.mjs telemetry:status",
|
|
26
|
+
"telemetry:show": "node bin/job-forge.mjs telemetry:show",
|
|
27
|
+
"telemetry:watch": "node bin/job-forge.mjs telemetry:watch",
|
|
24
28
|
"plan": "iso plan .",
|
|
25
29
|
"lint:agentmd": "agentmd lint iso/instructions.md",
|
|
26
30
|
"lint:modes": "isolint lint modes/",
|
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from 'child_process';
|
|
4
|
+
import { existsSync, readdirSync, statSync } from 'fs';
|
|
5
|
+
import { join, resolve } from 'path';
|
|
6
|
+
import { defaultOpenCodeDbPath, findSessionById, parseSinceCutoff } from '@razroo/iso-trace';
|
|
7
|
+
|
|
8
|
+
const PROJECT_DIR = process.env.JOB_FORGE_PROJECT || process.cwd();
|
|
9
|
+
const DEFAULT_SINCE = '24h';
|
|
10
|
+
|
|
11
|
+
const USAGE = `job-forge telemetry — JobForge pipeline view over local OpenCode traces
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
job-forge telemetry:list [--since 24h] [--cwd <dir>] [--json]
|
|
15
|
+
job-forge telemetry:status [--since 24h] [--cwd <dir>] [--json]
|
|
16
|
+
job-forge telemetry:show <id-or-prefix> [--cwd <dir>] [--json]
|
|
17
|
+
job-forge telemetry:watch [--since 24h] [--cwd <dir>] [--interval 5]
|
|
18
|
+
|
|
19
|
+
Telemetry is local-only and passive. It derives status from OpenCode's SQLite DB
|
|
20
|
+
plus JobForge tracker files; agents do not need to emit custom events.`;
|
|
21
|
+
|
|
22
|
+
const [cmd = 'help', ...args] = process.argv.slice(2);
|
|
23
|
+
|
|
24
|
+
function parseArgs(rawArgs, { allowSession = false, allowInterval = false } = {}) {
|
|
25
|
+
const opts = { since: DEFAULT_SINCE, cwd: PROJECT_DIR, json: false, interval: 5 };
|
|
26
|
+
const positional = [];
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
29
|
+
const arg = rawArgs[i];
|
|
30
|
+
if (arg === '--since') {
|
|
31
|
+
opts.since = rawArgs[++i];
|
|
32
|
+
} else if (arg.startsWith('--since=')) {
|
|
33
|
+
opts.since = arg.slice('--since='.length);
|
|
34
|
+
} else if (arg === '--cwd') {
|
|
35
|
+
opts.cwd = rawArgs[++i];
|
|
36
|
+
} else if (arg.startsWith('--cwd=')) {
|
|
37
|
+
opts.cwd = arg.slice('--cwd='.length);
|
|
38
|
+
} else if (arg === '--json') {
|
|
39
|
+
opts.json = true;
|
|
40
|
+
} else if (allowInterval && arg === '--interval') {
|
|
41
|
+
opts.interval = Number(rawArgs[++i] || 5);
|
|
42
|
+
} else if (allowInterval && arg.startsWith('--interval=')) {
|
|
43
|
+
opts.interval = Number(arg.slice('--interval='.length));
|
|
44
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
45
|
+
opts.help = true;
|
|
46
|
+
} else if (arg.startsWith('--')) {
|
|
47
|
+
opts.error = `unknown flag "${arg}"`;
|
|
48
|
+
} else if (allowSession) {
|
|
49
|
+
positional.push(arg);
|
|
50
|
+
} else {
|
|
51
|
+
opts.error = `unexpected argument "${arg}"`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
opts.cwd = resolve(opts.cwd || PROJECT_DIR);
|
|
56
|
+
if (!Number.isFinite(opts.interval) || opts.interval < 1) opts.interval = 5;
|
|
57
|
+
return { opts, positional };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function queryOpenCodeDb(dbPath, sql) {
|
|
61
|
+
const result = spawnSync('sqlite3', ['-json', dbPath, sql], {
|
|
62
|
+
encoding: 'utf8',
|
|
63
|
+
maxBuffer: 24 * 1024 * 1024,
|
|
64
|
+
});
|
|
65
|
+
if ((result.status ?? 0) !== 0) {
|
|
66
|
+
const detail = result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status ?? 1}`;
|
|
67
|
+
throw new Error(`job-forge telemetry: sqlite3 query failed: ${detail}`);
|
|
68
|
+
}
|
|
69
|
+
return JSON.parse(result.stdout || '[]');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sqlString(value) {
|
|
73
|
+
return `'${String(value).replaceAll("'", "''")}'`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function msToIso(ms) {
|
|
77
|
+
return new Date(Number(ms)).toISOString();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function discoverSessions(opts, { includeAllForShow = false } = {}) {
|
|
81
|
+
const dbPath = defaultOpenCodeDbPath();
|
|
82
|
+
if (!existsSync(dbPath)) return [];
|
|
83
|
+
|
|
84
|
+
const where = [
|
|
85
|
+
's.time_archived is null',
|
|
86
|
+
`s.directory = ${sqlString(opts.cwd)}`,
|
|
87
|
+
];
|
|
88
|
+
const sinceMs = includeAllForShow ? undefined : parseSinceCutoff(opts.since);
|
|
89
|
+
if (sinceMs !== undefined) where.push(`s.time_created >= ${Number(sinceMs)}`);
|
|
90
|
+
|
|
91
|
+
const rows = queryOpenCodeDb(dbPath, [
|
|
92
|
+
'select',
|
|
93
|
+
' s.id,',
|
|
94
|
+
' s.parent_id,',
|
|
95
|
+
' s.title,',
|
|
96
|
+
' s.directory,',
|
|
97
|
+
' s.time_created,',
|
|
98
|
+
' s.time_updated,',
|
|
99
|
+
' (select count(*) from message m where m.session_id = s.id) as turn_count,',
|
|
100
|
+
' (',
|
|
101
|
+
' (select coalesce(sum(length(data)), 0) from message m where m.session_id = s.id) +',
|
|
102
|
+
' (select coalesce(sum(length(data)), 0) from part p where p.session_id = s.id)',
|
|
103
|
+
' ) as size_bytes',
|
|
104
|
+
'from session s',
|
|
105
|
+
`where ${where.join(' and ')}`,
|
|
106
|
+
'order by s.time_updated desc',
|
|
107
|
+
].join(' '));
|
|
108
|
+
|
|
109
|
+
return rows.map((row) => ({
|
|
110
|
+
id: row.id,
|
|
111
|
+
parentId: row.parent_id || null,
|
|
112
|
+
title: row.title || '',
|
|
113
|
+
cwd: row.directory,
|
|
114
|
+
startedAt: msToIso(row.time_created),
|
|
115
|
+
endedAt: msToIso(row.time_updated),
|
|
116
|
+
turnCount: row.turn_count ?? 0,
|
|
117
|
+
sizeBytes: row.size_bytes ?? 0,
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function loadRows(sessionId) {
|
|
122
|
+
const dbPath = defaultOpenCodeDbPath();
|
|
123
|
+
const id = sqlString(sessionId);
|
|
124
|
+
return {
|
|
125
|
+
messages: queryOpenCodeDb(dbPath, `select id, time_created, data from message where session_id = ${id} order by time_created, id`),
|
|
126
|
+
parts: queryOpenCodeDb(dbPath, `select id, message_id, time_created, data from part where session_id = ${id} order by time_created, id`),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseJson(raw) {
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(raw || '{}');
|
|
133
|
+
} catch (error) {
|
|
134
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
135
|
+
return { __parseError: message, __raw: raw || '' };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function analyzeSession(session, allSessions, opts) {
|
|
140
|
+
const rows = loadRows(session.id);
|
|
141
|
+
const messages = rows.messages.map((row) => ({ row, data: parseJson(row.data) }));
|
|
142
|
+
const parts = rows.parts.map((row) => ({ row, data: parseJson(row.data) }));
|
|
143
|
+
const messageById = new Map(messages.map((m) => [m.row.id, m.data]));
|
|
144
|
+
const textParts = parts.filter((p) => p.data.type === 'text');
|
|
145
|
+
const userPrompt = firstUserText(textParts, messageById);
|
|
146
|
+
const taskCalls = parts.filter((p) => p.data.type === 'tool' && p.data.tool === 'task').map(taskCallSummary);
|
|
147
|
+
const providerErrors = messages.map(providerErrorSummary).filter(Boolean);
|
|
148
|
+
const policyIssues = detectPolicyIssues(session, parts, textParts, messageById, providerErrors);
|
|
149
|
+
const tracker = trackerStatus(opts.cwd);
|
|
150
|
+
const children = allSessions
|
|
151
|
+
.filter((candidate) => candidate.parentId === session.id)
|
|
152
|
+
.sort((a, b) => a.startedAt.localeCompare(b.startedAt))
|
|
153
|
+
.map((child) => childSummary(child));
|
|
154
|
+
const childOutcomes = children.filter((child) => child.outcome !== 'unknown').length;
|
|
155
|
+
const childProviderErrors = children.reduce((sum, child) => sum + child.providerErrors, 0);
|
|
156
|
+
const status = sessionStatus({ session, taskCalls, children, childOutcomes, childProviderErrors, policyIssues, providerErrors });
|
|
157
|
+
const recommendations = nextActions({ tracker, policyIssues, providerErrors, taskCalls, children });
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
session,
|
|
161
|
+
projectDir: opts.cwd,
|
|
162
|
+
status,
|
|
163
|
+
prompt: userPrompt,
|
|
164
|
+
tasks: {
|
|
165
|
+
total: taskCalls.length,
|
|
166
|
+
statusPolls: taskCalls.filter((task) => task.isStatusPoll).length,
|
|
167
|
+
calls: taskCalls,
|
|
168
|
+
},
|
|
169
|
+
children: {
|
|
170
|
+
total: children.length,
|
|
171
|
+
withOutcomes: childOutcomes,
|
|
172
|
+
providerErrors: childProviderErrors,
|
|
173
|
+
sessions: children,
|
|
174
|
+
},
|
|
175
|
+
providerErrors,
|
|
176
|
+
policyIssues,
|
|
177
|
+
tracker,
|
|
178
|
+
recommendations,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function firstUserText(textParts, messageById) {
|
|
183
|
+
for (const part of textParts) {
|
|
184
|
+
if (messageById.get(part.row.message_id)?.role === 'user') {
|
|
185
|
+
return clean(redactSecrets(part.data.text || ''));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return '';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function taskCallSummary(part) {
|
|
192
|
+
const input = objectOrEmpty(part.data.state?.input);
|
|
193
|
+
const metadata = objectOrEmpty(part.data.state?.metadata);
|
|
194
|
+
const prompt = typeof input.prompt === 'string' ? input.prompt : '';
|
|
195
|
+
const description = stringValue(input.description || metadata.description || part.data.state?.title);
|
|
196
|
+
const sessionId = stringValue(input.task_id || metadata.sessionId);
|
|
197
|
+
const subagentType = stringValue(input.subagent_type || metadata.subagent_type || metadata.agent);
|
|
198
|
+
const isStatusPoll = Boolean(input.task_id) ||
|
|
199
|
+
/\b(check|poll|status|force|abort|progress|result)\b/i.test(description) ||
|
|
200
|
+
/\b(return your final outcome now|if still working|current status|report your current status|still running)\b/i.test(prompt);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
at: msToIso(part.row.time_created),
|
|
204
|
+
description,
|
|
205
|
+
subagentType,
|
|
206
|
+
sessionId,
|
|
207
|
+
status: stringValue(part.data.state?.status),
|
|
208
|
+
isStatusPoll,
|
|
209
|
+
promptBytes: Buffer.byteLength(prompt, 'utf8'),
|
|
210
|
+
proxyLeak: hasProxyLeak(prompt),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function providerErrorSummary(message) {
|
|
215
|
+
const error = message.data.error;
|
|
216
|
+
if (!error) return null;
|
|
217
|
+
const rawMessage = stringValue(error.data?.message || error.message || error.name || 'unknown provider error');
|
|
218
|
+
const statusCode = error.data?.statusCode ?? statusCodeFromText(rawMessage);
|
|
219
|
+
return {
|
|
220
|
+
at: msToIso(message.row.time_created),
|
|
221
|
+
provider: stringValue(message.data.providerID),
|
|
222
|
+
model: stringValue(message.data.modelID),
|
|
223
|
+
statusCode,
|
|
224
|
+
category: providerErrorCategory(rawMessage, statusCode),
|
|
225
|
+
message: redactSecrets(rawMessage),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function detectPolicyIssues(session, parts, textParts, messageById, providerErrors) {
|
|
230
|
+
const issues = [];
|
|
231
|
+
const taskParts = parts.filter((p) => p.data.type === 'tool' && p.data.tool === 'task');
|
|
232
|
+
const statusPolls = taskParts.map(taskCallSummary).filter((task) => task.isStatusPoll);
|
|
233
|
+
if (statusPolls.length > 0) {
|
|
234
|
+
issues.push({
|
|
235
|
+
type: 'task_status_poll',
|
|
236
|
+
severity: 'high',
|
|
237
|
+
count: statusPolls.length,
|
|
238
|
+
detail: 'A task call tried to poll/check an existing task session.',
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const proxyLeakCount = parts.reduce((count, part) => count + (partHasProxyLeak(part) ? 1 : 0), 0);
|
|
243
|
+
if (proxyLeakCount > 0) {
|
|
244
|
+
issues.push({
|
|
245
|
+
type: 'proxy_prompt_leak',
|
|
246
|
+
severity: 'high',
|
|
247
|
+
count: proxyLeakCount,
|
|
248
|
+
detail: 'Prompt/tool input appears to contain proxy field values. Values are intentionally not printed.',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const childTaskCalls = session.parentId ? taskParts.length : 0;
|
|
253
|
+
if (childTaskCalls > 0) {
|
|
254
|
+
issues.push({
|
|
255
|
+
type: 'subagent_spawned_task',
|
|
256
|
+
severity: 'high',
|
|
257
|
+
count: childTaskCalls,
|
|
258
|
+
detail: 'A child/subagent session used the task tool.',
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const provider402 = providerErrors.filter((err) => err.statusCode === 402).length;
|
|
263
|
+
if (provider402 > 0) {
|
|
264
|
+
issues.push({
|
|
265
|
+
type: 'provider_balance_error',
|
|
266
|
+
severity: 'medium',
|
|
267
|
+
count: provider402,
|
|
268
|
+
detail: 'Provider reported insufficient balance/credits.',
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const finalText = textParts
|
|
273
|
+
.filter((part) => messageById.get(part.row.message_id)?.role === 'assistant')
|
|
274
|
+
.slice(-5)
|
|
275
|
+
.map((part) => part.data.text || '')
|
|
276
|
+
.join('\n');
|
|
277
|
+
if (taskParts.length > 0 && !hasOutcome(finalText) && !/round .*in flight|still running|waiting/i.test(finalText)) {
|
|
278
|
+
issues.push({
|
|
279
|
+
type: 'no_visible_final_outcome',
|
|
280
|
+
severity: 'medium',
|
|
281
|
+
count: 1,
|
|
282
|
+
detail: 'Session dispatched task work but recent assistant text has no final outcome or in-flight notice.',
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return issues;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function partHasProxyLeak(part) {
|
|
290
|
+
const data = part.data;
|
|
291
|
+
if (data.type === 'text' || data.type === 'reasoning') return hasProxyLeak(data.text || '');
|
|
292
|
+
if (data.type === 'tool') return hasProxyLeak(JSON.stringify(data.state?.input || {}));
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function childSummary(session) {
|
|
297
|
+
const rows = loadRows(session.id);
|
|
298
|
+
const messages = rows.messages.map((row) => ({ row, data: parseJson(row.data) }));
|
|
299
|
+
const parts = rows.parts.map((row) => ({ row, data: parseJson(row.data) }));
|
|
300
|
+
const text = parts.filter((p) => p.data.type === 'text').map((p) => p.data.text || '').join('\n');
|
|
301
|
+
const providerErrors = messages.map(providerErrorSummary).filter(Boolean);
|
|
302
|
+
const taskCalls = parts.filter((p) => p.data.type === 'tool' && p.data.tool === 'task').length;
|
|
303
|
+
const trackerWrites = parts.filter((p) => p.data.type === 'tool' && /batch\/tracker-additions\/.*\.tsv/.test(JSON.stringify(p.data.state?.input || {}))).length;
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
id: session.id,
|
|
307
|
+
title: session.title,
|
|
308
|
+
startedAt: session.startedAt,
|
|
309
|
+
endedAt: session.endedAt,
|
|
310
|
+
outcome: outcomeFromText(text, trackerWrites),
|
|
311
|
+
providerErrors: providerErrors.length,
|
|
312
|
+
taskCalls,
|
|
313
|
+
trackerWrites,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function outcomeFromText(text, trackerWrites = 0) {
|
|
318
|
+
if (/\bAPPLIED\b|status[^\n|]*Applied|successfully submitted|Applied via/i.test(text)) return 'Applied';
|
|
319
|
+
if (/\bDiscarded\b|\bSKIP\b/i.test(text)) return 'Discarded';
|
|
320
|
+
if (/\bAPPLY FAILED\b|\bFailed\b|\bFAILED\b/i.test(text)) return 'Failed';
|
|
321
|
+
if (trackerWrites > 0) return 'TSV written';
|
|
322
|
+
return 'unknown';
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function hasOutcome(text) {
|
|
326
|
+
return outcomeFromText(text) !== 'unknown' || /tracker-additions\/.*\.tsv/i.test(text);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function sessionStatus({ taskCalls, children, childOutcomes, childProviderErrors, policyIssues, providerErrors }) {
|
|
330
|
+
if (policyIssues.some((issue) => issue.severity === 'high')) return 'attention';
|
|
331
|
+
if (providerErrors.length > 0) return 'attention';
|
|
332
|
+
if (childProviderErrors > 0) return 'attention';
|
|
333
|
+
if (taskCalls.length > 0 && children.length > childOutcomes) return 'in-flight-or-incomplete';
|
|
334
|
+
if (taskCalls.length > 0 && children.length === childOutcomes) return 'complete';
|
|
335
|
+
return 'observed';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function trackerStatus(projectDir) {
|
|
339
|
+
const pendingDir = join(projectDir, 'batch', 'tracker-additions');
|
|
340
|
+
const mergedDir = join(pendingDir, 'merged');
|
|
341
|
+
return {
|
|
342
|
+
pending: listTsv(pendingDir),
|
|
343
|
+
mergedCount: listTsv(mergedDir).length,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function listTsv(dir) {
|
|
348
|
+
try {
|
|
349
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
|
|
350
|
+
return readdirSync(dir)
|
|
351
|
+
.filter((name) => name.endsWith('.tsv'))
|
|
352
|
+
.sort()
|
|
353
|
+
.map((name) => join(dir, name));
|
|
354
|
+
} catch {
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function nextActions({ tracker, policyIssues, providerErrors, children }) {
|
|
360
|
+
const actions = [];
|
|
361
|
+
if (tracker.pending.length > 0) actions.push('Run `npm run merge && npm run verify` when you are ready to fold pending TSV outcomes into day files.');
|
|
362
|
+
if (policyIssues.some((issue) => issue.type === 'task_status_poll')) actions.push('Avoid resuming by spawning "check task status" tasks; inspect telemetry/trace and tracker files instead.');
|
|
363
|
+
if (policyIssues.some((issue) => issue.type === 'proxy_prompt_leak')) actions.push('Restart OpenCode after updating the harness so new sessions load the proxy prompt hygiene rule.');
|
|
364
|
+
if (providerErrors.some((err) => err.statusCode === 402)) actions.push('Provider balance errors occurred; use a non-402 fallback or add provider credits before retrying paid routes.');
|
|
365
|
+
if (children.some((child) => child.outcome === 'unknown')) actions.push('Some child sessions have no visible final outcome; inspect them with `npm run telemetry:show -- <child-session-id>`.');
|
|
366
|
+
return actions;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function summaryForList(telemetry) {
|
|
370
|
+
return {
|
|
371
|
+
id: telemetry.session.id,
|
|
372
|
+
startedAt: telemetry.session.startedAt,
|
|
373
|
+
updatedAt: telemetry.session.endedAt,
|
|
374
|
+
status: telemetry.status,
|
|
375
|
+
prompt: telemetry.prompt,
|
|
376
|
+
tasks: telemetry.tasks.total,
|
|
377
|
+
children: telemetry.children.total,
|
|
378
|
+
outcomes: telemetry.children.withOutcomes,
|
|
379
|
+
issues: telemetry.policyIssues.length,
|
|
380
|
+
providerErrors: telemetry.providerErrors.length + telemetry.children.providerErrors,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function printList(items) {
|
|
385
|
+
const rows = items.map((item) => [
|
|
386
|
+
item.id,
|
|
387
|
+
item.startedAt.replace('T', ' ').replace(/\.\d+Z$/, 'Z'),
|
|
388
|
+
item.status,
|
|
389
|
+
String(item.tasks),
|
|
390
|
+
`${item.outcomes}/${item.children}`,
|
|
391
|
+
String(item.issues + item.providerErrors),
|
|
392
|
+
shorten(item.prompt || '', 42),
|
|
393
|
+
]);
|
|
394
|
+
const header = ['session', 'started', 'status', 'tasks', 'outcomes', 'alerts', 'prompt'];
|
|
395
|
+
printTable(header, rows);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function printStatus(telemetry) {
|
|
399
|
+
console.log(`project: ${telemetry.projectDir}`);
|
|
400
|
+
console.log(`session: ${telemetry.session.id}`);
|
|
401
|
+
console.log(`status: ${telemetry.status}`);
|
|
402
|
+
console.log(`started: ${telemetry.session.startedAt}`);
|
|
403
|
+
console.log(`prompt: ${shorten(telemetry.prompt || '', 100)}`);
|
|
404
|
+
console.log(`tasks: ${telemetry.tasks.total} (${telemetry.tasks.statusPolls} status-poll)`);
|
|
405
|
+
console.log(`children: ${telemetry.children.withOutcomes}/${telemetry.children.total} with outcomes`);
|
|
406
|
+
console.log(`tracker: ${telemetry.tracker.pending.length} pending TSVs, ${telemetry.tracker.mergedCount} merged TSVs`);
|
|
407
|
+
console.log(`errors: ${telemetry.providerErrors.length} root, ${telemetry.children.providerErrors} child provider errors`);
|
|
408
|
+
console.log(`issues: ${telemetry.policyIssues.length}`);
|
|
409
|
+
|
|
410
|
+
if (telemetry.policyIssues.length > 0) {
|
|
411
|
+
console.log('\nissues:');
|
|
412
|
+
for (const issue of telemetry.policyIssues) {
|
|
413
|
+
console.log(` - ${issue.severity} ${issue.type} x${issue.count}: ${issue.detail}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (telemetry.tracker.pending.length > 0) {
|
|
418
|
+
console.log('\npending TSVs:');
|
|
419
|
+
for (const file of telemetry.tracker.pending.slice(0, 12)) {
|
|
420
|
+
console.log(` - ${relativeToProject(file, telemetry.projectDir)}`);
|
|
421
|
+
}
|
|
422
|
+
if (telemetry.tracker.pending.length > 12) console.log(` - ...${telemetry.tracker.pending.length - 12} more`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (telemetry.children.sessions.length > 0) {
|
|
426
|
+
console.log('\nchild sessions:');
|
|
427
|
+
for (const child of telemetry.children.sessions) {
|
|
428
|
+
const alerts = [];
|
|
429
|
+
if (child.providerErrors) alerts.push(`${child.providerErrors} provider error`);
|
|
430
|
+
if (child.taskCalls) alerts.push(`${child.taskCalls} task call`);
|
|
431
|
+
console.log(` - ${child.id} ${child.outcome} ${child.title}${alerts.length ? ` (${alerts.join(', ')})` : ''}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (telemetry.recommendations.length > 0) {
|
|
436
|
+
console.log('\nnext:');
|
|
437
|
+
for (const action of telemetry.recommendations) console.log(` - ${action}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function printShow(telemetry) {
|
|
442
|
+
printStatus(telemetry);
|
|
443
|
+
if (telemetry.tasks.calls.length > 0) {
|
|
444
|
+
console.log('\ntask dispatches:');
|
|
445
|
+
for (const task of telemetry.tasks.calls) {
|
|
446
|
+
const flags = [
|
|
447
|
+
task.isStatusPoll ? 'status-poll' : '',
|
|
448
|
+
task.proxyLeak ? 'proxy-values-detected' : '',
|
|
449
|
+
].filter(Boolean).join(', ');
|
|
450
|
+
console.log(` - ${task.at} ${task.description || '(no description)'} ${task.sessionId || ''} ${task.subagentType || ''}${flags ? ` [${flags}]` : ''}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (telemetry.providerErrors.length > 0) {
|
|
454
|
+
console.log('\nprovider errors:');
|
|
455
|
+
for (const err of telemetry.providerErrors) {
|
|
456
|
+
console.log(` - ${err.at} ${err.provider}/${err.model} ${err.statusCode || ''} ${err.category}: ${err.message}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function printTable(header, rows) {
|
|
462
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i].length)));
|
|
463
|
+
console.log(header.map((h, i) => pad(h, widths[i])).join(' '));
|
|
464
|
+
console.log(widths.map((w) => '-'.repeat(w)).join(' '));
|
|
465
|
+
for (const row of rows) console.log(row.map((cell, i) => pad(cell, widths[i])).join(' '));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function latestRootTelemetry(opts) {
|
|
469
|
+
const sessions = discoverSessions(opts);
|
|
470
|
+
const roots = sessions.filter((session) => !session.parentId);
|
|
471
|
+
if (roots.length === 0) return { sessions, telemetry: null };
|
|
472
|
+
return { sessions, telemetry: analyzeSession(roots[0], sessions, opts) };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function objectOrEmpty(value) {
|
|
476
|
+
return value && typeof value === 'object' ? value : {};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function stringValue(value) {
|
|
480
|
+
return typeof value === 'string' ? value : value == null ? '' : String(value);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function statusCodeFromText(text) {
|
|
484
|
+
const match = String(text).match(/\b(40[0-9]|42[0-9]|50[0-9])\b/);
|
|
485
|
+
return match ? Number(match[1]) : undefined;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function providerErrorCategory(text, statusCode) {
|
|
489
|
+
if (statusCode === 402 || /insufficient|balance|credits|diem/i.test(text)) return 'balance';
|
|
490
|
+
if (statusCode === 429 || /rate.?limit|quota/i.test(text)) return 'rate-limit';
|
|
491
|
+
if (/overload|temporarily unavailable|timeout/i.test(text)) return 'transient';
|
|
492
|
+
return 'provider-error';
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function hasProxyLeak(text) {
|
|
496
|
+
const raw = String(text || '');
|
|
497
|
+
if (!/proxy/i.test(raw)) return false;
|
|
498
|
+
return /\b(server|username|password|bypass)["']?\s*[:=]\s*["']?[^"',\s)}]+/i.test(raw) ||
|
|
499
|
+
/brd-customer|superproxy|oxylabs|smartproxy|soax/i.test(raw);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function redactSecrets(text) {
|
|
503
|
+
return String(text || '')
|
|
504
|
+
.replace(/\b(password|username|server|bypass)["']?\s*[:=]\s*["']?[^"',\s)}]+/gi, '$1=<redacted>')
|
|
505
|
+
.replace(/brd-customer-[A-Za-z0-9_.-]+/g, '<redacted-proxy-user>');
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function relativeToProject(file, projectDir = PROJECT_DIR) {
|
|
509
|
+
return file.startsWith(`${projectDir}/`) ? file.slice(projectDir.length + 1) : file;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function clean(text) {
|
|
513
|
+
return String(text || '').replace(/\s+/g, ' ').trim();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function shorten(value, max) {
|
|
517
|
+
const text = clean(value);
|
|
518
|
+
if (text.length <= max) return text;
|
|
519
|
+
return `${text.slice(0, max - 1)}...`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function pad(value, width) {
|
|
523
|
+
const text = String(value ?? '');
|
|
524
|
+
return text.length >= width ? text : text + ' '.repeat(width - text.length);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function runWatch(opts) {
|
|
528
|
+
while (true) {
|
|
529
|
+
console.clear();
|
|
530
|
+
console.log(new Date().toISOString());
|
|
531
|
+
const { telemetry } = latestRootTelemetry(opts);
|
|
532
|
+
if (!telemetry) {
|
|
533
|
+
console.log('No recent JobForge OpenCode sessions found.');
|
|
534
|
+
} else {
|
|
535
|
+
printStatus(telemetry);
|
|
536
|
+
}
|
|
537
|
+
await new Promise((resolveTimer) => setTimeout(resolveTimer, opts.interval * 1000));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function main() {
|
|
542
|
+
if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
543
|
+
console.log(USAGE);
|
|
544
|
+
return 0;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (cmd === 'list') {
|
|
548
|
+
const { opts } = parseArgs(args);
|
|
549
|
+
if (opts.help) {
|
|
550
|
+
console.log(USAGE);
|
|
551
|
+
return 0;
|
|
552
|
+
}
|
|
553
|
+
if (opts.error) {
|
|
554
|
+
console.error(`job-forge telemetry:list: ${opts.error}`);
|
|
555
|
+
return 2;
|
|
556
|
+
}
|
|
557
|
+
const sessions = discoverSessions(opts);
|
|
558
|
+
const items = sessions
|
|
559
|
+
.filter((session) => !session.parentId)
|
|
560
|
+
.map((session) => summaryForList(analyzeSession(session, sessions, opts)));
|
|
561
|
+
if (opts.json) {
|
|
562
|
+
console.log(JSON.stringify(items, null, 2));
|
|
563
|
+
} else if (items.length === 0) {
|
|
564
|
+
console.error('job-forge telemetry:list: no recent JobForge OpenCode sessions found');
|
|
565
|
+
return 2;
|
|
566
|
+
} else {
|
|
567
|
+
printList(items);
|
|
568
|
+
}
|
|
569
|
+
return 0;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (cmd === 'status') {
|
|
573
|
+
const { opts } = parseArgs(args);
|
|
574
|
+
if (opts.help) {
|
|
575
|
+
console.log(USAGE);
|
|
576
|
+
return 0;
|
|
577
|
+
}
|
|
578
|
+
if (opts.error) {
|
|
579
|
+
console.error(`job-forge telemetry:status: ${opts.error}`);
|
|
580
|
+
return 2;
|
|
581
|
+
}
|
|
582
|
+
const { telemetry } = latestRootTelemetry(opts);
|
|
583
|
+
if (!telemetry) {
|
|
584
|
+
console.error('job-forge telemetry:status: no recent JobForge OpenCode sessions found');
|
|
585
|
+
return 2;
|
|
586
|
+
}
|
|
587
|
+
if (opts.json) console.log(JSON.stringify(telemetry, null, 2));
|
|
588
|
+
else printStatus(telemetry);
|
|
589
|
+
return 0;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (cmd === 'show') {
|
|
593
|
+
const { opts, positional } = parseArgs(args, { allowSession: true });
|
|
594
|
+
if (opts.help) {
|
|
595
|
+
console.log(USAGE);
|
|
596
|
+
return 0;
|
|
597
|
+
}
|
|
598
|
+
if (opts.error) {
|
|
599
|
+
console.error(`job-forge telemetry:show: ${opts.error}`);
|
|
600
|
+
return 2;
|
|
601
|
+
}
|
|
602
|
+
if (positional.length === 0) {
|
|
603
|
+
console.error('job-forge telemetry:show: missing <id-or-prefix>');
|
|
604
|
+
return 2;
|
|
605
|
+
}
|
|
606
|
+
const sessions = discoverSessions(opts, { includeAllForShow: true });
|
|
607
|
+
const session = findSessionById(sessions, positional[0]);
|
|
608
|
+
if (!session) {
|
|
609
|
+
console.error(`job-forge telemetry:show: no session matches "${positional[0]}"`);
|
|
610
|
+
return 2;
|
|
611
|
+
}
|
|
612
|
+
const telemetry = analyzeSession(session, sessions, opts);
|
|
613
|
+
if (opts.json) console.log(JSON.stringify(telemetry, null, 2));
|
|
614
|
+
else printShow(telemetry);
|
|
615
|
+
return 0;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (cmd === 'watch') {
|
|
619
|
+
const { opts } = parseArgs(args, { allowInterval: true });
|
|
620
|
+
if (opts.help) {
|
|
621
|
+
console.log(USAGE);
|
|
622
|
+
return 0;
|
|
623
|
+
}
|
|
624
|
+
if (opts.error) {
|
|
625
|
+
console.error(`job-forge telemetry:watch: ${opts.error}`);
|
|
626
|
+
return 2;
|
|
627
|
+
}
|
|
628
|
+
await runWatch(opts);
|
|
629
|
+
return 0;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
console.error(`job-forge telemetry: unknown command "${cmd}"\n`);
|
|
633
|
+
console.error(USAGE);
|
|
634
|
+
return 2;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
main()
|
|
638
|
+
.then((code) => process.exit(code))
|
|
639
|
+
.catch((error) => {
|
|
640
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
641
|
+
console.error(message);
|
|
642
|
+
process.exit(1);
|
|
643
|
+
});
|