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.
- package/.codex/config.toml +8 -0
- package/.cursor/mcp.json +21 -0
- package/.cursor/rules/main.mdc +519 -0
- package/.mcp.json +21 -0
- package/.opencode/agents/general-free.md +85 -0
- package/.opencode/agents/general-paid.md +39 -0
- package/.opencode/agents/glm-minimal.md +50 -0
- package/.opencode/skills/job-forge.md +185 -0
- package/AGENTS.md +514 -0
- package/CLAUDE.md +514 -0
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/batch/README.md +60 -0
- package/batch/batch-prompt.md +399 -0
- package/batch/batch-runner.sh +673 -0
- package/bin/create-job-forge.mjs +375 -0
- package/bin/job-forge.mjs +120 -0
- package/bin/sync.mjs +141 -0
- package/config/profile.example.yml +67 -0
- package/cv-sync-check.mjs +128 -0
- package/dedup-tracker.mjs +201 -0
- package/docs/ARCHITECTURE.md +220 -0
- package/docs/CUSTOMIZATION.md +101 -0
- package/docs/MODEL-ROUTING.md +195 -0
- package/docs/README.md +54 -0
- package/docs/SETUP.md +186 -0
- package/docs/demo.gif +0 -0
- package/fonts/dm-sans-latin-ext.woff2 +0 -0
- package/fonts/dm-sans-latin.woff2 +0 -0
- package/fonts/space-grotesk-latin-ext.woff2 +0 -0
- package/fonts/space-grotesk-latin.woff2 +0 -0
- package/generate-pdf.mjs +168 -0
- package/iso/agents/general-free.md +90 -0
- package/iso/agents/general-paid.md +44 -0
- package/iso/agents/glm-minimal.md +55 -0
- package/iso/commands/job-forge.md +188 -0
- package/iso/config.json +7 -0
- package/iso/instructions.md +514 -0
- package/iso/mcp.json +15 -0
- package/merge-tracker.mjs +377 -0
- package/modes/README.md +30 -0
- package/modes/_shared-calibration.md +26 -0
- package/modes/_shared.md +272 -0
- package/modes/apply.md +257 -0
- package/modes/auto-pipeline.md +70 -0
- package/modes/batch.md +110 -0
- package/modes/compare.md +23 -0
- package/modes/contact.md +82 -0
- package/modes/deep.md +99 -0
- package/modes/followup.md +68 -0
- package/modes/negotiation.md +146 -0
- package/modes/offer.md +199 -0
- package/modes/pdf.md +121 -0
- package/modes/pipeline.md +83 -0
- package/modes/project.md +30 -0
- package/modes/rejection.md +92 -0
- package/modes/scan.md +185 -0
- package/modes/tracker.md +31 -0
- package/modes/training.md +27 -0
- package/normalize-statuses.mjs +152 -0
- package/opencode.json +28 -0
- package/package.json +78 -0
- package/scripts/add-tags.mjs +894 -0
- package/scripts/cursor-agent-loop.sh +211 -0
- package/scripts/cursor-agent-stream-format.py +134 -0
- package/scripts/next-num.mjs +33 -0
- package/scripts/release/check-source.mjs +37 -0
- package/scripts/render-report-header.mjs +78 -0
- package/scripts/session-report.mjs +129 -0
- package/scripts/slugify.mjs +27 -0
- package/scripts/today.mjs +20 -0
- package/scripts/token-usage-report.mjs +315 -0
- package/scripts/tracker-line.mjs +67 -0
- package/scripts/verify-greenhouse-urls.mjs +195 -0
- package/templates/cv-template.html +395 -0
- package/templates/portals.example.yml +3140 -0
- package/templates/states.yml +62 -0
- package/tracker-lib.mjs +257 -0
- 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})`);
|