job-forge 2.14.15 → 2.14.17

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,8 @@ 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';
33
+ import { formatContractIssues, parseTrackerRow } from './lib/jobforge-contracts.mjs';
32
34
 
33
35
  const ADDITIONS_DIR = join(PROJECT_DIR, 'batch/tracker-additions');
34
36
  const MERGED_DIR = join(ADDITIONS_DIR, 'merged');
@@ -155,64 +157,22 @@ function parseTsvContent(content, filename) {
155
157
  content = content.trim();
156
158
  if (!content) return null;
157
159
 
158
- let parts;
159
- let addition;
160
+ const format = detectTrackerRowFormat(content);
161
+ const parsed = parseTrackerRow(content, format, {
162
+ projectDir: PROJECT_DIR,
163
+ normalizeStatus: validateStatus,
164
+ });
160
165
 
161
- if (content.startsWith('|')) {
162
- parts = content.split('|').map(s => s.trim()).filter(Boolean);
163
- if (parts.length < 8) {
164
- console.warn(`⚠️ Skipping malformed pipe-delimited ${filename}: ${parts.length} fields`);
165
- return null;
166
- }
167
- addition = {
168
- num: parseInt(parts[0]),
169
- date: parts[1],
170
- company: parts[2],
171
- role: parts[3],
172
- score: parts[4],
173
- status: validateStatus(parts[5]),
174
- pdf: parts[6],
175
- report: parts[7],
176
- notes: parts[8] || '',
177
- };
178
- } else {
179
- parts = content.split('\t');
180
- if (parts.length < 8) {
181
- console.warn(`⚠️ Skipping malformed TSV ${filename}: ${parts.length} fields`);
182
- return null;
183
- }
184
-
185
- const col4 = parts[4].trim();
186
- const col5 = parts[5].trim();
187
- const col4LooksLikeScore = /^\d+\.?\d*\/5$/.test(col4) || col4 === 'N/A' || col4 === 'DUP';
188
- const col5LooksLikeScore = /^\d+\.?\d*\/5$/.test(col5) || col5 === 'N/A' || col5 === 'DUP';
189
- const col4LooksLikeStatus = STATUS_DETECT_RE.test(col4);
190
- const col5LooksLikeStatus = STATUS_DETECT_RE.test(col5);
191
-
192
- let statusCol, scoreCol;
193
- if (col4LooksLikeStatus && !col4LooksLikeScore) {
194
- statusCol = col4; scoreCol = col5;
195
- } else if (col4LooksLikeScore && col5LooksLikeStatus) {
196
- statusCol = col5; scoreCol = col4;
197
- } else if (col5LooksLikeScore && !col4LooksLikeScore) {
198
- statusCol = col4; scoreCol = col5;
199
- } else {
200
- statusCol = col4; scoreCol = col5;
201
- }
202
-
203
- addition = {
204
- num: parseInt(parts[0]),
205
- date: parts[1],
206
- company: parts[2],
207
- role: parts[3],
208
- status: validateStatus(statusCol),
209
- score: scoreCol,
210
- pdf: parts[6],
211
- report: parts[7],
212
- notes: parts[8] || '',
213
- };
166
+ if (!parsed.validation.ok) {
167
+ console.warn(`⚠️ Skipping ${filename}: tracker contract failed (${format}) ${formatContractIssues(parsed.validation)}`);
168
+ return null;
214
169
  }
215
170
 
171
+ const addition = {
172
+ ...parsed.validation.record,
173
+ num: Number(parsed.validation.record.num),
174
+ };
175
+
216
176
  if (isNaN(addition.num) || addition.num === 0) {
217
177
  console.warn(`⚠️ Skipping ${filename}: invalid entry number`);
218
178
  return null;
@@ -221,6 +181,18 @@ function parseTsvContent(content, filename) {
221
181
  return addition;
222
182
  }
223
183
 
184
+ function detectTrackerRowFormat(content) {
185
+ if (content.startsWith('|')) return 'markdown';
186
+
187
+ const parts = content.split('\t');
188
+ const col4 = (parts[4] || '').trim();
189
+ const col5 = (parts[5] || '').trim();
190
+ const col4LooksLikeScore = /^\d+\.?\d*\/5$/.test(col4) || col4 === 'N/A' || col4 === 'DUP';
191
+ const col5LooksLikeStatus = STATUS_DETECT_RE.test(col5);
192
+
193
+ return col4LooksLikeScore && col5LooksLikeStatus ? 'day-tsv' : 'tsv';
194
+ }
195
+
224
196
  // ---- Main ----
225
197
 
226
198
  if (!existsSync(ADDITIONS_DIR)) {
@@ -288,6 +260,7 @@ let added = 0;
288
260
  let updated = 0;
289
261
  let skipped = 0;
290
262
  const newEntries = [];
263
+ const ledgerRecords = [];
291
264
 
292
265
  for (const file of tsvFiles) {
293
266
  const content = readFileSync(join(ADDITIONS_DIR, file), 'utf-8').trim();
@@ -359,12 +332,15 @@ for (const file of tsvFiles) {
359
332
  }
360
333
  }
361
334
  updated++;
335
+ ledgerRecords.push({ addition, outcome: 'updated', sourceFile: join(ADDITIONS_DIR, file), duplicateNum: duplicate.num, reason });
362
336
  } else if (statusRegresses) {
363
337
  console.log(`⏭️ Skip: ${addition.company} — ${addition.role} (existing #${duplicate.num} status ${duplicate.status} outranks new ${addition.status})`);
364
338
  skipped++;
339
+ ledgerRecords.push({ addition, outcome: 'skipped', sourceFile: join(ADDITIONS_DIR, file), duplicateNum: duplicate.num, reason: 'status-regression' });
365
340
  } else {
366
341
  console.log(`⏭️ Skip: ${addition.company} — ${addition.role} (existing #${duplicate.num} ${oldScore} >= new ${newScore})`);
367
342
  skipped++;
343
+ ledgerRecords.push({ addition, outcome: 'skipped', sourceFile: join(ADDITIONS_DIR, file), duplicateNum: duplicate.num, reason: 'no-improvement' });
368
344
  }
369
345
  } else {
370
346
  const entryNum = addition.num > maxNum ? addition.num : ++maxNum;
@@ -376,6 +352,7 @@ for (const file of tsvFiles) {
376
352
  });
377
353
  added++;
378
354
  console.log(`➕ Add #${entryNum}: ${addition.company} — ${addition.role} (${addition.score})`);
355
+ ledgerRecords.push({ addition: { ...addition, num: entryNum }, outcome: 'added', sourceFile: join(ADDITIONS_DIR, file), reason: 'new-entry' });
379
356
  }
380
357
  }
381
358
 
@@ -410,6 +387,23 @@ if (!DRY_RUN) {
410
387
  renameSync(join(ADDITIONS_DIR, file), join(MERGED_DIR, file));
411
388
  }
412
389
  console.log(`\n✅ Moved ${tsvFiles.length} TSVs to merged/`);
390
+
391
+ let ledgerEvents = 0;
392
+ for (const record of ledgerRecords) {
393
+ try {
394
+ const result = recordTrackerMergeResult(record.addition, {
395
+ projectDir: PROJECT_DIR,
396
+ sourceFile: record.sourceFile,
397
+ outcome: record.outcome,
398
+ duplicateNum: record.duplicateNum,
399
+ reason: record.reason,
400
+ });
401
+ if (result.appended) ledgerEvents++;
402
+ } catch (error) {
403
+ console.warn(`⚠️ Could not append ledger event for ${record.sourceFile}: ${error instanceof Error ? error.message : String(error)}`);
404
+ }
405
+ }
406
+ console.log(`🧾 Ledger: ${ledgerEvents} event(s) appended`);
413
407
  }
414
408
 
415
409
  console.log(`\n📊 Summary: +${added} added, 🔄${updated} updated, ⏭️${skipped} skipped`);
@@ -112,6 +112,18 @@ Mode routing is specified in the top-level **## Routing** section. Each mode is
112
112
 
113
113
  ## TSV Format for Tracker Additions
114
114
 
115
+ Prefer the deterministic helper:
116
+
117
+ ```bash
118
+ npx job-forge tracker-line --num 521 --date 2026-04-15 --company "Anthropic" --role "Manager, FDE" --status Evaluated --score 4.2 --pdf no --slug anthropic-manager-fde --notes "Strong fit" --write
119
+ ```
120
+
121
+ The helper renders and validates the row against `templates/contracts.json` via `@razroo/iso-contract`. To inspect the contract directly:
122
+
123
+ ```bash
124
+ npx iso-contract explain jobforge.tracker-row --contracts templates/contracts.json
125
+ ```
126
+
115
127
  Write one TSV file per evaluation to `batch/tracker-additions/{num}-{company-slug}.tsv`. Single line, 9 tab-separated columns:
116
128
 
117
129
  ```
@@ -129,7 +141,7 @@ Write one TSV file per evaluation to `batch/tracker-additions/{num}-{company-slu
129
141
  8. `report` -- markdown link `[num](reports/...)`
130
142
  9. `notes` -- one-line summary
131
143
 
132
- **Note:** In applications.md, score comes BEFORE status. The merge script handles this column swap automatically.
144
+ **Note:** In applications.md, score comes BEFORE status. The merge script handles this column swap automatically and validates both shapes with the tracker-row contract.
133
145
 
134
146
  - Scripts in `.mjs`, configuration in YAML
135
147
  - Output in `output/` (gitignored), Reports in `reports/`
@@ -146,9 +158,10 @@ Write one TSV file per evaluation to `batch/tracker-additions/{num}-{company-slu
146
158
  2. **YES you can edit day files in `data/applications/` to UPDATE status/notes of existing entries.**
147
159
  3. All reports MUST include `**URL:**` in the header (between Score and PDF).
148
160
  4. All statuses MUST be canonical (see `templates/states.yml`).
149
- 5. Health check: `npx job-forge verify`
150
- 6. Normalize statuses: `npx job-forge normalize`
151
- 7. Dedup: `npx job-forge dedup`
161
+ 5. Tracker rows MUST satisfy `templates/contracts.json`.
162
+ 6. Health check: `npx job-forge verify`
163
+ 7. Normalize statuses: `npx job-forge normalize`
164
+ 8. Dedup: `npx job-forge dedup`
152
165
 
153
166
  ### Canonical States (applications day files)
154
167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-forge",
3
- "version": "2.14.15",
3
+ "version": "2.14.17",
4
4
  "description": "AI-powered job search pipeline built on opencode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,11 @@
27
27
  "telemetry:watch": "node bin/job-forge.mjs telemetry:watch",
28
28
  "guard:audit": "node bin/job-forge.mjs guard:audit",
29
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",
30
35
  "plan": "iso plan .",
31
36
  "lint:agentmd": "agentmd lint iso/instructions.md",
32
37
  "lint:modes": "isolint lint modes/",
@@ -91,7 +96,9 @@
91
96
  "node": ">=20.6.0"
92
97
  },
93
98
  "dependencies": {
99
+ "@razroo/iso-contract": "^0.1.0",
94
100
  "@razroo/iso-guard": "^0.1.0",
101
+ "@razroo/iso-ledger": "^0.1.0",
95
102
  "@razroo/iso-orchestrator": "^0.1.0",
96
103
  "@razroo/iso-trace": "^0.4.0",
97
104
  "playwright": "^1.58.1"