job-forge 2.14.14 → 2.14.16

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,359 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'fs';
4
+ import { dirname, join } from 'path';
5
+ import {
6
+ formatEvents,
7
+ formatVerifyResult,
8
+ queryEvents,
9
+ } from '@razroo/iso-ledger';
10
+ import { PROJECT_DIR, readAllEntries } from '../tracker-lib.mjs';
11
+ import {
12
+ appendJobForgeEvent,
13
+ buildApplicationEvent,
14
+ buildPipelineEvent,
15
+ companyRoleKey,
16
+ jobForgeLedgerPath,
17
+ jobForgeLedgerSummary,
18
+ ledgerExists,
19
+ readJobForgeLedger,
20
+ urlKey,
21
+ verifyJobForgeLedger,
22
+ } from '../lib/jobforge-ledger.mjs';
23
+
24
+ const USAGE = `job-forge ledger - local deterministic workflow state
25
+
26
+ Usage:
27
+ job-forge ledger:status [--json]
28
+ job-forge ledger:rebuild [--reset] [--json]
29
+ job-forge ledger:verify [--json]
30
+ job-forge ledger:has --url <url> [--json]
31
+ job-forge ledger:has --company <name> --role <role> [--status Applied] [--json]
32
+ job-forge ledger:query [--type <type>] [--key <key>] [--where field=value] [--limit N] [--json]
33
+ job-forge ledger:path
34
+
35
+ The ledger is stored at .jobforge-ledger/events.jsonl by default. It is local
36
+ personal workflow state, not an MCP and not prompt context.`;
37
+
38
+ const [cmd = 'help', ...rawArgs] = process.argv.slice(2);
39
+ const opts = parseArgs(rawArgs);
40
+
41
+ if (opts.help || cmd === 'help' || cmd === '--help' || cmd === '-h') {
42
+ console.log(USAGE);
43
+ process.exit(0);
44
+ }
45
+
46
+ try {
47
+ if (cmd === 'path') {
48
+ console.log(jobForgeLedgerPath(PROJECT_DIR));
49
+ } else if (cmd === 'status') {
50
+ status(opts);
51
+ } else if (cmd === 'rebuild') {
52
+ rebuild(opts);
53
+ } else if (cmd === 'verify') {
54
+ verify(opts);
55
+ } else if (cmd === 'has') {
56
+ has(opts);
57
+ } else if (cmd === 'query') {
58
+ query(opts);
59
+ } else {
60
+ console.error(`unknown ledger command "${cmd}"\n`);
61
+ console.error(USAGE);
62
+ process.exit(2);
63
+ }
64
+ } catch (error) {
65
+ console.error(error instanceof Error ? error.message : String(error));
66
+ process.exit(1);
67
+ }
68
+
69
+ function parseArgs(args) {
70
+ const opts = { where: {}, json: false, reset: false };
71
+ for (let i = 0; i < args.length; i++) {
72
+ const arg = args[i];
73
+ if (arg === '--json') {
74
+ opts.json = true;
75
+ } else if (arg === '--reset') {
76
+ opts.reset = true;
77
+ } else if (arg === '--url') {
78
+ opts.url = valueAfter(args, ++i, '--url');
79
+ } else if (arg.startsWith('--url=')) {
80
+ opts.url = arg.slice('--url='.length);
81
+ } else if (arg === '--company') {
82
+ opts.company = valueAfter(args, ++i, '--company');
83
+ } else if (arg.startsWith('--company=')) {
84
+ opts.company = arg.slice('--company='.length);
85
+ } else if (arg === '--role') {
86
+ opts.role = valueAfter(args, ++i, '--role');
87
+ } else if (arg.startsWith('--role=')) {
88
+ opts.role = arg.slice('--role='.length);
89
+ } else if (arg === '--status') {
90
+ opts.status = valueAfter(args, ++i, '--status');
91
+ } else if (arg.startsWith('--status=')) {
92
+ opts.status = arg.slice('--status='.length);
93
+ } else if (arg === '--type') {
94
+ opts.type = valueAfter(args, ++i, '--type');
95
+ } else if (arg.startsWith('--type=')) {
96
+ opts.type = arg.slice('--type='.length);
97
+ } else if (arg === '--key') {
98
+ opts.key = valueAfter(args, ++i, '--key');
99
+ } else if (arg.startsWith('--key=')) {
100
+ opts.key = arg.slice('--key='.length);
101
+ } else if (arg === '--where') {
102
+ addWhere(opts.where, valueAfter(args, ++i, '--where'));
103
+ } else if (arg.startsWith('--where=')) {
104
+ addWhere(opts.where, arg.slice('--where='.length));
105
+ } else if (arg === '--limit') {
106
+ opts.limit = Number(valueAfter(args, ++i, '--limit'));
107
+ } else if (arg.startsWith('--limit=')) {
108
+ opts.limit = Number(arg.slice('--limit='.length));
109
+ } else if (arg === '--help' || arg === '-h') {
110
+ opts.help = true;
111
+ } else {
112
+ throw new Error(`unknown flag "${arg}"`);
113
+ }
114
+ }
115
+ if (opts.status) opts.where.status = opts.status;
116
+ return opts;
117
+ }
118
+
119
+ function valueAfter(values, index, flag) {
120
+ const value = values[index];
121
+ if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
122
+ return value;
123
+ }
124
+
125
+ function addWhere(where, raw) {
126
+ const index = raw.indexOf('=');
127
+ if (index <= 0) throw new Error('--where must be field=value');
128
+ where[raw.slice(0, index)] = parsePrimitive(raw.slice(index + 1));
129
+ }
130
+
131
+ function parsePrimitive(value) {
132
+ if (value === 'true') return true;
133
+ if (value === 'false') return false;
134
+ if (value === 'null') return null;
135
+ const number = Number(value);
136
+ return Number.isFinite(number) && value.trim() !== '' ? number : value;
137
+ }
138
+
139
+ function status(opts) {
140
+ const summary = jobForgeLedgerSummary(PROJECT_DIR);
141
+ if (opts.json) {
142
+ console.log(JSON.stringify(summary, null, 2));
143
+ return;
144
+ }
145
+ if (!summary.exists) {
146
+ console.log(`ledger: missing (${relativeLedgerPath()})`);
147
+ console.log('run: job-forge ledger:rebuild');
148
+ return;
149
+ }
150
+ const verifyResult = verifyJobForgeLedger(PROJECT_DIR);
151
+ console.log(`ledger: ${relativeLedgerPath()}`);
152
+ console.log(`events: ${summary.events}`);
153
+ console.log(`entities: ${summary.entities}`);
154
+ console.log(`verify: ${verifyResult.ok ? 'PASS' : 'FAIL'} (${verifyResult.errors} errors, ${verifyResult.warnings} warnings)`);
155
+ if (summary.latest) {
156
+ console.log(`latest: ${summary.latest.type} @ ${summary.latest.at}`);
157
+ }
158
+ }
159
+
160
+ function rebuild(opts) {
161
+ const ledgerPath = jobForgeLedgerPath(PROJECT_DIR);
162
+ if (opts.reset && existsSync(ledgerPath)) rmSync(ledgerPath);
163
+ mkdirSync(dirname(ledgerPath), { recursive: true });
164
+
165
+ const results = [];
166
+ for (const event of collectProjectEvents()) {
167
+ results.push(appendJobForgeEvent(event, PROJECT_DIR));
168
+ }
169
+
170
+ const summary = {
171
+ path: ledgerPath,
172
+ eventsSeen: results.length,
173
+ appended: results.filter((result) => result.appended).length,
174
+ deduped: results.filter((result) => !result.appended).length,
175
+ };
176
+
177
+ if (opts.json) {
178
+ console.log(JSON.stringify(summary, null, 2));
179
+ return;
180
+ }
181
+ console.log(`ledger: ${relativeLedgerPath()}`);
182
+ console.log(`events: ${summary.appended} appended, ${summary.deduped} already present`);
183
+ }
184
+
185
+ function verify(opts) {
186
+ if (!ledgerExists(PROJECT_DIR)) {
187
+ if (opts.json) {
188
+ console.log(JSON.stringify({ ok: true, missing: true, path: jobForgeLedgerPath(PROJECT_DIR) }, null, 2));
189
+ } else {
190
+ console.log(`ledger: missing (${relativeLedgerPath()})`);
191
+ }
192
+ return;
193
+ }
194
+ const result = verifyJobForgeLedger(PROJECT_DIR);
195
+ if (opts.json) {
196
+ console.log(JSON.stringify(result, null, 2));
197
+ } else {
198
+ console.log(formatVerifyResult(result));
199
+ }
200
+ process.exit(result.errors > 0 ? 1 : 0);
201
+ }
202
+
203
+ function has(opts) {
204
+ const filters = queryFilters(opts);
205
+ const events = queryEvents(readJobForgeLedger(PROJECT_DIR), filters);
206
+ if (opts.json) {
207
+ console.log(JSON.stringify({ match: events.length > 0, count: events.length, filters }, null, 2));
208
+ } else if (events.length > 0) {
209
+ console.log(`MATCH (${events.length} event(s))`);
210
+ } else {
211
+ console.log('MISS');
212
+ }
213
+ process.exit(events.length > 0 ? 0 : 1);
214
+ }
215
+
216
+ function query(opts) {
217
+ const filters = queryFilters(opts);
218
+ const events = queryEvents(readJobForgeLedger(PROJECT_DIR), filters);
219
+ if (opts.json) {
220
+ console.log(JSON.stringify(events, null, 2));
221
+ return;
222
+ }
223
+ console.log(formatEvents(events));
224
+ }
225
+
226
+ function queryFilters(opts) {
227
+ const filters = {};
228
+ if (opts.type) filters.type = opts.type;
229
+ if (opts.key) filters.key = opts.key;
230
+ if (opts.url) filters.key = urlKey(opts.url);
231
+ if (opts.company || opts.role) {
232
+ if (!opts.company || !opts.role) throw new Error('--company and --role must be provided together');
233
+ filters.key = companyRoleKey(opts.company, opts.role);
234
+ }
235
+ if (Object.keys(opts.where || {}).length > 0) filters.where = opts.where;
236
+ if (Number.isFinite(opts.limit) && opts.limit > 0) filters.limit = opts.limit;
237
+ return filters;
238
+ }
239
+
240
+ function collectProjectEvents() {
241
+ const events = [];
242
+ const { entries } = readAllEntries();
243
+ for (const entry of entries) {
244
+ events.push(buildApplicationEvent('jobforge.application.tracker', entry, {
245
+ projectDir: PROJECT_DIR,
246
+ sourceFile: entry._sourceFile,
247
+ idempotencyPrefix: 'tracker-entry',
248
+ }));
249
+ }
250
+
251
+ for (const item of collectTrackerTsvs('batch/tracker-additions', 'pending')) {
252
+ events.push(buildApplicationEvent(`jobforge.tracker_addition.${item.state}`, item.addition, {
253
+ projectDir: PROJECT_DIR,
254
+ sourceFile: item.path,
255
+ idempotencyPrefix: `tracker-addition-${item.state}`,
256
+ data: { state: item.state },
257
+ }));
258
+ }
259
+
260
+ for (const item of collectTrackerTsvs('batch/tracker-additions/merged', 'merged')) {
261
+ events.push(buildApplicationEvent(`jobforge.tracker_addition.${item.state}`, item.addition, {
262
+ projectDir: PROJECT_DIR,
263
+ sourceFile: item.path,
264
+ idempotencyPrefix: `tracker-addition-${item.state}`,
265
+ data: { state: item.state },
266
+ }));
267
+ }
268
+
269
+ for (const item of collectPipelineItems()) {
270
+ events.push(buildPipelineEvent(item, {
271
+ projectDir: PROJECT_DIR,
272
+ sourceFile: join(PROJECT_DIR, 'data', 'pipeline.md'),
273
+ }));
274
+ }
275
+
276
+ return events;
277
+ }
278
+
279
+ function collectTrackerTsvs(relDir, state) {
280
+ const dir = join(PROJECT_DIR, relDir);
281
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
282
+ const out = [];
283
+ for (const name of readdirSync(dir).filter((file) => file.endsWith('.tsv')).sort()) {
284
+ const path = join(dir, name);
285
+ const addition = parseTsvContent(readFileSync(path, 'utf8'), name);
286
+ if (addition) out.push({ path, state, addition });
287
+ }
288
+ return out;
289
+ }
290
+
291
+ function parseTsvContent(content, filename) {
292
+ const text = content.trim();
293
+ if (!text) return null;
294
+ let parts;
295
+ if (text.startsWith('|')) {
296
+ parts = text.split('|').map((part) => part.trim()).filter(Boolean);
297
+ if (parts.length < 8) return null;
298
+ return {
299
+ num: parts[0],
300
+ date: parts[1],
301
+ company: parts[2],
302
+ role: parts[3],
303
+ score: parts[4],
304
+ status: parts[5],
305
+ pdf: parts[6],
306
+ report: parts[7],
307
+ notes: parts[8] || '',
308
+ };
309
+ }
310
+
311
+ parts = text.split('\t');
312
+ if (parts.length < 8) return null;
313
+ const col4 = parts[4].trim();
314
+ const col5 = parts[5].trim();
315
+ const col4LooksLikeScore = looksLikeScore(col4);
316
+ const col5LooksLikeScore = looksLikeScore(col5);
317
+ return {
318
+ num: parts[0],
319
+ date: parts[1],
320
+ company: parts[2],
321
+ role: parts[3],
322
+ status: col4LooksLikeScore && !col5LooksLikeScore ? col5 : col4,
323
+ score: col4LooksLikeScore && !col5LooksLikeScore ? col4 : col5,
324
+ pdf: parts[6],
325
+ report: parts[7],
326
+ notes: parts[8] || '',
327
+ sourceFile: filename,
328
+ };
329
+ }
330
+
331
+ function looksLikeScore(value) {
332
+ return /^\d+\.?\d*\/5$/.test(value) || value === 'N/A' || value === 'DUP';
333
+ }
334
+
335
+ function collectPipelineItems() {
336
+ const path = join(PROJECT_DIR, 'data', 'pipeline.md');
337
+ if (!existsSync(path)) return [];
338
+ const lines = readFileSync(path, 'utf8').split('\n');
339
+ const out = [];
340
+ lines.forEach((line, index) => {
341
+ const match = line.match(/^\s*-\s*\[([ xX])\]\s+([^|#\s]+)(.*)$/);
342
+ if (!match) return;
343
+ const rest = match[3] || '';
344
+ const fields = rest.split('|').map((field) => field.trim()).filter(Boolean);
345
+ out.push({
346
+ checked: match[1].toLowerCase() === 'x',
347
+ url: match[2].trim(),
348
+ company: fields[0] || '',
349
+ role: fields[1] || '',
350
+ line,
351
+ lineNumber: index + 1,
352
+ });
353
+ });
354
+ return out;
355
+ }
356
+
357
+ function relativeLedgerPath() {
358
+ return jobForgeLedgerPath(PROJECT_DIR).replace(`${PROJECT_DIR}/`, '');
359
+ }
@@ -4,6 +4,7 @@ import { spawnSync } from 'child_process';
4
4
  import { existsSync, readdirSync, statSync } from 'fs';
5
5
  import { join, resolve } from 'path';
6
6
  import { defaultOpenCodeDbPath, findSessionById, parseSinceCutoff } from '@razroo/iso-trace';
7
+ import { jobForgeLedgerSummary } from '../lib/jobforge-ledger.mjs';
7
8
 
8
9
  const PROJECT_DIR = process.env.JOB_FORGE_PROJECT || process.cwd();
9
10
  const DEFAULT_SINCE = '24h';
@@ -485,9 +486,21 @@ function sessionStatus({ taskCalls, children, childOutcomes, childProviderErrors
485
486
  function trackerStatus(projectDir) {
486
487
  const pendingDir = join(projectDir, 'batch', 'tracker-additions');
487
488
  const mergedDir = join(pendingDir, 'merged');
489
+ let ledger;
490
+ try {
491
+ ledger = jobForgeLedgerSummary(projectDir);
492
+ } catch (error) {
493
+ ledger = {
494
+ exists: true,
495
+ events: 0,
496
+ entities: 0,
497
+ error: error instanceof Error ? error.message : String(error),
498
+ };
499
+ }
488
500
  return {
489
501
  pending: listTsv(pendingDir),
490
502
  mergedCount: listTsv(mergedDir).length,
503
+ ledger,
491
504
  };
492
505
  }
493
506
 
@@ -636,6 +649,7 @@ function printStatus(telemetry) {
636
649
  console.log(`tasks: ${telemetry.tasks.total} (${telemetry.tasks.statusPolls} status-poll, ${telemetry.tasks.running} running)`);
637
650
  console.log(`children: ${telemetry.children.withOutcomes}/${telemetry.children.total} with outcomes`);
638
651
  console.log(`tracker: ${telemetry.tracker.pending.length} pending TSVs, ${telemetry.tracker.mergedCount} merged TSVs`);
652
+ console.log(`ledger: ${telemetry.tracker.ledger.error ? `error: ${telemetry.tracker.ledger.error}` : telemetry.tracker.ledger.exists ? `${telemetry.tracker.ledger.events} events` : 'missing'}`);
639
653
  console.log(`models: ${telemetry.models.slice(0, 3).map(modelLabel).join(', ') || 'none'}`);
640
654
  console.log(`errors: ${telemetry.providerErrors.length} root, ${telemetry.children.providerErrors} child provider errors, ${telemetry.children.toolErrors} child tool errors`);
641
655
  console.log(`issues: ${telemetry.policyIssues.length}`);
@@ -24,6 +24,7 @@
24
24
 
25
25
  import { writeFileSync, mkdirSync, existsSync } from 'fs';
26
26
  import { join } from 'path';
27
+ import { recordTrackerAdditionWritten } from '../lib/jobforge-ledger.mjs';
27
28
 
28
29
  const PROJECT_DIR = process.env.JOB_FORGE_PROJECT || process.cwd();
29
30
 
@@ -61,6 +62,13 @@ if (write) {
61
62
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
62
63
  const path = join(dir, `${num}.tsv`);
63
64
  writeFileSync(path, line + '\n', 'utf-8');
65
+ try {
66
+ recordTrackerAdditionWritten({
67
+ num, date, company, role, status, score: scoreField, pdf, report: reportLink, notes,
68
+ }, { projectDir: PROJECT_DIR, sourceFile: path });
69
+ } catch (error) {
70
+ console.warn(`warning: could not append tracker-line ledger event: ${error instanceof Error ? error.message : String(error)}`);
71
+ }
64
72
  console.log(path);
65
73
  } else {
66
74
  console.log(line);
@@ -0,0 +1,50 @@
1
+ version: 1
2
+ rules:
3
+ - id: JF-H1
4
+ type: max-per-group
5
+ severity: error
6
+ description: Do not dispatch more than two task subagents from one assistant message.
7
+ match:
8
+ type: tool_call
9
+ name: task
10
+ groupBy: sessionMessageId
11
+ max: 2
12
+
13
+ - id: JF-H5b
14
+ type: forbid-text
15
+ severity: error
16
+ description: Do not use task to poll task/session status.
17
+ match:
18
+ type: tool_call
19
+ name: task
20
+ patterns:
21
+ - source: '"task_id"\s*:'
22
+ - source: '\b(return your final outcome now|report your current status|current status|still working|still running)\b'
23
+ flags: i
24
+ - source: '\b(check|poll|fetch|ask)\b.{0,80}\b(task|session)\b.{0,60}\b(status|state|result|outcome)\b'
25
+ flags: i
26
+
27
+ - id: JF-H5-child-no-task
28
+ type: forbid-text
29
+ severity: error
30
+ description: Child/subagent sessions must not spawn more task subagents.
31
+ match:
32
+ type: tool_call
33
+ name: task
34
+ fields:
35
+ isChildSession: true
36
+ patterns:
37
+ - source: '[\s\S]'
38
+
39
+ - id: JF-H8
40
+ type: forbid-text
41
+ severity: error
42
+ description: Task prompts must not inline proxy configuration or proxy credentials.
43
+ match:
44
+ type: tool_call
45
+ name: task
46
+ patterns:
47
+ - source: '\bproxy\s*:\s*\{'
48
+ flags: i
49
+ - source: '\bproxy[_.-]?(server|username|password|bypass)\b\s*[:=]'
50
+ flags: i
@@ -15,6 +15,7 @@
15
15
  * 6. No pending TSVs in tracker-additions/ (runs even when tracker file is missing)
16
16
  * 7. No markdown bold in score column
17
17
  * 8. Drift warning if states.yml ids differ from the built-in fallback list
18
+ * 9. Ledger file verifies if .jobforge-ledger/events.jsonl exists
18
19
  *
19
20
  * Run: node verify-pipeline.mjs (from repo root; same as npm run verify)
20
21
  */
@@ -26,6 +27,7 @@ import {
26
27
  PROJECT_DIR, DATA_APPS_DIR, DATA_APPS_FILE, ROOT_APPS_FILE,
27
28
  usesDayFiles, readAllEntries, listDayFiles, dayFilePath,
28
29
  } from './tracker-lib.mjs';
30
+ import { jobForgeLedgerPath, ledgerExists, verifyJobForgeLedger } from './lib/jobforge-ledger.mjs';
29
31
 
30
32
  const ADDITIONS_DIR = join(PROJECT_DIR, 'batch/tracker-additions');
31
33
  const STATES_FILE = existsSync(join(PROJECT_DIR, 'templates/states.yml'))
@@ -127,6 +129,23 @@ function verifyStatesYamlDrift() {
127
129
  }
128
130
  }
129
131
 
132
+ function verifyLedgerIfPresent() {
133
+ if (!ledgerExists(PROJECT_DIR)) {
134
+ ok('Ledger not initialized');
135
+ return;
136
+ }
137
+ const result = verifyJobForgeLedger(PROJECT_DIR);
138
+ for (const issue of result.issues) {
139
+ const prefix = issue.line ? `ledger line ${issue.line}` : 'ledger';
140
+ const msg = `${prefix}: ${issue.code}: ${issue.message}`;
141
+ if (issue.severity === 'error') error(msg);
142
+ else warn(msg);
143
+ }
144
+ if (result.errors === 0) {
145
+ ok(`Ledger valid (${result.eventCount} events at ${relative(PROJECT_DIR, jobForgeLedgerPath(PROJECT_DIR))})`);
146
+ }
147
+ }
148
+
130
149
  // --- Read entries ---
131
150
  const { entries, source } = readAllEntries();
132
151
 
@@ -135,6 +154,7 @@ if (entries.length === 0) {
135
154
  console.log(' This is normal for a fresh setup.\n');
136
155
  checkPendingTrackerAdditions();
137
156
  verifyStatesYamlDrift();
157
+ verifyLedgerIfPresent();
138
158
  console.log('\n' + '='.repeat(50));
139
159
  console.log(`📊 Pipeline Health: ${errors} errors, ${warnings} warnings`);
140
160
  if (errors === 0 && warnings === 0) console.log('🟢 Pipeline is clean!');
@@ -254,6 +274,7 @@ for (const e of entries) {
254
274
  if (boldScores === 0) ok('No bold in scores');
255
275
 
256
276
  verifyStatesYamlDrift();
277
+ verifyLedgerIfPresent();
257
278
 
258
279
  console.log('\n' + '='.repeat(50));
259
280
  console.log(`📊 Pipeline Health: ${errors} errors, ${warnings} warnings`);