job-forge 2.14.31 → 2.14.33
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.
- package/.claude/agents/general-paid.md +1 -1
- package/.cursor/rules/agent-general-paid.mdc +1 -1
- package/.cursor/rules/main.mdc +10 -4
- package/.opencode/agents/general-paid.md +1 -1
- package/.opencode/skills/job-forge.md +12 -0
- package/AGENTS.md +10 -4
- package/CLAUDE.md +10 -4
- package/README.md +10 -7
- package/bin/job-forge.mjs +67 -0
- package/docs/ARCHITECTURE.md +14 -5
- package/docs/CUSTOMIZATION.md +8 -0
- package/docs/README.md +1 -1
- package/docs/SETUP.md +5 -0
- package/iso/agents/general-paid.md +1 -1
- package/iso/commands/job-forge.md +12 -0
- package/iso/instructions.md +10 -4
- package/lib/jobforge-score.mjs +212 -0
- package/lib/jobforge-timeline.mjs +294 -0
- package/modes/_shared.md +4 -3
- package/modes/followup.md +6 -6
- package/package.json +16 -1
- package/scripts/check-iso-smoke.mjs +2 -0
- package/scripts/score.mjs +209 -0
- package/scripts/timeline.mjs +237 -0
- package/templates/migrations.json +16 -0
- package/templates/score.json +128 -0
- package/templates/timeline.json +86 -0
- package/verify-pipeline.mjs +20 -0
|
@@ -93,6 +93,10 @@ Postflight dispatch settlement (terminal, outside opencode):
|
|
|
93
93
|
npx job-forge postflight:status --plan batch/preflight-plan.json --outcomes batch/postflight-outcomes.json
|
|
94
94
|
npx job-forge postflight:check --plan batch/preflight-plan.json --outcomes batch/postflight-outcomes.json
|
|
95
95
|
|
|
96
|
+
Follow-up timeline (terminal, outside opencode):
|
|
97
|
+
npx job-forge timeline:due
|
|
98
|
+
npx job-forge timeline:check --fail-on overdue
|
|
99
|
+
|
|
96
100
|
Consumer migrations (terminal, outside opencode):
|
|
97
101
|
npx job-forge migrate:plan # preview package.json/.gitignore drift
|
|
98
102
|
npx job-forge migrate:apply # apply safe harness upgrade migrations
|
|
@@ -102,6 +106,11 @@ Artifact contracts (terminal, outside opencode):
|
|
|
102
106
|
npx iso-contract explain jobforge.tracker-row --contracts templates/contracts.json
|
|
103
107
|
npx job-forge tracker-line ... --write # renders + validates tracker TSV locally
|
|
104
108
|
|
|
109
|
+
Score policy (terminal, outside opencode):
|
|
110
|
+
npx job-forge score:check --input /tmp/score.json
|
|
111
|
+
npx job-forge score:gate --input /tmp/score.json --gate apply
|
|
112
|
+
npx job-forge score:explain
|
|
113
|
+
|
|
105
114
|
Role capabilities (terminal, outside opencode):
|
|
106
115
|
npx job-forge capabilities:explain general-free
|
|
107
116
|
npx job-forge capabilities:check general-free --tool browser --mcp geometra --filesystem write
|
|
@@ -210,6 +219,9 @@ Step 4 — Materialize and check the dispatch plan
|
|
|
210
219
|
(or another explicit JSON file). Include source paths for company, role,
|
|
211
220
|
companyRoleKey, URL, score, duplicate/location gates, and any skip/block
|
|
212
221
|
decision.
|
|
222
|
+
- If the candidate came from a fresh evaluation score JSON, run npx job-forge
|
|
223
|
+
score:check --input <score.json> and npx job-forge score:gate --input
|
|
224
|
+
<score.json> --gate apply before using that score as an apply gate.
|
|
213
225
|
- Run npx job-forge preflight:check --candidates <file> to fail on missing
|
|
214
226
|
sources or blocked gates, then npx job-forge preflight:plan --candidates
|
|
215
227
|
<file> --json > batch/preflight-plan.json to get the bounded round list.
|
package/iso/instructions.md
CHANGED
|
@@ -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`, 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 ...`, 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 ...`, 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.
|
|
@@ -90,16 +90,22 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
90
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.
|
|
91
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
|
|
92
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
|
+
|
|
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
|
+
|
|
93
99
|
## Procedure
|
|
94
100
|
|
|
95
101
|
1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
|
|
96
102
|
2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
|
|
97
|
-
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 migration checks for harness drift [D14]. Use redaction checks before exporting local artifacts [D18]. Decide inline vs delegated work [D1].
|
|
103
|
+
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 migration checks for harness drift [D14]. Use redaction checks before exporting local artifacts [D18]. Decide inline vs delegated work [D1].
|
|
98
104
|
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].
|
|
99
105
|
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].
|
|
100
106
|
6. Keep multi-job form-filling out of the orchestrator [H4].
|
|
101
107
|
7. Cross-check subagent facts against authoritative files [H7].
|
|
102
|
-
8. Apply score gate [D4].
|
|
108
|
+
8. Apply score gate [D4, D19].
|
|
103
109
|
9. Merge contract-validated TSV outcomes [H6, D9].
|
|
104
110
|
10. Verify tracker and run postflight check before ending [H6, D17].
|
|
105
111
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname, isAbsolute, join, relative } from 'path';
|
|
3
|
+
import {
|
|
4
|
+
checkTimeline,
|
|
5
|
+
filterTimelineResult,
|
|
6
|
+
loadTimelineConfig,
|
|
7
|
+
parseJson,
|
|
8
|
+
parseJsonLines,
|
|
9
|
+
planTimeline,
|
|
10
|
+
verifyTimelineResult,
|
|
11
|
+
} from '@razroo/iso-timeline';
|
|
12
|
+
import { DATA_APPS_DIR, PROJECT_DIR, readAllEntries } from '../tracker-lib.mjs';
|
|
13
|
+
import { jobForgeCompanyRoleKey, jobForgeUrlKey, legacyCompanyRoleKey, legacyUrlKey } from './jobforge-canon.mjs';
|
|
14
|
+
|
|
15
|
+
export const TIMELINE_CONFIG_FILE = 'templates/timeline.json';
|
|
16
|
+
export const TIMELINE_FILE = '.jobforge-timeline.json';
|
|
17
|
+
export const TIMELINE_EVENTS_FILE = '.jobforge-timeline-events.jsonl';
|
|
18
|
+
export const USER_TIMELINE_EVENTS_FILE = 'data/timeline-events.jsonl';
|
|
19
|
+
|
|
20
|
+
export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
|
|
21
|
+
return projectDir;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function jobForgeTimelineConfigPath(projectDir = resolveProjectDir()) {
|
|
25
|
+
return process.env.JOB_FORGE_TIMELINE_CONFIG || join(projectDir, TIMELINE_CONFIG_FILE);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function jobForgeTimelinePath(projectDir = resolveProjectDir()) {
|
|
29
|
+
return process.env.JOB_FORGE_TIMELINE || join(projectDir, TIMELINE_FILE);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function jobForgeTimelineEventsPath(projectDir = resolveProjectDir()) {
|
|
33
|
+
return process.env.JOB_FORGE_TIMELINE_EVENTS || join(projectDir, TIMELINE_EVENTS_FILE);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readJobForgeTimelineConfig(projectDir = resolveProjectDir()) {
|
|
37
|
+
const path = jobForgeTimelineConfigPath(projectDir);
|
|
38
|
+
return loadTimelineConfig(parseJson(readFileSync(path, 'utf8'), path));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function timelineExists(projectDir = resolveProjectDir()) {
|
|
42
|
+
return existsSync(jobForgeTimelinePath(projectDir));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function timelineEventsExist(projectDir = resolveProjectDir()) {
|
|
46
|
+
return existsSync(jobForgeTimelineEventsPath(projectDir));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readJobForgeTimeline(projectDir = resolveProjectDir()) {
|
|
50
|
+
const path = jobForgeTimelinePath(projectDir);
|
|
51
|
+
return parseJson(readFileSync(path, 'utf8'), path);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildJobForgeTimelineEvents(projectDir = resolveProjectDir()) {
|
|
55
|
+
const events = [
|
|
56
|
+
...applicationEvents(projectDir),
|
|
57
|
+
...pipelineEvents(projectDir),
|
|
58
|
+
...userEvents(projectDir),
|
|
59
|
+
];
|
|
60
|
+
events.sort(compareEvents);
|
|
61
|
+
return events;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function writeJobForgeTimelineEvents(events, options = {}, projectDir = resolveProjectDir()) {
|
|
65
|
+
const out = options.out || jobForgeTimelineEventsPath(projectDir);
|
|
66
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
67
|
+
const content = events.map((event) => JSON.stringify(event)).join('\n');
|
|
68
|
+
writeFileSync(out, `${content}${content ? '\n' : ''}`, 'utf8');
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function planJobForgeTimeline(options = {}, projectDir = resolveProjectDir()) {
|
|
73
|
+
const config = readJobForgeTimelineConfig(projectDir);
|
|
74
|
+
const events = options.events || buildJobForgeTimelineEvents(projectDir);
|
|
75
|
+
return planTimeline(config, events, { now: options.now });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function dueJobForgeTimeline(options = {}, projectDir = resolveProjectDir()) {
|
|
79
|
+
return filterTimelineResult(planJobForgeTimeline(options, projectDir), ['overdue', 'due']);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function checkJobForgeTimeline(options = {}, projectDir = resolveProjectDir()) {
|
|
83
|
+
const config = readJobForgeTimelineConfig(projectDir);
|
|
84
|
+
const events = options.events || buildJobForgeTimelineEvents(projectDir);
|
|
85
|
+
return checkTimeline(config, events, { now: options.now, failOn: options.failOn });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildJobForgeTimeline(options = {}, projectDir = resolveProjectDir()) {
|
|
89
|
+
const events = buildJobForgeTimelineEvents(projectDir);
|
|
90
|
+
const result = planJobForgeTimeline({ now: options.now, events }, projectDir);
|
|
91
|
+
const eventsOut = writeJobForgeTimelineEvents(events, { out: options.eventsOut }, projectDir);
|
|
92
|
+
const out = options.out || jobForgeTimelinePath(projectDir);
|
|
93
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
94
|
+
writeFileSync(out, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
|
95
|
+
return { result, events, out, eventsOut };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function verifyJobForgeTimeline(options = {}, projectDir = resolveProjectDir()) {
|
|
99
|
+
const result = options.result || readJobForgeTimeline(projectDir);
|
|
100
|
+
return verifyTimelineResult(result);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function jobForgeTimelineSummary(projectDir = resolveProjectDir()) {
|
|
104
|
+
if (!timelineExists(projectDir)) {
|
|
105
|
+
return {
|
|
106
|
+
path: jobForgeTimelinePath(projectDir),
|
|
107
|
+
eventsPath: jobForgeTimelineEventsPath(projectDir),
|
|
108
|
+
config: jobForgeTimelineConfigPath(projectDir),
|
|
109
|
+
exists: false,
|
|
110
|
+
eventsExists: timelineEventsExist(projectDir),
|
|
111
|
+
items: 0,
|
|
112
|
+
due: 0,
|
|
113
|
+
overdue: 0,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const result = readJobForgeTimeline(projectDir);
|
|
117
|
+
return {
|
|
118
|
+
path: jobForgeTimelinePath(projectDir),
|
|
119
|
+
eventsPath: jobForgeTimelineEventsPath(projectDir),
|
|
120
|
+
config: jobForgeTimelineConfigPath(projectDir),
|
|
121
|
+
exists: true,
|
|
122
|
+
eventsExists: timelineEventsExist(projectDir),
|
|
123
|
+
items: result.stats?.total || 0,
|
|
124
|
+
due: result.stats?.due || 0,
|
|
125
|
+
overdue: result.stats?.overdue || 0,
|
|
126
|
+
generatedAt: result.generatedAt,
|
|
127
|
+
id: result.id,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function applicationEvents(projectDir) {
|
|
132
|
+
const { entries } = readAllEntries();
|
|
133
|
+
return entries
|
|
134
|
+
.map((entry) => applicationEvent(entry, projectDir))
|
|
135
|
+
.filter(Boolean);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function applicationEvent(entry, projectDir) {
|
|
139
|
+
const at = dateToIso(entry.date);
|
|
140
|
+
if (!at || !entry.company || !entry.role || !entry.status) return null;
|
|
141
|
+
const status = canonicalStatus(entry.status);
|
|
142
|
+
const key = safeCompanyRoleKey(entry.company, entry.role, projectDir);
|
|
143
|
+
return {
|
|
144
|
+
id: `jobforge:application-status:${entry.num}:${key}:${at}`,
|
|
145
|
+
key,
|
|
146
|
+
type: 'application.status',
|
|
147
|
+
at,
|
|
148
|
+
data: compactObject({
|
|
149
|
+
num: entry.num,
|
|
150
|
+
date: entry.date,
|
|
151
|
+
company: entry.company,
|
|
152
|
+
role: entry.role,
|
|
153
|
+
score: entry.score,
|
|
154
|
+
status,
|
|
155
|
+
pdf: entry.pdf,
|
|
156
|
+
report: entry.report,
|
|
157
|
+
notes: entry.notes,
|
|
158
|
+
}),
|
|
159
|
+
source: sourceForApplication(entry, projectDir),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function pipelineEvents(projectDir) {
|
|
164
|
+
const pipelinePath = join(projectDir, 'data', 'pipeline.md');
|
|
165
|
+
if (!existsSync(pipelinePath)) return [];
|
|
166
|
+
const scanDates = scanHistoryDates(projectDir);
|
|
167
|
+
const lines = readFileSync(pipelinePath, 'utf8').split('\n');
|
|
168
|
+
const events = [];
|
|
169
|
+
lines.forEach((line, index) => {
|
|
170
|
+
const match = line.match(/^\s*-\s*\[([ xX])\]\s+(https?:\/\/[^|\s#]+)(.*)$/);
|
|
171
|
+
if (!match) return;
|
|
172
|
+
const url = match[2].trim();
|
|
173
|
+
const at = dateToIso(scanDates.get(url) || firstDateInText(line));
|
|
174
|
+
if (!at) return;
|
|
175
|
+
const fields = (match[3] || '').split('|').map((field) => field.trim()).filter(Boolean);
|
|
176
|
+
const status = match[1].toLowerCase() === 'x' ? 'processed' : 'pending';
|
|
177
|
+
const key = safeUrlKey(url, projectDir);
|
|
178
|
+
events.push({
|
|
179
|
+
id: `jobforge:pipeline:${status}:${key}:${at}`,
|
|
180
|
+
key,
|
|
181
|
+
type: status === 'processed' ? 'pipeline.processed' : 'pipeline.item',
|
|
182
|
+
at,
|
|
183
|
+
data: compactObject({
|
|
184
|
+
status,
|
|
185
|
+
url,
|
|
186
|
+
company: fields[0],
|
|
187
|
+
role: fields[1],
|
|
188
|
+
}),
|
|
189
|
+
source: {
|
|
190
|
+
path: 'data/pipeline.md',
|
|
191
|
+
line: index + 1,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
return events;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function userEvents(projectDir) {
|
|
199
|
+
const path = join(projectDir, USER_TIMELINE_EVENTS_FILE);
|
|
200
|
+
if (!existsSync(path)) return [];
|
|
201
|
+
return parseJsonLines(readFileSync(path, 'utf8'), path);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function scanHistoryDates(projectDir) {
|
|
205
|
+
const path = join(projectDir, 'data', 'scan-history.tsv');
|
|
206
|
+
const dates = new Map();
|
|
207
|
+
if (!existsSync(path)) return dates;
|
|
208
|
+
const lines = readFileSync(path, 'utf8').split('\n');
|
|
209
|
+
for (const line of lines) {
|
|
210
|
+
if (!line.trim()) continue;
|
|
211
|
+
const parts = line.split('\t');
|
|
212
|
+
if (parts.length < 4) continue;
|
|
213
|
+
const date = parts[0]?.trim();
|
|
214
|
+
const url = parts[3]?.trim();
|
|
215
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(date) && /^https?:\/\//.test(url)) dates.set(url, date);
|
|
216
|
+
}
|
|
217
|
+
return dates;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function sourceForApplication(entry, projectDir) {
|
|
221
|
+
const raw = String(entry._sourceFile || '');
|
|
222
|
+
if (/^\d{4}-\d{2}-\d{2}\.md$/.test(raw)) {
|
|
223
|
+
return { path: relativePath(projectDir, join(DATA_APPS_DIR, raw)) };
|
|
224
|
+
}
|
|
225
|
+
if (raw) return { path: relativePath(projectDir, raw) };
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function dateToIso(value) {
|
|
230
|
+
const text = String(value || '').trim();
|
|
231
|
+
if (!text) return null;
|
|
232
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return `${text}T12:00:00.000Z`;
|
|
233
|
+
const parsed = new Date(text);
|
|
234
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function firstDateInText(value) {
|
|
238
|
+
return String(value || '').match(/\b\d{4}-\d{2}-\d{2}\b/)?.[0] || '';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function canonicalStatus(value) {
|
|
242
|
+
const text = String(value || '').trim();
|
|
243
|
+
const lower = text.toLowerCase();
|
|
244
|
+
const map = new Map([
|
|
245
|
+
['evaluated', 'Evaluated'],
|
|
246
|
+
['applied', 'Applied'],
|
|
247
|
+
['responded', 'Responded'],
|
|
248
|
+
['contacted', 'Contacted'],
|
|
249
|
+
['interview', 'Interview'],
|
|
250
|
+
['offer', 'Offer'],
|
|
251
|
+
['rejected', 'Rejected'],
|
|
252
|
+
['discarded', 'Discarded'],
|
|
253
|
+
['failed', 'Failed'],
|
|
254
|
+
['skip', 'SKIP'],
|
|
255
|
+
]);
|
|
256
|
+
return map.get(lower) || text;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function safeCompanyRoleKey(company, role, projectDir) {
|
|
260
|
+
try {
|
|
261
|
+
return jobForgeCompanyRoleKey(company, role, projectDir);
|
|
262
|
+
} catch {
|
|
263
|
+
return legacyCompanyRoleKey(company, role);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function safeUrlKey(url, projectDir) {
|
|
268
|
+
try {
|
|
269
|
+
return jobForgeUrlKey(url, projectDir);
|
|
270
|
+
} catch {
|
|
271
|
+
return legacyUrlKey(url);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function compactObject(obj) {
|
|
276
|
+
const out = {};
|
|
277
|
+
for (const [key, value] of Object.entries(obj || {})) {
|
|
278
|
+
if (value === undefined || value === null || value === '') continue;
|
|
279
|
+
out[key] = value;
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function compareEvents(a, b) {
|
|
285
|
+
return `${a.at}\0${a.key}\0${a.type}\0${a.id || ''}`.localeCompare(`${b.at}\0${b.key}\0${b.type}\0${b.id || ''}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function relativePath(projectDir, value) {
|
|
289
|
+
const text = String(value || '');
|
|
290
|
+
if (!text) return '';
|
|
291
|
+
const rel = relative(projectDir, text);
|
|
292
|
+
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return rel.replace(/\\/g, '/');
|
|
293
|
+
return text.replace(/\\/g, '/');
|
|
294
|
+
}
|
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.
|
|
213
|
-
3.
|
|
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/modes/followup.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Mode: followup — Follow-Up Timing & Nudge System
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Uses `job-forge timeline:*` to scan local tracker and dated pipeline sources for entries that need follow-up action based on their current state and how long they've been in that state.
|
|
4
4
|
|
|
5
5
|
**This mode is read-only on existing pipeline logic.** It reads the tracker and suggests actions — it never changes scores, reports, or pipeline behavior.
|
|
6
6
|
|
|
@@ -19,11 +19,11 @@ Scans all day files in `data/applications/` for entries that need follow-up acti
|
|
|
19
19
|
|
|
20
20
|
## Run This Workflow
|
|
21
21
|
|
|
22
|
-
1.
|
|
23
|
-
2.
|
|
24
|
-
3.
|
|
25
|
-
4.
|
|
26
|
-
5.
|
|
22
|
+
1. Run `npx job-forge timeline:due` first. It rebuilds the due queue from local tracker/pipeline sources without loading growing files into prompt context.
|
|
23
|
+
2. If the user wants a persistent artifact, run `npx job-forge timeline:build`.
|
|
24
|
+
3. Use `npx job-forge timeline:check --fail-on overdue` when the workflow should fail only on stale actions.
|
|
25
|
+
4. Present the action list grouped by `OVERDUE`, `DUE`, and upcoming manual context if needed.
|
|
26
|
+
5. Only read individual tracker/report files after the user selects an action that needs message drafting.
|
|
27
27
|
|
|
28
28
|
```
|
|
29
29
|
## Follow-Up Actions — {today's date}
|