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
package/modes/scan.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Mode: scan ā Portal Scanner (Offer Discovery)
|
|
2
|
+
|
|
3
|
+
Scans configured job portals, filters by title relevance, and adds new offers to the pipeline for later evaluation.
|
|
4
|
+
|
|
5
|
+
## Recommended Execution
|
|
6
|
+
|
|
7
|
+
Run as a subagent to avoid consuming main context:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Agent(
|
|
11
|
+
subagent_type="general-purpose",
|
|
12
|
+
prompt="[contents of this file + specific data]",
|
|
13
|
+
run_in_background=True
|
|
14
|
+
)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Read This Configuration
|
|
18
|
+
|
|
19
|
+
Read `portals.yml` which contains:
|
|
20
|
+
- `search_queries`: List of WebSearch queries with `site:` filters per portal (broad discovery)
|
|
21
|
+
- `tracked_companies`: Specific companies with `careers_url` for direct navigation
|
|
22
|
+
- `title_filter`: Positive/negative/seniority_boost keywords for title filtering
|
|
23
|
+
|
|
24
|
+
## Apply This Discovery Strategy (3 levels)
|
|
25
|
+
|
|
26
|
+
### Use Level 1 ā Direct Geometra (PRIMARY)
|
|
27
|
+
|
|
28
|
+
**For each company in `tracked_companies`:** Connect to its `careers_url` with Geometra MCP (`geometra_connect` + `geometra_page_model` / `geometra_list_items`), read ALL visible job listings, and extract the title + URL of each one. Direct Geometra is the most reliable method because:
|
|
29
|
+
|
|
30
|
+
- It sees the page in real time (not cached Google results).
|
|
31
|
+
- It works with SPAs (Ashby, Lever, Workday).
|
|
32
|
+
- It detects new offers instantly.
|
|
33
|
+
- It doesn't depend on Google indexing.
|
|
34
|
+
|
|
35
|
+
**Every company MUST have a `careers_url` in portals.yml.** If it doesn't, search for it once, save it, and use it in future scans.
|
|
36
|
+
|
|
37
|
+
### Use Level 2 ā Greenhouse API (COMPLEMENTARY)
|
|
38
|
+
|
|
39
|
+
For companies using Greenhouse, the JSON API (`boards-api.greenhouse.io/v1/boards/{slug}/jobs`) returns clean structured data. Use as a quick complement to Level 1 ā it's faster than Geometra but only works with Greenhouse.
|
|
40
|
+
|
|
41
|
+
### Use Level 3 ā WebSearch Queries (BROAD DISCOVERY)
|
|
42
|
+
|
|
43
|
+
The `search_queries` with `site:` filters cover portals broadly (all Ashby boards, all Greenhouse boards, all Lever boards, all Workday boards). Useful for discovering NEW companies not yet in `tracked_companies`, but results may be outdated.
|
|
44
|
+
|
|
45
|
+
**Execution priority:**
|
|
46
|
+
1. Level 1: Geometra ā all `tracked_companies` with `careers_url`
|
|
47
|
+
2. Level 2: API ā all `tracked_companies` with `api:`
|
|
48
|
+
3. Level 3: WebSearch ā all `search_queries` with `enabled: true`
|
|
49
|
+
|
|
50
|
+
The levels are additive ā all are executed, results are merged and deduplicated.
|
|
51
|
+
|
|
52
|
+
## Run This Workflow
|
|
53
|
+
|
|
54
|
+
1. **Read configuration**: `portals.yml`
|
|
55
|
+
2. **Read history**: `data/scan-history.tsv` ā previously seen URLs
|
|
56
|
+
3. **Read dedup sources**: all day files in `data/applications/` + `data/pipeline.md`
|
|
57
|
+
|
|
58
|
+
4. **Level 1 ā Geometra scan** (sequential, or ā¤2 parallel via `task` subagents per Hard Limit #1 in `AGENTS.md`):
|
|
59
|
+
For each company in `tracked_companies` with `enabled: true` and `careers_url` defined:
|
|
60
|
+
a. `geometra_connect` to the `careers_url`
|
|
61
|
+
b. `geometra_page_model` or `geometra_list_items` to read all job listings
|
|
62
|
+
c. If the page has filters/departments, navigate the relevant sections
|
|
63
|
+
d. For each job listing extract: `{title, url, company}`
|
|
64
|
+
e. If the page paginates results, navigate additional pages
|
|
65
|
+
f. Accumulate in candidates list
|
|
66
|
+
g. If `careers_url` fails (404, redirect), try `scan_query` as fallback and note for URL update
|
|
67
|
+
|
|
68
|
+
5. **Level 2 ā Greenhouse APIs** (WebFetch can batch freely ā it's cheap and doesn't use Geometra sessions):
|
|
69
|
+
For each company in `tracked_companies` with `api:` defined and `enabled: true`:
|
|
70
|
+
a. WebFetch the API URL ā JSON with job list
|
|
71
|
+
b. For each job extract: `{title, url, company}`
|
|
72
|
+
c. Accumulate in candidates list (dedup with Level 1)
|
|
73
|
+
|
|
74
|
+
6. **Level 3 ā WebSearch queries** (WebSearch is parallel-safe; batch freely):
|
|
75
|
+
For each query in `search_queries` with `enabled: true`:
|
|
76
|
+
a. Execute WebSearch with the defined `query`
|
|
77
|
+
b. From each result extract: `{title, url, company}`
|
|
78
|
+
- **title**: from the result title (before " @ " or " | ")
|
|
79
|
+
- **url**: result URL
|
|
80
|
+
- **company**: after " @ " in the title, or extract from domain/path
|
|
81
|
+
c. Accumulate in candidates list (dedup with Level 1+2)
|
|
82
|
+
|
|
83
|
+
6. **Filter by title** using `title_filter` from `portals.yml`:
|
|
84
|
+
- At least 1 keyword from `positive` must appear in the title (case-insensitive)
|
|
85
|
+
- 0 keywords from `negative` must appear
|
|
86
|
+
- `seniority_boost` keywords give priority but are not required
|
|
87
|
+
|
|
88
|
+
7. **Deduplicate** against 3 sources (URL-exact + fuzzy company+role):
|
|
89
|
+
|
|
90
|
+
**Layer 1 ā URL-exact:**
|
|
91
|
+
- `scan-history.tsv` ā exact URL already seen
|
|
92
|
+
- `pipeline.md` ā exact URL already in pending or processed
|
|
93
|
+
|
|
94
|
+
**Layer 2 ā Company + role fuzzy match (catches reposts with new URLs):**
|
|
95
|
+
- all day files in `data/applications/` ā normalize company name (lowercase, strip non-alphanumeric) + fuzzy role match (2+ significant words in common, words > 3 chars). This is the same logic used in `dedup-tracker.mjs` and `merge-tracker.mjs`.
|
|
96
|
+
- `scan-history.tsv` ā same fuzzy match against company + title columns (not just URL). A role reposted on a new URL but with the same company and similar title is a duplicate.
|
|
97
|
+
- `pipeline.md` ā same fuzzy match against company + title in pending items that include metadata (format: `- [ ] {url} | {company} | {title}`)
|
|
98
|
+
|
|
99
|
+
**Fuzzy match rules:**
|
|
100
|
+
- Normalize company: `company.toLowerCase().replace(/[^a-z0-9]/g, '')`
|
|
101
|
+
- Fuzzy role match: split both titles into words > 3 chars, match if 2+ words overlap (substring match, case-insensitive). E.g., "Senior AI Engineer" and "Staff AI Engineer" share "engineer" ā only 1 overlap, not a match. But "AI Platform Engineer" and "AI Platform Eng" share "platform" + partial "engineer" ā match.
|
|
102
|
+
- When a fuzzy match is found but the URL is new, log it as `skipped_repost` (not `skipped_dup`) with a note referencing the original entry number.
|
|
103
|
+
|
|
104
|
+
8. **For each new offer that passes filters**:
|
|
105
|
+
a. Add to `pipeline.md` section "Pending": `- [ ] {url} | {company} | {title}`
|
|
106
|
+
b. Record in `scan-history.tsv`: `{url}\t{date}\t{query_name}\t{title}\t{company}\tadded`
|
|
107
|
+
|
|
108
|
+
9. **Offers filtered by title**: record in `scan-history.tsv` with status `skipped_title`
|
|
109
|
+
10. **Duplicate offers (URL-exact)**: record with status `skipped_dup`
|
|
110
|
+
11. **Duplicate offers (fuzzy repost)**: record with status `skipped_repost` and note `repost of #{original_entry_num}`
|
|
111
|
+
|
|
112
|
+
## Extract Title And Company From WebSearch Results
|
|
113
|
+
|
|
114
|
+
WebSearch results come in the format: `"Job Title @ Company"` or `"Job Title | Company"` or `"Job Title ā Company"`.
|
|
115
|
+
|
|
116
|
+
Extraction patterns by portal:
|
|
117
|
+
- **Ashby**: `"Senior AI PM (Remote) @ EverAI"` ā title: `Senior AI PM`, company: `EverAI`
|
|
118
|
+
- **Greenhouse**: `"AI Engineer at Anthropic"` ā title: `AI Engineer`, company: `Anthropic`
|
|
119
|
+
- **Lever**: `"Product Manager - AI @ Temporal"` ā title: `Product Manager - AI`, company: `Temporal`
|
|
120
|
+
|
|
121
|
+
Generic regex: `(.+?)(?:\s*[@|āā-]\s*|\s+at\s+)(.+?)$`
|
|
122
|
+
|
|
123
|
+
## Resolve Private URLs
|
|
124
|
+
|
|
125
|
+
If a publicly inaccessible URL is found:
|
|
126
|
+
1. Save the JD to `jds/{company}-{role-slug}.md`
|
|
127
|
+
2. Add to pipeline.md as: `- [ ] local:jds/{company}-{role-slug}.md | {company} | {title}`
|
|
128
|
+
|
|
129
|
+
## Scan History
|
|
130
|
+
|
|
131
|
+
`data/scan-history.tsv` tracks ALL seen URLs:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
url first_seen portal title company status
|
|
135
|
+
https://... 2026-02-10 Ashby ā AI PM PM AI Acme added
|
|
136
|
+
https://... 2026-02-10 Greenhouse ā SA Junior Dev BigCo skipped_title
|
|
137
|
+
https://... 2026-02-10 Ashby ā AI PM SA AI OldCo skipped_dup
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Output Summary
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
Portal Scan ā {YYYY-MM-DD}
|
|
144
|
+
āāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
145
|
+
Queries executed: N
|
|
146
|
+
Offers found: N total
|
|
147
|
+
Filtered by title: N relevant
|
|
148
|
+
Duplicates: N (already evaluated or in pipeline)
|
|
149
|
+
New added to pipeline.md: N
|
|
150
|
+
|
|
151
|
+
+ {company} | {title} | {query_name}
|
|
152
|
+
...
|
|
153
|
+
|
|
154
|
+
ā Run /job-forge pipeline to evaluate the new offers.
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Update careers_url
|
|
158
|
+
|
|
159
|
+
Each company in `tracked_companies` MUST have a `careers_url` ā the direct URL to its job listings page. The stored URL avoids searching for it every time.
|
|
160
|
+
|
|
161
|
+
**Known patterns by platform:**
|
|
162
|
+
- **Ashby:** `https://jobs.ashbyhq.com/{slug}`
|
|
163
|
+
- **Greenhouse:** `https://job-boards.greenhouse.io/{slug}` or `https://job-boards.eu.greenhouse.io/{slug}`
|
|
164
|
+
- **Lever:** `https://jobs.lever.co/{slug}`
|
|
165
|
+
- **Custom:** The company's own URL (e.g., `https://openai.com/careers`)
|
|
166
|
+
|
|
167
|
+
**If `careers_url` doesn't exist** for a company:
|
|
168
|
+
1. Try the pattern for its known platform
|
|
169
|
+
2. If that fails, do a quick WebSearch: `"{company}" careers jobs`
|
|
170
|
+
3. Navigate with Geometra (`geometra_connect`) to confirm it works
|
|
171
|
+
4. **Save the found URL in portals.yml** for future scans
|
|
172
|
+
|
|
173
|
+
**If `careers_url` returns 404 or redirect:**
|
|
174
|
+
1. Note in the output summary
|
|
175
|
+
2. Try scan_query as fallback
|
|
176
|
+
3. Flag for manual update
|
|
177
|
+
|
|
178
|
+
## Update portals.yml
|
|
179
|
+
|
|
180
|
+
- **ALWAYS save `careers_url`** when adding a new company
|
|
181
|
+
- Add new queries as interesting portals or roles are discovered
|
|
182
|
+
- Disable queries with `enabled: false` if they generate too much noise
|
|
183
|
+
- Adjust filtering keywords as target roles evolve
|
|
184
|
+
- Add companies to `tracked_companies` when you want to follow them closely
|
|
185
|
+
- Verify `careers_url` periodically ā companies change ATS platforms
|
package/modes/tracker.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Mode: tracker ā Application Tracker
|
|
2
|
+
|
|
3
|
+
Reads and displays the application tracker: day-based files in `data/applications/` (format: `YYYY-MM-DD.md`).
|
|
4
|
+
|
|
5
|
+
**Tracker format:**
|
|
6
|
+
```markdown
|
|
7
|
+
| # | Date | Company | Role | Score | Status | PDF | Report | Notes |
|
|
8
|
+
|---|------|---------|------|-------|--------|-----|--------|-------|
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Possible states (canonical, per `templates/states.yml`):
|
|
12
|
+
|
|
13
|
+
`Evaluated` ā `Applied` ā `Contacted` ā `Responded` ā `Interview` ā `Offer` / `Rejected` / `Discarded` / `SKIP`
|
|
14
|
+
|
|
15
|
+
- `Applied` = the candidate submitted their application
|
|
16
|
+
- `Contacted` = the candidate proactively reached out to someone at the company (outbound, e.g., LinkedIn power move via `/job-forge contact`)
|
|
17
|
+
- `Responded` = a recruiter/company contacted back and the candidate responded (inbound)
|
|
18
|
+
|
|
19
|
+
If the user asks to update a status, edit the corresponding row in the day file where the entry exists.
|
|
20
|
+
|
|
21
|
+
Also display statistics:
|
|
22
|
+
- Total applications
|
|
23
|
+
- By status
|
|
24
|
+
- Average score
|
|
25
|
+
- % with generated PDF
|
|
26
|
+
- % with generated report
|
|
27
|
+
|
|
28
|
+
If any entries look overdue for follow-up (Applied 7+ days ago, Contacted 5+ days ago, Interviewed with no update 7+ days), mention it:
|
|
29
|
+
> "3 entries may need follow-up. Run `/job-forge followup` for details."
|
|
30
|
+
|
|
31
|
+
The followup reminder above is a passive hint ā it does NOT change tracker behavior or output format.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Mode: training ā Training Evaluation
|
|
2
|
+
|
|
3
|
+
For each course/cert the candidate asks about, evaluate across 6 dimensions:
|
|
4
|
+
|
|
5
|
+
| Dimension | What it evaluates |
|
|
6
|
+
|-----------|-------------------|
|
|
7
|
+
| North Star alignment | Does it move closer to or away from the goal? |
|
|
8
|
+
| Recruiter signal | What do HMs think when they see this on a CV? |
|
|
9
|
+
| Time and effort | Weeks x hours/week |
|
|
10
|
+
| Opportunity cost | What can't they do during that time? |
|
|
11
|
+
| Risks | Outdated content? Weak brand? Too basic? |
|
|
12
|
+
| Portfolio deliverable | Does it produce a demonstrable artifact? |
|
|
13
|
+
|
|
14
|
+
## Return One Of These Verdicts
|
|
15
|
+
|
|
16
|
+
- **DO IT** ā 4-12 week plan with weekly deliverables and scoreboard
|
|
17
|
+
- **DON'T DO IT** ā better alternative with justification
|
|
18
|
+
- **DO IT WITH TIMEBOX** (max X weeks) ā condensed plan, essentials only
|
|
19
|
+
|
|
20
|
+
## Apply This Priority Order
|
|
21
|
+
|
|
22
|
+
Training that improves credibility in "production-grade AI":
|
|
23
|
+
1. Evals and LLM testing
|
|
24
|
+
2. Observability and monitoring
|
|
25
|
+
3. Cost/reliability trade-offs
|
|
26
|
+
4. AI governance and safety
|
|
27
|
+
5. Enterprise AI architecture
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* normalize-statuses.mjs ā Clean non-canonical states in 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
|
+
* Maps all non-canonical statuses to canonical ones per templates/states.yml:
|
|
10
|
+
* Evaluated, Applied, Responded, Contacted, Interview, Offer, Rejected, Discarded, SKIP
|
|
11
|
+
*
|
|
12
|
+
* Also strips markdown bold (**) and dates from the status field,
|
|
13
|
+
* moving DUPLICADO info to the notes column.
|
|
14
|
+
*
|
|
15
|
+
* Run: node normalize-statuses.mjs [--dry-run] (from repo root)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, writeFileSync, copyFileSync, existsSync } from 'fs';
|
|
19
|
+
import { join, relative, dirname } from 'path';
|
|
20
|
+
import { fileURLToPath } from 'url';
|
|
21
|
+
import {
|
|
22
|
+
PROJECT_DIR, DATA_APPS_DIR, DATA_APPS_FILE, ROOT_APPS_FILE,
|
|
23
|
+
usesDayFiles, ensureDayDir, parseAppLine, formatAppLine,
|
|
24
|
+
readAllEntries, writeToDayFiles, listDayFiles,
|
|
25
|
+
} from './tracker-lib.mjs';
|
|
26
|
+
|
|
27
|
+
const DRY_RUN = process.argv.includes('--dry-run');
|
|
28
|
+
|
|
29
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
30
|
+
console.log(`normalize-statuses.mjs ā map tracker status column to canonical labels
|
|
31
|
+
|
|
32
|
+
Supports day-based (data/applications/YYYY-MM-DD.md) and single-file layouts.
|
|
33
|
+
Uses templates/states.yml display labels when present. Strips markdown bold
|
|
34
|
+
and dates from the status field; moves duplicate/repost markers into notes
|
|
35
|
+
where applicable.
|
|
36
|
+
|
|
37
|
+
Usage:
|
|
38
|
+
node normalize-statuses.mjs [--dry-run]
|
|
39
|
+
npm run normalize [-- --dry-run]
|
|
40
|
+
|
|
41
|
+
Exits successfully when no tracker entries exist (nothing to do).
|
|
42
|
+
Creates a .bak copy next to the tracker before writing (single-file mode).
|
|
43
|
+
|
|
44
|
+
Run from the repository root.`);
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeStatus(raw) {
|
|
49
|
+
let s = raw.replace(/\*\*/g, '').trim();
|
|
50
|
+
const lower = s.toLowerCase();
|
|
51
|
+
|
|
52
|
+
if (/^dup(licate)?/i.test(s)) {
|
|
53
|
+
return { status: 'Discarded', moveToNotes: raw.trim() };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (/^contacted$/i.test(s)) return { status: 'Contacted' };
|
|
57
|
+
|
|
58
|
+
if (/^hold$/i.test(s)) return { status: 'Evaluated' };
|
|
59
|
+
|
|
60
|
+
if (/^repost/i.test(s)) return { status: 'Discarded', moveToNotes: raw.trim() };
|
|
61
|
+
|
|
62
|
+
if (s === 'ā' || s === '-' || s === '') return { status: 'Discarded' };
|
|
63
|
+
|
|
64
|
+
const canonical = [
|
|
65
|
+
'Evaluated', 'Applied', 'Contacted', 'Responded', 'Interview',
|
|
66
|
+
'Offer', 'Rejected', 'Discarded', 'SKIP',
|
|
67
|
+
];
|
|
68
|
+
for (const c of canonical) {
|
|
69
|
+
if (lower === c.toLowerCase()) return { status: c };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (['applied', 'sent'].includes(lower)) return { status: 'Applied' };
|
|
73
|
+
if (['skip'].includes(lower)) return { status: 'SKIP' };
|
|
74
|
+
|
|
75
|
+
return { status: null, unknown: true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Read entries
|
|
79
|
+
const { entries, source } = readAllEntries();
|
|
80
|
+
|
|
81
|
+
if (entries.length === 0) {
|
|
82
|
+
console.log('No tracker entries found. Nothing to normalize.');
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let changes = 0;
|
|
87
|
+
let unknowns = [];
|
|
88
|
+
const updated = entries.map(app => {
|
|
89
|
+
const result = normalizeStatus(app.status);
|
|
90
|
+
|
|
91
|
+
if (result.unknown) {
|
|
92
|
+
unknowns.push({ num: app.num, rawStatus: app.status });
|
|
93
|
+
return app;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (result.status === app.status) return app;
|
|
97
|
+
|
|
98
|
+
changes++;
|
|
99
|
+
console.log(`#${app.num}: "${app.status}" ā "${result.status}"`);
|
|
100
|
+
|
|
101
|
+
let notes = app.notes || '';
|
|
102
|
+
if (result.moveToNotes && !notes.includes(result.moveToNotes)) {
|
|
103
|
+
notes = result.moveToNotes + (notes ? '. ' + notes : '');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Also strip bold from score
|
|
107
|
+
const score = app.score ? app.score.replace(/\*\*/g, '') : app.score;
|
|
108
|
+
|
|
109
|
+
return { ...app, status: result.status, notes, score };
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (unknowns.length > 0) {
|
|
113
|
+
console.log(`\nā ļø ${unknowns.length} unknown statuses:`);
|
|
114
|
+
for (const u of unknowns) {
|
|
115
|
+
console.log(` #${u.num}: "${u.rawStatus}"`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log(`\nš ${changes} statuses normalized`);
|
|
120
|
+
|
|
121
|
+
if (!DRY_RUN && changes > 0) {
|
|
122
|
+
if (source === 'day') {
|
|
123
|
+
writeToDayFiles(updated);
|
|
124
|
+
console.log('ā
Written to day files');
|
|
125
|
+
} else {
|
|
126
|
+
const APPS_FILE = existsSync(DATA_APPS_FILE) ? DATA_APPS_FILE : ROOT_APPS_FILE;
|
|
127
|
+
const appsDisplay = relative(PROJECT_DIR, APPS_FILE).replace(/\\/g, '/');
|
|
128
|
+
copyFileSync(APPS_FILE, APPS_FILE + '.bak');
|
|
129
|
+
// Rewrite single-file
|
|
130
|
+
const filePath = APPS_FILE;
|
|
131
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
132
|
+
const lines = content.split('\n');
|
|
133
|
+
const updatedLines = [];
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
const app = parseAppLine(line);
|
|
136
|
+
if (app) {
|
|
137
|
+
const newApp = updated.find(u => u.num === app.num);
|
|
138
|
+
if (newApp) {
|
|
139
|
+
updatedLines.push(formatAppLine(newApp));
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
updatedLines.push(line);
|
|
144
|
+
}
|
|
145
|
+
writeFileSync(filePath, updatedLines.join('\n'));
|
|
146
|
+
console.log(`ā
Written to ${appsDisplay} (backup: ${appsDisplay}.bak)`);
|
|
147
|
+
}
|
|
148
|
+
} else if (DRY_RUN) {
|
|
149
|
+
console.log('(dry-run ā no changes written)');
|
|
150
|
+
} else {
|
|
151
|
+
console.log('ā
No changes needed');
|
|
152
|
+
}
|
package/opencode.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://opencode.ai/config.json",
|
|
3
|
+
"mcp": {
|
|
4
|
+
"geometra": {
|
|
5
|
+
"type": "local",
|
|
6
|
+
"command": [
|
|
7
|
+
"npx",
|
|
8
|
+
"-y",
|
|
9
|
+
"@geometra/mcp"
|
|
10
|
+
],
|
|
11
|
+
"environment": {}
|
|
12
|
+
},
|
|
13
|
+
"gmail": {
|
|
14
|
+
"type": "local",
|
|
15
|
+
"command": [
|
|
16
|
+
"npx",
|
|
17
|
+
"-y",
|
|
18
|
+
"@razroo/gmail-mcp"
|
|
19
|
+
],
|
|
20
|
+
"environment": {
|
|
21
|
+
"DISABLE_HTTP": "true"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"instructions": [
|
|
26
|
+
"templates/states.yml"
|
|
27
|
+
]
|
|
28
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "job-forge",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "AI-powered job search pipeline built on opencode",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"job-forge": "bin/job-forge.mjs",
|
|
8
|
+
"create-job-forge": "bin/create-job-forge.mjs"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"verify": "node verify-pipeline.mjs",
|
|
12
|
+
"build:dashboard": "cd dashboard && go build .",
|
|
13
|
+
"normalize": "node normalize-statuses.mjs",
|
|
14
|
+
"dedup": "node dedup-tracker.mjs",
|
|
15
|
+
"merge": "node merge-tracker.mjs",
|
|
16
|
+
"pdf": "node generate-pdf.mjs",
|
|
17
|
+
"sync-check": "node cv-sync-check.mjs",
|
|
18
|
+
"tokens": "node scripts/token-usage-report.mjs",
|
|
19
|
+
"tokens:today": "node scripts/token-usage-report.mjs --days 1",
|
|
20
|
+
"tokens:log": "node scripts/token-usage-report.mjs --days 1 --append",
|
|
21
|
+
"build:config": "iso-harness build --source iso --out .",
|
|
22
|
+
"prepack": "iso-harness build --source iso --out .",
|
|
23
|
+
"release:check-source": "node ./scripts/release/check-source.mjs",
|
|
24
|
+
"postinstall": "node bin/sync.mjs"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin/",
|
|
28
|
+
"iso/",
|
|
29
|
+
".cursor/mcp.json",
|
|
30
|
+
".cursor/rules/",
|
|
31
|
+
".opencode/",
|
|
32
|
+
".codex/",
|
|
33
|
+
".mcp.json",
|
|
34
|
+
"CLAUDE.md",
|
|
35
|
+
"AGENTS.md",
|
|
36
|
+
"opencode.json",
|
|
37
|
+
"modes/",
|
|
38
|
+
"templates/",
|
|
39
|
+
"config/profile.example.yml",
|
|
40
|
+
"fonts/",
|
|
41
|
+
"scripts/",
|
|
42
|
+
"batch/batch-prompt.md",
|
|
43
|
+
"batch/batch-runner.sh",
|
|
44
|
+
"batch/README.md",
|
|
45
|
+
"docs/",
|
|
46
|
+
"tracker-lib.mjs",
|
|
47
|
+
"merge-tracker.mjs",
|
|
48
|
+
"dedup-tracker.mjs",
|
|
49
|
+
"verify-pipeline.mjs",
|
|
50
|
+
"normalize-statuses.mjs",
|
|
51
|
+
"generate-pdf.mjs",
|
|
52
|
+
"cv-sync-check.mjs",
|
|
53
|
+
"README.md",
|
|
54
|
+
"LICENSE"
|
|
55
|
+
],
|
|
56
|
+
"keywords": [
|
|
57
|
+
"ai",
|
|
58
|
+
"job-search",
|
|
59
|
+
"opencode",
|
|
60
|
+
"career",
|
|
61
|
+
"automation"
|
|
62
|
+
],
|
|
63
|
+
"author": "Charlie Greenman",
|
|
64
|
+
"repository": {
|
|
65
|
+
"type": "git",
|
|
66
|
+
"url": "https://github.com/razroo/JobForge"
|
|
67
|
+
},
|
|
68
|
+
"license": "MIT",
|
|
69
|
+
"engines": {
|
|
70
|
+
"node": ">=18"
|
|
71
|
+
},
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"playwright": "^1.58.1"
|
|
74
|
+
},
|
|
75
|
+
"devDependencies": {
|
|
76
|
+
"@razroo/iso-harness": "^0.1.3"
|
|
77
|
+
}
|
|
78
|
+
}
|