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.
@@ -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`);
@@ -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 |
@@ -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.10",
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
+ });