job-forge 2.14.32 → 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.
@@ -19,13 +19,13 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
19
19
  - [H5] Re-dispatch the same company only AFTER the previous subagent returns. Never fire the same `task` twice while the first is still in flight.
20
20
  why: two in-flight subagents for the same URL race on Geometra sessions and on tracker TSV writes, corrupting state and sometimes double-submitting
21
21
 
22
- - [H5b] Do not use `task` to poll task status. If OpenCode returns a task/session id without a final result, record the id, stop dispatching new rounds, and tell the user the round is still in flight. When the user asks to check later, inspect authoritative files (`batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`, day files, `.jobforge-ledger/events.jsonl`, `.jobforge-index.json`, `.jobforge-facts.json`, or `iso-trace`) rather than spawning a "check task status" subagent.
22
+ - [H5b] Do not use `task` to poll task status. If OpenCode returns a task/session id without a final result, record the id, stop dispatching new rounds, and tell the user the round is still in flight. When the user asks to check later, inspect authoritative files (`batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`, day files, `.jobforge-ledger/events.jsonl`, `.jobforge-index.json`, `.jobforge-facts.json`, `.jobforge-timeline.json`, `.jobforge-prioritize.json`, `.jobforge-lineage.json`, or `iso-trace`) rather than spawning a "check task status" subagent.
23
23
  why: OpenCode status prompts can be delivered into the target subagent as a new user message; a 2026-04-25 trace caused a subagent to call `task` recursively instead of finishing the application
24
24
 
25
25
  - [H6] Application outcomes flow through `batch/tracker-additions/*.tsv`, not `data/pipeline.md`. After any multi-apply run, the orchestrator MUST run `npx job-forge merge` then `npx job-forge verify` before ending the session.
26
26
  why: `pipeline.md` is the URL inbox (`[ ]` pending → `[x]` processed); `data/applications/YYYY-MM-DD.md` is the outcome log; the TSV pathway is the only safe bridge because `merge` handles column order and duplicate detection
27
27
 
28
- - [H7] Load-bearing facts passed to downstream subagents must originate from a file, not from prior subagent prose. Authoritative sources: `data/pipeline.md`, `data/scan-history.tsv`, `batch/scan-output-*.md`, `reports/{num}-*.md` with `**URL:**` / `**Score:**` headers, emitted score JSON validated by `npx job-forge score:check --input ...`, `batch/tracker-additions/*.tsv`, cached JD content returned by `npx job-forge cache:get --url ...`, source path/line pointers returned by `npx job-forge index:query ...`, and materialized fact records returned by `npx job-forge facts:query ...`.
28
+ - [H7] Load-bearing facts passed to downstream subagents must originate from a file, not from prior subagent prose. Authoritative sources: `data/pipeline.md`, `data/scan-history.tsv`, `batch/scan-output-*.md`, `reports/{num}-*.md` with `**URL:**` / `**Score:**` headers, emitted score JSON validated by `npx job-forge score:check --input ...`, `batch/tracker-additions/*.tsv`, cached JD content returned by `npx job-forge cache:get --url ...`, source path/line pointers returned by `npx job-forge index:query ...`, materialized fact records returned by `npx job-forge facts:query ...`, selected next actions returned by `npx job-forge prioritize:select ...`, and lineage records returned by `npx job-forge lineage:explain ...`.
29
29
  why: 2026-04-18 scan subagent returned 30 fabricated Greenhouse IDs in prose (plausible-looking, non-existent); orchestrator dispatched 30 downstream subagents that all 404'd. Subagents can hallucinate IDs, scores, and confirmation text — round-trip through a file or don't trust the value
30
30
 
31
31
  - [H8] Never paste proxy values from `config/profile.yml` into `task` prompts, status text, or summaries. If a proxy is configured, tell the subagent exactly: "Proxy is configured; read `config/profile.yml` and pass its top-level `proxy:` object to every `geometra_connect` call." Do not transcribe `server`, `username`, `password`, or `bypass`, even if you just read them from disk.
@@ -93,11 +93,20 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
93
93
  - [D19] Treat `templates/score.json` as the source of truth for offer scoring weights, bands, and gates. After emitting a report score JSON that will drive PDF/application/batch decisions, run `npx job-forge score:check --input <file>`; for apply decisions run `npx job-forge score:gate --input <file> --gate apply`. Do not recalculate weighted totals or thresholds manually when the local helper can check them.
94
94
  why: `iso-score` is not an MCP and adds no prompt/tool-schema tokens; it makes scoring math, recommendation bands, and threshold booleans executable local policy instead of repeated model prose
95
95
 
96
+ - [D20] Treat `templates/timeline.json` as the source of truth for follow-up and next-action timing. For follow-up triage, run `npx job-forge timeline:due` before reading tracker files; use `npx job-forge timeline:check --fail-on overdue` when a workflow must fail only on stale actions. Use `timeline:build` when a durable `.jobforge-timeline.json` artifact is useful.
97
+ why: `iso-timeline` is not an MCP and adds no prompt/tool-schema tokens; it turns timing windows over tracker/pipeline sources into executable local policy instead of repeated date math in model context
98
+
99
+ - [D21] Treat `templates/prioritize.json` as the source of truth for choosing between evaluated applications, candidate facts, and due follow-ups. When the user asks what to do next or when a batch needs replacement candidates, run `npx job-forge prioritize:build` or `npx job-forge prioritize:select --limit N` instead of ranking items in prose.
100
+ why: `iso-prioritize` is not an MCP and adds no prompt/tool-schema tokens; it converts facts and timeline records into a deterministic ranked queue so agents do not reread broad tracker/report trees or invent priorities
101
+
102
+ - [D22] Treat `.jobforge-lineage.json` as the source of truth for whether generated reports/PDFs are current. After creating or refreshing a derived report/PDF, record its inputs with `npx job-forge lineage:record --artifact <file> --input <source>...`; before reusing an old artifact after source changes, run `npx job-forge lineage:check --artifact <file>` or `npx job-forge lineage:check`.
103
+ why: `iso-lineage` is not an MCP and adds no prompt/tool-schema tokens; it detects stale outputs by hashing local files instead of asking agents to remember which CV/profile/report version produced an artifact
104
+
96
105
  ## Procedure
97
106
 
98
107
  1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
99
108
  2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
100
- 3. Read the active mode file [D3]. Use context bundle checks when changing context loads [D11]. Check cached artifacts before URL/JD refetches [D12]. Use artifact index lookups before broad file reads when they can answer the question [D13]. Use materialized facts when a fact query can answer the question [D13b]. Use canonical identity keys for duplicate checks [D15]. Use score checks/gates for scoring decisions [D19]. Use migration checks for harness drift [D14]. Use redaction checks before exporting local artifacts [D18]. Decide inline vs delegated work [D1].
109
+ 3. Read the active mode file [D3]. Use context bundle checks when changing context loads [D11]. Check cached artifacts before URL/JD refetches [D12]. Use artifact index lookups before broad file reads when they can answer the question [D13]. Use materialized facts when a fact query can answer the question [D13b]. Use canonical identity keys for duplicate checks [D15]. Use score checks/gates for scoring decisions [D19]. Use timeline due/check commands for follow-up timing [D20]. Use priority queues when choosing next actions or replacements [D21]. Use lineage checks before reusing generated artifacts [D22]. Use migration checks for harness drift [D14]. Use redaction checks before exporting local artifacts [D18]. Decide inline vs delegated work [D1].
101
110
  4. Prepare Geometra dispatches: cleanup [H3], canon/index/facts/ledger prefilter when useful [D8, D13, D13b, D15], dedupe [H2], location filter [D5], materialize candidate facts/gates and run preflight plan/check [D16], routing [D2, D10], proxy prompt hygiene [H8].
102
111
  5. Dispatch at most 2 tasks per round [H1]; wait for final outcomes, not just task ids [H5b], then settle the round with postflight status [D17].
103
112
  6. Keep multi-job form-filling out of the orchestrator [H4].
@@ -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
+ }