job-forge 2.0.0

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.
Files changed (79) hide show
  1. package/.codex/config.toml +8 -0
  2. package/.cursor/mcp.json +21 -0
  3. package/.cursor/rules/main.mdc +519 -0
  4. package/.mcp.json +21 -0
  5. package/.opencode/agents/general-free.md +85 -0
  6. package/.opencode/agents/general-paid.md +39 -0
  7. package/.opencode/agents/glm-minimal.md +50 -0
  8. package/.opencode/skills/job-forge.md +185 -0
  9. package/AGENTS.md +514 -0
  10. package/CLAUDE.md +514 -0
  11. package/LICENSE +21 -0
  12. package/README.md +195 -0
  13. package/batch/README.md +60 -0
  14. package/batch/batch-prompt.md +399 -0
  15. package/batch/batch-runner.sh +673 -0
  16. package/bin/create-job-forge.mjs +375 -0
  17. package/bin/job-forge.mjs +120 -0
  18. package/bin/sync.mjs +141 -0
  19. package/config/profile.example.yml +67 -0
  20. package/cv-sync-check.mjs +128 -0
  21. package/dedup-tracker.mjs +201 -0
  22. package/docs/ARCHITECTURE.md +220 -0
  23. package/docs/CUSTOMIZATION.md +101 -0
  24. package/docs/MODEL-ROUTING.md +195 -0
  25. package/docs/README.md +54 -0
  26. package/docs/SETUP.md +186 -0
  27. package/docs/demo.gif +0 -0
  28. package/fonts/dm-sans-latin-ext.woff2 +0 -0
  29. package/fonts/dm-sans-latin.woff2 +0 -0
  30. package/fonts/space-grotesk-latin-ext.woff2 +0 -0
  31. package/fonts/space-grotesk-latin.woff2 +0 -0
  32. package/generate-pdf.mjs +168 -0
  33. package/iso/agents/general-free.md +90 -0
  34. package/iso/agents/general-paid.md +44 -0
  35. package/iso/agents/glm-minimal.md +55 -0
  36. package/iso/commands/job-forge.md +188 -0
  37. package/iso/config.json +7 -0
  38. package/iso/instructions.md +514 -0
  39. package/iso/mcp.json +15 -0
  40. package/merge-tracker.mjs +377 -0
  41. package/modes/README.md +30 -0
  42. package/modes/_shared-calibration.md +26 -0
  43. package/modes/_shared.md +272 -0
  44. package/modes/apply.md +257 -0
  45. package/modes/auto-pipeline.md +70 -0
  46. package/modes/batch.md +110 -0
  47. package/modes/compare.md +23 -0
  48. package/modes/contact.md +82 -0
  49. package/modes/deep.md +99 -0
  50. package/modes/followup.md +68 -0
  51. package/modes/negotiation.md +146 -0
  52. package/modes/offer.md +199 -0
  53. package/modes/pdf.md +121 -0
  54. package/modes/pipeline.md +83 -0
  55. package/modes/project.md +30 -0
  56. package/modes/rejection.md +92 -0
  57. package/modes/scan.md +185 -0
  58. package/modes/tracker.md +31 -0
  59. package/modes/training.md +27 -0
  60. package/normalize-statuses.mjs +152 -0
  61. package/opencode.json +28 -0
  62. package/package.json +78 -0
  63. package/scripts/add-tags.mjs +894 -0
  64. package/scripts/cursor-agent-loop.sh +211 -0
  65. package/scripts/cursor-agent-stream-format.py +134 -0
  66. package/scripts/next-num.mjs +33 -0
  67. package/scripts/release/check-source.mjs +37 -0
  68. package/scripts/render-report-header.mjs +78 -0
  69. package/scripts/session-report.mjs +129 -0
  70. package/scripts/slugify.mjs +27 -0
  71. package/scripts/today.mjs +20 -0
  72. package/scripts/token-usage-report.mjs +315 -0
  73. package/scripts/tracker-line.mjs +67 -0
  74. package/scripts/verify-greenhouse-urls.mjs +195 -0
  75. package/templates/cv-template.html +395 -0
  76. package/templates/portals.example.yml +3140 -0
  77. package/templates/states.yml +62 -0
  78. package/tracker-lib.mjs +257 -0
  79. package/verify-pipeline.mjs +267 -0
@@ -0,0 +1,67 @@
1
+ # JobForge Profile Configuration
2
+ # Copy this file to config/profile.yml and fill in your details.
3
+ # This is the single source of truth for your personal data across all modes.
4
+
5
+ candidate:
6
+ full_name: "Jane Smith"
7
+ email: "jane@example.com"
8
+ phone: "+1-555-0123"
9
+ location: "San Francisco, CA"
10
+ linkedin: "linkedin.com/in/janesmith"
11
+ portfolio_url: "https://janesmith.dev"
12
+ github: "github.com/janesmith"
13
+ twitter: "https://x.com/janesmith"
14
+
15
+ target_roles:
16
+ # Your North Star roles — what you're optimizing for
17
+ primary:
18
+ - "Senior AI Engineer"
19
+ - "Staff ML Engineer"
20
+ # Archetypes help the evaluation system score fit
21
+ archetypes:
22
+ - name: "AI/ML Engineer"
23
+ level: "Senior/Staff"
24
+ fit: "primary" # primary = dream role, secondary = good fit, adjacent = stretch
25
+ - name: "AI Product Manager"
26
+ level: "Senior"
27
+ fit: "secondary"
28
+ - name: "Solutions Architect"
29
+ level: "Mid-Senior"
30
+ fit: "adjacent"
31
+
32
+ narrative:
33
+ # Your professional headline (1 line)
34
+ headline: "ML Engineer turned AI product builder"
35
+ # Your exit story — what makes you unique
36
+ exit_story: "Built and sold my SaaS after 5 years. Now focused on applied AI at scale."
37
+ # Your top 3-5 superpowers
38
+ superpowers:
39
+ - "End-to-end ML pipelines"
40
+ - "Fast prototyping (idea to prod in 2 weeks)"
41
+ - "Cross-functional communication"
42
+ # Proof points — projects, articles, case studies with measurable impact
43
+ proof_points:
44
+ - name: "Project Alpha"
45
+ url: "https://janesmith.dev/project-alpha"
46
+ hero_metric: "Reduced inference latency 40%"
47
+ - name: "Open Source Tool"
48
+ url: "https://github.com/janesmith/tool"
49
+ hero_metric: "2K+ GitHub stars"
50
+ # Optional: dashboard/demo URL with credentials
51
+ # dashboard:
52
+ # url: "https://janesmith.dev/demo"
53
+ # password: "demo-2026"
54
+
55
+ compensation:
56
+ target_range: "$150K-200K" # Your target total comp
57
+ currency: "USD"
58
+ minimum: "$120K" # Walk-away number
59
+ location_flexibility: "Remote preferred, 1 week/month on-site possible"
60
+
61
+ location:
62
+ country: "United States"
63
+ city: "San Francisco"
64
+ timezone: "PST"
65
+ visa_status: "No sponsorship needed"
66
+ # For remote roles outside your country:
67
+ # onsite_availability: "1 week/month in any city"
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * cv-sync-check.mjs — Validates that the job-forge setup is consistent.
5
+ *
6
+ * Checks:
7
+ * 1. cv.md exists
8
+ * 2. config/profile.yml exists and has required fields
9
+ * 3. No hardcoded metrics in _shared.md or batch/batch-prompt.md
10
+ * 4. article-digest.md freshness (if exists)
11
+ *
12
+ * Usage:
13
+ * node cv-sync-check.mjs
14
+ * npm run sync-check
15
+ * npm run sync-check -- --help
16
+ */
17
+
18
+ import { readFileSync, existsSync, statSync } from 'fs';
19
+ import { join, dirname } from 'path';
20
+ import { fileURLToPath } from 'url';
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ // Consumer's project root. When installed as a package, we operate on cwd.
24
+ const projectRoot = process.env.JOB_FORGE_PROJECT || process.cwd();
25
+ // Harness dir (where shipped files like modes/_shared.md live). When installed
26
+ // as a package, the consumer has modes/ as a symlink, so either path resolves.
27
+ const harnessRoot = __dirname;
28
+
29
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
30
+ console.log(`cv-sync-check.mjs — optional setup lint for a personalized clone
31
+
32
+ Checks that cv.md and config/profile.yml exist, scans modes/_shared.md and
33
+ batch/batch-prompt.md for lines that look like hardcoded metrics, and warns
34
+ if article-digest.md is stale when present.
35
+
36
+ Usage:
37
+ node cv-sync-check.mjs
38
+ npm run sync-check
39
+
40
+ Exits with code 1 if cv.md or config/profile.yml is missing; exits 0 when
41
+ those exist (warnings only). Not part of the default PR gate — see CONTRIBUTING.md.
42
+
43
+ Run from the repository root.`);
44
+ process.exit(0);
45
+ }
46
+
47
+ const warnings = [];
48
+ const errors = [];
49
+
50
+ // 1. Check cv.md exists
51
+ const cvPath = join(projectRoot, 'cv.md');
52
+ if (!existsSync(cvPath)) {
53
+ errors.push('cv.md not found in project root. Create it with your CV in markdown format.');
54
+ } else {
55
+ const cvContent = readFileSync(cvPath, 'utf-8');
56
+ if (cvContent.trim().length < 100) {
57
+ warnings.push('cv.md seems too short. Make sure it contains your full CV.');
58
+ }
59
+ }
60
+
61
+ // 2. Check profile.yml exists
62
+ const profilePath = join(projectRoot, 'config', 'profile.yml');
63
+ if (!existsSync(profilePath)) {
64
+ errors.push('config/profile.yml not found. Copy from config/profile.example.yml and fill in your details.');
65
+ } else {
66
+ const profileContent = readFileSync(profilePath, 'utf-8');
67
+ const requiredFields = ['full_name', 'email', 'location'];
68
+ for (const field of requiredFields) {
69
+ if (!profileContent.includes(field) || profileContent.includes(`"Jane Smith"`)) {
70
+ warnings.push(`config/profile.yml may still have example data. Check field: ${field}`);
71
+ break;
72
+ }
73
+ }
74
+ }
75
+
76
+ // 3. Check for hardcoded metrics in prompt files
77
+ const filesToCheck = [
78
+ { path: join(projectRoot, 'modes', '_shared.md'), name: '_shared.md' },
79
+ { path: join(projectRoot, 'batch', 'batch-prompt.md'), name: 'batch-prompt.md' },
80
+ ];
81
+
82
+ // Pattern: numbers that look like hardcoded metrics (e.g., "170+ hours", "90% self-service")
83
+ const metricPattern = /\b\d{2,4}\+?\s*(hours?|%|evals?|layers?|tests?|fields?|bases?)\b/gi;
84
+
85
+ for (const { path, name } of filesToCheck) {
86
+ if (!existsSync(path)) continue;
87
+ const content = readFileSync(path, 'utf-8');
88
+
89
+ // Skip lines that are clearly instructions (contain "NEVER hardcode" etc.)
90
+ const lines = content.split('\n');
91
+ for (let i = 0; i < lines.length; i++) {
92
+ const line = lines[i];
93
+ if (line.includes('NEVER hardcode') || line.includes('NUNCA hardcode') || line.startsWith('#') || line.startsWith('<!--')) continue;
94
+ const matches = line.match(metricPattern);
95
+ if (matches) {
96
+ warnings.push(`${name}:${i + 1} — Possible hardcoded metric: "${matches[0]}". Should this be read from cv.md/article-digest.md?`);
97
+ }
98
+ }
99
+ }
100
+
101
+ // 4. Check article-digest.md freshness
102
+ const digestPath = join(projectRoot, 'article-digest.md');
103
+ if (existsSync(digestPath)) {
104
+ const stats = statSync(digestPath);
105
+ const daysSinceModified = (Date.now() - stats.mtimeMs) / (1000 * 60 * 60 * 24);
106
+ if (daysSinceModified > 30) {
107
+ warnings.push(`article-digest.md is ${Math.round(daysSinceModified)} days old. Consider updating if your projects have new metrics.`);
108
+ }
109
+ }
110
+
111
+ // Output results
112
+ console.log('\n=== job-forge sync check ===\n');
113
+
114
+ if (errors.length === 0 && warnings.length === 0) {
115
+ console.log('All checks passed.');
116
+ } else {
117
+ if (errors.length > 0) {
118
+ console.log(`ERRORS (${errors.length}):`);
119
+ errors.forEach(e => console.log(` ERROR: ${e}`));
120
+ }
121
+ if (warnings.length > 0) {
122
+ console.log(`\nWARNINGS (${warnings.length}):`);
123
+ warnings.forEach(w => console.log(` WARN: ${w}`));
124
+ }
125
+ }
126
+
127
+ console.log('');
128
+ process.exit(errors.length > 0 ? 1 : 0);
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dedup-tracker.mjs — Remove duplicate entries from the application tracker
4
+ *
5
+ * Supports both layouts:
6
+ * - Day-based: data/applications/YYYY-MM-DD.md (preferred)
7
+ * - Single-file: data/applications.md or applications.md (legacy)
8
+ *
9
+ * Groups by normalized company + fuzzy role match.
10
+ * Keeps entry with highest score. If discarded entry had more advanced status,
11
+ * preserves that status. Merges notes.
12
+ *
13
+ * Run: node dedup-tracker.mjs [--dry-run] (from repo root)
14
+ */
15
+
16
+ import { readFileSync, writeFileSync, copyFileSync, existsSync } from 'fs';
17
+ import { join, relative, dirname } from 'path';
18
+ import { fileURLToPath } from 'url';
19
+ import {
20
+ PROJECT_DIR, DATA_APPS_DIR, DATA_APPS_FILE, ROOT_APPS_FILE,
21
+ usesDayFiles, ensureDayDir, getHeader, formatAppLine, parseAppLine,
22
+ readAllEntries, writeToDayFiles, listDayFiles, dayFilePath,
23
+ } from './tracker-lib.mjs';
24
+
25
+ const DRY_RUN = process.argv.includes('--dry-run');
26
+
27
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
28
+ console.log(`dedup-tracker.mjs — remove duplicate tracker rows by company and role
29
+
30
+ Supports day-based (data/applications/YYYY-MM-DD.md) and single-file layouts.
31
+ Keeps the highest-scoring row per cluster; may promote status when a removed
32
+ row was further along in the pipeline. Merges notes where applicable.
33
+
34
+ Usage:
35
+ node dedup-tracker.mjs [--dry-run]
36
+ npm run dedup [-- --dry-run]
37
+
38
+ Exits successfully when no tracker exists (nothing to do).
39
+ Creates a .bak copy next to the tracker before writing (single-file mode).
40
+
41
+ Run from the repository root.`);
42
+ process.exit(0);
43
+ }
44
+
45
+ const STATUS_RANK = {
46
+ 'skip': 0,
47
+ 'discarded': 0,
48
+ 'rejected': 1,
49
+ 'evaluated': 2,
50
+ 'applied': 3,
51
+ 'contacted': 3.5,
52
+ 'responded': 4,
53
+ 'interview': 5,
54
+ 'offer': 6,
55
+ };
56
+
57
+ function normalizeCompany(name) {
58
+ return name.toLowerCase()
59
+ .replace(/[()]/g, '')
60
+ .replace(/\s+/g, ' ')
61
+ .replace(/[^a-z0-9 ]/g, '')
62
+ .trim();
63
+ }
64
+
65
+ function normalizeRole(role) {
66
+ return role.toLowerCase()
67
+ .replace(/[()]/g, ' ')
68
+ .replace(/\s+/g, ' ')
69
+ .replace(/[^a-z0-9 /]/g, '')
70
+ .trim();
71
+ }
72
+
73
+ function roleMatch(a, b) {
74
+ const wordsA = normalizeRole(a).split(/\s+/).filter(w => w.length > 3);
75
+ const wordsB = normalizeRole(b).split(/\s+/).filter(w => w.length > 3);
76
+ const overlap = wordsA.filter(w => wordsB.some(wb => wb.includes(w) || w.includes(wb)));
77
+ return overlap.length >= 2;
78
+ }
79
+
80
+ function parseScore(s) {
81
+ const m = s.replace(/\*\*/g, '').match(/([\d.]+)/);
82
+ return m ? parseFloat(m[1]) : 0;
83
+ }
84
+
85
+ // Read entries
86
+ const { entries, source } = readAllEntries();
87
+ if (entries.length === 0) {
88
+ console.log('No tracker entries found. Nothing to dedup.');
89
+ process.exit(0);
90
+ }
91
+
92
+ console.log(`📊 ${entries.length} entries loaded from ${source === 'day' ? 'day files' : 'single file'}`);
93
+
94
+ // Group by company+role
95
+ const groups = new Map();
96
+ for (const entry of entries) {
97
+ const key = normalizeCompany(entry.company);
98
+ if (!groups.has(key)) groups.set(key, []);
99
+ groups.get(key).push(entry);
100
+ }
101
+
102
+ // Find duplicates
103
+ let removed = 0;
104
+ const toRemove = new Set(); // entry.num values to remove
105
+ const statusUpdates = new Map(); // num → new status
106
+
107
+ for (const [company, companyEntries] of groups) {
108
+ if (companyEntries.length < 2) continue;
109
+
110
+ const processed = new Set();
111
+ for (let i = 0; i < companyEntries.length; i++) {
112
+ if (processed.has(i)) continue;
113
+ const cluster = [companyEntries[i]];
114
+ processed.add(i);
115
+
116
+ for (let j = i + 1; j < companyEntries.length; j++) {
117
+ if (processed.has(j)) continue;
118
+ if (roleMatch(companyEntries[i].role, companyEntries[j].role)) {
119
+ cluster.push(companyEntries[j]);
120
+ processed.add(j);
121
+ }
122
+ }
123
+
124
+ if (cluster.length < 2) continue;
125
+
126
+ // Keep the one with highest score
127
+ cluster.sort((a, b) => parseScore(b.score) - parseScore(a.score));
128
+ const keeper = cluster[0];
129
+
130
+ // Check if any removed entry has more advanced status
131
+ let bestStatusRank = STATUS_RANK[keeper.status.toLowerCase()] || 0;
132
+ let bestStatus = keeper.status;
133
+ for (let k = 1; k < cluster.length; k++) {
134
+ const rank = STATUS_RANK[cluster[k].status.toLowerCase()] || 0;
135
+ if (rank > bestStatusRank) {
136
+ bestStatusRank = rank;
137
+ bestStatus = cluster[k].status;
138
+ }
139
+ }
140
+
141
+ if (bestStatus !== keeper.status) {
142
+ statusUpdates.set(keeper.num, bestStatus);
143
+ console.log(` 📝 #${keeper.num}: status promoted to "${bestStatus}" (from #${cluster.find(e => e.status === bestStatus)?.num})`);
144
+ }
145
+
146
+ // Mark duplicates for removal
147
+ for (let k = 1; k < cluster.length; k++) {
148
+ const dup = cluster[k];
149
+ toRemove.add(dup.num);
150
+ removed++;
151
+ console.log(`🗑️ Remove #${dup.num} (${dup.company} — ${dup.role}, ${dup.score}) → kept #${keeper.num} (${keeper.score})`);
152
+ }
153
+ }
154
+ }
155
+
156
+ console.log(`\n📊 ${removed} duplicates found`);
157
+
158
+ if (!DRY_RUN && (removed > 0 || statusUpdates.size > 0)) {
159
+ if (source === 'day') {
160
+ // Filter out removed entries and apply status updates, then rewrite
161
+ const kept = entries
162
+ .filter(e => !toRemove.has(e.num))
163
+ .map(e => {
164
+ if (statusUpdates.has(e.num)) {
165
+ return { ...e, status: statusUpdates.get(e.num) };
166
+ }
167
+ return e;
168
+ });
169
+ writeToDayFiles(kept);
170
+ console.log(`✅ Written to day files`);
171
+ } else {
172
+ // Single-file mode
173
+ const APPS_FILE = existsSync(DATA_APPS_FILE) ? DATA_APPS_FILE : ROOT_APPS_FILE;
174
+ const appsDisplay = relative(PROJECT_DIR, APPS_FILE).replace(/\\/g, '/');
175
+ copyFileSync(APPS_FILE, APPS_FILE + '.bak');
176
+
177
+ let content = readFileSync(APPS_FILE, 'utf-8');
178
+ const lines = content.split('\n');
179
+
180
+ const updatedLines = [];
181
+ for (const line of lines) {
182
+ const app = parseAppLine(line);
183
+ if (app && toRemove.has(app.num)) continue; // skip removed
184
+ if (app && statusUpdates.has(app.num)) {
185
+ const newStatus = statusUpdates.get(app.num);
186
+ const parts = line.split('|').map(s => s.trim());
187
+ parts[6] = newStatus;
188
+ updatedLines.push('| ' + parts.slice(1, -1).join(' | ') + ' |');
189
+ } else {
190
+ updatedLines.push(line);
191
+ }
192
+ }
193
+
194
+ writeFileSync(APPS_FILE, updatedLines.join('\n'));
195
+ console.log(`✅ Written to ${appsDisplay} (backup: ${appsDisplay}.bak)`);
196
+ }
197
+ } else if (DRY_RUN) {
198
+ console.log('(dry-run — no changes written)');
199
+ } else {
200
+ console.log('✅ No duplicates found');
201
+ }
@@ -0,0 +1,220 @@
1
+ # Architecture
2
+
3
+ ## Package architecture (v2.0.0+)
4
+
5
+ JobForge ships as an npm package. There are two kinds of repo involved:
6
+
7
+ - **Harness** — this repo, `razroo/JobForge`. Installable via `github:razroo/JobForge` (no npm registry). Contains modes, scripts, skill router, templates, fonts, dashboard, and bin entries.
8
+ - **Consumer project** — what users interact with day-to-day. Scaffolded via `npx create-job-forge <dir>`, or hand-authored with `job-forge` listed in `package.json` dependencies.
9
+
10
+ The consumer's project root contains only personal data:
11
+
12
+ ```
13
+ my-search/
14
+ ├── package.json # depends on "job-forge"
15
+ ├── opencode.json # instructions: ["templates/states.yml"]
16
+ ├── cv.md # personal
17
+ ├── config/profile.yml # personal
18
+ ├── portals.yml # personal
19
+ ├── data/ # personal (gitignored)
20
+ ├── reports/ # personal (gitignored)
21
+ ├── modes/ # → symlink to node_modules/job-forge/modes/
22
+ ├── templates/ # → symlink to node_modules/job-forge/templates/
23
+ ├── .opencode/skills/job-forge.md # → symlink
24
+ ├── batch/batch-prompt.md # → symlink
25
+ ├── batch/batch-runner.sh # → symlink
26
+ └── node_modules/job-forge/ # harness, fetched from github
27
+ ```
28
+
29
+ Symlinks are created by the harness's `postinstall` hook (`bin/sync.mjs`) on every `npm install`. They are gitignored in the scaffolder template. Real files at those paths are preserved — if a user locally customizes a mode file, the sync skips that symlink and warns.
30
+
31
+ The consumer's `opencode.json` loads a small set of stable files as always-present instructions: `AGENTS.harness.md` (harness operational rules), `templates/states.yml` (canonical application states), `modes/_shared.md` (scoring model), and `cv.md` (the candidate's CV). Caching these in the prefix means agents never Read them as tool calls. Churning content (score calibration anchors, specific mode files) stays out of `instructions` and is Read on demand.
32
+
33
+ The skill router (`.opencode/skills/job-forge.md`) loads mode and data files on demand, keeping per-session input tokens low (~20-40K for most modes instead of ~130-170K when everything was force-loaded).
34
+
35
+ **Cost-tiered subagents** live in `.opencode/agents/` (`general-free`, `general-paid`, `glm-minimal`) — the orchestrator delegates procedural work to free-tier models and reserves paid models for quality-sensitive writing. See [MODEL-ROUTING.md](MODEL-ROUTING.md) for the routing architecture, why it exists, and how to customize.
36
+
37
+ **Upgrading** the harness in a consumer project is `npm run update-harness` — fetches the latest harness (`github:razroo/JobForge`) and `@razroo/opencode-model-fallback` plugin, re-runs symlink sync, and prints the resolved commit SHA.
38
+
39
+ ## System Overview
40
+
41
+ ```
42
+ ┌─────────────────────────────────┐
43
+ │ opencode Agent │
44
+ │ (reads OPENCODE.md + modes/*.md) │
45
+ └──────────┬──────────────────────┘
46
+
47
+ ┌──────────────────┼──────────────────────┐
48
+ │ │ │
49
+ ┌──────▼──────┐ ┌──────▼──────┐ ┌───────────▼────────┐
50
+ │ Single Eval │ │ Portal Scan │ │ Batch Process │
51
+ │ (auto-pipe) │ │ (scan.md) │ │ (batch-runner) │
52
+ └──────┬──────┘ └──────┬──────┘ └───────────┬────────┘
53
+ │ │ │
54
+ │ ┌─────────▼─────────┐ ┌────▼─────┐
55
+ │ │ data/pipeline.md │ │ N workers│
56
+ │ │ (URL inbox) │ │ (opencode run)
57
+ │ └─────────┬─────────┘ └────┬─────┘
58
+ │ │
59
+ ┌──────▼──────────────────────────────────────────▼──────┐
60
+ │ Output Pipeline │
61
+ │ ┌──────────┐ ┌────────────┐ ┌───────────────────┐ │
62
+ │ │ Report.md│ │ PDF (HTML │ │ Tracker TSV │ │
63
+ │ │ (A-F eval)│ │ → Geometra) │ │ (merge-tracker) │ │
64
+ │ └──────────┘ └────────────┘ └───────────────────┘ │
65
+ └────────────────────────────────────────────────────────┘
66
+
67
+ ┌──────────▼──────────┐
68
+ │ data/applications/ │
69
+ │ (day-based tracker) │
70
+ └──────────────────────┘
71
+ ```
72
+
73
+ ## Modes (`modes/`)
74
+
75
+ Markdown mode files in `modes/` define how the opencode workflow behaves together with the root `OPENCODE.md`. **`_shared.md`** is the shared layer (archetypes, scoring dimensions, negotiation scaffolding); the rest align with `/job-forge` command entry points listed in `OPENCODE.md`.
76
+
77
+ | File | Focus |
78
+ |------|--------|
79
+ | `_shared.md` | Archetypes, evaluation axes, shared prompts |
80
+ | `auto-pipeline.md` | Default path: evaluate, report, PDF, tracker |
81
+ | `offer.md` | Single-offer analysis |
82
+ | `compare.md` | Comparing multiple offers |
83
+ | `contact.md` | Outreach (e.g. LinkedIn) |
84
+ | `deep.md` | Company research |
85
+ | `pdf.md` | CV / PDF generation |
86
+ | `training.md` | Courses and certifications |
87
+ | `project.md` | Portfolio projects |
88
+ | `tracker.md` | Application tracker review |
89
+ | `apply.md` | Application forms |
90
+ | `scan.md` | Portal / job-board scanning |
91
+ | `pipeline.md` | Pending URL inbox |
92
+ | `batch.md` | Parallel batch runs (`batch/batch-runner.sh`) |
93
+ | `followup.md` | Follow-up triage |
94
+ | `rejection.md` | Rejection handling |
95
+ | `negotiation.md` | Offer negotiation |
96
+
97
+ For customization (archetypes, weights, tone), start with `_shared.md` and [CUSTOMIZATION.md](CUSTOMIZATION.md).
98
+
99
+ ## Evaluation Flow (Single Offer)
100
+
101
+ 1. **Input**: User pastes JD text or URL
102
+ 2. **Extract**: Geometra MCP/WebFetch extracts JD from URL
103
+ 3. **Classify**: Detect archetype (one row from the archetype table in `modes/_shared.md`)
104
+ 4. **Evaluate**: 6 blocks (A-F).
105
+ - A: Role summary.
106
+ - B: CV match (gaps + mitigation).
107
+ - C: Level strategy.
108
+ - D: Comp research (WebSearch).
109
+ - E: CV personalization plan.
110
+ - F: Interview prep (STAR stories).
111
+ 5. **Score**: Weighted average across 10 dimensions (1-5)
112
+ 6. **Report**: Save as `reports/{num}-{company}-{date}.md`
113
+ 7. **PDF**: Generate ATS-optimized CV (`generate-pdf.mjs`)
114
+ 8. **Track**: Write one TSV per evaluation under `batch/tracker-additions/` (see [OPENCODE.md](../OPENCODE.md) TSV layout); fold rows into `data/applications.md` with `npm run merge` / `merge-tracker.mjs` when you are ready (not automatic in every workflow)
115
+
116
+ ## Batch Processing
117
+
118
+ The batch system processes multiple offers in parallel:
119
+
120
+ ```
121
+ batch-input.tsv → batch-runner.sh → N × opencode run workers
122
+ (id, url, source, notes) (orchestrator) (self-contained prompt)
123
+
124
+ batch-state.tsv
125
+ (tracks progress)
126
+ ```
127
+
128
+ Each worker is a headless opencode instance (`opencode run`) that receives the full `batch-prompt.md` as context. Workers produce:
129
+ - Report .md
130
+ - PDF
131
+ - Tracker TSV line
132
+
133
+ The orchestrator manages parallelism, state, retries, and resume.
134
+
135
+ **Local batch artifacts:** `batch/batch-input.tsv`, `batch/batch-state.tsv`, `batch/logs/`, and `batch/tracker-additions/*.tsv` are created when you run the runner; they are gitignored (with `.gitkeep` in `batch/logs/` and `batch/tracker-additions/`). A fresh clone ships `batch/batch-runner.sh` and `batch/batch-prompt.md` only until you add an input file — see [`batch/README.md`](../batch/README.md) and `batch/batch-runner.sh --help` for the TSV layout and workflow.
136
+
137
+ ## Data Flow
138
+
139
+ ```
140
+ cv.md → Evaluation context
141
+ article-digest.md → Proof points for matching
142
+ config/profile.yml → Candidate identity
143
+ portals.yml → Scanner configuration
144
+ data/pipeline.md → Pending URLs and `local:jds/...` inbox (see modes/pipeline.md)
145
+ jds/*.md → Saved job descriptions referenced from the pipeline (`local:jds/{file}`)
146
+ templates/states.yml → Canonical status values
147
+ templates/cv-template.html → PDF generation template
148
+ examples/*.md → Fictional layouts only (not read by scripts; see examples/README.md)
149
+ ```
150
+
151
+ Create `data/pipeline.md` when you start using the URL inbox (`/job-forge pipeline`); format and `local:jds/...` lines are described in [`modes/pipeline.md`](../modes/pipeline.md).
152
+
153
+ ## File Naming Conventions
154
+
155
+ - Reports: `{###}-{company-slug}-{YYYY-MM-DD}.md` (3-digit zero-padded)
156
+ - PDFs: `cv-candidate-{company-slug}-{YYYY-MM-DD}.pdf`
157
+ - Tracker TSVs: `batch/tracker-additions/{num}-{company-slug}.tsv` (one file per evaluation; merged files move under `batch/tracker-additions/merged/`)
158
+
159
+ ## Pipeline Integrity
160
+
161
+ 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. 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`).
162
+
163
+ **`verify-pipeline.mjs` checks (same order as the script header):**
164
+
165
+ 1. Status column uses canonical ids (from `templates/states.yml` when parseable, else built-in ids and aliases), with no markdown bold and no dates embedded in the status cell.
166
+ 2. Warn when multiple rows share the same normalized company + role (possible duplicates).
167
+ 3. Report column markdown links resolve to files under the repo root.
168
+ 4. Score column matches `X.X/5`, `N/A`, or `DUP`.
169
+ 5. Table data rows have enough pipe-delimited columns.
170
+ 6. No unmerged `batch/tracker-additions/*.tsv` files (warns if any remain).
171
+ 7. Score column has no markdown bold.
172
+ 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).
173
+
174
+ When the tracker file is missing, checks 1–5 and 7 are skipped; checks 6 and 8 still run.
175
+
176
+ ## Contributing touchpoints
177
+
178
+ Prefer one focused change per pull request: a single mode under `modes/`, one repository-root `.mjs` utility, documentation under `docs/`, fictional samples under [`examples/`](../examples/README.md), templates such as [`templates/portals.example.yml`](../templates/portals.example.yml), the batch flow described in [`batch/README.md`](../batch/README.md), or the Go TUI under `dashboard/` — not a repo-wide refactor across 3+ of those at once. Branch workflow, the verify + dashboard build gate, and starter ideas are in [CONTRIBUTING.md](../CONTRIBUTING.md) (**What to Contribute** and **Development**). To look for in-repo `TODO`, `FIXME`, or `HACK` markers before choosing a task, use the `rg` one-liner in [CONTRIBUTING.md — Optional: scripted agent iterations](../CONTRIBUTING.md#optional-scripted-agent-iterations). Upstream PRs MUST stay generic: do not commit real candidate data (`cv.md`, `config/profile.yml`, personalized `portals.yml`, `data/applications.md`, `reports/`, or similar paths called out in CONTRIBUTING and `.gitignore`).
179
+
180
+ **PR / maintainer gate:** Before opening a pull request against `razroo/JobForge`, run `npm run verify` and `npm run build:dashboard` (or `(cd dashboard && go build .)`) from the harness repo root (same as [CONTRIBUTING.md](../CONTRIBUTING.md#development)). For optional scripted iterations that repeat that gate and commit one small change per pass, see [`scripts/cursor-agent-loop.sh`](../scripts/cursor-agent-loop.sh) (environment variables and usage in the script header; overview in [CONTRIBUTING.md](../CONTRIBUTING.md#optional-scripted-agent-iterations)).
181
+
182
+ Scripts maintain data consistency. In a consumer project they're invoked via the `job-forge` CLI (`npx job-forge <cmd>`); in the harness repo they're also directly runnable as `node <script>.mjs`.
183
+
184
+ | Script (in harness) | CLI | Purpose |
185
+ |---------------------|-----|---------|
186
+ | `merge-tracker.mjs` | `npx job-forge merge` | Merges TSV rows from `batch/tracker-additions/` into day files under `data/applications/`, or `data/applications.md` when the directory is absent |
187
+ | `verify-pipeline.mjs` | `npx job-forge verify` | Health check — see the verify paragraph above |
188
+ | `dedup-tracker.mjs` | `npx job-forge dedup` | Removes duplicate entries by company+role |
189
+ | `normalize-statuses.mjs` | `npx job-forge normalize` | Maps status aliases to canonical values |
190
+ | `generate-pdf.mjs` | `npx job-forge pdf` | Renders HTML to PDF via Geometra MCP (`geometra_generate_pdf`) or standalone Playwright/Chromium (`npx job-forge pdf <input.html> <output.pdf>`) |
191
+ | `cv-sync-check.mjs` | `npx job-forge sync-check` | Setup lint: `cv.md` + `config/profile.yml`, hardcoded-metric scan on `modes/_shared.md` and `batch/batch-prompt.md`, optional `article-digest.md` freshness |
192
+ | `scripts/token-usage-report.mjs` | `npx job-forge tokens` | Per-session opencode token/cost report from the SQLite DB |
193
+ | `tracker-lib.mjs` | _(library)_ | Shared helpers for reading/writing day-based tracker files — imported by merge/dedup/verify/normalize |
194
+ | `bin/sync.mjs` | `npx job-forge sync` | Creates the harness symlinks in a consumer project (also runs as `postinstall`) |
195
+ | `bin/create-job-forge.mjs` | `npx create-job-forge <dir>` | Scaffolds a new personal project |
196
+
197
+ All scripts resolve the consumer project dir via `process.env.JOB_FORGE_PROJECT || process.cwd()`, so running the CLI from anywhere in the consumer project Just Works.
198
+
199
+ ## Dashboard TUI
200
+
201
+ The `dashboard/` directory contains a standalone Go TUI application that visualizes the pipeline.
202
+
203
+ **Repo root:** The program needs the path to the JobForge checkout (the directory that contains `modes/`, `reports/`, and the tracker). Flag `-path` sets that directory (default `.`, i.e. the process working directory). If you run the binary from inside `dashboard/` after `go build`, use `-path ..` so the tracker is found.
204
+
205
+ **Tracker file:** Day-based directory `data/applications/` (preferred) with `YYYY-MM-DD.md` files. Falls back to single-file `data/applications.md` or root `applications.md` for legacy setups.
206
+
207
+ **Build / run** (see also [SETUP.md](SETUP.md#build-dashboard-optional)):
208
+
209
+ ```bash
210
+ cd dashboard && go build -o job-forge-dashboard .
211
+ ./job-forge-dashboard -path ..
212
+ ```
213
+
214
+ **UI:**
215
+
216
+ - Filter tabs: All, Evaluated, Applied, Interview, Top ≥4, SKIP
217
+ - Sort modes: Score, Date, Company, Status
218
+ - Grouped/flat view
219
+ - Lazy-loaded report previews
220
+ - Inline status picker; on-screen key hints at the bottom of the pipeline view