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,214 @@
1
+ import { existsSync } from 'fs';
2
+ import { isAbsolute, join, relative } from 'path';
3
+ import {
4
+ appendEvent,
5
+ hasEvent,
6
+ materializeLedger,
7
+ queryEvents,
8
+ readLedger,
9
+ verifyLedger,
10
+ } from '@razroo/iso-ledger';
11
+
12
+ export const LEDGER_DIR = '.jobforge-ledger';
13
+ export const LEDGER_FILE = 'events.jsonl';
14
+
15
+ export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
16
+ return projectDir;
17
+ }
18
+
19
+ export function jobForgeLedgerPath(projectDir = resolveProjectDir()) {
20
+ return process.env.JOB_FORGE_LEDGER || join(projectDir, LEDGER_DIR, LEDGER_FILE);
21
+ }
22
+
23
+ export function jobForgeLedgerOptions(projectDir = resolveProjectDir()) {
24
+ return { path: jobForgeLedgerPath(projectDir) };
25
+ }
26
+
27
+ export function ledgerExists(projectDir = resolveProjectDir()) {
28
+ return existsSync(jobForgeLedgerPath(projectDir));
29
+ }
30
+
31
+ export function readJobForgeLedger(projectDir = resolveProjectDir()) {
32
+ if (!ledgerExists(projectDir)) return [];
33
+ return readLedger(jobForgeLedgerOptions(projectDir));
34
+ }
35
+
36
+ export function verifyJobForgeLedger(projectDir = resolveProjectDir()) {
37
+ return verifyLedger(jobForgeLedgerOptions(projectDir));
38
+ }
39
+
40
+ export function queryJobForgeLedger(options = {}, projectDir = resolveProjectDir()) {
41
+ return queryEvents(readJobForgeLedger(projectDir), options);
42
+ }
43
+
44
+ export function hasJobForgeEvent(options = {}, projectDir = resolveProjectDir()) {
45
+ return hasEvent(readJobForgeLedger(projectDir), options);
46
+ }
47
+
48
+ export function jobForgeLedgerSummary(projectDir = resolveProjectDir()) {
49
+ const events = readJobForgeLedger(projectDir);
50
+ const materialized = materializeLedger(events);
51
+ return {
52
+ path: jobForgeLedgerPath(projectDir),
53
+ exists: ledgerExists(projectDir),
54
+ events: events.length,
55
+ entities: materialized.entityCount,
56
+ latest: events.at(-1) || null,
57
+ };
58
+ }
59
+
60
+ export function appendJobForgeEvent(input, projectDir = resolveProjectDir()) {
61
+ return appendEvent(jobForgeLedgerOptions(projectDir), input);
62
+ }
63
+
64
+ export function recordTrackerAdditionWritten(addition, options = {}) {
65
+ const projectDir = resolveProjectDir(options.projectDir);
66
+ return appendJobForgeEvent(buildApplicationEvent('jobforge.tracker_addition.written', addition, {
67
+ projectDir,
68
+ sourceFile: options.sourceFile,
69
+ idempotencyPrefix: 'tracker-addition-written',
70
+ meta: options.meta,
71
+ }), projectDir);
72
+ }
73
+
74
+ export function recordTrackerMergeResult(addition, options = {}) {
75
+ const projectDir = resolveProjectDir(options.projectDir);
76
+ const outcome = options.outcome || 'processed';
77
+ return appendJobForgeEvent(buildApplicationEvent(`jobforge.tracker_merge.${outcome}`, addition, {
78
+ projectDir,
79
+ sourceFile: options.sourceFile,
80
+ idempotencyPrefix: `tracker-merge-${outcome}`,
81
+ data: {
82
+ outcome,
83
+ duplicateNum: jsonValue(options.duplicateNum),
84
+ reason: jsonValue(options.reason),
85
+ },
86
+ meta: options.meta,
87
+ }), projectDir);
88
+ }
89
+
90
+ export function buildApplicationEvent(type, app, options = {}) {
91
+ const projectDir = resolveProjectDir(options.projectDir);
92
+ const key = companyRoleKey(app.company, app.role);
93
+ const sourceFile = options.sourceFile ? relativePath(projectDir, options.sourceFile) : '';
94
+ const idempotencyParts = [
95
+ options.idempotencyPrefix || type,
96
+ sourceFile,
97
+ app.num,
98
+ app.date,
99
+ key,
100
+ app.status,
101
+ app.score,
102
+ ].filter((value) => value !== undefined && value !== null && String(value).length > 0);
103
+
104
+ return {
105
+ type,
106
+ key,
107
+ subject: applicationSubject(app.company, app.role),
108
+ idempotencyKey: idempotencyParts.join(':'),
109
+ data: compactObject({
110
+ num: numberOrString(app.num),
111
+ date: stringOrEmpty(app.date),
112
+ company: stringOrEmpty(app.company),
113
+ role: stringOrEmpty(app.role),
114
+ score: stringOrEmpty(app.score),
115
+ status: stringOrEmpty(app.status),
116
+ pdf: stringOrEmpty(app.pdf),
117
+ report: stringOrEmpty(app.report),
118
+ notes: stringOrEmpty(app.notes),
119
+ sourceFile,
120
+ ...compactObject(options.data || {}),
121
+ }),
122
+ meta: compactObject({
123
+ source: 'job-forge',
124
+ ...compactObject(options.meta || {}),
125
+ }),
126
+ };
127
+ }
128
+
129
+ export function buildPipelineEvent(item, options = {}) {
130
+ const projectDir = resolveProjectDir(options.projectDir);
131
+ const key = item.url ? urlKey(item.url) : `pipeline:${item.lineNumber || 'unknown'}`;
132
+ const sourceFile = options.sourceFile ? relativePath(projectDir, options.sourceFile) : 'data/pipeline.md';
133
+ const state = item.checked ? 'processed' : 'pending';
134
+
135
+ return {
136
+ type: 'jobforge.pipeline.item',
137
+ key,
138
+ subject: key,
139
+ idempotencyKey: `pipeline:${state}:${item.url || item.lineNumber || item.line || 'unknown'}`,
140
+ data: compactObject({
141
+ state,
142
+ checked: Boolean(item.checked),
143
+ url: stringOrEmpty(item.url),
144
+ company: stringOrEmpty(item.company),
145
+ role: stringOrEmpty(item.role),
146
+ line: stringOrEmpty(item.line),
147
+ lineNumber: numberOrString(item.lineNumber),
148
+ sourceFile,
149
+ }),
150
+ meta: compactObject({
151
+ source: 'job-forge',
152
+ ...compactObject(options.meta || {}),
153
+ }),
154
+ };
155
+ }
156
+
157
+ export function companyRoleKey(company, role) {
158
+ return `company-role:${slugPart(company)}:${slugPart(role)}`;
159
+ }
160
+
161
+ export function applicationSubject(company, role) {
162
+ return `application:${slugPart(company)}:${slugPart(role)}`;
163
+ }
164
+
165
+ export function urlKey(url) {
166
+ return `url:${String(url || '').trim()}`;
167
+ }
168
+
169
+ export function slugPart(value) {
170
+ const slug = String(value || 'unknown')
171
+ .toLowerCase()
172
+ .replace(/&/g, ' and ')
173
+ .replace(/[^a-z0-9]+/g, '-')
174
+ .replace(/^-+|-+$/g, '');
175
+ return slug || 'unknown';
176
+ }
177
+
178
+ function relativePath(projectDir, value) {
179
+ const text = String(value || '');
180
+ if (!text) return '';
181
+ const rel = relative(projectDir, text);
182
+ if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return rel.replace(/\\/g, '/');
183
+ return text.replace(/\\/g, '/');
184
+ }
185
+
186
+ function compactObject(obj) {
187
+ const out = {};
188
+ for (const [key, value] of Object.entries(obj || {})) {
189
+ const clean = jsonValue(value);
190
+ if (clean !== undefined) out[key] = clean;
191
+ }
192
+ return out;
193
+ }
194
+
195
+ function jsonValue(value) {
196
+ if (value === undefined) return undefined;
197
+ if (value === null) return null;
198
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
199
+ if (Array.isArray(value)) return value.map(jsonValue).filter((item) => item !== undefined);
200
+ if (typeof value === 'object') return compactObject(value);
201
+ return String(value);
202
+ }
203
+
204
+ function stringOrEmpty(value) {
205
+ if (value === undefined || value === null) return undefined;
206
+ const text = String(value);
207
+ return text.length > 0 ? text : undefined;
208
+ }
209
+
210
+ function numberOrString(value) {
211
+ if (value === undefined || value === null || value === '') return undefined;
212
+ const number = Number(value);
213
+ return Number.isFinite(number) && String(value).trim() !== '' ? number : String(value);
214
+ }
package/merge-tracker.mjs CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  import {
30
30
  DEFAULT_STATES, loadCanonicalStates, buildStatusDetectionRegex,
31
31
  } from './lib/canonical-states.mjs';
32
+ import { recordTrackerMergeResult } from './lib/jobforge-ledger.mjs';
32
33
 
33
34
  const ADDITIONS_DIR = join(PROJECT_DIR, 'batch/tracker-additions');
34
35
  const MERGED_DIR = join(ADDITIONS_DIR, 'merged');
@@ -288,6 +289,7 @@ let added = 0;
288
289
  let updated = 0;
289
290
  let skipped = 0;
290
291
  const newEntries = [];
292
+ const ledgerRecords = [];
291
293
 
292
294
  for (const file of tsvFiles) {
293
295
  const content = readFileSync(join(ADDITIONS_DIR, file), 'utf-8').trim();
@@ -359,12 +361,15 @@ for (const file of tsvFiles) {
359
361
  }
360
362
  }
361
363
  updated++;
364
+ ledgerRecords.push({ addition, outcome: 'updated', sourceFile: join(ADDITIONS_DIR, file), duplicateNum: duplicate.num, reason });
362
365
  } else if (statusRegresses) {
363
366
  console.log(`⏭️ Skip: ${addition.company} — ${addition.role} (existing #${duplicate.num} status ${duplicate.status} outranks new ${addition.status})`);
364
367
  skipped++;
368
+ ledgerRecords.push({ addition, outcome: 'skipped', sourceFile: join(ADDITIONS_DIR, file), duplicateNum: duplicate.num, reason: 'status-regression' });
365
369
  } else {
366
370
  console.log(`⏭️ Skip: ${addition.company} — ${addition.role} (existing #${duplicate.num} ${oldScore} >= new ${newScore})`);
367
371
  skipped++;
372
+ ledgerRecords.push({ addition, outcome: 'skipped', sourceFile: join(ADDITIONS_DIR, file), duplicateNum: duplicate.num, reason: 'no-improvement' });
368
373
  }
369
374
  } else {
370
375
  const entryNum = addition.num > maxNum ? addition.num : ++maxNum;
@@ -376,6 +381,7 @@ for (const file of tsvFiles) {
376
381
  });
377
382
  added++;
378
383
  console.log(`➕ Add #${entryNum}: ${addition.company} — ${addition.role} (${addition.score})`);
384
+ ledgerRecords.push({ addition: { ...addition, num: entryNum }, outcome: 'added', sourceFile: join(ADDITIONS_DIR, file), reason: 'new-entry' });
379
385
  }
380
386
  }
381
387
 
@@ -410,6 +416,23 @@ if (!DRY_RUN) {
410
416
  renameSync(join(ADDITIONS_DIR, file), join(MERGED_DIR, file));
411
417
  }
412
418
  console.log(`\n✅ Moved ${tsvFiles.length} TSVs to merged/`);
419
+
420
+ let ledgerEvents = 0;
421
+ for (const record of ledgerRecords) {
422
+ try {
423
+ const result = recordTrackerMergeResult(record.addition, {
424
+ projectDir: PROJECT_DIR,
425
+ sourceFile: record.sourceFile,
426
+ outcome: record.outcome,
427
+ duplicateNum: record.duplicateNum,
428
+ reason: record.reason,
429
+ });
430
+ if (result.appended) ledgerEvents++;
431
+ } catch (error) {
432
+ console.warn(`⚠️ Could not append ledger event for ${record.sourceFile}: ${error instanceof Error ? error.message : String(error)}`);
433
+ }
434
+ }
435
+ console.log(`🧾 Ledger: ${ledgerEvents} event(s) appended`);
413
436
  }
414
437
 
415
438
  console.log(`\n📊 Summary: +${added} added, 🔄${updated} updated, ⏭️${skipped} skipped`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-forge",
3
- "version": "2.14.14",
3
+ "version": "2.14.16",
4
4
  "description": "AI-powered job search pipeline built on opencode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,6 +25,13 @@
25
25
  "telemetry:status": "node bin/job-forge.mjs telemetry:status",
26
26
  "telemetry:show": "node bin/job-forge.mjs telemetry:show",
27
27
  "telemetry:watch": "node bin/job-forge.mjs telemetry:watch",
28
+ "guard:audit": "node bin/job-forge.mjs guard:audit",
29
+ "guard:explain": "node bin/job-forge.mjs guard:explain",
30
+ "ledger:status": "node bin/job-forge.mjs ledger:status",
31
+ "ledger:rebuild": "node bin/job-forge.mjs ledger:rebuild",
32
+ "ledger:verify": "node bin/job-forge.mjs ledger:verify",
33
+ "ledger:has": "node bin/job-forge.mjs ledger:has",
34
+ "ledger:query": "node bin/job-forge.mjs ledger:query",
28
35
  "plan": "iso plan .",
29
36
  "lint:agentmd": "agentmd lint iso/instructions.md",
30
37
  "lint:modes": "isolint lint modes/",
@@ -89,6 +96,8 @@
89
96
  "node": ">=20.6.0"
90
97
  },
91
98
  "dependencies": {
99
+ "@razroo/iso-guard": "^0.1.0",
100
+ "@razroo/iso-ledger": "^0.1.0",
92
101
  "@razroo/iso-orchestrator": "^0.1.0",
93
102
  "@razroo/iso-trace": "^0.4.0",
94
103
  "playwright": "^1.58.1"