job-forge 2.14.30 → 2.14.32

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.
@@ -102,6 +102,11 @@ Artifact contracts (terminal, outside opencode):
102
102
  npx iso-contract explain jobforge.tracker-row --contracts templates/contracts.json
103
103
  npx job-forge tracker-line ... --write # renders + validates tracker TSV locally
104
104
 
105
+ Score policy (terminal, outside opencode):
106
+ npx job-forge score:check --input /tmp/score.json
107
+ npx job-forge score:gate --input /tmp/score.json --gate apply
108
+ npx job-forge score:explain
109
+
105
110
  Role capabilities (terminal, outside opencode):
106
111
  npx job-forge capabilities:explain general-free
107
112
  npx job-forge capabilities:check general-free --tool browser --mcp geometra --filesystem write
@@ -210,6 +215,9 @@ Step 4 — Materialize and check the dispatch plan
210
215
  (or another explicit JSON file). Include source paths for company, role,
211
216
  companyRoleKey, URL, score, duplicate/location gates, and any skip/block
212
217
  decision.
218
+ - If the candidate came from a fresh evaluation score JSON, run npx job-forge
219
+ score:check --input <score.json> and npx job-forge score:gate --input
220
+ <score.json> --gate apply before using that score as an apply gate.
213
221
  - Run npx job-forge preflight:check --candidates <file> to fail on missing
214
222
  sources or blocked gates, then npx job-forge preflight:plan --candidates
215
223
  <file> --json > batch/preflight-plan.json to get the bounded round list.
@@ -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`, 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`, 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, `batch/tracker-additions/*.tsv`, cached JD content returned by `npx job-forge cache:get --url ...`, and source path/line pointers returned by `npx job-forge index: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 ...`, and materialized fact records returned by `npx job-forge facts:query ...`.
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.
@@ -72,6 +72,9 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
72
72
  - [D13] Use `job-forge index:*` for deterministic artifact lookup when available. `index:has` and `index:query` rebuild `.jobforge-index.json` from `templates/index.json` on demand, covering reports, tracker day files, tracker TSVs, pipeline URLs, scan history, and ledger events without loading those growing files into prompt context.
73
73
  why: `iso-index` is not an MCP and adds no prompt/tool-schema tokens; it gives agents compact file/line pointers and duplicate prefilters before expensive reads or browser dispatches
74
74
 
75
+ - [D13b] Use `job-forge facts:*` for deterministic source-backed fact materialization when available. `facts:has` and `facts:query` rebuild `.jobforge-facts.json` from `templates/facts.json` on demand, covering job URLs, scores, application statuses, tracker TSVs, preflight candidates, scan history, and ledger events with path/line provenance.
76
+ why: `iso-facts` is not an MCP and adds no prompt/tool-schema tokens; it turns authoritative files into compact queryable fact records so agents do not repeatedly reread broad artifact trees
77
+
75
78
  - [D14] Treat `templates/migrations.json` as the source of truth for consumer-project upgrades. Use `npx job-forge migrate:plan` or `npx job-forge migrate:check` when diagnosing harness drift; `job-forge sync` applies safe migrations automatically unless `JOB_FORGE_SKIP_MIGRATIONS=1` is set.
76
79
  why: `iso-migrate` is not an MCP and adds no prompt/tool-schema tokens; it prevents stale consumer scripts and generated-artifact ignores without asking agents to hand-edit package.json
77
80
 
@@ -87,16 +90,19 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
87
90
  - [D18] Treat `templates/redact.json` as the source of truth before exporting local traces, prompts, reports, or fixtures outside the project. Use `npx job-forge redact:scan --input <file>`, `redact:apply --input <file> --output .jobforge-redacted/<file>`, or `redact:verify --input <file>` instead of hand-redacting with prose. This complements H8; it does not make it acceptable to paste secrets into prompts.
88
91
  why: `iso-redact` is not an MCP and adds no prompt/tool-schema tokens; it gives deterministic safe-export checks whose findings do not print matched secret values
89
92
 
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
+ 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
+
90
96
  ## Procedure
91
97
 
92
98
  1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
93
99
  2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
94
- 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 canonical identity keys for duplicate checks [D15]. Use migration checks for harness drift [D14]. Use redaction checks before exporting local artifacts [D18]. Decide inline vs delegated work [D1].
95
- 4. Prepare Geometra dispatches: cleanup [H3], canon/index/ledger prefilter when useful [D8, D13, D15], dedupe [H2], location filter [D5], materialize candidate facts/gates and run preflight plan/check [D16], routing [D2, D10], proxy prompt hygiene [H8].
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].
101
+ 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].
96
102
  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].
97
103
  6. Keep multi-job form-filling out of the orchestrator [H4].
98
104
  7. Cross-check subagent facts against authoritative files [H7].
99
- 8. Apply score gate [D4].
105
+ 8. Apply score gate [D4, D19].
100
106
  9. Merge contract-validated TSV outcomes [H6, D9].
101
107
  10. Verify tracker and run postflight check before ending [H6, D17].
102
108
 
@@ -0,0 +1,178 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import {
4
+ buildFacts,
5
+ checkFactRequirements,
6
+ factId,
7
+ hasFact,
8
+ loadFactsConfig,
9
+ parseJson,
10
+ queryFacts,
11
+ verifyFactSet,
12
+ } from '@razroo/iso-facts';
13
+ import {
14
+ jobForgeCompanyRoleKey,
15
+ jobForgeUrlKey,
16
+ legacyCompanyRoleKey,
17
+ legacyUrlKey,
18
+ } from './jobforge-canon.mjs';
19
+
20
+ export const FACTS_FILE = '.jobforge-facts.json';
21
+ export const FACTS_CONFIG_FILE = 'templates/facts.json';
22
+
23
+ export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
24
+ return projectDir;
25
+ }
26
+
27
+ export function jobForgeFactsPath(projectDir = resolveProjectDir()) {
28
+ return process.env.JOB_FORGE_FACTS || join(projectDir, FACTS_FILE);
29
+ }
30
+
31
+ export function jobForgeFactsConfigPath(projectDir = resolveProjectDir()) {
32
+ return process.env.JOB_FORGE_FACTS_CONFIG || join(projectDir, FACTS_CONFIG_FILE);
33
+ }
34
+
35
+ export function factsExist(projectDir = resolveProjectDir()) {
36
+ return existsSync(jobForgeFactsPath(projectDir));
37
+ }
38
+
39
+ export function readJobForgeFactsConfig(projectDir = resolveProjectDir()) {
40
+ const path = jobForgeFactsConfigPath(projectDir);
41
+ return loadFactsConfig(parseJson(readFileSync(path, 'utf8'), path));
42
+ }
43
+
44
+ export function buildJobForgeFacts(options = {}, projectDir = resolveProjectDir()) {
45
+ const config = readJobForgeFactsConfig(projectDir);
46
+ const factSet = canonicalizeJobForgeFacts(buildFacts(config, { root: projectDir }), projectDir);
47
+ const out = options.out || jobForgeFactsPath(projectDir);
48
+ if (options.write !== false) {
49
+ writeFileSync(out, `${JSON.stringify(factSet, null, 2)}\n`, 'utf8');
50
+ }
51
+ return { factSet, out };
52
+ }
53
+
54
+ export function readJobForgeFacts(projectDir = resolveProjectDir()) {
55
+ const path = jobForgeFactsPath(projectDir);
56
+ return parseJson(readFileSync(path, 'utf8'), path);
57
+ }
58
+
59
+ export function ensureJobForgeFacts(options = {}, projectDir = resolveProjectDir()) {
60
+ if (options.rebuild !== false || !factsExist(projectDir)) {
61
+ return buildJobForgeFacts({ out: options.out }, projectDir).factSet;
62
+ }
63
+ return readJobForgeFacts(projectDir);
64
+ }
65
+
66
+ export function queryJobForgeFacts(query = {}, options = {}, projectDir = resolveProjectDir()) {
67
+ return queryFacts(ensureJobForgeFacts(options, projectDir), query);
68
+ }
69
+
70
+ export function hasJobForgeFact(query = {}, options = {}, projectDir = resolveProjectDir()) {
71
+ return hasFact(ensureJobForgeFacts(options, projectDir), query);
72
+ }
73
+
74
+ export function verifyJobForgeFacts(options = {}, projectDir = resolveProjectDir()) {
75
+ const factSet = options.factSet || ensureJobForgeFacts(options, projectDir);
76
+ return verifyFactSet(factSet);
77
+ }
78
+
79
+ export function checkJobForgeFacts(options = {}, projectDir = resolveProjectDir()) {
80
+ const factSet = options.factSet || ensureJobForgeFacts(options, projectDir);
81
+ const config = readJobForgeFactsConfig(projectDir);
82
+ return checkFactRequirements(factSet, config.requirements || []);
83
+ }
84
+
85
+ export function jobForgeFactsSummary(projectDir = resolveProjectDir()) {
86
+ if (!factsExist(projectDir)) {
87
+ return {
88
+ path: jobForgeFactsPath(projectDir),
89
+ config: jobForgeFactsConfigPath(projectDir),
90
+ exists: false,
91
+ facts: 0,
92
+ files: 0,
93
+ sources: 0,
94
+ };
95
+ }
96
+ const factSet = readJobForgeFacts(projectDir);
97
+ return {
98
+ path: jobForgeFactsPath(projectDir),
99
+ config: jobForgeFactsConfigPath(projectDir),
100
+ exists: true,
101
+ facts: factSet.stats?.facts || 0,
102
+ files: factSet.stats?.files || 0,
103
+ sources: factSet.stats?.sources || 0,
104
+ configHash: factSet.configHash,
105
+ };
106
+ }
107
+
108
+ function canonicalizeJobForgeFacts(factSet, projectDir) {
109
+ const facts = (factSet.facts || []).map((fact) => canonicalizeJobForgeFact(fact, projectDir));
110
+ facts.sort(compareFacts);
111
+ return {
112
+ ...factSet,
113
+ facts,
114
+ stats: {
115
+ ...(factSet.stats || {}),
116
+ facts: facts.length,
117
+ },
118
+ };
119
+ }
120
+
121
+ function canonicalizeJobForgeFact(fact, projectDir) {
122
+ const key = canonicalFactKey(fact, projectDir);
123
+ if (key === fact.key) return fact;
124
+ const updated = { ...fact, key };
125
+ return { ...updated, id: factId(updated) };
126
+ }
127
+
128
+ function canonicalFactKey(fact, projectDir) {
129
+ if (isCompanyRoleFact(fact)) {
130
+ const { company, role } = companyRoleFields(fact);
131
+ if (company && role) return safeCompanyRoleKey(company, role, projectDir);
132
+ }
133
+ if (isUrlFact(fact)) {
134
+ const url = fact.fields?.url;
135
+ if (url) return safeUrlKey(url, projectDir);
136
+ }
137
+ return fact.key;
138
+ }
139
+
140
+ function isCompanyRoleFact(fact) {
141
+ return fact.key?.startsWith('company-role:') ||
142
+ fact.fact === 'application.status' ||
143
+ fact.fact === 'tracker.addition' ||
144
+ fact.fact === 'candidate.ready';
145
+ }
146
+
147
+ function companyRoleFields(fact) {
148
+ const fields = fact.fields || {};
149
+ return {
150
+ company: fields.company || fields.Company,
151
+ role: fields.role || fields.Role,
152
+ };
153
+ }
154
+
155
+ function isUrlFact(fact) {
156
+ return fact.key?.startsWith('url:') || fact.fact === 'job.url';
157
+ }
158
+
159
+ function safeCompanyRoleKey(company, role, projectDir) {
160
+ try {
161
+ return jobForgeCompanyRoleKey(company, role, projectDir);
162
+ } catch {
163
+ return legacyCompanyRoleKey(company, role);
164
+ }
165
+ }
166
+
167
+ function safeUrlKey(url, projectDir) {
168
+ try {
169
+ return jobForgeUrlKey(url, projectDir);
170
+ } catch {
171
+ return legacyUrlKey(url);
172
+ }
173
+ }
174
+
175
+ function compareFacts(a, b) {
176
+ return `${a.fact}\0${a.key || ''}\0${a.value || ''}\0${a.source?.path || ''}\0${a.source?.line || ''}\0${a.id}`
177
+ .localeCompare(`${b.fact}\0${b.key || ''}\0${b.value || ''}\0${b.source?.path || ''}\0${b.source?.line || ''}\0${b.id}`);
178
+ }
@@ -0,0 +1,212 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import {
4
+ compareScoreResults,
5
+ computeScore,
6
+ evaluateGate,
7
+ loadScoreConfig,
8
+ parseJson,
9
+ scoreResultId,
10
+ verifyScoreResult,
11
+ } from '@razroo/iso-score';
12
+
13
+ export const SCORE_CONFIG_FILE = 'templates/score.json';
14
+ export const SCORE_PROFILE = 'jobforge';
15
+
16
+ export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
17
+ return projectDir;
18
+ }
19
+
20
+ export function jobForgeScoreConfigPath(projectDir = resolveProjectDir()) {
21
+ return process.env.JOB_FORGE_SCORE_CONFIG || join(projectDir, SCORE_CONFIG_FILE);
22
+ }
23
+
24
+ export function readJobForgeScoreConfig(projectDir = resolveProjectDir()) {
25
+ const path = jobForgeScoreConfigPath(projectDir);
26
+ return loadScoreConfig(parseJson(readFileSync(path, 'utf8'), path));
27
+ }
28
+
29
+ export function readJsonFile(path) {
30
+ return parseJson(readFileSync(path, 'utf8'), path);
31
+ }
32
+
33
+ export function normalizeJobForgeScoreInput(input) {
34
+ if (!isObject(input)) throw new Error('score input must be a JSON object');
35
+
36
+ if ('dimensions' in input) {
37
+ return {
38
+ ...input,
39
+ profile: stringOr(input.profile, SCORE_PROFILE),
40
+ };
41
+ }
42
+
43
+ if (!isObject(input.scores)) {
44
+ throw new Error('score input must contain either dimensions or JobForge scores');
45
+ }
46
+
47
+ const dimensions = {};
48
+ for (const [id, raw] of Object.entries(input.scores)) {
49
+ if (!isObject(raw)) throw new Error(`scores.${id} must be a JSON object`);
50
+ dimensions[id] = {
51
+ score: Number(raw.score),
52
+ note: stringOr(raw.rationale, stringOr(raw.note, '')),
53
+ evidence: Array.isArray(raw.evidence) ? raw.evidence.filter((item) => typeof item === 'string') : [],
54
+ };
55
+ }
56
+
57
+ return stripUndefined({
58
+ subject: [input.company, input.role].filter((value) => typeof value === 'string' && value.length > 0).join(' - ') || undefined,
59
+ profile: SCORE_PROFILE,
60
+ dimensions,
61
+ facts: stripUndefined({
62
+ report_num: jsonScalar(input.report_num),
63
+ company: jsonScalar(input.company),
64
+ role: jsonScalar(input.role),
65
+ archetype: jsonScalar(input.archetype),
66
+ url: jsonScalar(input.url),
67
+ date: jsonScalar(input.date),
68
+ }),
69
+ meta: stripUndefined({
70
+ sourceShape: 'jobforge-score-json',
71
+ expectedWeightedTotal: jsonScalar(input.weighted_total),
72
+ recommendation: jsonScalar(input.recommendation),
73
+ pdf_threshold_met: typeof input.pdf_threshold_met === 'boolean' ? input.pdf_threshold_met : undefined,
74
+ draft_answers_threshold_met: typeof input.draft_answers_threshold_met === 'boolean' ? input.draft_answers_threshold_met : undefined,
75
+ }),
76
+ });
77
+ }
78
+
79
+ export function computeJobForgeScore(input, options = {}, projectDir = resolveProjectDir()) {
80
+ const config = readJobForgeScoreConfig(projectDir);
81
+ const normalized = normalizeJobForgeScoreInput(input);
82
+ const result = computeScore(config, normalized, { profile: options.profile || normalized.profile || SCORE_PROFILE });
83
+ return withJobForgeIssues(result, normalized);
84
+ }
85
+
86
+ export function checkJobForgeScore(input, options = {}, projectDir = resolveProjectDir()) {
87
+ const result = computeJobForgeScore(input, options, projectDir);
88
+ const errors = result.issues.filter((issue) => issue.severity === 'error').length;
89
+ const warnings = result.issues.filter((issue) => issue.severity === 'warn').length;
90
+ return {
91
+ ok: errors === 0,
92
+ errors,
93
+ warnings,
94
+ result,
95
+ issues: result.issues,
96
+ };
97
+ }
98
+
99
+ export function evaluateJobForgeScoreGate(input, options = {}, projectDir = resolveProjectDir()) {
100
+ const config = readJobForgeScoreConfig(projectDir);
101
+ const normalized = normalizeJobForgeScoreInput(input);
102
+ const base = evaluateGate(config, normalized, {
103
+ profile: options.profile || normalized.profile || SCORE_PROFILE,
104
+ gate: options.gate,
105
+ });
106
+ const result = withJobForgeIssues(base.result, normalized);
107
+ const errors = result.issues.some((issue) => issue.severity === 'error');
108
+ const gate = errors
109
+ ? { ...base.gate, pass: false, reason: `${base.gate.reason}; score has error issues` }
110
+ : base.gate;
111
+ return {
112
+ ok: gate.pass,
113
+ gate,
114
+ result,
115
+ };
116
+ }
117
+
118
+ export function verifyJobForgeScoreResult(result) {
119
+ return verifyScoreResult(result);
120
+ }
121
+
122
+ export function compareJobForgeScores(leftInput, rightInput, options = {}, projectDir = resolveProjectDir()) {
123
+ const left = computeJobForgeScore(leftInput, options, projectDir);
124
+ const right = computeJobForgeScore(rightInput, options, projectDir);
125
+ return compareScoreResults(left, right);
126
+ }
127
+
128
+ function withJobForgeIssues(result, normalized) {
129
+ const issues = [...result.issues, ...jobForgeShapeIssues(result, normalized)];
130
+ if (issues.length === result.issues.length) return result;
131
+ const updated = { ...result, issues };
132
+ updated.id = scoreResultId(updated);
133
+ return updated;
134
+ }
135
+
136
+ function jobForgeShapeIssues(result, normalized) {
137
+ if (normalized.meta?.sourceShape !== 'jobforge-score-json') return [];
138
+
139
+ const issues = [];
140
+ const expectedTotal = normalized.meta.expectedWeightedTotal;
141
+ if (typeof expectedTotal === 'number' && Math.abs(round1(expectedTotal) - result.score) > 0.0001) {
142
+ issues.push(error('weighted-total-mismatch', `weighted_total ${expectedTotal} does not match computed score ${result.score}`));
143
+ }
144
+
145
+ const expectedRecommendation = recommendationFor(result.score);
146
+ if (normalized.meta.recommendation !== undefined && normalized.meta.recommendation !== expectedRecommendation) {
147
+ issues.push(error('recommendation-mismatch', `recommendation must be "${expectedRecommendation}" for score ${result.score}`));
148
+ }
149
+
150
+ const expectedPdf = result.score >= 3;
151
+ if (normalized.meta.pdf_threshold_met !== undefined && normalized.meta.pdf_threshold_met !== expectedPdf) {
152
+ issues.push(error('pdf-threshold-mismatch', `pdf_threshold_met must be ${expectedPdf} for score ${result.score}`));
153
+ }
154
+
155
+ const expectedDraft = result.score >= 3.5;
156
+ if (normalized.meta.draft_answers_threshold_met !== undefined && normalized.meta.draft_answers_threshold_met !== expectedDraft) {
157
+ issues.push(error('draft-answers-threshold-mismatch', `draft_answers_threshold_met must be ${expectedDraft} for score ${result.score}`));
158
+ }
159
+
160
+ for (const dimension of result.dimensions) {
161
+ if (!isHalfStep(dimension.score)) {
162
+ issues.push(error('invalid-score-step', `dimension "${dimension.id}" score must use 0.5 increments`, dimension.id));
163
+ }
164
+ if (!dimension.note || dimension.note.trim().length === 0) {
165
+ issues.push(error('missing-rationale', `dimension "${dimension.id}" rationale is required`, dimension.id));
166
+ } else if (dimension.note.length > 80) {
167
+ issues.push(error('rationale-too-long', `dimension "${dimension.id}" rationale must be <= 80 characters`, dimension.id));
168
+ } else if (hasMarkdown(dimension.note)) {
169
+ issues.push(error('rationale-markdown', `dimension "${dimension.id}" rationale must not contain markdown`, dimension.id));
170
+ }
171
+ }
172
+
173
+ return issues;
174
+ }
175
+
176
+ function recommendationFor(score) {
177
+ if (score >= 3.5) return 'apply';
178
+ if (score >= 3) return 'apply_with_caveats';
179
+ return 'skip';
180
+ }
181
+
182
+ function isHalfStep(value) {
183
+ return Number.isFinite(value) && Number.isInteger(value * 2);
184
+ }
185
+
186
+ function hasMarkdown(value) {
187
+ return /(`|\*\*|__|\[[^\]]+\]\(|^#{1,6}\s)/.test(value);
188
+ }
189
+
190
+ function error(code, message, dimension) {
191
+ return stripUndefined({ severity: 'error', code, message, dimension });
192
+ }
193
+
194
+ function stringOr(value, fallback) {
195
+ return typeof value === 'string' ? value : fallback;
196
+ }
197
+
198
+ function jsonScalar(value) {
199
+ return value === null || ['string', 'number', 'boolean'].includes(typeof value) ? value : undefined;
200
+ }
201
+
202
+ function round1(value) {
203
+ return Math.round(Number(value) * 10) / 10;
204
+ }
205
+
206
+ function isObject(value) {
207
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
208
+ }
209
+
210
+ function stripUndefined(value) {
211
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
212
+ }
package/modes/_shared.md CHANGED
@@ -147,7 +147,7 @@ If the candidate has a live demo/dashboard (check profile.yml), offer access in
147
147
 
148
148
  ## Use this canonical scoring model (SINGLE SOURCE OF TRUTH)
149
149
 
150
- **ALL evaluation modes MUST use this exact model.** Whether the offer is evaluated via `offer`, `auto-pipeline`, `batch`, or compared via `compare`, the score is computed the same way. This ensures scores are comparable across the entire pipeline.
150
+ **ALL evaluation modes MUST use this exact model.** Whether the offer is evaluated via `offer`, `auto-pipeline`, `batch`, or compared via `compare`, the score is computed the same way. The executable source of truth is `templates/score.json`; use `npx job-forge score:check --input <score.json>` and `score:gate --gate apply` when validating emitted score JSON instead of recalculating gates in prose. This ensures scores are comparable across the entire pipeline.
151
151
 
152
152
  | # | Dimension | Weight | 1 | 3 | 5 |
153
153
  |---|-----------|--------|---|---|---|
@@ -209,8 +209,9 @@ If the candidate has a live demo/dashboard (check profile.yml), offer access in
209
209
  **After emitting the JSON:**
210
210
 
211
211
  1. Embed the same JSON block verbatim in the report `.md` under a `## Score` section (fenced as ` ```json `).
212
- 2. Write Blocks A-F **referencing** the scores by key (e.g., "Seniority fit: 3/5 Senior IC, no formal management"). Do NOT re-list all 10 dimensions in prose. Do NOT repeat the rationales verbatim.
213
- 3. Do NOT narrate the scoring process in thinking before emitting the JSON. Decide, emit, move on.
212
+ 2. Save/check the emitted block when it will drive a PDF, application gate, comparison, or batch dispatch: `npx job-forge score:check --input <score.json>` and, for apply decisions, `npx job-forge score:gate --input <score.json> --gate apply`.
213
+ 3. Write Blocks A-F **referencing** the scores by key (e.g., "Seniority fit: 3/5 Senior IC, no formal management"). Do NOT re-list all 10 dimensions in prose. Do NOT repeat the rationales verbatim.
214
+ 4. Do NOT narrate the scoring process in thinking before emitting the JSON. Decide, emit, move on.
214
215
 
215
216
  **Score interpretation (use consistently everywhere):**
216
217
  - **4.5-5.0** — Strong match. Generate PDF + draft answers. Apply promptly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-forge",
3
- "version": "2.14.30",
3
+ "version": "2.14.32",
4
4
  "description": "AI-powered job search pipeline built on opencode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -55,6 +55,19 @@
55
55
  "index:has": "node bin/job-forge.mjs index:has",
56
56
  "index:verify": "node bin/job-forge.mjs index:verify",
57
57
  "index:explain": "node bin/job-forge.mjs index:explain",
58
+ "facts:build": "node bin/job-forge.mjs facts:build",
59
+ "facts:status": "node bin/job-forge.mjs facts:status",
60
+ "facts:verify": "node bin/job-forge.mjs facts:verify",
61
+ "facts:check": "node bin/job-forge.mjs facts:check",
62
+ "facts:has": "node bin/job-forge.mjs facts:has",
63
+ "facts:query": "node bin/job-forge.mjs facts:query",
64
+ "facts:explain": "node bin/job-forge.mjs facts:explain",
65
+ "score:compute": "node bin/job-forge.mjs score:compute",
66
+ "score:verify": "node bin/job-forge.mjs score:verify",
67
+ "score:check": "node bin/job-forge.mjs score:check",
68
+ "score:gate": "node bin/job-forge.mjs score:gate",
69
+ "score:compare": "node bin/job-forge.mjs score:compare",
70
+ "score:explain": "node bin/job-forge.mjs score:explain",
58
71
  "canon:normalize": "node bin/job-forge.mjs canon:normalize",
59
72
  "canon:key": "node bin/job-forge.mjs canon:key",
60
73
  "canon:compare": "node bin/job-forge.mjs canon:compare",
@@ -144,6 +157,7 @@
144
157
  "@razroo/iso-capabilities": "^0.1.0",
145
158
  "@razroo/iso-context": "^0.1.0",
146
159
  "@razroo/iso-contract": "^0.1.0",
160
+ "@razroo/iso-facts": "^0.1.0",
147
161
  "@razroo/iso-guard": "^0.1.0",
148
162
  "@razroo/iso-index": "^0.1.0",
149
163
  "@razroo/iso-ledger": "^0.1.0",
@@ -152,6 +166,7 @@
152
166
  "@razroo/iso-postflight": "^0.1.0",
153
167
  "@razroo/iso-preflight": "^0.1.0",
154
168
  "@razroo/iso-redact": "^0.1.0",
169
+ "@razroo/iso-score": "^0.1.0",
155
170
  "@razroo/iso-trace": "^0.4.0",
156
171
  "playwright": "^1.58.1"
157
172
  },
@@ -19,6 +19,7 @@ const checks = [
19
19
  ["H5 blocks same-company concurrent retry", () => every(files.instructions, ["Re-dispatch the same company only AFTER", "previous subagent returns"])],
20
20
  ["H6 requires merge and verify", () => every(files.instructions, ["batch/tracker-additions/*.tsv", "npx job-forge merge", "npx job-forge verify"])],
21
21
  ["H7 distrusts subagent prose", () => every(files.instructions, ["must originate from a file", "not from prior subagent prose"])],
22
+ ["score policy points to local helper", () => every(files.instructions, ["[D19]", "templates/score.json", "npx job-forge score:check", "npx job-forge score:gate"])],
22
23
  ["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"])],
23
24
  ["apply mode owns high-stakes upgrade", () => every(files.apply, ["[D8]", "@general-paid", "4.0/5", "high-stakes"])],
24
25
  ["apply mode blocks provider auto-downgrade", () => every(files.apply, ["[D9]", "do not auto-downgrade", "inspect telemetry before retrying"])],