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,375 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * create-job-forge — Scaffold a new job-forge personal project.
4
+ *
5
+ * Usage:
6
+ * npx create-job-forge <dir> # scaffold into <dir>
7
+ * npx create-job-forge . # scaffold into cwd
8
+ * npx create-job-forge <dir> --force # overwrite existing files
9
+ *
10
+ * Creates the minimum a consumer needs:
11
+ * package.json with job-forge as a dependency
12
+ * opencode.json thin config enabling MCPs + states.yml instruction
13
+ * config/profile.yml copied from profile.example.yml
14
+ * cv.md stub for the user to fill in
15
+ * portals.yml copied from templates/portals.example.yml
16
+ * data/ empty dir for tracker/pipeline/scan history
17
+ * reports/ empty dir for generated reports
18
+ * .gitignore excludes personal data from sharing
19
+ * README.md setup instructions
20
+ *
21
+ * After scaffold, prompts the user to run `npm install`, which triggers the
22
+ * postinstall symlink sync.
23
+ */
24
+
25
+ import { existsSync, mkdirSync, writeFileSync, copyFileSync, readFileSync } from 'fs';
26
+ import { dirname, join, resolve, basename } from 'path';
27
+ import { fileURLToPath } from 'url';
28
+
29
+ const __dirname = dirname(fileURLToPath(import.meta.url));
30
+ const PKG_ROOT = resolve(__dirname, '..');
31
+
32
+ const args = process.argv.slice(2);
33
+ const FORCE = args.includes('--force');
34
+ const HELP = args.includes('--help') || args.includes('-h');
35
+ const positional = args.filter(a => !a.startsWith('--'));
36
+
37
+ if (HELP || positional.length === 0) {
38
+ console.log(`create-job-forge — scaffold a new job-forge personal project
39
+
40
+ Usage:
41
+ npx create-job-forge <dir> [--force]
42
+
43
+ Examples:
44
+ npx create-job-forge my-job-search
45
+ npx create-job-forge .
46
+ npx create-job-forge existing-project --force
47
+
48
+ Flags:
49
+ --force Overwrite files that already exist
50
+ --help Show this message
51
+
52
+ After scaffolding, cd into the directory and run:
53
+ npm install # pulls the harness and creates symlinks
54
+ # Edit cv.md, config/profile.yml, portals.yml with your personal data
55
+ opencode # start the TUI
56
+ `);
57
+ process.exit(HELP ? 0 : 1);
58
+ }
59
+
60
+ const targetDir = resolve(positional[0]);
61
+ const name = basename(targetDir);
62
+
63
+ console.log(`\nScaffolding job-forge project in ${targetDir}\n`);
64
+
65
+ if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
66
+
67
+ function write(rel, content, { overwrite = FORCE } = {}) {
68
+ const abs = join(targetDir, rel);
69
+ if (existsSync(abs) && !overwrite) {
70
+ console.log(` skip: ${rel} (exists)`);
71
+ return;
72
+ }
73
+ const parent = dirname(abs);
74
+ if (!existsSync(parent)) mkdirSync(parent, { recursive: true });
75
+ writeFileSync(abs, content, 'utf-8');
76
+ console.log(` create: ${rel}`);
77
+ }
78
+
79
+ function copy(srcRel, dstRel, { overwrite = FORCE } = {}) {
80
+ const abs = join(targetDir, dstRel);
81
+ const src = join(PKG_ROOT, srcRel);
82
+ if (!existsSync(src)) {
83
+ console.log(` skip: ${dstRel} (template ${srcRel} not found)`);
84
+ return;
85
+ }
86
+ if (existsSync(abs) && !overwrite) {
87
+ console.log(` skip: ${dstRel} (exists)`);
88
+ return;
89
+ }
90
+ const parent = dirname(abs);
91
+ if (!existsSync(parent)) mkdirSync(parent, { recursive: true });
92
+ copyFileSync(src, abs);
93
+ console.log(` create: ${dstRel}`);
94
+ }
95
+
96
+ // ---------- package.json ----------
97
+
98
+ const consumerPkg = {
99
+ name,
100
+ version: '0.1.0',
101
+ private: true,
102
+ scripts: {
103
+ sync: 'job-forge sync',
104
+ merge: 'job-forge merge',
105
+ verify: 'job-forge verify',
106
+ dedup: 'job-forge dedup',
107
+ normalize: 'job-forge normalize',
108
+ pdf: 'job-forge pdf',
109
+ 'sync-check': 'job-forge sync-check',
110
+ tokens: 'job-forge tokens',
111
+ 'tokens:today': 'job-forge tokens --days 1',
112
+ 'tokens:log': 'job-forge tokens --days 1 --append',
113
+ // One command to pull the latest harness, companion plugin, and any
114
+ // locally-pinned MCP packages. npm update is a no-op on packages not
115
+ // in package.json, so listing @razroo/gmail-mcp + @geometra/mcp is
116
+ // safe for consumers that invoke them via `npx -y` without pinning.
117
+ 'update-harness': 'npm update job-forge @razroo/opencode-model-fallback @razroo/gmail-mcp @geometra/mcp && job-forge sync && node -e "console.log(\'✅ harness at\', require(\'./package-lock.json\').packages[\'node_modules/job-forge\'].resolved)"',
118
+ },
119
+ dependencies: {
120
+ 'job-forge': 'github:razroo/JobForge',
121
+ // Model-fallback plugin: rotates agents through their fallback_models
122
+ // chain on rate-limit / 5xx errors so a rate-limited free-tier model
123
+ // doesn't wedge the whole flow. The chains live upstream in each
124
+ // agent's MD frontmatter (`.opencode/agents/*.md` in the harness);
125
+ // consumers can override individual chains by adding their own
126
+ // agent.<name>.fallback_models block to opencode.json. Requires
127
+ // 0.3.1+ for the frontmatter-merge path.
128
+ '@razroo/opencode-model-fallback': '^0.3.1',
129
+ },
130
+ engines: { node: '>=18' },
131
+ };
132
+ write('package.json', JSON.stringify(consumerPkg, null, 2) + '\n');
133
+
134
+ // ---------- opencode.json ----------
135
+
136
+ const opencodeCfg = {
137
+ $schema: 'https://opencode.ai/config.json',
138
+ // Model-fallback plugin: on rate-limit / 5xx / known provider errors,
139
+ // rotates the agent's model to the next entry in its fallback_models
140
+ // chain (see `agent` below) and replays the request. Without this, a
141
+ // rate-limited free-tier model wedges the whole subagent flow.
142
+ plugin: ['@razroo/opencode-model-fallback'],
143
+ // Files listed here load into every session's cached prefix, so they're
144
+ // cached once (on Anthropic) instead of Read-as-tool-call on every session.
145
+ // AGENTS.harness.md → symlink to node_modules/job-forge/AGENTS.md (harness rules)
146
+ // modes/_shared.md → symlink into node_modules; canonical scoring model
147
+ // cv.md → candidate's CV (personal, created during onboarding)
148
+ // templates/states.yml → canonical application states (validated by merge-tracker.mjs)
149
+ // Ordering matters for cache prefix stability: put most-stable files first.
150
+ instructions: [
151
+ 'AGENTS.harness.md',
152
+ 'templates/states.yml',
153
+ 'modes/_shared.md',
154
+ 'cv.md',
155
+ ],
156
+ mcp: {
157
+ geometra: {
158
+ type: 'local',
159
+ command: ['npx', '-y', '@geometra/mcp'],
160
+ enabled: true,
161
+ },
162
+ gmail: {
163
+ type: 'local',
164
+ command: ['npx', '-y', '@razroo/gmail-mcp'],
165
+ enabled: true,
166
+ // @razroo/gmail-mcp >=1.7.9 honors DISABLE_HTTP=true to skip its
167
+ // Streamable HTTP listener. Opencode uses stdio transport, so
168
+ // the HTTP server is unused and its port (default 3000) only
169
+ // causes EADDRINUSE conflicts with other local processes.
170
+ environment: { DISABLE_HTTP: 'true' },
171
+ },
172
+ },
173
+ // Restrict the primary orchestrator to dispatching only the three harness
174
+ // subagents. Prevents accidental self-calls or unregistered agents.
175
+ // Override locally in opencode.json if you add project-specific agents.
176
+ permission: {
177
+ task: {
178
+ 'general-free': 'allow',
179
+ 'general-paid': 'allow',
180
+ 'glm-minimal': 'allow',
181
+ },
182
+ },
183
+ // Tool-surface trimming — opencode ships every MCP tool's schema in every
184
+ // request unless disabled. The harness uses ~10 of Geometra's ~30 tools
185
+ // and ~4 of Gmail's. Disable all at the root level and re-enable the
186
+ // specific ones needed per agent in .opencode/agents/<name>.md. Saves
187
+ // ~2-3K tokens per request in the orchestrator's context window.
188
+ tools: {
189
+ 'geometra_*': false,
190
+ 'gmail_*': false,
191
+ },
192
+ };
193
+ write('opencode.json', JSON.stringify(opencodeCfg, null, 2) + '\n');
194
+
195
+ // ---------- AGENTS.md (auto-loaded by opencode on every session) ----------
196
+ //
197
+ // This file is intentionally thin. The harness's operational rules (Session
198
+ // Hygiene, OTP handling, batch best practices, scoring) live in
199
+ // node_modules/job-forge/AGENTS.md and load via opencode.json:instructions →
200
+ // AGENTS.harness.md (symlink created by sync.mjs). Keep *this* file for
201
+ // personal overrides.
202
+
203
+ write('AGENTS.md', `# AGENTS — ${name}
204
+
205
+ Personal job search project using the [job-forge](https://github.com/razroo/JobForge) harness. The harness lives in \`node_modules/job-forge/\`; most files you need are accessible through symlinks at the project root.
206
+
207
+ **How context loads in this project:** opencode auto-loads *this* file as the project-root AGENTS.md, and also loads \`AGENTS.harness.md\` via \`opencode.json:instructions\` — that second file is a symlink to \`node_modules/job-forge/AGENTS.md\` and carries the shared operational rules (Session Hygiene, OTP handling, batch best practices, scoring). Keep *this* file for personal overrides — anything you want to diverge from or add on top.
208
+
209
+ ---
210
+
211
+ ## Project Layout — start here
212
+
213
+ Before doing any work, remember where things live in *this* project:
214
+
215
+ | What | Where | Notes |
216
+ |------|-------|-------|
217
+ | Application tracker | \`data/applications/YYYY-MM-DD.md\` | **Day-based**. One markdown table per day. **There is NO \`applications.md\` — do not look for it.** |
218
+ | Inbox of pending URLs | \`data/pipeline.md\` | The queue for \`/job-forge pipeline\` |
219
+ | Scanner dedup history | \`data/scan-history.tsv\` | Only touch in \`/job-forge scan\` |
220
+ | Scanner config | \`portals.yml\` (project root) | Company configs |
221
+ | Profile / identity | \`config/profile.yml\` | Candidate name, email, target roles |
222
+ | CV | \`cv.md\` (project root) | Markdown, source of truth |
223
+ | Proof points | \`article-digest.md\` | Optional, at project root |
224
+ | Skill modes | \`modes/\` (symlink) | \`.md\` files, one per skill. Read \`modes/_shared.md\` for scoring and \`modes/{mode}.md\` for the mode. |
225
+ | Skill router | \`.opencode/skills/job-forge.md\` (symlink) | How \`/job-forge <mode>\` dispatches |
226
+ | Batch prompt template | \`batch/batch-prompt.md\` (symlink) | Used by \`batch/batch-runner.sh\` |
227
+ | Batch runner | \`batch/batch-runner.sh\` (symlink) | Parallel \`opencode run\` orchestrator |
228
+ | Batch input / state | \`batch/batch-input.tsv\`, \`batch/batch-state.tsv\` | Personal data |
229
+ | Generated reports | \`reports/{###}-{company-slug}-{YYYY-MM-DD}.md\` | Gitignored |
230
+ | Generated PDFs | \`output/\` | Gitignored |
231
+ | Templates | \`templates/\` (symlink) | \`cv-template.html\`, \`portals.example.yml\`, \`states.yml\` |
232
+ | Harness rules | \`AGENTS.harness.md\` (symlink) | Shared operational guide, loaded via \`opencode.json:instructions\` |
233
+ | Harness source | \`node_modules/job-forge/\` | Read this for harness internals |
234
+
235
+ **\`modes/\`, \`templates/\`, \`.opencode/skills/job-forge.md\`, \`batch/batch-prompt.md\`, \`batch/batch-runner.sh\`, \`batch/README.md\`, and \`AGENTS.harness.md\` are all symlinks into \`node_modules/job-forge/\`.** Symlinks behave like real files for Read/Glob/Grep — no need to chase them into \`node_modules\` unless you want to see their real path.
236
+
237
+ When the user says something like "apply to N jobs", the candidates to apply to are either:
238
+ 1. Entries in \`data/applications/*.md\` with status **Evaluated** (already scored, ready to submit)
239
+ 2. URLs in \`data/pipeline.md\` that haven't been evaluated yet
240
+
241
+ Check both. Read today's day file (\`data/applications/$(date +%Y-%m-%d).md\`) plus the latest few day files for recent Evaluated entries.
242
+
243
+ ---
244
+
245
+ ## Personal additions
246
+
247
+ (Add project-specific rules below — model preferences, Geometra quirks, overrides to harness defaults, etc. Shared operational rules live in \`AGENTS.harness.md\`.)
248
+ `);
249
+
250
+ // ---------- Personal files (from templates) ----------
251
+
252
+ copy('config/profile.example.yml', 'config/profile.yml');
253
+ copy('templates/portals.example.yml', 'portals.yml');
254
+
255
+ // ---------- CV stub ----------
256
+
257
+ write('cv.md', `# Your Name
258
+
259
+ your.email@example.com · +1 (XXX) XXX-XXXX · City, Country
260
+ [LinkedIn](https://linkedin.com/in/you) · [GitHub](https://github.com/you)
261
+
262
+ ## Summary
263
+
264
+ (One-paragraph pitch about who you are, what you've built, and what you're looking for.)
265
+
266
+ ## Experience
267
+
268
+ ### Current Company — Title
269
+ *Dates*
270
+
271
+ - Bullet describing impact with a metric.
272
+ - Bullet describing impact with a metric.
273
+
274
+ ## Skills
275
+
276
+ (Comma-separated list grouped by category.)
277
+
278
+ ## Education
279
+
280
+ Degree, Institution, Year
281
+ `);
282
+
283
+ // ---------- Empty personal dirs ----------
284
+
285
+ for (const dir of ['data', 'data/applications', 'reports', 'batch/tracker-additions']) {
286
+ const abs = join(targetDir, dir);
287
+ if (!existsSync(abs)) {
288
+ mkdirSync(abs, { recursive: true });
289
+ writeFileSync(join(abs, '.gitkeep'), '', 'utf-8');
290
+ console.log(` create: ${dir}/`);
291
+ }
292
+ }
293
+
294
+ // ---------- .gitignore ----------
295
+
296
+ write('.gitignore', `# Personal data (your job search — don't share)
297
+ cv.md
298
+ article-digest.md
299
+ portals.yml
300
+ config/profile.yml
301
+ data/applications/
302
+ !data/applications/.gitkeep
303
+ data/applications.md
304
+ data/pipeline.md
305
+ data/scan-history.tsv
306
+ data/token-usage.tsv
307
+ reports/
308
+ !reports/.gitkeep
309
+ batch/batch-state.tsv
310
+ batch/batch-state.tsv.bak
311
+ batch/batch-input.tsv
312
+ batch/tracker-additions/
313
+ !batch/tracker-additions/.gitkeep
314
+ batch/logs/
315
+
316
+ # Harness symlinks (regenerated by npm install)
317
+ /modes
318
+ /templates
319
+ /.cursor/mcp.json
320
+ /.opencode/skills/job-forge.md
321
+ /.opencode/agents
322
+ /batch/batch-prompt.md
323
+ /batch/batch-runner.sh
324
+ /batch/README.md
325
+ /AGENTS.harness.md
326
+
327
+ # Standard
328
+ node_modules/
329
+ .DS_Store
330
+ *.log
331
+ `);
332
+
333
+ // ---------- README ----------
334
+
335
+ write('README.md', `# ${name}
336
+
337
+ Personal job search project using the [job-forge](https://github.com/razroo/JobForge) harness.
338
+
339
+ ## Setup
340
+
341
+ \`\`\`bash
342
+ npm install # pulls the harness and creates symlinks to modes/, templates/, etc.
343
+ \`\`\`
344
+
345
+ Then fill in:
346
+
347
+ - \`cv.md\` — your CV in markdown
348
+ - \`config/profile.yml\` — your identity and target roles
349
+ - \`portals.yml\` — companies you want to scan
350
+
351
+ ## Updating the harness
352
+
353
+ \`\`\`bash
354
+ npm update job-forge # pulls the latest from razroo/JobForge
355
+ job-forge sync # re-run if symlinks drift
356
+ \`\`\`
357
+
358
+ ## Common commands
359
+
360
+ \`\`\`bash
361
+ job-forge merge # merge batch/tracker-additions/*.tsv into the tracker
362
+ job-forge verify # verify pipeline integrity
363
+ job-forge pdf cv.md out.pdf
364
+ job-forge tokens --days 1 # per-session opencode token usage
365
+ \`\`\`
366
+ `);
367
+
368
+ console.log(`
369
+ Done. Next steps:
370
+
371
+ cd ${targetDir}
372
+ npm install
373
+ # edit cv.md, config/profile.yml, portals.yml
374
+ opencode
375
+ `);
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * job-forge — CLI dispatcher for the job-forge harness.
4
+ *
5
+ * Runs the .mjs scripts shipped in this package against the consumer's cwd.
6
+ * All scripts resolve the project dir via `process.env.JOB_FORGE_PROJECT ||
7
+ * process.cwd()`, so running this bin from a consumer project Just Works.
8
+ *
9
+ * Usage:
10
+ * job-forge <command> [args...]
11
+ *
12
+ * Commands:
13
+ * merge Run merge-tracker.mjs
14
+ * dedup Run dedup-tracker.mjs
15
+ * verify Run verify-pipeline.mjs
16
+ * normalize Run normalize-statuses.mjs
17
+ * pdf Run generate-pdf.mjs
18
+ * sync-check Run cv-sync-check.mjs
19
+ * tokens Run scripts/token-usage-report.mjs
20
+ * sync Re-run the harness symlink sync (bin/sync.mjs)
21
+ * help, --help Show this message
22
+ */
23
+
24
+ import { spawnSync } from 'child_process';
25
+ import { dirname, join, resolve } from 'path';
26
+ import { fileURLToPath } from 'url';
27
+ import { existsSync } from 'fs';
28
+
29
+ const __dirname = dirname(fileURLToPath(import.meta.url));
30
+ const PKG_ROOT = resolve(__dirname, '..');
31
+
32
+ const commands = {
33
+ merge: 'merge-tracker.mjs',
34
+ dedup: 'dedup-tracker.mjs',
35
+ verify: 'verify-pipeline.mjs',
36
+ normalize: 'normalize-statuses.mjs',
37
+ pdf: 'generate-pdf.mjs',
38
+ 'sync-check': 'cv-sync-check.mjs',
39
+ tokens: 'scripts/token-usage-report.mjs',
40
+ sync: 'bin/sync.mjs',
41
+ // Deterministic helpers — agents call these instead of deriving values
42
+ // themselves, which saves thinking + Bash + verify tokens per invocation.
43
+ 'next-num': 'scripts/next-num.mjs',
44
+ slugify: 'scripts/slugify.mjs',
45
+ today: 'scripts/today.mjs',
46
+ 'tracker-line': 'scripts/tracker-line.mjs',
47
+ // Auto-visibility into cost: run at end of session or batch to log usage
48
+ // and warn on >$budget sessions. No opencode lifecycle hook exists, so
49
+ // this is the closest to a SessionEnd trigger — wire it into your
50
+ // shell wrapper around `opencode`, or into batch-runner.sh (already done).
51
+ 'session-report': 'scripts/session-report.mjs',
52
+ 'render-report-header': 'scripts/render-report-header.mjs',
53
+ };
54
+
55
+ const [, , cmd, ...rest] = process.argv;
56
+
57
+ function printHelp() {
58
+ console.log(`job-forge — CLI for the job-forge harness
59
+
60
+ Usage:
61
+ job-forge <command> [args...]
62
+
63
+ Commands:
64
+ merge Merge batch/tracker-additions/*.tsv into the tracker
65
+ dedup Remove duplicate entries from the tracker
66
+ verify Verify pipeline integrity (reports, URLs, dedup)
67
+ normalize Normalize status values across the tracker
68
+ pdf Generate ATS-optimized CV PDF from cv.md
69
+ sync-check Lint: verify cv.md and profile.yml are filled in
70
+ tokens Show opencode token usage and cost by session/day
71
+ sync Re-create harness symlinks in the current project
72
+
73
+ Deterministic helpers (prefer these over LLM-derived values):
74
+ next-num Print next sequential report number (e.g. 521)
75
+ slugify NAME Convert a company/role name to a filename-safe slug
76
+ today Print today's date in YYYY-MM-DD
77
+ tracker-line Emit a 9-col TSV row for batch/tracker-additions/
78
+
79
+ Cost visibility:
80
+ session-report Summarize recent session costs, warn on >budget sessions
81
+ (e.g. job-forge session-report --since-minutes 60 --log)
82
+
83
+ Report assembly:
84
+ render-report-header Given a score JSON on stdin, print the canonical
85
+ report header + "## Score" section. Agents append
86
+ Blocks A-F after this instead of re-emitting the
87
+ standard boilerplate every evaluation.
88
+
89
+ Pass --help after a command to see its own flags, e.g.:
90
+ job-forge merge --help
91
+ job-forge tokens --days 1
92
+ job-forge slugify "Anthropic, PBC"
93
+
94
+ Project directory resolves to $JOB_FORGE_PROJECT or cwd.`);
95
+ }
96
+
97
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
98
+ printHelp();
99
+ process.exit(0);
100
+ }
101
+
102
+ const rel = commands[cmd];
103
+ if (!rel) {
104
+ console.error(`Unknown command: ${cmd}\n`);
105
+ printHelp();
106
+ process.exit(2);
107
+ }
108
+
109
+ const scriptPath = join(PKG_ROOT, rel);
110
+ if (!existsSync(scriptPath)) {
111
+ console.error(`Internal error: script ${rel} not found at ${scriptPath}`);
112
+ process.exit(2);
113
+ }
114
+
115
+ const result = spawnSync(process.execPath, [scriptPath, ...rest], {
116
+ stdio: 'inherit',
117
+ env: process.env,
118
+ });
119
+
120
+ process.exit(result.status ?? 1);
package/bin/sync.mjs ADDED
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sync.mjs — Create/refresh harness symlinks in the consumer's project.
4
+ *
5
+ * When job-forge is installed as an npm dependency, opencode / cursor /
6
+ * claude code / codex need to see certain files at the *consumer project
7
+ * root* (not inside node_modules). All of these are generated at publish
8
+ * time by iso-harness from the harness's iso/ source; this script mirrors
9
+ * them into the consumer's layout via symlinks.
10
+ *
11
+ * This script creates symlinks to the harness copies. Idempotent:
12
+ * - If the symlink already points to the harness, skip.
13
+ * - If a real file/dir exists at the target (user customized), warn and skip.
14
+ * - Otherwise create the symlink.
15
+ *
16
+ * Invoked automatically by `postinstall` in the package, or manually via
17
+ * `npx job-forge sync`.
18
+ *
19
+ * Skipped when running inside the harness repo itself (detected by checking
20
+ * whether the cwd contains the harness's own package.json with name=job-forge).
21
+ */
22
+
23
+ import { existsSync, lstatSync, readlinkSync, symlinkSync, mkdirSync, readFileSync } from 'fs';
24
+ import { dirname, join, resolve, relative } from 'path';
25
+ import { fileURLToPath } from 'url';
26
+
27
+ const __dirname = dirname(fileURLToPath(import.meta.url));
28
+ const PKG_ROOT = resolve(__dirname, '..');
29
+
30
+ // Resolve the consumer's project root. During npm install, INIT_CWD is the
31
+ // directory from which npm install was run (the consumer project).
32
+ // Fallback to cwd.
33
+ const PROJECT_DIR = process.env.INIT_CWD || process.env.JOB_FORGE_PROJECT || process.cwd();
34
+
35
+ // Skip if we're inside the harness itself (avoid self-symlinking during dev).
36
+ const pkgJsonPath = join(PROJECT_DIR, 'package.json');
37
+ if (existsSync(pkgJsonPath)) {
38
+ try {
39
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
40
+ if (pkg.name === 'job-forge' && PROJECT_DIR === PKG_ROOT) {
41
+ console.log('job-forge sync: skipping (running inside harness repo).');
42
+ process.exit(0);
43
+ }
44
+ } catch { /* ignore */ }
45
+ }
46
+
47
+ if (PROJECT_DIR === PKG_ROOT) {
48
+ console.log('job-forge sync: skipping (PROJECT_DIR == PKG_ROOT).');
49
+ process.exit(0);
50
+ }
51
+
52
+ // ---------- Symlink plan ----------
53
+
54
+ // Each entry: { source (inside harness), target (inside consumer project) }
55
+ const links = [
56
+ // Cursor: MCP servers + always-apply rule (harness-level). Consumers can
57
+ // add their own rules in .cursor/rules/ alongside this one.
58
+ { src: '.cursor/mcp.json', dst: '.cursor/mcp.json' },
59
+ { src: '.cursor/rules/main.mdc', dst: '.cursor/rules/main.mdc' },
60
+
61
+ // Claude Code: MCP config (.mcp.json is what claude-code reads for
62
+ // project-scoped MCP). No subagents/commands emitted because iso/agents/
63
+ // and iso/commands/ are flagged claude: skip.
64
+ { src: '.mcp.json', dst: '.mcp.json' },
65
+
66
+ // Codex: MCP config.
67
+ { src: '.codex/config.toml', dst: '.codex/config.toml' },
68
+
69
+ // OpenCode: skill router + subagent definitions. Users can override any
70
+ // single subagent by replacing its symlink with a local file.
71
+ { src: '.opencode/skills/job-forge.md', dst: '.opencode/skills/job-forge.md' },
72
+ { src: '.opencode/agents', dst: '.opencode/agents' },
73
+
74
+ // Shared content directories referenced by opencode.json instructions +
75
+ // skill router (Read's modes/{mode}.md, etc).
76
+ { src: 'modes', dst: 'modes' },
77
+ { src: 'templates', dst: 'templates' },
78
+ { src: 'batch/batch-prompt.md', dst: 'batch/batch-prompt.md' },
79
+ { src: 'batch/batch-runner.sh', dst: 'batch/batch-runner.sh' },
80
+ { src: 'batch/README.md', dst: 'batch/README.md' },
81
+
82
+ // Top-level instructions surfaced at project root with a `.harness`
83
+ // suffix so the consumer's own AGENTS.md / CLAUDE.md stay fully personal.
84
+ // The consumer's opencode.json / CLAUDE.md / AGENTS.md references the
85
+ // .harness.md variants to pull in shared context.
86
+ { src: 'AGENTS.md', dst: 'AGENTS.harness.md' },
87
+ { src: 'CLAUDE.md', dst: 'CLAUDE.harness.md' },
88
+ ];
89
+
90
+ let created = 0, skipped = 0, warned = 0;
91
+
92
+ for (const { src, dst } of links) {
93
+ const absSrc = join(PKG_ROOT, src);
94
+ const absDst = join(PROJECT_DIR, dst);
95
+
96
+ if (!existsSync(absSrc)) {
97
+ console.warn(` skip: ${src} not found in harness`);
98
+ continue;
99
+ }
100
+
101
+ // Ensure parent dir exists
102
+ const parent = dirname(absDst);
103
+ if (!existsSync(parent)) mkdirSync(parent, { recursive: true });
104
+
105
+ // Check current state of target
106
+ let stat = null;
107
+ try { stat = lstatSync(absDst); } catch {}
108
+
109
+ if (stat) {
110
+ if (stat.isSymbolicLink()) {
111
+ const current = readlinkSync(absDst);
112
+ const expected = relative(dirname(absDst), absSrc);
113
+ if (current === expected || resolve(dirname(absDst), current) === absSrc) {
114
+ skipped++;
115
+ continue;
116
+ }
117
+ // Points elsewhere — user may have pinned to a different version
118
+ console.warn(` warn: ${dst} is a symlink pointing elsewhere (${current}) — leaving alone`);
119
+ warned++;
120
+ continue;
121
+ }
122
+ // Real file/dir exists
123
+ console.warn(` warn: ${dst} already exists as a real file/dir — leaving alone`);
124
+ warned++;
125
+ continue;
126
+ }
127
+
128
+ // Create symlink (relative, so the project remains portable)
129
+ const relSrc = relative(dirname(absDst), absSrc);
130
+ const type = lstatSync(absSrc).isDirectory() ? 'dir' : 'file';
131
+ try {
132
+ symlinkSync(relSrc, absDst, type);
133
+ console.log(` linked: ${dst} → ${relSrc}`);
134
+ created++;
135
+ } catch (e) {
136
+ console.error(` error: failed to symlink ${dst}: ${e.message}`);
137
+ warned++;
138
+ }
139
+ }
140
+
141
+ console.log(`\njob-forge sync: ${created} created, ${skipped} up-to-date, ${warned} warnings (project: ${PROJECT_DIR})`);