job-forge 2.14.33 → 2.14.34

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,122 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'path';
3
+ import {
4
+ checkLineage,
5
+ emptyLineageGraph,
6
+ loadLineageGraph,
7
+ parseJson,
8
+ recordLineage,
9
+ verifyLineageGraph,
10
+ } from '@razroo/iso-lineage';
11
+
12
+ export const LINEAGE_FILE = '.jobforge-lineage.json';
13
+
14
+ export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
15
+ return projectDir;
16
+ }
17
+
18
+ export function jobForgeLineagePath(projectDir = resolveProjectDir()) {
19
+ return process.env.JOB_FORGE_LINEAGE || join(projectDir, LINEAGE_FILE);
20
+ }
21
+
22
+ export function lineageExists(projectDir = resolveProjectDir()) {
23
+ return existsSync(jobForgeLineagePath(projectDir));
24
+ }
25
+
26
+ export function readJobForgeLineage(projectDir = resolveProjectDir()) {
27
+ const path = jobForgeLineagePath(projectDir);
28
+ return loadLineageGraph(parseJson(readFileSync(path, 'utf8'), path));
29
+ }
30
+
31
+ export function readJobForgeLineageOrEmpty(projectDir = resolveProjectDir()) {
32
+ return lineageExists(projectDir) ? readJobForgeLineage(projectDir) : emptyLineageGraph();
33
+ }
34
+
35
+ export function writeJobForgeLineage(graph, options = {}, projectDir = resolveProjectDir()) {
36
+ const out = options.out || jobForgeLineagePath(projectDir);
37
+ mkdirSync(dirname(out), { recursive: true });
38
+ writeFileSync(out, `${JSON.stringify(graph, null, 2)}\n`, 'utf8');
39
+ return out;
40
+ }
41
+
42
+ export function recordJobForgeLineage(options = {}, projectDir = resolveProjectDir()) {
43
+ const graph = options.graph || readJobForgeLineageOrEmpty(projectDir);
44
+ const updated = recordLineage(graph, {
45
+ root: projectDir,
46
+ artifact: required(options.artifact, '--artifact'),
47
+ inputs: options.inputs || [],
48
+ optionalInputs: options.optionalInputs || [],
49
+ ...(options.kind ? { kind: options.kind } : {}),
50
+ ...(options.command ? { command: options.command } : {}),
51
+ ...(options.now ? { now: options.now } : {}),
52
+ ...(options.metadata ? { metadata: options.metadata } : {}),
53
+ });
54
+ const out = writeJobForgeLineage(updated, { out: options.out }, projectDir);
55
+ const artifact = storedPath(projectDir, options.artifact);
56
+ const record = updated.records.find((item) => item.artifact.path === artifact);
57
+ if (!record) throw new Error(`${artifact} was not recorded`);
58
+ return { graph: updated, record, out };
59
+ }
60
+
61
+ export function checkJobForgeLineage(options = {}, projectDir = resolveProjectDir()) {
62
+ const graph = options.graph || readJobForgeLineage(projectDir);
63
+ return checkLineage(graph, {
64
+ root: projectDir,
65
+ ...(options.artifact ? { artifact: options.artifact } : {}),
66
+ });
67
+ }
68
+
69
+ export function staleJobForgeLineage(options = {}, projectDir = resolveProjectDir()) {
70
+ return checkJobForgeLineage(options, projectDir);
71
+ }
72
+
73
+ export function verifyJobForgeLineage(options = {}, projectDir = resolveProjectDir()) {
74
+ const graph = options.graph || readJobForgeLineage(projectDir);
75
+ return verifyLineageGraph(graph);
76
+ }
77
+
78
+ export function jobForgeLineageSummary(projectDir = resolveProjectDir()) {
79
+ if (!lineageExists(projectDir)) {
80
+ return {
81
+ path: jobForgeLineagePath(projectDir),
82
+ exists: false,
83
+ records: 0,
84
+ current: 0,
85
+ stale: 0,
86
+ missing: 0,
87
+ };
88
+ }
89
+ const graph = readJobForgeLineage(projectDir);
90
+ const result = checkJobForgeLineage({ graph }, projectDir);
91
+ return {
92
+ path: jobForgeLineagePath(projectDir),
93
+ exists: true,
94
+ id: graph.id,
95
+ records: graph.records.length,
96
+ current: result.current,
97
+ stale: result.stale,
98
+ missing: result.missing,
99
+ ok: result.ok,
100
+ };
101
+ }
102
+
103
+ export function normalizeJobForgeLineageArtifact(projectDir = resolveProjectDir(), artifact = '') {
104
+ return storedPath(projectDir, artifact);
105
+ }
106
+
107
+ function required(value, flag) {
108
+ if (!value) throw new Error(`lineage:record requires ${flag}`);
109
+ return value;
110
+ }
111
+
112
+ function storedPath(root, path) {
113
+ const absRoot = resolve(root);
114
+ const abs = resolve(absRoot, path);
115
+ const rel = relative(absRoot, abs);
116
+ if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return normalizePath(rel);
117
+ return normalizePath(abs);
118
+ }
119
+
120
+ function normalizePath(path) {
121
+ return path.split(sep).join('/');
122
+ }
@@ -0,0 +1,294 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import {
4
+ checkPrioritize,
5
+ loadPrioritizeConfig,
6
+ parseJson,
7
+ prioritize,
8
+ selectPrioritized,
9
+ verifyPrioritizeResult,
10
+ } from '@razroo/iso-prioritize';
11
+ import { ensureJobForgeFacts } from './jobforge-facts.mjs';
12
+ import { dueJobForgeTimeline } from './jobforge-timeline.mjs';
13
+
14
+ export const PRIORITIZE_CONFIG_FILE = 'templates/prioritize.json';
15
+ export const PRIORITIZE_FILE = '.jobforge-prioritize.json';
16
+ export const PRIORITIZE_ITEMS_FILE = '.jobforge-prioritize-items.json';
17
+
18
+ export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
19
+ return projectDir;
20
+ }
21
+
22
+ export function jobForgePrioritizeConfigPath(projectDir = resolveProjectDir()) {
23
+ return process.env.JOB_FORGE_PRIORITIZE_CONFIG || join(projectDir, PRIORITIZE_CONFIG_FILE);
24
+ }
25
+
26
+ export function jobForgePrioritizePath(projectDir = resolveProjectDir()) {
27
+ return process.env.JOB_FORGE_PRIORITIZE || join(projectDir, PRIORITIZE_FILE);
28
+ }
29
+
30
+ export function jobForgePrioritizeItemsPath(projectDir = resolveProjectDir()) {
31
+ return process.env.JOB_FORGE_PRIORITIZE_ITEMS || join(projectDir, PRIORITIZE_ITEMS_FILE);
32
+ }
33
+
34
+ export function prioritizeExists(projectDir = resolveProjectDir()) {
35
+ return existsSync(jobForgePrioritizePath(projectDir));
36
+ }
37
+
38
+ export function readJobForgePrioritizeConfig(projectDir = resolveProjectDir()) {
39
+ const path = jobForgePrioritizeConfigPath(projectDir);
40
+ return loadPrioritizeConfig(parseJson(readFileSync(path, 'utf8'), path));
41
+ }
42
+
43
+ export function readJobForgePrioritize(projectDir = resolveProjectDir()) {
44
+ const path = jobForgePrioritizePath(projectDir);
45
+ return parseJson(readFileSync(path, 'utf8'), path);
46
+ }
47
+
48
+ export function readJobForgePrioritizeItems(projectDir = resolveProjectDir()) {
49
+ const path = jobForgePrioritizeItemsPath(projectDir);
50
+ return parseJson(readFileSync(path, 'utf8'), path);
51
+ }
52
+
53
+ export function buildJobForgePrioritizeItems(options = {}, projectDir = resolveProjectDir()) {
54
+ const factSet = options.facts || ensureJobForgeFacts({ rebuild: options.rebuild !== false }, projectDir);
55
+ const items = [];
56
+
57
+ for (const fact of factSet.facts || []) {
58
+ const candidate = candidateItem(fact);
59
+ if (candidate) items.push(candidate);
60
+
61
+ const evaluated = evaluatedTrackerItem(fact);
62
+ if (evaluated) items.push(evaluated);
63
+ }
64
+
65
+ for (const item of dueJobForgeTimeline({ now: options.now }, projectDir).items || []) {
66
+ const followup = timelineItem(item);
67
+ if (followup) items.push(followup);
68
+ }
69
+
70
+ return { items: dedupeItems(items) };
71
+ }
72
+
73
+ export function rankJobForgePrioritize(options = {}, projectDir = resolveProjectDir()) {
74
+ const config = options.config || readJobForgePrioritizeConfig(projectDir);
75
+ const items = options.items || buildJobForgePrioritizeItems(options, projectDir);
76
+ return prioritize(config, items, { profile: options.profile, limit: options.limit });
77
+ }
78
+
79
+ export function selectJobForgePrioritize(options = {}, projectDir = resolveProjectDir()) {
80
+ return selectPrioritized(rankJobForgePrioritize(options, projectDir));
81
+ }
82
+
83
+ export function checkJobForgePrioritize(options = {}, projectDir = resolveProjectDir()) {
84
+ const config = options.config || readJobForgePrioritizeConfig(projectDir);
85
+ const items = options.items || buildJobForgePrioritizeItems(options, projectDir);
86
+ return checkPrioritize(config, items, {
87
+ profile: options.profile,
88
+ limit: options.limit,
89
+ minSelected: options.minSelected,
90
+ failOn: options.failOn,
91
+ });
92
+ }
93
+
94
+ export function writeJobForgePrioritize(result, options = {}, projectDir = resolveProjectDir()) {
95
+ const out = options.out || jobForgePrioritizePath(projectDir);
96
+ mkdirSync(dirname(out), { recursive: true });
97
+ writeFileSync(out, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
98
+ return out;
99
+ }
100
+
101
+ export function writeJobForgePrioritizeItems(items, options = {}, projectDir = resolveProjectDir()) {
102
+ const out = options.out || jobForgePrioritizeItemsPath(projectDir);
103
+ mkdirSync(dirname(out), { recursive: true });
104
+ writeFileSync(out, `${JSON.stringify(items, null, 2)}\n`, 'utf8');
105
+ return out;
106
+ }
107
+
108
+ export function verifyJobForgePrioritize(options = {}, projectDir = resolveProjectDir()) {
109
+ const result = options.result || readJobForgePrioritize(projectDir);
110
+ return verifyPrioritizeResult(result);
111
+ }
112
+
113
+ export function jobForgePrioritizeSummary(projectDir = resolveProjectDir()) {
114
+ if (!prioritizeExists(projectDir)) {
115
+ return {
116
+ path: jobForgePrioritizePath(projectDir),
117
+ itemsPath: jobForgePrioritizeItemsPath(projectDir),
118
+ config: jobForgePrioritizeConfigPath(projectDir),
119
+ exists: false,
120
+ items: 0,
121
+ selected: 0,
122
+ candidate: 0,
123
+ skipped: 0,
124
+ blocked: 0,
125
+ };
126
+ }
127
+
128
+ const result = readJobForgePrioritize(projectDir);
129
+ return {
130
+ path: jobForgePrioritizePath(projectDir),
131
+ itemsPath: jobForgePrioritizeItemsPath(projectDir),
132
+ config: jobForgePrioritizeConfigPath(projectDir),
133
+ exists: true,
134
+ items: result.stats?.total || 0,
135
+ selected: result.stats?.selected || 0,
136
+ candidate: result.stats?.candidate || 0,
137
+ skipped: result.stats?.skipped || 0,
138
+ blocked: result.stats?.blocked || 0,
139
+ id: result.id,
140
+ profile: result.profile,
141
+ };
142
+ }
143
+
144
+ function candidateItem(fact) {
145
+ if (fact.fact !== 'candidate.ready') return null;
146
+ const fields = fact.fields || {};
147
+ const company = stringField(fields.company);
148
+ const role = stringField(fields.role);
149
+ const score = parseScore(fields.score);
150
+
151
+ return compactItem({
152
+ id: `candidate-${slug(fields.id || fact.key || fact.id)}`,
153
+ key: fact.key,
154
+ type: 'apply',
155
+ title: title(company, role, 'apply'),
156
+ tags: ['apply', 'candidate'],
157
+ data: compactObject({
158
+ company,
159
+ role,
160
+ score,
161
+ urgency: score >= 4 ? 8 : 6,
162
+ ageDays: 0,
163
+ sourceQuality: sourceQuality(fact, score),
164
+ status: fields.gateStatus || 'Evaluated',
165
+ gateStatus: fields.gateStatus,
166
+ locationStatus: fields.locationStatus,
167
+ url: fields.url,
168
+ }),
169
+ source: fact.source,
170
+ });
171
+ }
172
+
173
+ function evaluatedTrackerItem(fact) {
174
+ if (fact.fact !== 'application.status') return null;
175
+ const fields = fact.fields || {};
176
+ const status = stringField(fields.status || fact.value);
177
+ if (status !== 'Evaluated') return null;
178
+ const company = stringField(fields.company);
179
+ const role = stringField(fields.role);
180
+ const score = parseScore(fields.score);
181
+
182
+ return compactItem({
183
+ id: `evaluated-${slug(fields.num || fact.key || fact.id)}`,
184
+ key: fact.key,
185
+ type: 'apply',
186
+ title: title(company, role, 'apply'),
187
+ tags: ['apply', 'tracker'],
188
+ data: compactObject({
189
+ company,
190
+ role,
191
+ score,
192
+ urgency: score >= 4 ? 8 : 5,
193
+ ageDays: ageDays(fields.date),
194
+ sourceQuality: sourceQuality(fact, score),
195
+ status,
196
+ report: fields.report,
197
+ pdf: fields.pdf,
198
+ }),
199
+ source: fact.source,
200
+ });
201
+ }
202
+
203
+ function timelineItem(item) {
204
+ const data = item.event?.data || {};
205
+ const company = stringField(data.company);
206
+ const role = stringField(data.role);
207
+ const score = parseScore(data.score);
208
+
209
+ return compactItem({
210
+ id: `timeline-${slug(item.id)}`,
211
+ key: item.key,
212
+ type: 'followup',
213
+ title: title(company, role, item.action || 'follow-up'),
214
+ tags: ['followup', item.state],
215
+ data: compactObject({
216
+ company,
217
+ role,
218
+ score,
219
+ urgency: item.state === 'overdue' ? 10 : 8,
220
+ ageDays: ageDays(item.event?.at),
221
+ sourceQuality: sourceQuality(item.event, score),
222
+ status: data.status,
223
+ timelineState: item.state,
224
+ action: item.action,
225
+ rule: item.rule,
226
+ }),
227
+ source: item.event?.source,
228
+ });
229
+ }
230
+
231
+ function dedupeItems(items) {
232
+ const byId = new Map();
233
+ for (const item of items) {
234
+ const existing = byId.get(item.id);
235
+ if (!existing || priorityValue(item) > priorityValue(existing)) byId.set(item.id, item);
236
+ }
237
+ return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
238
+ }
239
+
240
+ function priorityValue(item) {
241
+ return Number(item.data?.score || 0) * 100 +
242
+ Number(item.data?.urgency || 0) * 10 +
243
+ Number(item.data?.sourceQuality || 0);
244
+ }
245
+
246
+ function parseScore(value) {
247
+ const match = String(value || '').match(/([0-9]+(?:\.[0-9]+)?)(?:\s*\/\s*5)?/);
248
+ if (!match) return 0;
249
+ return Math.max(0, Math.min(5, Number(match[1])));
250
+ }
251
+
252
+ function ageDays(value) {
253
+ const text = String(value || '').trim();
254
+ if (!text) return 0;
255
+ const parsed = new Date(text);
256
+ if (Number.isNaN(parsed.getTime())) return 0;
257
+ return Math.max(0, Math.floor((Date.now() - parsed.getTime()) / 86400000));
258
+ }
259
+
260
+ function sourceQuality(sourceLike, score) {
261
+ if (sourceLike?.source?.path && score > 0) return 1;
262
+ return score > 0 ? 0.8 : 0;
263
+ }
264
+
265
+ function stringField(value) {
266
+ return value === undefined || value === null ? '' : String(value);
267
+ }
268
+
269
+ function title(company, role, suffix) {
270
+ const base = [company, role].filter(Boolean).join(' - ');
271
+ return suffix && base ? `${base} ${suffix}` : base || suffix || 'JobForge item';
272
+ }
273
+
274
+ function slug(value) {
275
+ return String(value || 'item')
276
+ .toLowerCase()
277
+ .replace(/[^a-z0-9]+/g, '-')
278
+ .replace(/^-+|-+$/g, '')
279
+ .slice(0, 80) || 'item';
280
+ }
281
+
282
+ function compactItem(item) {
283
+ return {
284
+ ...item,
285
+ tags: (item.tags || []).filter(Boolean),
286
+ ...(item.source ? { source: item.source } : {}),
287
+ };
288
+ }
289
+
290
+ function compactObject(input) {
291
+ return Object.fromEntries(
292
+ Object.entries(input).filter(([, value]) => value !== undefined && value !== null && value !== ''),
293
+ );
294
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-forge",
3
- "version": "2.14.33",
3
+ "version": "2.14.34",
4
4
  "description": "AI-powered job search pipeline built on opencode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -85,6 +85,20 @@
85
85
  "timeline:check": "node bin/job-forge.mjs timeline:check",
86
86
  "timeline:verify": "node bin/job-forge.mjs timeline:verify",
87
87
  "timeline:explain": "node bin/job-forge.mjs timeline:explain",
88
+ "prioritize:status": "node bin/job-forge.mjs prioritize:status",
89
+ "prioritize:items": "node bin/job-forge.mjs prioritize:items",
90
+ "prioritize:build": "node bin/job-forge.mjs prioritize:build",
91
+ "prioritize:rank": "node bin/job-forge.mjs prioritize:rank",
92
+ "prioritize:select": "node bin/job-forge.mjs prioritize:select",
93
+ "prioritize:check": "node bin/job-forge.mjs prioritize:check",
94
+ "prioritize:verify": "node bin/job-forge.mjs prioritize:verify",
95
+ "prioritize:explain": "node bin/job-forge.mjs prioritize:explain",
96
+ "lineage:status": "node bin/job-forge.mjs lineage:status",
97
+ "lineage:record": "node bin/job-forge.mjs lineage:record",
98
+ "lineage:check": "node bin/job-forge.mjs lineage:check",
99
+ "lineage:stale": "node bin/job-forge.mjs lineage:stale",
100
+ "lineage:verify": "node bin/job-forge.mjs lineage:verify",
101
+ "lineage:explain": "node bin/job-forge.mjs lineage:explain",
88
102
  "redact:scan": "node bin/job-forge.mjs redact:scan",
89
103
  "redact:verify": "node bin/job-forge.mjs redact:verify",
90
104
  "redact:apply": "node bin/job-forge.mjs redact:apply",
@@ -168,10 +182,12 @@
168
182
  "@razroo/iso-guard": "^0.1.0",
169
183
  "@razroo/iso-index": "^0.1.0",
170
184
  "@razroo/iso-ledger": "^0.1.0",
185
+ "@razroo/iso-lineage": "^0.1.0",
171
186
  "@razroo/iso-migrate": "^0.1.0",
172
187
  "@razroo/iso-orchestrator": "^0.1.0",
173
188
  "@razroo/iso-postflight": "^0.1.0",
174
189
  "@razroo/iso-preflight": "^0.1.0",
190
+ "@razroo/iso-prioritize": "^0.1.0",
175
191
  "@razroo/iso-redact": "^0.1.0",
176
192
  "@razroo/iso-score": "^0.1.0",
177
193
  "@razroo/iso-timeline": "^0.1.0",
@@ -21,6 +21,8 @@ const checks = [
21
21
  ["H7 distrusts subagent prose", () => every(files.instructions, ["must originate from a file", "not from prior subagent prose"])],
22
22
  ["score policy points to local helper", () => every(files.instructions, ["[D19]", "templates/score.json", "npx job-forge score:check", "npx job-forge score:gate"])],
23
23
  ["timeline policy points to local helper", () => every(files.instructions, ["[D20]", "templates/timeline.json", "npx job-forge timeline:due", "npx job-forge timeline:check --fail-on overdue"])],
24
+ ["prioritize policy points to local helper", () => every(files.instructions, ["[D21]", "templates/prioritize.json", "npx job-forge prioritize:build", "npx job-forge prioritize:select --limit N"])],
25
+ ["lineage policy points to local helper", () => every(files.instructions, ["[D22]", ".jobforge-lineage.json", "npx job-forge lineage:record", "npx job-forge lineage:check"])],
24
26
  ["shared prompt points to on-demand references", () => every(files.instructions, ["modes/{mode}.md", "modes/reference-setup.md", "modes/reference-portals.md", "modes/reference-geometra.md"])],
25
27
  ["apply mode owns high-stakes upgrade", () => every(files.apply, ["[D8]", "@general-paid", "4.0/5", "high-stakes"])],
26
28
  ["apply mode blocks provider auto-downgrade", () => every(files.apply, ["[D9]", "do not auto-downgrade", "inspect telemetry before retrying"])],