job-forge 2.14.16 → 2.14.18

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.
@@ -62,6 +62,9 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
62
62
  - [D8] Use `job-forge ledger:*` for cheap local workflow-state checks when available. `iso-ledger` is not an MCP and adds no prompt/tool schema tokens; it records tracker TSV writes, merge outcomes, rebuilt tracker snapshots, and pipeline items in `.jobforge-ledger/events.jsonl`.
63
63
  why: state-trace remains working memory, while iso-ledger is deterministic append-only workflow truth that can answer duplicate/status questions without loading growing markdown/TSV files into the model context
64
64
 
65
+ - [D9] Treat `templates/contracts.json` as the source of truth for machine-readable artifacts. Prefer `npx job-forge tracker-line ... --write` for tracker additions; if emitting TSV manually, inspect `npx iso-contract explain jobforge.tracker-row --contracts templates/contracts.json` first. `merge` and `verify` enforce the tracker-row contract locally.
66
+ why: deterministic code owns the exact tracker TSV/table shape; repeated prose gets re-tokenized and agents occasionally misremember it
67
+
65
68
  ## Procedure
66
69
 
67
70
  1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
@@ -72,7 +75,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
72
75
  6. Keep multi-job form-filling out of the orchestrator [H4].
73
76
  7. Cross-check subagent facts against authoritative files [H7].
74
77
  8. Apply score gate [D4].
75
- 9. Merge TSV outcomes [H6].
78
+ 9. Merge contract-validated TSV outcomes [H6, D9].
76
79
  10. Verify tracker before ending [H6].
77
80
 
78
81
  ## Routing
@@ -72,6 +72,10 @@ Token usage check (terminal, outside opencode):
72
72
  Local workflow ledger (terminal, outside opencode):
73
73
  npx job-forge ledger:status # .jobforge-ledger/events.jsonl summary
74
74
  npx job-forge ledger:has --company "Acme" --role "Staff Engineer" --status Applied
75
+
76
+ Artifact contracts (terminal, outside opencode):
77
+ npx iso-contract explain jobforge.tracker-row --contracts templates/contracts.json
78
+ npx job-forge tracker-line ... --write # renders + validates tracker TSV locally
75
79
  ```
76
80
 
77
81
  ---
package/AGENTS.md CHANGED
@@ -57,6 +57,9 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
57
57
  - [D8] Use `job-forge ledger:*` for cheap local workflow-state checks when available. `iso-ledger` is not an MCP and adds no prompt/tool schema tokens; it records tracker TSV writes, merge outcomes, rebuilt tracker snapshots, and pipeline items in `.jobforge-ledger/events.jsonl`.
58
58
  why: state-trace remains working memory, while iso-ledger is deterministic append-only workflow truth that can answer duplicate/status questions without loading growing markdown/TSV files into the model context
59
59
 
60
+ - [D9] Treat `templates/contracts.json` as the source of truth for machine-readable artifacts. Prefer `npx job-forge tracker-line ... --write` for tracker additions; if emitting TSV manually, inspect `npx iso-contract explain jobforge.tracker-row --contracts templates/contracts.json` first. `merge` and `verify` enforce the tracker-row contract locally.
61
+ why: deterministic code owns the exact tracker TSV/table shape; repeated prose gets re-tokenized and agents occasionally misremember it
62
+
60
63
  ## Procedure
61
64
 
62
65
  1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
@@ -67,7 +70,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
67
70
  6. Keep multi-job form-filling out of the orchestrator [H4].
68
71
  7. Cross-check subagent facts against authoritative files [H7].
69
72
  8. Apply score gate [D4].
70
- 9. Merge TSV outcomes [H6].
73
+ 9. Merge contract-validated TSV outcomes [H6, D9].
71
74
  10. Verify tracker before ending [H6].
72
75
 
73
76
  ## Routing
package/CLAUDE.md CHANGED
@@ -57,6 +57,9 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
57
57
  - [D8] Use `job-forge ledger:*` for cheap local workflow-state checks when available. `iso-ledger` is not an MCP and adds no prompt/tool schema tokens; it records tracker TSV writes, merge outcomes, rebuilt tracker snapshots, and pipeline items in `.jobforge-ledger/events.jsonl`.
58
58
  why: state-trace remains working memory, while iso-ledger is deterministic append-only workflow truth that can answer duplicate/status questions without loading growing markdown/TSV files into the model context
59
59
 
60
+ - [D9] Treat `templates/contracts.json` as the source of truth for machine-readable artifacts. Prefer `npx job-forge tracker-line ... --write` for tracker additions; if emitting TSV manually, inspect `npx iso-contract explain jobforge.tracker-row --contracts templates/contracts.json` first. `merge` and `verify` enforce the tracker-row contract locally.
61
+ why: deterministic code owns the exact tracker TSV/table shape; repeated prose gets re-tokenized and agents occasionally misremember it
62
+
60
63
  ## Procedure
61
64
 
62
65
  1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
@@ -67,7 +70,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
67
70
  6. Keep multi-job form-filling out of the orchestrator [H4].
68
71
  7. Cross-check subagent facts against authoritative files [H7].
69
72
  8. Apply score gate [D4].
70
- 9. Merge TSV outcomes [H6].
73
+ 9. Merge contract-validated TSV outcomes [H6, D9].
71
74
  10. Verify tracker before ending [H6].
72
75
 
73
76
  ## Routing
package/README.md CHANGED
@@ -31,7 +31,7 @@ The scaffolded `opencode.json` already has three MCPs wired up — they launch a
31
31
  - **Gmail** — reads replies from recruiters
32
32
  - **state-trace** — typed working memory for cross-session context (resumed batches, recent decisions, repeated portal quirks). Install once with `python3 -m pip install "state-trace[mcp]"`; the MCP command is `state-trace-mcp`.
33
33
 
34
- JobForge also keeps an MCP-free local workflow ledger at `.jobforge-ledger/events.jsonl` when you use `job-forge ledger:*`, `tracker-line --write`, or `merge`. This is deterministic state for duplicate/status checks; it does not add prompt or tool-schema tokens.
34
+ JobForge also keeps MCP-free local workflow state: `templates/contracts.json` defines tracker/apply artifact shapes via `@razroo/iso-contract`, and `.jobforge-ledger/events.jsonl` records deterministic duplicate/status events via `@razroo/iso-ledger`. Neither adds prompt or tool-schema tokens.
35
35
 
36
36
  `npm install` also materializes symlinks for every supported agent harness — OpenCode, Cursor, Claude Code, and Codex — so you can run `opencode`, `cursor`, `claude`, or `codex` in the same project and each picks up the shared MCP config and instructions.
37
37
 
@@ -78,7 +78,7 @@ JobForge turns opencode into a full job search command center. Instead of manual
78
78
  | **Durable Batch Orchestration** | `batch-runner.sh` uses `@razroo/iso-orchestrator` for resumable bundle execution, bounded fan-out, mutexed state writes, and workflow records in `.jobforge-runs/`. |
79
79
  | **Pipeline Integrity** | Automated merge, dedup, status normalization, health checks |
80
80
  | **Cost-Aware Agent Routing** | Three subagents (`@general-free`, `@general-paid`, `@glm-minimal`) with per-task tool surfaces. On OpenCode, JobForge pins all tiers to `opencode-go/deepseek-v4-flash` so application runs avoid overloaded free-model pools. See [Subagent Routing in AGENTS.md](AGENTS.md) for the task-to-agent mapping. |
81
- | **Trace + Telemetry + Guard + Ledger** | `job-forge trace:*` exposes local OpenCode transcripts, `job-forge telemetry:*` summarizes runs, `job-forge guard:*` audits deterministic policy rules, and `job-forge ledger:*` queries append-only workflow state without MCP/token overhead. |
81
+ | **Trace + Telemetry + Guard + Contract + Ledger** | `job-forge trace:*` exposes local OpenCode transcripts, `job-forge telemetry:*` summarizes runs, `job-forge guard:*` audits deterministic policy rules, `templates/contracts.json` enforces artifact shape with `iso-contract`, and `job-forge ledger:*` queries append-only workflow state without MCP/token overhead. |
82
82
  | **Token Cost Visibility** | `job-forge tokens --days 1` for per-session breakdown; `job-forge session-report --since-minutes 60 --log` to flag sessions over budget and append history to `data/token-usage.tsv`. Auto-logged after every batch run. |
83
83
 
84
84
  ## Usage
@@ -193,6 +193,7 @@ JobForge/
193
193
  ├── batch/{batch-prompt.md,batch-runner.sh} # batch orchestrator
194
194
  ├── scripts/
195
195
  │ ├── batch-orchestrator.mjs # iso-orchestrator-backed batch control loop
196
+ │ ├── tracker-line.mjs # iso-contract-backed tracker TSV renderer
196
197
  │ ├── ledger.mjs # iso-ledger-backed workflow-state CLI
197
198
  │ ├── token-usage-report.mjs # opencode cost analyzer
198
199
  │ └── release/check-source.mjs # version gate for npm publish
package/bin/job-forge.mjs CHANGED
@@ -120,7 +120,7 @@ Deterministic helpers (prefer these over LLM-derived values):
120
120
  next-num Print next sequential report number (e.g. 521)
121
121
  slugify NAME Convert a company/role name to a filename-safe slug
122
122
  today Print today's date in YYYY-MM-DD
123
- tracker-line Emit a 9-col TSV row for batch/tracker-additions/
123
+ tracker-line Render and validate a tracker TSV row for batch/tracker-additions/
124
124
 
125
125
  Cost visibility:
126
126
  session-report Summarize recent session costs, warn on >budget sessions
@@ -173,12 +173,12 @@ Create `data/pipeline.md` when you start using the URL inbox (`/job-forge pipeli
173
173
 
174
174
  - Reports: `{###}-{company-slug}-{YYYY-MM-DD}.md` (3-digit zero-padded)
175
175
  - PDFs: `cv-candidate-{company-slug}-{YYYY-MM-DD}.pdf`
176
- - Tracker TSVs: `batch/tracker-additions/{num}-{company-slug}.tsv` (one file per evaluation; merged files move under `batch/tracker-additions/merged/`)
176
+ - Tracker TSVs: `batch/tracker-additions/{num}-{company-slug}.tsv` (one file per evaluation; merged files move under `batch/tracker-additions/merged/`; shape enforced by `templates/contracts.json`)
177
177
  - Ledger: `.jobforge-ledger/events.jsonl` (created by `job-forge ledger:rebuild`, `tracker-line --write`, or `merge`; gitignored personal state)
178
178
 
179
179
  ## Pipeline Integrity
180
180
 
181
- From the project root, `npx job-forge verify` (or `npm run verify`) runs `verify-pipeline.mjs`. When a tracker file exists, it validates canonical statuses (using `templates/states.yml` when that file is present and parseable), warns on probable duplicate company/role rows, checks that report column markdown links resolve to files in the repo, validates score column format (`X.X/5`, `N/A`, or `DUP`), rejects table rows with too few columns, flags markdown bold inside the score column, and warns if any `batch/tracker-additions/*.tsv` files are still waiting to be merged. If `.jobforge-ledger/events.jsonl` exists, verify also validates the append-only ledger. It also compares state ids from `templates/states.yml` to an internal fallback list and warns when the two sets drift. **Fresh clone:** the command exits successfully when neither `data/applications.md` nor root `applications.md` exists yet; pending-TSV and states-drift checks still run so contributors see unmerged batch output early. Optional setup validation after you add `cv.md` and `config/profile.yml`: `npm run sync-check` (`cv-sync-check.mjs`).
181
+ From the project root, `npx job-forge verify` (or `npm run verify`) runs `verify-pipeline.mjs`. When a tracker file exists, it validates canonical statuses (using `templates/states.yml` when that file is present and parseable), validates every tracker row against `templates/contracts.json`, warns on probable duplicate company/role rows, checks that report column markdown links resolve to files in the repo, validates score column format (`X.X/5`, `N/A`, or `DUP`), rejects table rows with too few columns, flags markdown bold inside the score column, and warns if any `batch/tracker-additions/*.tsv` files are still waiting to be merged. If `.jobforge-ledger/events.jsonl` exists, verify also validates the append-only ledger. It also compares state ids from `templates/states.yml` to an internal fallback list and warns when the two sets drift. **Fresh clone:** the command exits successfully when neither `data/applications.md` nor root `applications.md` exists yet; pending-TSV and states-drift checks still run so contributors see unmerged batch output early. Optional setup validation after you add `cv.md` and `config/profile.yml`: `npm run sync-check` (`cv-sync-check.mjs`).
182
182
 
183
183
  **`verify-pipeline.mjs` checks (same order as the script header):**
184
184
 
@@ -187,12 +187,13 @@ From the project root, `npx job-forge verify` (or `npm run verify`) runs `verify
187
187
  3. Report column markdown links resolve to files under the repo root.
188
188
  4. Score column matches `X.X/5`, `N/A`, or `DUP`.
189
189
  5. Table data rows have enough pipe-delimited columns.
190
- 6. No unmerged `batch/tracker-additions/*.tsv` files (warns if any remain).
191
- 7. Score column has no markdown bold.
192
- 8. Warn when state ids in `templates/states.yml` drift from the script’s built-in fallback list (or when the file exists but ids failed to parse).
193
- 9. Validate `.jobforge-ledger/events.jsonl` when present.
190
+ 6. Tracker rows satisfy the `jobforge.tracker-row` contract in `templates/contracts.json`.
191
+ 7. No unmerged `batch/tracker-additions/*.tsv` files (warns if any remain).
192
+ 8. Score column has no markdown bold.
193
+ 9. Warn when state ids in `templates/states.yml` drift from the script’s built-in fallback list (or when the file exists but ids failed to parse).
194
+ 10. Validate `.jobforge-ledger/events.jsonl` when present.
194
195
 
195
- When the tracker file is missing, checks 1–5 and 7 are skipped; checks 6 and 8 still run.
196
+ When the tracker file is missing, checks 1-6 and 8 are skipped; checks 7, 9, and 10 still run when applicable.
196
197
 
197
198
  ## Contributing touchpoints
198
199
 
@@ -138,6 +138,10 @@ npx job-forge ledger:verify
138
138
 
139
139
  `tracker-line --write` records tracker-addition events, `merge` records add/update/skip outcomes, and `ledger:rebuild` backfills events from `data/applications/`, `batch/tracker-additions/`, `batch/tracker-additions/merged/`, and `data/pipeline.md`.
140
140
 
141
+ ## JobForge artifact contracts
142
+
143
+ Machine-readable artifact shapes live in `templates/contracts.json` and are enforced by `@razroo/iso-contract`. `job-forge tracker-line` renders tracker additions through the `jobforge.tracker-row` contract, `merge` validates pending TSV/table rows before writing tracker files, and `verify` validates existing tracker rows against the same contract. Custom forks can extend `templates/contracts.json`, but keep the tracker status enum aligned with `templates/states.yml`.
144
+
141
145
  ## JobForge guard audits
142
146
 
143
147
  Guard audits run deterministic `@razroo/iso-guard` policies over the same local OpenCode traces. The default policy lives at `templates/guards/jobforge-baseline.yaml` and checks rules that are reliable from transcript data, including max two task dispatches per assistant message, no task-status polling via `task`, no raw proxy configuration in task prompts, and no child session task recursion.
package/docs/SETUP.md CHANGED
@@ -125,6 +125,7 @@ From your project root, these commands maintain the tracker and pipeline checks.
125
125
  |--------|---------|-----------|
126
126
  | Pipeline health check | `npx job-forge verify` | `npm run verify` |
127
127
  | Merge `batch/tracker-additions/*.tsv` into the tracker | `npx job-forge merge` | `npm run merge` |
128
+ | Inspect tracker row contract | `npx iso-contract explain jobforge.tracker-row --contracts templates/contracts.json` | _(none)_ |
128
129
  | Map status column to canonical labels | `npx job-forge normalize` | `npm run normalize` |
129
130
  | Merge duplicate company/role rows | `npx job-forge dedup` | `npm run dedup` |
130
131
  | Generate ATS-optimized CV PDF | `npx job-forge pdf` | `npm run pdf` |
@@ -75,6 +75,10 @@ Token usage check (terminal, outside opencode):
75
75
  Local workflow ledger (terminal, outside opencode):
76
76
  npx job-forge ledger:status # .jobforge-ledger/events.jsonl summary
77
77
  npx job-forge ledger:has --company "Acme" --role "Staff Engineer" --status Applied
78
+
79
+ Artifact contracts (terminal, outside opencode):
80
+ npx iso-contract explain jobforge.tracker-row --contracts templates/contracts.json
81
+ npx job-forge tracker-line ... --write # renders + validates tracker TSV locally
78
82
  ```
79
83
 
80
84
  ---
@@ -57,6 +57,9 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
57
57
  - [D8] Use `job-forge ledger:*` for cheap local workflow-state checks when available. `iso-ledger` is not an MCP and adds no prompt/tool schema tokens; it records tracker TSV writes, merge outcomes, rebuilt tracker snapshots, and pipeline items in `.jobforge-ledger/events.jsonl`.
58
58
  why: state-trace remains working memory, while iso-ledger is deterministic append-only workflow truth that can answer duplicate/status questions without loading growing markdown/TSV files into the model context
59
59
 
60
+ - [D9] Treat `templates/contracts.json` as the source of truth for machine-readable artifacts. Prefer `npx job-forge tracker-line ... --write` for tracker additions; if emitting TSV manually, inspect `npx iso-contract explain jobforge.tracker-row --contracts templates/contracts.json` first. `merge` and `verify` enforce the tracker-row contract locally.
61
+ why: deterministic code owns the exact tracker TSV/table shape; repeated prose gets re-tokenized and agents occasionally misremember it
62
+
60
63
  ## Procedure
61
64
 
62
65
  1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
@@ -67,7 +70,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
67
70
  6. Keep multi-job form-filling out of the orchestrator [H4].
68
71
  7. Cross-check subagent facts against authoritative files [H7].
69
72
  8. Apply score gate [D4].
70
- 9. Merge TSV outcomes [H6].
73
+ 9. Merge contract-validated TSV outcomes [H6, D9].
71
74
  10. Verify tracker before ending [H6].
72
75
 
73
76
  ## Routing
@@ -0,0 +1,109 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { dirname, join, resolve } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import {
5
+ contractNames,
6
+ explainContract,
7
+ formatIssue,
8
+ getContract,
9
+ loadContractCatalog,
10
+ parseRecord,
11
+ renderRecord,
12
+ validateRecord,
13
+ } from '@razroo/iso-contract';
14
+ import { DEFAULT_STATES, loadCanonicalStates } from './canonical-states.mjs';
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const PKG_ROOT = resolve(__dirname, '..');
18
+ export const CONTRACTS_RELATIVE_PATH = 'templates/contracts.json';
19
+
20
+ export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
21
+ return projectDir;
22
+ }
23
+
24
+ export function jobForgeContractsPath(projectDir = resolveProjectDir()) {
25
+ const projectPath = join(projectDir, CONTRACTS_RELATIVE_PATH);
26
+ if (existsSync(projectPath)) return projectPath;
27
+ return join(PKG_ROOT, CONTRACTS_RELATIVE_PATH);
28
+ }
29
+
30
+ export function loadJobForgeContractCatalog(projectDir = resolveProjectDir()) {
31
+ const path = jobForgeContractsPath(projectDir);
32
+ const input = JSON.parse(readFileSync(path, 'utf-8'));
33
+ applyCanonicalStatusValues(input, projectDir);
34
+ return loadContractCatalog(input);
35
+ }
36
+
37
+ export function listJobForgeContracts(projectDir = resolveProjectDir()) {
38
+ return contractNames(loadJobForgeContractCatalog(projectDir));
39
+ }
40
+
41
+ export function getJobForgeContract(name, projectDir = resolveProjectDir()) {
42
+ return getContract(loadJobForgeContractCatalog(projectDir), name);
43
+ }
44
+
45
+ export function explainJobForgeContract(name, projectDir = resolveProjectDir()) {
46
+ return explainContract(getJobForgeContract(name, projectDir));
47
+ }
48
+
49
+ export function getTrackerRowContract(projectDir = resolveProjectDir()) {
50
+ return getJobForgeContract('jobforge.tracker-row', projectDir);
51
+ }
52
+
53
+ export function validateTrackerRow(record, options = {}) {
54
+ const projectDir = resolveProjectDir(options.projectDir);
55
+ const normalized = normalizeTrackerRowRecord(record, options);
56
+ const contract = options.allowMissingReport
57
+ ? trackerRowContractWithOptionalReport(getTrackerRowContract(projectDir))
58
+ : getTrackerRowContract(projectDir);
59
+ return validateRecord(contract, normalized);
60
+ }
61
+
62
+ export function parseTrackerRow(text, formatName = 'tsv', options = {}) {
63
+ const projectDir = resolveProjectDir(options.projectDir);
64
+ const contract = getTrackerRowContract(projectDir);
65
+ const parsed = parseRecord(contract, text, formatName);
66
+ const normalized = normalizeTrackerRowRecord(parsed.record, options);
67
+ const validation = validateRecord(contract, normalized);
68
+ return { record: validation.record, validation, format: formatName };
69
+ }
70
+
71
+ export function renderTrackerRow(record, formatName = 'tsv', options = {}) {
72
+ const projectDir = resolveProjectDir(options.projectDir);
73
+ const normalized = normalizeTrackerRowRecord(record, options);
74
+ return renderRecord(getTrackerRowContract(projectDir), normalized, formatName);
75
+ }
76
+
77
+ export function formatContractIssues(result) {
78
+ return result.issues.map(formatIssue).join('; ');
79
+ }
80
+
81
+ export function canonicalStatusValues(projectDir = resolveProjectDir()) {
82
+ return loadCanonicalStates(projectDir) || loadCanonicalStates(PKG_ROOT) || DEFAULT_STATES;
83
+ }
84
+
85
+ function normalizeTrackerRowRecord(record, options = {}) {
86
+ const out = { ...record };
87
+ if (out.status !== undefined && options.normalizeStatus) {
88
+ out.status = options.normalizeStatus(String(out.status));
89
+ }
90
+ return out;
91
+ }
92
+
93
+ function trackerRowContractWithOptionalReport(contract) {
94
+ return {
95
+ ...contract,
96
+ fields: contract.fields.map((field) => (
97
+ field.name === 'report' ? { ...field, required: false } : field
98
+ )),
99
+ };
100
+ }
101
+
102
+ function applyCanonicalStatusValues(input, projectDir) {
103
+ const statuses = canonicalStatusValues(projectDir);
104
+ for (const contract of input.contracts || []) {
105
+ if (contract.name !== 'jobforge.tracker-row') continue;
106
+ const field = (contract.fields || []).find((item) => item.name === 'status');
107
+ if (field) field.values = statuses;
108
+ }
109
+ }
package/merge-tracker.mjs CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  DEFAULT_STATES, loadCanonicalStates, buildStatusDetectionRegex,
31
31
  } from './lib/canonical-states.mjs';
32
32
  import { recordTrackerMergeResult } from './lib/jobforge-ledger.mjs';
33
+ import { formatContractIssues, parseTrackerRow } from './lib/jobforge-contracts.mjs';
33
34
 
34
35
  const ADDITIONS_DIR = join(PROJECT_DIR, 'batch/tracker-additions');
35
36
  const MERGED_DIR = join(ADDITIONS_DIR, 'merged');
@@ -156,64 +157,22 @@ function parseTsvContent(content, filename) {
156
157
  content = content.trim();
157
158
  if (!content) return null;
158
159
 
159
- let parts;
160
- let addition;
160
+ const format = detectTrackerRowFormat(content);
161
+ const parsed = parseTrackerRow(content, format, {
162
+ projectDir: PROJECT_DIR,
163
+ normalizeStatus: validateStatus,
164
+ });
161
165
 
162
- if (content.startsWith('|')) {
163
- parts = content.split('|').map(s => s.trim()).filter(Boolean);
164
- if (parts.length < 8) {
165
- console.warn(`⚠️ Skipping malformed pipe-delimited ${filename}: ${parts.length} fields`);
166
- return null;
167
- }
168
- addition = {
169
- num: parseInt(parts[0]),
170
- date: parts[1],
171
- company: parts[2],
172
- role: parts[3],
173
- score: parts[4],
174
- status: validateStatus(parts[5]),
175
- pdf: parts[6],
176
- report: parts[7],
177
- notes: parts[8] || '',
178
- };
179
- } else {
180
- parts = content.split('\t');
181
- if (parts.length < 8) {
182
- console.warn(`⚠️ Skipping malformed TSV ${filename}: ${parts.length} fields`);
183
- return null;
184
- }
185
-
186
- const col4 = parts[4].trim();
187
- const col5 = parts[5].trim();
188
- const col4LooksLikeScore = /^\d+\.?\d*\/5$/.test(col4) || col4 === 'N/A' || col4 === 'DUP';
189
- const col5LooksLikeScore = /^\d+\.?\d*\/5$/.test(col5) || col5 === 'N/A' || col5 === 'DUP';
190
- const col4LooksLikeStatus = STATUS_DETECT_RE.test(col4);
191
- const col5LooksLikeStatus = STATUS_DETECT_RE.test(col5);
192
-
193
- let statusCol, scoreCol;
194
- if (col4LooksLikeStatus && !col4LooksLikeScore) {
195
- statusCol = col4; scoreCol = col5;
196
- } else if (col4LooksLikeScore && col5LooksLikeStatus) {
197
- statusCol = col5; scoreCol = col4;
198
- } else if (col5LooksLikeScore && !col4LooksLikeScore) {
199
- statusCol = col4; scoreCol = col5;
200
- } else {
201
- statusCol = col4; scoreCol = col5;
202
- }
203
-
204
- addition = {
205
- num: parseInt(parts[0]),
206
- date: parts[1],
207
- company: parts[2],
208
- role: parts[3],
209
- status: validateStatus(statusCol),
210
- score: scoreCol,
211
- pdf: parts[6],
212
- report: parts[7],
213
- notes: parts[8] || '',
214
- };
166
+ if (!parsed.validation.ok) {
167
+ console.warn(`⚠️ Skipping ${filename}: tracker contract failed (${format}) ${formatContractIssues(parsed.validation)}`);
168
+ return null;
215
169
  }
216
170
 
171
+ const addition = {
172
+ ...parsed.validation.record,
173
+ num: Number(parsed.validation.record.num),
174
+ };
175
+
217
176
  if (isNaN(addition.num) || addition.num === 0) {
218
177
  console.warn(`⚠️ Skipping ${filename}: invalid entry number`);
219
178
  return null;
@@ -222,6 +181,18 @@ function parseTsvContent(content, filename) {
222
181
  return addition;
223
182
  }
224
183
 
184
+ function detectTrackerRowFormat(content) {
185
+ if (content.startsWith('|')) return 'markdown';
186
+
187
+ const parts = content.split('\t');
188
+ const col4 = (parts[4] || '').trim();
189
+ const col5 = (parts[5] || '').trim();
190
+ const col4LooksLikeScore = /^\d+\.?\d*\/5$/.test(col4) || col4 === 'N/A' || col4 === 'DUP';
191
+ const col5LooksLikeStatus = STATUS_DETECT_RE.test(col5);
192
+
193
+ return col4LooksLikeScore && col5LooksLikeStatus ? 'day-tsv' : 'tsv';
194
+ }
195
+
225
196
  // ---- Main ----
226
197
 
227
198
  if (!existsSync(ADDITIONS_DIR)) {
@@ -112,6 +112,18 @@ Mode routing is specified in the top-level **## Routing** section. Each mode is
112
112
 
113
113
  ## TSV Format for Tracker Additions
114
114
 
115
+ Prefer the deterministic helper:
116
+
117
+ ```bash
118
+ npx job-forge tracker-line --num 521 --date 2026-04-15 --company "Anthropic" --role "Manager, FDE" --status Evaluated --score 4.2 --pdf no --slug anthropic-manager-fde --notes "Strong fit" --write
119
+ ```
120
+
121
+ The helper renders and validates the row against `templates/contracts.json` via `@razroo/iso-contract`. To inspect the contract directly:
122
+
123
+ ```bash
124
+ npx iso-contract explain jobforge.tracker-row --contracts templates/contracts.json
125
+ ```
126
+
115
127
  Write one TSV file per evaluation to `batch/tracker-additions/{num}-{company-slug}.tsv`. Single line, 9 tab-separated columns:
116
128
 
117
129
  ```
@@ -129,7 +141,7 @@ Write one TSV file per evaluation to `batch/tracker-additions/{num}-{company-slu
129
141
  8. `report` -- markdown link `[num](reports/...)`
130
142
  9. `notes` -- one-line summary
131
143
 
132
- **Note:** In applications.md, score comes BEFORE status. The merge script handles this column swap automatically.
144
+ **Note:** In applications.md, score comes BEFORE status. The merge script handles this column swap automatically and validates both shapes with the tracker-row contract.
133
145
 
134
146
  - Scripts in `.mjs`, configuration in YAML
135
147
  - Output in `output/` (gitignored), Reports in `reports/`
@@ -146,9 +158,10 @@ Write one TSV file per evaluation to `batch/tracker-additions/{num}-{company-slu
146
158
  2. **YES you can edit day files in `data/applications/` to UPDATE status/notes of existing entries.**
147
159
  3. All reports MUST include `**URL:**` in the header (between Score and PDF).
148
160
  4. All statuses MUST be canonical (see `templates/states.yml`).
149
- 5. Health check: `npx job-forge verify`
150
- 6. Normalize statuses: `npx job-forge normalize`
151
- 7. Dedup: `npx job-forge dedup`
161
+ 5. Tracker rows MUST satisfy `templates/contracts.json`.
162
+ 6. Health check: `npx job-forge verify`
163
+ 7. Normalize statuses: `npx job-forge normalize`
164
+ 8. Dedup: `npx job-forge dedup`
152
165
 
153
166
  ### Canonical States (applications day files)
154
167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-forge",
3
- "version": "2.14.16",
3
+ "version": "2.14.18",
4
4
  "description": "AI-powered job search pipeline built on opencode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -96,6 +96,7 @@
96
96
  "node": ">=20.6.0"
97
97
  },
98
98
  "dependencies": {
99
+ "@razroo/iso-contract": "^0.1.0",
99
100
  "@razroo/iso-guard": "^0.1.0",
100
101
  "@razroo/iso-ledger": "^0.1.0",
101
102
  "@razroo/iso-orchestrator": "^0.1.0",
@@ -25,6 +25,7 @@
25
25
  import { writeFileSync, mkdirSync, existsSync } from 'fs';
26
26
  import { join } from 'path';
27
27
  import { recordTrackerAdditionWritten } from '../lib/jobforge-ledger.mjs';
28
+ import { formatContractIssues, renderTrackerRow } from '../lib/jobforge-contracts.mjs';
28
29
 
29
30
  const PROJECT_DIR = process.env.JOB_FORGE_PROJECT || process.cwd();
30
31
 
@@ -54,8 +55,15 @@ const write = process.argv.includes('--write');
54
55
  const paddedNum = String(num).padStart(3, '0');
55
56
  const reportLink = `[${num}](reports/${paddedNum}-${slug}-${date}.md)`;
56
57
  const scoreField = score.includes('/') ? score : `${score}/5`;
58
+ const record = { num, date, company, role, status, score: scoreField, pdf, report: reportLink, notes };
59
+ const rendered = renderTrackerRow(record, 'tsv', { projectDir: PROJECT_DIR });
57
60
 
58
- const line = [num, date, company, role, status, scoreField, pdf, reportLink, notes].join('\t');
61
+ if (!rendered.validation.ok) {
62
+ console.error(`tracker-line contract failed: ${formatContractIssues(rendered.validation)}`);
63
+ process.exit(1);
64
+ }
65
+
66
+ const line = rendered.text;
59
67
 
60
68
  if (write) {
61
69
  const dir = join(PROJECT_DIR, 'batch/tracker-additions');
@@ -63,9 +71,7 @@ if (write) {
63
71
  const path = join(dir, `${num}.tsv`);
64
72
  writeFileSync(path, line + '\n', 'utf-8');
65
73
  try {
66
- recordTrackerAdditionWritten({
67
- num, date, company, role, status, score: scoreField, pdf, report: reportLink, notes,
68
- }, { projectDir: PROJECT_DIR, sourceFile: path });
74
+ recordTrackerAdditionWritten(rendered.validation.record, { projectDir: PROJECT_DIR, sourceFile: path });
69
75
  } catch (error) {
70
76
  console.warn(`warning: could not append tracker-line ledger event: ${error instanceof Error ? error.message : String(error)}`);
71
77
  }
@@ -0,0 +1,54 @@
1
+ {
2
+ "contracts": [
3
+ {
4
+ "name": "jobforge.tracker-row",
5
+ "version": "1.0.0",
6
+ "description": "Canonical JobForge tracker row used by batch/tracker-additions and data/applications day files.",
7
+ "fields": [
8
+ { "name": "num", "type": "integer", "required": true, "min": 1 },
9
+ { "name": "date", "type": "date", "required": true },
10
+ { "name": "company", "type": "string", "required": true },
11
+ { "name": "role", "type": "string", "required": true },
12
+ {
13
+ "name": "status",
14
+ "type": "enum",
15
+ "required": true,
16
+ "values": ["Evaluated", "Applied", "Responded", "Contacted", "Interview", "Offer", "Rejected", "Discarded", "Failed", "SKIP"]
17
+ },
18
+ { "name": "score", "type": "score", "required": true },
19
+ { "name": "pdf", "type": "string", "required": true },
20
+ { "name": "report", "type": "markdown-link", "required": true },
21
+ { "name": "notes", "type": "string" }
22
+ ],
23
+ "formats": {
24
+ "tsv": {
25
+ "style": "delimited",
26
+ "delimiter": "tab",
27
+ "fields": ["num", "date", "company", "role", "status", "score", "pdf", "report", "notes"]
28
+ },
29
+ "day-tsv": {
30
+ "style": "delimited",
31
+ "delimiter": "tab",
32
+ "fields": ["num", "date", "company", "role", "score", "status", "pdf", "report", "notes"]
33
+ },
34
+ "markdown": {
35
+ "style": "markdown-table-row",
36
+ "fields": ["num", "date", "company", "role", "score", "status", "pdf", "report", "notes"]
37
+ }
38
+ }
39
+ },
40
+ {
41
+ "name": "jobforge.apply-outcome",
42
+ "version": "1.0.0",
43
+ "description": "Structured final outcome emitted by an application subagent.",
44
+ "fields": [
45
+ { "name": "status", "type": "enum", "required": true, "values": ["APPLIED", "APPLY FAILED", "SKIP", "Discarded"] },
46
+ { "name": "company", "type": "string", "required": true },
47
+ { "name": "role", "type": "string", "required": true },
48
+ { "name": "url", "type": "url", "required": true },
49
+ { "name": "trackerTsv", "type": "string" },
50
+ { "name": "reason", "type": "string" }
51
+ ]
52
+ }
53
+ ]
54
+ }
@@ -12,10 +12,11 @@
12
12
  * 3. All report links point to existing files
13
13
  * 4. Scores match format X.XX/5 or N/A or DUP
14
14
  * 5. All rows have proper pipe-delimited format
15
- * 6. No pending TSVs in tracker-additions/ (runs even when tracker file is missing)
16
- * 7. No markdown bold in score column
17
- * 8. Drift warning if states.yml ids differ from the built-in fallback list
18
- * 9. Ledger file verifies if .jobforge-ledger/events.jsonl exists
15
+ * 6. Tracker rows match templates/contracts.json
16
+ * 7. No pending TSVs in tracker-additions/ (runs even when tracker file is missing)
17
+ * 8. No markdown bold in score column
18
+ * 9. Drift warning if states.yml ids differ from the built-in fallback list
19
+ * 10. Ledger file verifies if .jobforge-ledger/events.jsonl exists
19
20
  *
20
21
  * Run: node verify-pipeline.mjs (from repo root; same as npm run verify)
21
22
  */
@@ -28,6 +29,11 @@ import {
28
29
  usesDayFiles, readAllEntries, listDayFiles, dayFilePath,
29
30
  } from './tracker-lib.mjs';
30
31
  import { jobForgeLedgerPath, ledgerExists, verifyJobForgeLedger } from './lib/jobforge-ledger.mjs';
32
+ import {
33
+ canonicalStatusValues,
34
+ formatContractIssues,
35
+ validateTrackerRow,
36
+ } from './lib/jobforge-contracts.mjs';
31
37
 
32
38
  const ADDITIONS_DIR = join(PROJECT_DIR, 'batch/tracker-additions');
33
39
  const STATES_FILE = existsSync(join(PROJECT_DIR, 'templates/states.yml'))
@@ -42,7 +48,7 @@ const appsDisplay = usesDayFiles()
42
48
 
43
49
  const CANONICAL_STATUSES = [
44
50
  'evaluated', 'applied', 'contacted', 'responded', 'interview',
45
- 'offer', 'rejected', 'discarded', 'skip',
51
+ 'offer', 'rejected', 'discarded', 'failed', 'skip',
46
52
  ];
47
53
 
48
54
  const ALIASES = {
@@ -78,6 +84,7 @@ function loadStatesFromYaml(filePath) {
78
84
  }
79
85
 
80
86
  const statesMeta = loadStatesFromYaml(STATES_FILE);
87
+ const CONTRACT_STATUSES = canonicalStatusValues(PROJECT_DIR);
81
88
 
82
89
  function statusIsAllowed(statusOnlyLower) {
83
90
  if (statesMeta) {
@@ -167,6 +174,7 @@ console.log(`\n📊 Checking ${entries.length} entries from ${source === 'day' ?
167
174
 
168
175
  // --- Check 1: Canonical statuses ---
169
176
  let badStatuses = 0;
177
+ // --- Check 6: Contract ---
170
178
  for (const e of entries) {
171
179
  const clean = e.status.replace(/\*\*/g, '').trim().toLowerCase();
172
180
  const statusOnly = clean.replace(/\s+\d{4}-\d{2}-\d{2}.*$/, '').trim();
@@ -231,6 +239,7 @@ if (badScores === 0) ok('All scores valid');
231
239
 
232
240
  // --- Check 5: Row format ---
233
241
  let badRows = 0;
242
+ let contractFailures = 0;
234
243
  // Re-read raw lines for format check
235
244
  if (source === 'day') {
236
245
  for (const file of listDayFiles()) {
@@ -260,10 +269,23 @@ if (source === 'day') {
260
269
  }
261
270
  if (badRows === 0) ok('All rows properly formatted');
262
271
 
263
- // --- Check 6: Pending TSVs ---
272
+ for (const e of entries) {
273
+ const result = validateTrackerRow(contractRecordForEntry(e), {
274
+ allowMissingReport: true,
275
+ projectDir: PROJECT_DIR,
276
+ normalizeStatus: normalizeStatusForContract,
277
+ });
278
+ if (!result.ok) {
279
+ error(`#${e.num}: Tracker row contract failed: ${formatContractIssues(result)}`);
280
+ contractFailures++;
281
+ }
282
+ }
283
+ if (contractFailures === 0) ok('All tracker rows match iso-contract');
284
+
285
+ // --- Check 7: Pending TSVs ---
264
286
  checkPendingTrackerAdditions();
265
287
 
266
- // --- Check 7: Bold in scores ---
288
+ // --- Check 8: Bold in scores ---
267
289
  let boldScores = 0;
268
290
  for (const e of entries) {
269
291
  if (e.score.includes('**')) {
@@ -286,3 +308,30 @@ if (errors === 0 && warnings === 0) {
286
308
  console.log('🔴 Pipeline has errors — fix before proceeding');
287
309
  }
288
310
  process.exit(errors > 0 ? 1 : 0);
311
+
312
+ function normalizeStatusForContract(status) {
313
+ const clean = status.replace(/\*\*/g, '').replace(/\s+\d{4}-\d{2}-\d{2}.*$/, '').trim();
314
+ const lower = clean.toLowerCase();
315
+ const direct = CONTRACT_STATUSES.find((value) => value.toLowerCase() === lower);
316
+ if (direct) return direct;
317
+ if (ALIASES[lower] === 'applied') return 'Applied';
318
+ return clean;
319
+ }
320
+
321
+ function contractRecordForEntry(entry) {
322
+ const record = {
323
+ num: entry.num,
324
+ date: entry.date,
325
+ company: entry.company,
326
+ role: entry.role,
327
+ score: entry.score,
328
+ status: entry.status,
329
+ pdf: entry.pdf,
330
+ report: entry.report,
331
+ notes: entry.notes,
332
+ };
333
+ if (!/\]\([^)]+\)/.test(String(record.report || ''))) {
334
+ delete record.report;
335
+ }
336
+ return record;
337
+ }