job-forge 2.14.26 → 2.14.27
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/.cursor/rules/main.mdc +6 -3
- package/.opencode/skills/job-forge.md +11 -5
- package/AGENTS.md +6 -3
- package/CLAUDE.md +6 -3
- package/README.md +5 -4
- package/bin/create-job-forge.mjs +6 -0
- package/bin/job-forge.mjs +29 -0
- package/docs/ARCHITECTURE.md +3 -0
- package/docs/CUSTOMIZATION.md +5 -1
- package/docs/README.md +1 -1
- package/docs/SETUP.md +2 -0
- package/iso/commands/job-forge.md +11 -5
- package/iso/instructions.md +6 -3
- package/lib/jobforge-cache.mjs +9 -4
- package/lib/jobforge-canon.mjs +88 -0
- package/lib/jobforge-index.mjs +77 -1
- package/lib/jobforge-ledger.mjs +33 -15
- package/package.json +6 -1
- package/scripts/canon.mjs +178 -0
- package/scripts/ledger.mjs +27 -2
- package/templates/canon.json +65 -0
- package/templates/capabilities.json +4 -1
- package/templates/migrations.json +4 -0
package/.cursor/rules/main.mdc
CHANGED
|
@@ -12,7 +12,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
12
12
|
- [H1] Max 2 parallel `task` dispatches per message. For N jobs, run `ceil(N/2)` sequential rounds of 2. A round is not complete until both subagents return a final outcome (`APPLIED`, `APPLY FAILED`, `SKIP`, `Discarded`, or a written TSV path). A `task` tool result that only gives a session id / title is a launch acknowledgement, not completion. Applies in all modes, for all user phrasings ("urgent", "apply to 10 jobs now").
|
|
13
13
|
why: each subagent requires post-cleanup and racing more than 2 reliably loses at least one result. On 2026-04-25 the orchestrator launched round 2 while round 1 had only returned task ids, leaving four application subagents in flight and losing two provider recoveries
|
|
14
14
|
|
|
15
|
-
- [H2] Max 1 application per company+role. Before every `apply` dispatch, grep all four sources for the URL and for `company+role`: `data/pipeline.md`, all `data/applications/*.md` day files, `batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`. `npx job-forge index:has --key "company-role:..."` may be used first as a fast local artifact prefilter; it rebuilds `.jobforge-index.json` on demand from `templates/index.json`, and a company+role hit is enough to drop an obvious duplicate before dispatch. If `.jobforge-ledger/events.jsonl` exists, `npx job-forge ledger:has --company "..." --role "..." --status Applied` may also be used as a fast prefilter; a match is enough to drop that duplicate before dispatch. For candidates not rejected by the index or ledger, the four-source grep is still mandatory. If any source shows APPLIED / Applied, skip the dispatch and pick a replacement from the remaining candidate list. Do not count duplicates toward a requested "apply to N jobs" total, and do not delegate obvious duplicates just so a subagent can return SKIP.
|
|
15
|
+
- [H2] Max 1 application per company+role. Before every `apply` dispatch, grep all four sources for the URL and for `company+role`: `data/pipeline.md`, all `data/applications/*.md` day files, `batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`. Use `npx job-forge canon:key company-role --company "..." --role "..."` when deriving a stable duplicate key; do not invent slugs in prose. `npx job-forge index:has --key "company-role:..."` may be used first as a fast local artifact prefilter; it rebuilds `.jobforge-index.json` on demand from `templates/index.json`, and a company+role hit is enough to drop an obvious duplicate before dispatch. If `.jobforge-ledger/events.jsonl` exists, `npx job-forge ledger:has --company "..." --role "..." --status Applied` may also be used as a fast prefilter; a match is enough to drop that duplicate before dispatch. For candidates not rejected by the index or ledger, the four-source grep is still mandatory. If any source shows APPLIED / Applied, skip the dispatch and pick a replacement from the remaining candidate list. Do not count duplicates toward a requested "apply to N jobs" total, and do not delegate obvious duplicates just so a subagent can return SKIP.
|
|
16
16
|
why: 2026-04 same-day batch collision — when two batches target the same role, `npx job-forge merge` updates the existing day-file row rather than appending, so grepping day files alone misses earlier-batch applies; merged/*.tsv is the only place the breadcrumb remains
|
|
17
17
|
|
|
18
18
|
- [H3] Before every batch of `task` dispatches that will use Geometra, call `geometra_list_sessions` then `geometra_disconnect({closeBrowser: true})`. Every round, no exceptions. Name this cleanup as an explicit "step 0" in your first-response plan for any multi-apply request — it is the most frequently skipped guardrail in practice, and skipping it produces cascade "Not connected" failures on the next dispatch.
|
|
@@ -80,12 +80,15 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
80
80
|
- [D14] Treat `templates/migrations.json` as the source of truth for consumer-project upgrades. Use `npx job-forge migrate:plan` or `npx job-forge migrate:check` when diagnosing harness drift; `job-forge sync` applies safe migrations automatically unless `JOB_FORGE_SKIP_MIGRATIONS=1` is set.
|
|
81
81
|
why: `iso-migrate` is not an MCP and adds no prompt/tool-schema tokens; it prevents stale consumer scripts and generated-artifact ignores without asking agents to hand-edit package.json
|
|
82
82
|
|
|
83
|
+
- [D15] Treat `templates/canon.json` as the source of truth for URL/company/role identity keys. Use `npx job-forge canon:key ...` or `npx job-forge canon:compare ...` before broad duplicate checks when a stable key or same/possible/different decision is useful.
|
|
84
|
+
why: `iso-canon` is not an MCP and adds no prompt/tool-schema tokens; it centralizes duplicate-key rules so agents do not repeatedly derive inconsistent slugs for aliases, suffixes, remote/location noise, or tracking URLs
|
|
85
|
+
|
|
83
86
|
## Procedure
|
|
84
87
|
|
|
85
88
|
1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
|
|
86
89
|
2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
|
|
87
|
-
3. Read the active mode file [D3]. Use context bundle checks when changing context loads [D11]. Check cached artifacts before URL/JD refetches [D12]. Use artifact index lookups before broad file reads when they can answer the question [D13]. Use migration checks for harness drift [D14]. Decide inline vs delegated work [D1].
|
|
88
|
-
4. Prepare Geometra dispatches: cleanup [H3], index/ledger prefilter when useful [D8, D13], dedupe [H2], location filter [D5], routing [D2, D10], proxy prompt hygiene [H8].
|
|
90
|
+
3. Read the active mode file [D3]. Use context bundle checks when changing context loads [D11]. Check cached artifacts before URL/JD refetches [D12]. Use artifact index lookups before broad file reads when they can answer the question [D13]. Use canonical identity keys for duplicate checks [D15]. Use migration checks for harness drift [D14]. Decide inline vs delegated work [D1].
|
|
91
|
+
4. Prepare Geometra dispatches: cleanup [H3], canon/index/ledger prefilter when useful [D8, D13, D15], dedupe [H2], location filter [D5], routing [D2, D10], proxy prompt hygiene [H8].
|
|
89
92
|
5. Dispatch at most 2 tasks per round [H1]; wait for final outcomes, not just task ids [H5b].
|
|
90
93
|
6. Keep multi-job form-filling out of the orchestrator [H4].
|
|
91
94
|
7. Cross-check subagent facts against authoritative files [H7].
|
|
@@ -78,6 +78,10 @@ Local artifact index (terminal, outside opencode):
|
|
|
78
78
|
npx job-forge index:has --key "company-role:acme:staff-engineer"
|
|
79
79
|
npx job-forge index:query "acme"
|
|
80
80
|
|
|
81
|
+
Identity keys (terminal, outside opencode):
|
|
82
|
+
npx job-forge canon:key company-role --company "Acme" --role "Staff Engineer"
|
|
83
|
+
npx job-forge canon:compare company "OpenAI, Inc." "Open AI"
|
|
84
|
+
|
|
81
85
|
Consumer migrations (terminal, outside opencode):
|
|
82
86
|
npx job-forge migrate:plan # preview package.json/.gitignore drift
|
|
83
87
|
npx job-forge migrate:apply # apply safe harness upgrade migrations
|
|
@@ -168,11 +172,13 @@ Step 1 — Enumerate candidates
|
|
|
168
172
|
- Build ordered list: candidates = [job_1, job_2, ..., job_N]
|
|
169
173
|
|
|
170
174
|
Step 2 — Dedup against already-applied
|
|
171
|
-
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
175
|
+
- Derive the stable key with npx job-forge canon:key company-role --company
|
|
176
|
+
"<company>" --role "<role>" when company+role is known.
|
|
177
|
+
- Run npx job-forge index:has --key "<canon-key>" as a fast local artifact
|
|
178
|
+
prefilter. It rebuilds .jobforge-index.json on demand from
|
|
179
|
+
templates/index.json and canonicalizes indexed company/role records through
|
|
180
|
+
templates/canon.json. A hit means the role has already appeared in tracker
|
|
181
|
+
files or tracker TSVs and can be dropped before dispatch.
|
|
176
182
|
- If .jobforge-ledger/events.jsonl exists, use npx job-forge ledger:has as a
|
|
177
183
|
fast prefilter for obvious company+role Applied duplicates. A ledger match
|
|
178
184
|
can be dropped before dispatch without loading tracker files into context.
|
package/AGENTS.md
CHANGED
|
@@ -7,7 +7,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
7
7
|
- [H1] Max 2 parallel `task` dispatches per message. For N jobs, run `ceil(N/2)` sequential rounds of 2. A round is not complete until both subagents return a final outcome (`APPLIED`, `APPLY FAILED`, `SKIP`, `Discarded`, or a written TSV path). A `task` tool result that only gives a session id / title is a launch acknowledgement, not completion. Applies in all modes, for all user phrasings ("urgent", "apply to 10 jobs now").
|
|
8
8
|
why: each subagent requires post-cleanup and racing more than 2 reliably loses at least one result. On 2026-04-25 the orchestrator launched round 2 while round 1 had only returned task ids, leaving four application subagents in flight and losing two provider recoveries
|
|
9
9
|
|
|
10
|
-
- [H2] Max 1 application per company+role. Before every `apply` dispatch, grep all four sources for the URL and for `company+role`: `data/pipeline.md`, all `data/applications/*.md` day files, `batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`. `npx job-forge index:has --key "company-role:..."` may be used first as a fast local artifact prefilter; it rebuilds `.jobforge-index.json` on demand from `templates/index.json`, and a company+role hit is enough to drop an obvious duplicate before dispatch. If `.jobforge-ledger/events.jsonl` exists, `npx job-forge ledger:has --company "..." --role "..." --status Applied` may also be used as a fast prefilter; a match is enough to drop that duplicate before dispatch. For candidates not rejected by the index or ledger, the four-source grep is still mandatory. If any source shows APPLIED / Applied, skip the dispatch and pick a replacement from the remaining candidate list. Do not count duplicates toward a requested "apply to N jobs" total, and do not delegate obvious duplicates just so a subagent can return SKIP.
|
|
10
|
+
- [H2] Max 1 application per company+role. Before every `apply` dispatch, grep all four sources for the URL and for `company+role`: `data/pipeline.md`, all `data/applications/*.md` day files, `batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`. Use `npx job-forge canon:key company-role --company "..." --role "..."` when deriving a stable duplicate key; do not invent slugs in prose. `npx job-forge index:has --key "company-role:..."` may be used first as a fast local artifact prefilter; it rebuilds `.jobforge-index.json` on demand from `templates/index.json`, and a company+role hit is enough to drop an obvious duplicate before dispatch. If `.jobforge-ledger/events.jsonl` exists, `npx job-forge ledger:has --company "..." --role "..." --status Applied` may also be used as a fast prefilter; a match is enough to drop that duplicate before dispatch. For candidates not rejected by the index or ledger, the four-source grep is still mandatory. If any source shows APPLIED / Applied, skip the dispatch and pick a replacement from the remaining candidate list. Do not count duplicates toward a requested "apply to N jobs" total, and do not delegate obvious duplicates just so a subagent can return SKIP.
|
|
11
11
|
why: 2026-04 same-day batch collision — when two batches target the same role, `npx job-forge merge` updates the existing day-file row rather than appending, so grepping day files alone misses earlier-batch applies; merged/*.tsv is the only place the breadcrumb remains
|
|
12
12
|
|
|
13
13
|
- [H3] Before every batch of `task` dispatches that will use Geometra, call `geometra_list_sessions` then `geometra_disconnect({closeBrowser: true})`. Every round, no exceptions. Name this cleanup as an explicit "step 0" in your first-response plan for any multi-apply request — it is the most frequently skipped guardrail in practice, and skipping it produces cascade "Not connected" failures on the next dispatch.
|
|
@@ -75,12 +75,15 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
75
75
|
- [D14] Treat `templates/migrations.json` as the source of truth for consumer-project upgrades. Use `npx job-forge migrate:plan` or `npx job-forge migrate:check` when diagnosing harness drift; `job-forge sync` applies safe migrations automatically unless `JOB_FORGE_SKIP_MIGRATIONS=1` is set.
|
|
76
76
|
why: `iso-migrate` is not an MCP and adds no prompt/tool-schema tokens; it prevents stale consumer scripts and generated-artifact ignores without asking agents to hand-edit package.json
|
|
77
77
|
|
|
78
|
+
- [D15] Treat `templates/canon.json` as the source of truth for URL/company/role identity keys. Use `npx job-forge canon:key ...` or `npx job-forge canon:compare ...` before broad duplicate checks when a stable key or same/possible/different decision is useful.
|
|
79
|
+
why: `iso-canon` is not an MCP and adds no prompt/tool-schema tokens; it centralizes duplicate-key rules so agents do not repeatedly derive inconsistent slugs for aliases, suffixes, remote/location noise, or tracking URLs
|
|
80
|
+
|
|
78
81
|
## Procedure
|
|
79
82
|
|
|
80
83
|
1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
|
|
81
84
|
2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
|
|
82
|
-
3. Read the active mode file [D3]. Use context bundle checks when changing context loads [D11]. Check cached artifacts before URL/JD refetches [D12]. Use artifact index lookups before broad file reads when they can answer the question [D13]. Use migration checks for harness drift [D14]. Decide inline vs delegated work [D1].
|
|
83
|
-
4. Prepare Geometra dispatches: cleanup [H3], index/ledger prefilter when useful [D8, D13], dedupe [H2], location filter [D5], routing [D2, D10], proxy prompt hygiene [H8].
|
|
85
|
+
3. Read the active mode file [D3]. Use context bundle checks when changing context loads [D11]. Check cached artifacts before URL/JD refetches [D12]. Use artifact index lookups before broad file reads when they can answer the question [D13]. Use canonical identity keys for duplicate checks [D15]. Use migration checks for harness drift [D14]. Decide inline vs delegated work [D1].
|
|
86
|
+
4. Prepare Geometra dispatches: cleanup [H3], canon/index/ledger prefilter when useful [D8, D13, D15], dedupe [H2], location filter [D5], routing [D2, D10], proxy prompt hygiene [H8].
|
|
84
87
|
5. Dispatch at most 2 tasks per round [H1]; wait for final outcomes, not just task ids [H5b].
|
|
85
88
|
6. Keep multi-job form-filling out of the orchestrator [H4].
|
|
86
89
|
7. Cross-check subagent facts against authoritative files [H7].
|
package/CLAUDE.md
CHANGED
|
@@ -7,7 +7,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
7
7
|
- [H1] Max 2 parallel `task` dispatches per message. For N jobs, run `ceil(N/2)` sequential rounds of 2. A round is not complete until both subagents return a final outcome (`APPLIED`, `APPLY FAILED`, `SKIP`, `Discarded`, or a written TSV path). A `task` tool result that only gives a session id / title is a launch acknowledgement, not completion. Applies in all modes, for all user phrasings ("urgent", "apply to 10 jobs now").
|
|
8
8
|
why: each subagent requires post-cleanup and racing more than 2 reliably loses at least one result. On 2026-04-25 the orchestrator launched round 2 while round 1 had only returned task ids, leaving four application subagents in flight and losing two provider recoveries
|
|
9
9
|
|
|
10
|
-
- [H2] Max 1 application per company+role. Before every `apply` dispatch, grep all four sources for the URL and for `company+role`: `data/pipeline.md`, all `data/applications/*.md` day files, `batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`. `npx job-forge index:has --key "company-role:..."` may be used first as a fast local artifact prefilter; it rebuilds `.jobforge-index.json` on demand from `templates/index.json`, and a company+role hit is enough to drop an obvious duplicate before dispatch. If `.jobforge-ledger/events.jsonl` exists, `npx job-forge ledger:has --company "..." --role "..." --status Applied` may also be used as a fast prefilter; a match is enough to drop that duplicate before dispatch. For candidates not rejected by the index or ledger, the four-source grep is still mandatory. If any source shows APPLIED / Applied, skip the dispatch and pick a replacement from the remaining candidate list. Do not count duplicates toward a requested "apply to N jobs" total, and do not delegate obvious duplicates just so a subagent can return SKIP.
|
|
10
|
+
- [H2] Max 1 application per company+role. Before every `apply` dispatch, grep all four sources for the URL and for `company+role`: `data/pipeline.md`, all `data/applications/*.md` day files, `batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`. Use `npx job-forge canon:key company-role --company "..." --role "..."` when deriving a stable duplicate key; do not invent slugs in prose. `npx job-forge index:has --key "company-role:..."` may be used first as a fast local artifact prefilter; it rebuilds `.jobforge-index.json` on demand from `templates/index.json`, and a company+role hit is enough to drop an obvious duplicate before dispatch. If `.jobforge-ledger/events.jsonl` exists, `npx job-forge ledger:has --company "..." --role "..." --status Applied` may also be used as a fast prefilter; a match is enough to drop that duplicate before dispatch. For candidates not rejected by the index or ledger, the four-source grep is still mandatory. If any source shows APPLIED / Applied, skip the dispatch and pick a replacement from the remaining candidate list. Do not count duplicates toward a requested "apply to N jobs" total, and do not delegate obvious duplicates just so a subagent can return SKIP.
|
|
11
11
|
why: 2026-04 same-day batch collision — when two batches target the same role, `npx job-forge merge` updates the existing day-file row rather than appending, so grepping day files alone misses earlier-batch applies; merged/*.tsv is the only place the breadcrumb remains
|
|
12
12
|
|
|
13
13
|
- [H3] Before every batch of `task` dispatches that will use Geometra, call `geometra_list_sessions` then `geometra_disconnect({closeBrowser: true})`. Every round, no exceptions. Name this cleanup as an explicit "step 0" in your first-response plan for any multi-apply request — it is the most frequently skipped guardrail in practice, and skipping it produces cascade "Not connected" failures on the next dispatch.
|
|
@@ -75,12 +75,15 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
75
75
|
- [D14] Treat `templates/migrations.json` as the source of truth for consumer-project upgrades. Use `npx job-forge migrate:plan` or `npx job-forge migrate:check` when diagnosing harness drift; `job-forge sync` applies safe migrations automatically unless `JOB_FORGE_SKIP_MIGRATIONS=1` is set.
|
|
76
76
|
why: `iso-migrate` is not an MCP and adds no prompt/tool-schema tokens; it prevents stale consumer scripts and generated-artifact ignores without asking agents to hand-edit package.json
|
|
77
77
|
|
|
78
|
+
- [D15] Treat `templates/canon.json` as the source of truth for URL/company/role identity keys. Use `npx job-forge canon:key ...` or `npx job-forge canon:compare ...` before broad duplicate checks when a stable key or same/possible/different decision is useful.
|
|
79
|
+
why: `iso-canon` is not an MCP and adds no prompt/tool-schema tokens; it centralizes duplicate-key rules so agents do not repeatedly derive inconsistent slugs for aliases, suffixes, remote/location noise, or tracking URLs
|
|
80
|
+
|
|
78
81
|
## Procedure
|
|
79
82
|
|
|
80
83
|
1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
|
|
81
84
|
2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
|
|
82
|
-
3. Read the active mode file [D3]. Use context bundle checks when changing context loads [D11]. Check cached artifacts before URL/JD refetches [D12]. Use artifact index lookups before broad file reads when they can answer the question [D13]. Use migration checks for harness drift [D14]. Decide inline vs delegated work [D1].
|
|
83
|
-
4. Prepare Geometra dispatches: cleanup [H3], index/ledger prefilter when useful [D8, D13], dedupe [H2], location filter [D5], routing [D2, D10], proxy prompt hygiene [H8].
|
|
85
|
+
3. Read the active mode file [D3]. Use context bundle checks when changing context loads [D11]. Check cached artifacts before URL/JD refetches [D12]. Use artifact index lookups before broad file reads when they can answer the question [D13]. Use canonical identity keys for duplicate checks [D15]. Use migration checks for harness drift [D14]. Decide inline vs delegated work [D1].
|
|
86
|
+
4. Prepare Geometra dispatches: cleanup [H3], canon/index/ledger prefilter when useful [D8, D13, D15], dedupe [H2], location filter [D5], routing [D2, D10], proxy prompt hygiene [H8].
|
|
84
87
|
5. Dispatch at most 2 tasks per round [H1]; wait for final outcomes, not just task ids [H5b].
|
|
85
88
|
6. Keep multi-job form-filling out of the orchestrator [H4].
|
|
86
89
|
7. Cross-check subagent facts against authoritative files [H7].
|
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ The scaffolded `opencode.json` already has three MCPs wired up — they launch a
|
|
|
31
31
|
- **Gmail** — reads replies from recruiters
|
|
32
32
|
- **state-trace** — typed working memory for cross-session context (resumed batches, recent decisions, repeated portal quirks). Install once with `python3 -m pip install "state-trace[mcp]"`; the MCP command is `state-trace-mcp`.
|
|
33
33
|
|
|
34
|
-
JobForge also keeps MCP-free local workflow state: `templates/contracts.json` defines tracker/apply artifact shapes via `@razroo/iso-contract`, `templates/capabilities.json` defines role capability boundaries via `@razroo/iso-capabilities`, `templates/context.json` defines deterministic mode/reference bundles via `@razroo/iso-context`, `templates/migrations.json` defines safe consumer-project upgrades via `@razroo/iso-migrate`, `.jobforge-ledger/events.jsonl` records duplicate/status events via `@razroo/iso-ledger`, `.jobforge-cache/` stores reusable JD/artifact content via `@razroo/iso-cache`, and `.jobforge-index.json` indexes artifact source pointers via `@razroo/iso-index`. None of these add always-on prompt or tool-schema tokens.
|
|
34
|
+
JobForge also keeps MCP-free local workflow state: `templates/canon.json` defines URL/company/role identity keys via `@razroo/iso-canon`, `templates/contracts.json` defines tracker/apply artifact shapes via `@razroo/iso-contract`, `templates/capabilities.json` defines role capability boundaries via `@razroo/iso-capabilities`, `templates/context.json` defines deterministic mode/reference bundles via `@razroo/iso-context`, `templates/migrations.json` defines safe consumer-project upgrades via `@razroo/iso-migrate`, `.jobforge-ledger/events.jsonl` records duplicate/status events via `@razroo/iso-ledger`, `.jobforge-cache/` stores reusable JD/artifact content via `@razroo/iso-cache`, and `.jobforge-index.json` indexes artifact source pointers via `@razroo/iso-index`. None of these add always-on prompt or tool-schema tokens.
|
|
35
35
|
|
|
36
36
|
`npm install` also materializes symlinks for every supported agent harness — OpenCode, Cursor, Claude Code, and Codex — so you can run `opencode`, `cursor`, `claude`, or `codex` in the same project and each picks up the shared MCP config and instructions.
|
|
37
37
|
|
|
@@ -78,7 +78,7 @@ JobForge turns opencode into a full job search command center. Instead of manual
|
|
|
78
78
|
| **Durable Batch Orchestration** | `batch-runner.sh` uses `@razroo/iso-orchestrator` for resumable bundle execution, bounded fan-out, mutexed state writes, and workflow records in `.jobforge-runs/`. |
|
|
79
79
|
| **Pipeline Integrity** | Automated merge, dedup, status normalization, health checks |
|
|
80
80
|
| **Cost-Aware Agent Routing** | Three subagents (`@general-free`, `@general-paid`, `@glm-minimal`) with per-task tool surfaces. On OpenCode, JobForge pins all tiers to `opencode-go/deepseek-v4-flash` so application runs avoid overloaded free-model pools. See [Subagent Routing in AGENTS.md](AGENTS.md) for the task-to-agent mapping. |
|
|
81
|
-
| **Trace + Telemetry + Guard + Contract + Ledger + Capabilities + Context + Cache + Index + Migrate** | `job-forge trace:*` exposes local OpenCode transcripts, `job-forge telemetry:*` summarizes runs, `job-forge guard:*` audits deterministic policy rules, `templates/contracts.json` enforces artifact shape with `iso-contract`, `job-forge ledger:*` queries append-only workflow state, `job-forge capabilities:*` checks role boundaries, `job-forge context:*` plans mode/reference context bundles, `job-forge cache:*` reuses fetched JD/artifact content, `job-forge index:*` queries compact source pointers, and `job-forge migrate:*` applies safe consumer-project upgrades without MCP/tool-schema overhead. |
|
|
81
|
+
| **Trace + Telemetry + Guard + Contract + Canon + Ledger + Capabilities + Context + Cache + Index + Migrate** | `job-forge trace:*` exposes local OpenCode transcripts, `job-forge telemetry:*` summarizes runs, `job-forge guard:*` audits deterministic policy rules, `templates/contracts.json` enforces artifact shape with `iso-contract`, `job-forge canon:*` derives stable URL/company/role identity keys, `job-forge ledger:*` queries append-only workflow state, `job-forge capabilities:*` checks role boundaries, `job-forge context:*` plans mode/reference context bundles, `job-forge cache:*` reuses fetched JD/artifact content, `job-forge index:*` queries compact source pointers, and `job-forge migrate:*` applies safe consumer-project upgrades without MCP/tool-schema overhead. |
|
|
82
82
|
| **Token Cost Visibility** | `job-forge tokens --days 1` for per-session breakdown; `job-forge session-report --since-minutes 60 --log` to flag sessions over budget and append history to `data/token-usage.tsv`. Auto-logged after every batch run. |
|
|
83
83
|
|
|
84
84
|
## Usage
|
|
@@ -164,7 +164,7 @@ my-search/
|
|
|
164
164
|
├── .opencode/skills/job-forge.md # → skill router
|
|
165
165
|
├── .opencode/agents/ # → @general-free, @general-paid, @glm-minimal
|
|
166
166
|
├── modes/ # → _shared.md + skill modes
|
|
167
|
-
├── templates/ # → states.yml, portals.example.yml, cv-template.html, capabilities.json, context.json, index.json, migrations.json
|
|
167
|
+
├── templates/ # → states.yml, portals.example.yml, cv-template.html, canon.json, capabilities.json, context.json, index.json, migrations.json
|
|
168
168
|
├── batch/batch-prompt.md # → batch worker prompt
|
|
169
169
|
├── batch/batch-runner.sh # → parallel orchestrator
|
|
170
170
|
│
|
|
@@ -190,7 +190,7 @@ JobForge/
|
|
|
190
190
|
│ ├── sync.mjs # postinstall: creates symlinks in consumer project
|
|
191
191
|
│ └── create-job-forge.mjs # scaffolder
|
|
192
192
|
├── modes/ # _shared.md + 16 skill modes
|
|
193
|
-
├── templates/ # cv-template.html, portals.example.yml, states.yml, capabilities.json, context.json, migrations.json
|
|
193
|
+
├── templates/ # cv-template.html, portals.example.yml, states.yml, canon.json, capabilities.json, context.json, migrations.json
|
|
194
194
|
├── config/profile.example.yml # template for consumer's profile.yml
|
|
195
195
|
├── batch/{batch-prompt.md,batch-runner.sh} # batch orchestrator
|
|
196
196
|
├── scripts/
|
|
@@ -201,6 +201,7 @@ JobForge/
|
|
|
201
201
|
│ ├── context.mjs # iso-context-backed context bundle CLI
|
|
202
202
|
│ ├── cache.mjs # iso-cache-backed local artifact cache CLI
|
|
203
203
|
│ ├── index.mjs # iso-index-backed artifact lookup CLI
|
|
204
|
+
│ ├── canon.mjs # iso-canon-backed identity normalization CLI
|
|
204
205
|
│ ├── migrate.mjs # iso-migrate-backed consumer-project migrations
|
|
205
206
|
│ ├── token-usage-report.mjs # opencode cost analyzer
|
|
206
207
|
│ └── release/check-source.mjs # version gate for npm publish
|
package/bin/create-job-forge.mjs
CHANGED
|
@@ -147,6 +147,10 @@ const consumerPkg = {
|
|
|
147
147
|
'index:has': 'job-forge index:has',
|
|
148
148
|
'index:query': 'job-forge index:query',
|
|
149
149
|
'index:explain': 'job-forge index:explain',
|
|
150
|
+
'canon:normalize': 'job-forge canon:normalize',
|
|
151
|
+
'canon:key': 'job-forge canon:key',
|
|
152
|
+
'canon:compare': 'job-forge canon:compare',
|
|
153
|
+
'canon:explain': 'job-forge canon:explain',
|
|
150
154
|
'migrate:plan': 'job-forge migrate:plan',
|
|
151
155
|
'migrate:apply': 'job-forge migrate:apply',
|
|
152
156
|
'migrate:check': 'job-forge migrate:check',
|
|
@@ -252,6 +256,7 @@ Before doing any work, remember where things live in *this* project:
|
|
|
252
256
|
| Scanner dedup history | \`data/scan-history.tsv\` | Only touch in \`/job-forge scan\` |
|
|
253
257
|
| Local workflow ledger | \`.jobforge-ledger/events.jsonl\` | Deterministic append-only state; use \`job-forge ledger:*\` |
|
|
254
258
|
| Local artifact index | \`.jobforge-index.json\` | Deterministic file/line lookup; use \`job-forge index:*\` |
|
|
259
|
+
| Identity canonicalization | \`templates/canon.json\` | Stable URL/company/role keys; use \`job-forge canon:*\` |
|
|
255
260
|
| Consumer migrations | \`templates/migrations.json\` | Safe script/gitignore upgrades; use \`job-forge migrate:*\` |
|
|
256
261
|
| Scanner config | \`portals.yml\` (project root) | Company configs |
|
|
257
262
|
| Profile / identity | \`config/profile.yml\` | Candidate name, email, target roles |
|
|
@@ -402,6 +407,7 @@ job-forge merge # merge batch/tracker-additions/*.tsv into the tracke
|
|
|
402
407
|
job-forge verify # verify pipeline integrity
|
|
403
408
|
job-forge ledger:status # local deterministic workflow ledger status
|
|
404
409
|
job-forge index:status # local artifact index status
|
|
410
|
+
job-forge canon:key company-role --company "Acme, Inc." --role "Senior SWE"
|
|
405
411
|
job-forge migrate:check # verify consumer package scripts/gitignore are current
|
|
406
412
|
job-forge pdf cv.md out.pdf
|
|
407
413
|
job-forge tokens --days 1 # per-session opencode token usage
|
package/bin/job-forge.mjs
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* context:* Query/render deterministic context bundles via iso-context
|
|
26
26
|
* cache:* Reuse local deterministic artifacts via iso-cache
|
|
27
27
|
* index:* Query local artifacts via iso-index
|
|
28
|
+
* canon:* Compute deterministic identity keys via iso-canon
|
|
28
29
|
* migrate:* Apply deterministic consumer-project migrations via iso-migrate
|
|
29
30
|
* sync Re-run the harness symlink sync (bin/sync.mjs)
|
|
30
31
|
* help, --help Show this message
|
|
@@ -128,6 +129,14 @@ const indexAliases = {
|
|
|
128
129
|
'index:path': 'path',
|
|
129
130
|
};
|
|
130
131
|
|
|
132
|
+
const canonAliases = {
|
|
133
|
+
'canon:normalize': 'normalize',
|
|
134
|
+
'canon:key': 'key',
|
|
135
|
+
'canon:compare': 'compare',
|
|
136
|
+
'canon:explain': 'explain',
|
|
137
|
+
'canon:path': 'path',
|
|
138
|
+
};
|
|
139
|
+
|
|
131
140
|
const migrateAliases = {
|
|
132
141
|
'migrate:plan': 'plan',
|
|
133
142
|
'migrate:apply': 'apply',
|
|
@@ -186,6 +195,9 @@ Commands:
|
|
|
186
195
|
index:has Check indexed URL/company-role/report facts without loading source files
|
|
187
196
|
index:query Query indexed reports, tracker rows, TSVs, scan history, pipeline, and ledger
|
|
188
197
|
index:verify Validate local artifact index integrity
|
|
198
|
+
canon:key Print stable URL/company/role/company-role keys
|
|
199
|
+
canon:compare Compare two identifiers as same/possible/different
|
|
200
|
+
canon:explain Show the active identity canonicalization policy
|
|
189
201
|
migrate:plan Preview deterministic consumer-project migrations
|
|
190
202
|
migrate:apply Apply deterministic consumer-project migrations
|
|
191
203
|
migrate:check Fail if migrations are pending
|
|
@@ -228,6 +240,8 @@ Pass --help after a command to see its own flags, e.g.:
|
|
|
228
240
|
job-forge cache:put --url https://example.test/jobs/123 --input @jds/example.md
|
|
229
241
|
job-forge index:has --key "company-role:acme:staff-engineer"
|
|
230
242
|
job-forge index:query "acme"
|
|
243
|
+
job-forge canon:key company-role --company "Acme, Inc." --role "Senior SWE - Remote US"
|
|
244
|
+
job-forge canon:compare company "OpenAI, Inc." "Open AI"
|
|
231
245
|
job-forge migrate:check
|
|
232
246
|
job-forge migrate:apply
|
|
233
247
|
|
|
@@ -359,6 +373,21 @@ if (cmd === 'index' || indexAliases[cmd]) {
|
|
|
359
373
|
process.exit(result.status ?? 1);
|
|
360
374
|
}
|
|
361
375
|
|
|
376
|
+
if (cmd === 'canon' || canonAliases[cmd]) {
|
|
377
|
+
const canonArgs = cmd === 'canon'
|
|
378
|
+
? (rest.length === 0 ? ['help'] : rest)
|
|
379
|
+
: [canonAliases[cmd], ...rest];
|
|
380
|
+
|
|
381
|
+
const scriptPath = join(PKG_ROOT, 'scripts/canon.mjs');
|
|
382
|
+
const result = spawnSync(process.execPath, [scriptPath, ...canonArgs], {
|
|
383
|
+
stdio: 'inherit',
|
|
384
|
+
cwd: PROJECT_DIR,
|
|
385
|
+
env: process.env,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
process.exit(result.status ?? 1);
|
|
389
|
+
}
|
|
390
|
+
|
|
362
391
|
if (cmd === 'migrate' || migrateAliases[cmd]) {
|
|
363
392
|
const migrateArgs = cmd === 'migrate'
|
|
364
393
|
? (rest.length === 0 ? ['help'] : rest)
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -164,6 +164,7 @@ data/pipeline.md → Pending URLs and `local:jds/...` inbox (see modes/p
|
|
|
164
164
|
.jobforge-index.json → Deterministic artifact lookup index built from templates/index.json
|
|
165
165
|
jds/*.md → Saved job descriptions referenced from the pipeline (`local:jds/{file}`)
|
|
166
166
|
templates/states.yml → Canonical status values
|
|
167
|
+
templates/canon.json → Canonical URL/company/role identity keys
|
|
167
168
|
templates/context.json → Deterministic mode/reference context bundle policy
|
|
168
169
|
templates/migrations.json → Safe consumer-project upgrade policy
|
|
169
170
|
templates/cv-template.html → PDF generation template
|
|
@@ -179,6 +180,7 @@ Create `data/pipeline.md` when you start using the URL inbox (`/job-forge pipeli
|
|
|
179
180
|
- Tracker TSVs: `batch/tracker-additions/{num}-{company-slug}.tsv` (one file per evaluation; merged files move under `batch/tracker-additions/merged/`; shape enforced by `templates/contracts.json`)
|
|
180
181
|
- Ledger: `.jobforge-ledger/events.jsonl` (created by `job-forge ledger:rebuild`, `tracker-line --write`, or `merge`; gitignored personal state)
|
|
181
182
|
- Index: `.jobforge-index.json` (created on demand by `job-forge index:*`; gitignored local lookup state)
|
|
183
|
+
- Canon: `templates/canon.json` (identity rules inspected with `job-forge canon:*`)
|
|
182
184
|
- Migrations: `templates/migrations.json` (applied by `job-forge sync` and inspectable with `job-forge migrate:*`)
|
|
183
185
|
- Capabilities: `templates/capabilities.json` (role boundary policy inspected with `job-forge capabilities:*`)
|
|
184
186
|
- Context: `templates/context.json` (mode/reference file bundles inspected with `job-forge context:*`)
|
|
@@ -225,6 +227,7 @@ Scripts maintain data consistency. In a consumer project they're invoked via the
|
|
|
225
227
|
| `scripts/guard.mjs` | `npx job-forge guard:audit` / `guard:explain` | Deterministic `@razroo/iso-guard` policy audits over local OpenCode traces |
|
|
226
228
|
| `scripts/ledger.mjs` | `npx job-forge ledger:status` / `ledger:has` / `ledger:rebuild` | Deterministic `@razroo/iso-ledger` state over tracker, TSV, and pipeline files |
|
|
227
229
|
| `scripts/index.mjs` | `npx job-forge index:status` / `index:has` / `index:query` | Deterministic `@razroo/iso-index` lookup over reports, tracker rows, TSVs, pipeline, scan history, and ledger events |
|
|
230
|
+
| `scripts/canon.mjs` | `npx job-forge canon:normalize` / `canon:key` / `canon:compare` | Deterministic `@razroo/iso-canon` identity normalization for URLs, companies, roles, and company+role pairs |
|
|
228
231
|
| `scripts/context.mjs` | `npx job-forge context:list` / `context:plan` / `context:check` / `context:render` | Deterministic `@razroo/iso-context` mode/reference context bundle planning and rendering |
|
|
229
232
|
| `scripts/migrate.mjs` | `npx job-forge migrate:plan` / `migrate:apply` / `migrate:check` | Deterministic `@razroo/iso-migrate` consumer-project upgrades for scripts and generated-artifact ignores |
|
|
230
233
|
| `tracker-lib.mjs` | _(library)_ | Shared helpers for reading/writing day-based tracker files — imported by merge/dedup/verify/normalize |
|
package/docs/CUSTOMIZATION.md
CHANGED
|
@@ -152,7 +152,11 @@ Mode/reference context bundles live in `templates/context.json` and are planned
|
|
|
152
152
|
|
|
153
153
|
## JobForge artifact index
|
|
154
154
|
|
|
155
|
-
Artifact lookup policy lives in `templates/index.json` and is built locally by `@razroo/iso-index`. Use `job-forge index:has --key "company-role:acme:staff-engineer"` as a cheap duplicate/source prefilter, `job-forge index:query "acme"` to get compact source path/line pointers, and `job-forge index:verify` to validate `.jobforge-index.json`. Query, has, and verify rebuild the index on demand, so scaffolded projects need no setup. This is not an MCP and does not add tool-schema tokens.
|
|
155
|
+
Artifact lookup policy lives in `templates/index.json` and is built locally by `@razroo/iso-index`. Use `job-forge index:has --key "company-role:acme:staff-engineer"` as a cheap duplicate/source prefilter, `job-forge index:query "acme"` to get compact source path/line pointers, and `job-forge index:verify` to validate `.jobforge-index.json`. Query, has, and verify rebuild the index on demand, so scaffolded projects need no setup. JobForge canonicalizes company/role and URL records through `templates/canon.json` before writing the index. This is not an MCP and does not add tool-schema tokens.
|
|
156
|
+
|
|
157
|
+
## JobForge identity canonicalization
|
|
158
|
+
|
|
159
|
+
URL, company, role, and company+role identity rules live in `templates/canon.json` and are enforced locally by `@razroo/iso-canon`. Use `job-forge canon:key company-role --company "OpenAI, Inc." --role "Senior SWE, AI Platform"` to derive the same duplicate key used by ledger/index helpers, and `job-forge canon:compare company "OpenAI, Inc." "Open AI"` to explain whether two values resolve to the same entity. Custom forks can extend aliases, suffixes, stop words, and match thresholds in `templates/canon.json`. This is not an MCP and does not add prompt or tool-schema tokens.
|
|
156
160
|
|
|
157
161
|
## JobForge consumer migrations
|
|
158
162
|
|
package/docs/README.md
CHANGED
|
@@ -31,7 +31,7 @@ The harness exposes a single CLI (`job-forge`) installed as a `bin` entry. In a
|
|
|
31
31
|
|
|
32
32
|
| What you need | Where to read |
|
|
33
33
|
|---------------|---------------|
|
|
34
|
-
| Full command list (`verify`, `merge`, `dedup`, `normalize`, `pdf`, `sync-check`, `tokens`, `trace`, `telemetry`, `guard`, `ledger`, `context`, `sync`). | [SETUP.md — Tracker and scripts (terminal)](SETUP.md#tracker-and-scripts-terminal). |
|
|
34
|
+
| Full command list (`verify`, `merge`, `dedup`, `normalize`, `pdf`, `sync-check`, `tokens`, `trace`, `telemetry`, `guard`, `ledger`, `canon`, `context`, `sync`). | [SETUP.md — Tracker and scripts (terminal)](SETUP.md#tracker-and-scripts-terminal). |
|
|
35
35
|
| What each harness `.mjs` script does. | [ARCHITECTURE.md — Pipeline integrity](ARCHITECTURE.md#pipeline-integrity) and the scripts table underneath. |
|
|
36
36
|
| Batch runner, TSV layout, and `batch/tracker-additions/` merge flow. | [batch/README.md](../batch/README.md). |
|
|
37
37
|
| PR gate for harness contributions (`npm run verify` + `npm run build:dashboard`). | [CONTRIBUTING.md — Development](../CONTRIBUTING.md#development). |
|
package/docs/SETUP.md
CHANGED
|
@@ -126,6 +126,8 @@ From your project root, these commands maintain the tracker and pipeline checks.
|
|
|
126
126
|
| Pipeline health check | `npx job-forge verify` | `npm run verify` |
|
|
127
127
|
| Merge `batch/tracker-additions/*.tsv` into the tracker | `npx job-forge merge` | `npm run merge` |
|
|
128
128
|
| Inspect tracker row contract | `npx iso-contract explain jobforge.tracker-row --contracts templates/contracts.json` | _(none)_ |
|
|
129
|
+
| Derive canonical company/role key | `npx job-forge canon:key company-role --company "Acme" --role "Staff Engineer"` | `npm run canon:key -- company-role --company ...` |
|
|
130
|
+
| Compare identity values | `npx job-forge canon:compare company "OpenAI, Inc." "Open AI"` | `npm run canon:compare -- company ...` |
|
|
129
131
|
| Inspect role capabilities | `npx job-forge capabilities:explain general-free` | `npm run capabilities:explain -- general-free` |
|
|
130
132
|
| Inspect context bundle budget | `npx job-forge context:plan apply` | `npm run context:plan -- apply` |
|
|
131
133
|
| Inspect local JD/artifact cache | `npx job-forge cache:status` | `npm run cache:status` |
|
|
@@ -81,6 +81,10 @@ Local artifact index (terminal, outside opencode):
|
|
|
81
81
|
npx job-forge index:has --key "company-role:acme:staff-engineer"
|
|
82
82
|
npx job-forge index:query "acme"
|
|
83
83
|
|
|
84
|
+
Identity keys (terminal, outside opencode):
|
|
85
|
+
npx job-forge canon:key company-role --company "Acme" --role "Staff Engineer"
|
|
86
|
+
npx job-forge canon:compare company "OpenAI, Inc." "Open AI"
|
|
87
|
+
|
|
84
88
|
Consumer migrations (terminal, outside opencode):
|
|
85
89
|
npx job-forge migrate:plan # preview package.json/.gitignore drift
|
|
86
90
|
npx job-forge migrate:apply # apply safe harness upgrade migrations
|
|
@@ -171,11 +175,13 @@ Step 1 — Enumerate candidates
|
|
|
171
175
|
- Build ordered list: candidates = [job_1, job_2, ..., job_N]
|
|
172
176
|
|
|
173
177
|
Step 2 — Dedup against already-applied
|
|
174
|
-
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
178
|
+
- Derive the stable key with npx job-forge canon:key company-role --company
|
|
179
|
+
"<company>" --role "<role>" when company+role is known.
|
|
180
|
+
- Run npx job-forge index:has --key "<canon-key>" as a fast local artifact
|
|
181
|
+
prefilter. It rebuilds .jobforge-index.json on demand from
|
|
182
|
+
templates/index.json and canonicalizes indexed company/role records through
|
|
183
|
+
templates/canon.json. A hit means the role has already appeared in tracker
|
|
184
|
+
files or tracker TSVs and can be dropped before dispatch.
|
|
179
185
|
- If .jobforge-ledger/events.jsonl exists, use npx job-forge ledger:has as a
|
|
180
186
|
fast prefilter for obvious company+role Applied duplicates. A ledger match
|
|
181
187
|
can be dropped before dispatch without loading tracker files into context.
|
package/iso/instructions.md
CHANGED
|
@@ -7,7 +7,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
7
7
|
- [H1] Max 2 parallel `task` dispatches per message. For N jobs, run `ceil(N/2)` sequential rounds of 2. A round is not complete until both subagents return a final outcome (`APPLIED`, `APPLY FAILED`, `SKIP`, `Discarded`, or a written TSV path). A `task` tool result that only gives a session id / title is a launch acknowledgement, not completion. Applies in all modes, for all user phrasings ("urgent", "apply to 10 jobs now").
|
|
8
8
|
why: each subagent requires post-cleanup and racing more than 2 reliably loses at least one result. On 2026-04-25 the orchestrator launched round 2 while round 1 had only returned task ids, leaving four application subagents in flight and losing two provider recoveries
|
|
9
9
|
|
|
10
|
-
- [H2] Max 1 application per company+role. Before every `apply` dispatch, grep all four sources for the URL and for `company+role`: `data/pipeline.md`, all `data/applications/*.md` day files, `batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`. `npx job-forge index:has --key "company-role:..."` may be used first as a fast local artifact prefilter; it rebuilds `.jobforge-index.json` on demand from `templates/index.json`, and a company+role hit is enough to drop an obvious duplicate before dispatch. If `.jobforge-ledger/events.jsonl` exists, `npx job-forge ledger:has --company "..." --role "..." --status Applied` may also be used as a fast prefilter; a match is enough to drop that duplicate before dispatch. For candidates not rejected by the index or ledger, the four-source grep is still mandatory. If any source shows APPLIED / Applied, skip the dispatch and pick a replacement from the remaining candidate list. Do not count duplicates toward a requested "apply to N jobs" total, and do not delegate obvious duplicates just so a subagent can return SKIP.
|
|
10
|
+
- [H2] Max 1 application per company+role. Before every `apply` dispatch, grep all four sources for the URL and for `company+role`: `data/pipeline.md`, all `data/applications/*.md` day files, `batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`. Use `npx job-forge canon:key company-role --company "..." --role "..."` when deriving a stable duplicate key; do not invent slugs in prose. `npx job-forge index:has --key "company-role:..."` may be used first as a fast local artifact prefilter; it rebuilds `.jobforge-index.json` on demand from `templates/index.json`, and a company+role hit is enough to drop an obvious duplicate before dispatch. If `.jobforge-ledger/events.jsonl` exists, `npx job-forge ledger:has --company "..." --role "..." --status Applied` may also be used as a fast prefilter; a match is enough to drop that duplicate before dispatch. For candidates not rejected by the index or ledger, the four-source grep is still mandatory. If any source shows APPLIED / Applied, skip the dispatch and pick a replacement from the remaining candidate list. Do not count duplicates toward a requested "apply to N jobs" total, and do not delegate obvious duplicates just so a subagent can return SKIP.
|
|
11
11
|
why: 2026-04 same-day batch collision — when two batches target the same role, `npx job-forge merge` updates the existing day-file row rather than appending, so grepping day files alone misses earlier-batch applies; merged/*.tsv is the only place the breadcrumb remains
|
|
12
12
|
|
|
13
13
|
- [H3] Before every batch of `task` dispatches that will use Geometra, call `geometra_list_sessions` then `geometra_disconnect({closeBrowser: true})`. Every round, no exceptions. Name this cleanup as an explicit "step 0" in your first-response plan for any multi-apply request — it is the most frequently skipped guardrail in practice, and skipping it produces cascade "Not connected" failures on the next dispatch.
|
|
@@ -75,12 +75,15 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
75
75
|
- [D14] Treat `templates/migrations.json` as the source of truth for consumer-project upgrades. Use `npx job-forge migrate:plan` or `npx job-forge migrate:check` when diagnosing harness drift; `job-forge sync` applies safe migrations automatically unless `JOB_FORGE_SKIP_MIGRATIONS=1` is set.
|
|
76
76
|
why: `iso-migrate` is not an MCP and adds no prompt/tool-schema tokens; it prevents stale consumer scripts and generated-artifact ignores without asking agents to hand-edit package.json
|
|
77
77
|
|
|
78
|
+
- [D15] Treat `templates/canon.json` as the source of truth for URL/company/role identity keys. Use `npx job-forge canon:key ...` or `npx job-forge canon:compare ...` before broad duplicate checks when a stable key or same/possible/different decision is useful.
|
|
79
|
+
why: `iso-canon` is not an MCP and adds no prompt/tool-schema tokens; it centralizes duplicate-key rules so agents do not repeatedly derive inconsistent slugs for aliases, suffixes, remote/location noise, or tracking URLs
|
|
80
|
+
|
|
78
81
|
## Procedure
|
|
79
82
|
|
|
80
83
|
1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
|
|
81
84
|
2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
|
|
82
|
-
3. Read the active mode file [D3]. Use context bundle checks when changing context loads [D11]. Check cached artifacts before URL/JD refetches [D12]. Use artifact index lookups before broad file reads when they can answer the question [D13]. Use migration checks for harness drift [D14]. Decide inline vs delegated work [D1].
|
|
83
|
-
4. Prepare Geometra dispatches: cleanup [H3], index/ledger prefilter when useful [D8, D13], dedupe [H2], location filter [D5], routing [D2, D10], proxy prompt hygiene [H8].
|
|
85
|
+
3. Read the active mode file [D3]. Use context bundle checks when changing context loads [D11]. Check cached artifacts before URL/JD refetches [D12]. Use artifact index lookups before broad file reads when they can answer the question [D13]. Use canonical identity keys for duplicate checks [D15]. Use migration checks for harness drift [D14]. Decide inline vs delegated work [D1].
|
|
86
|
+
4. Prepare Geometra dispatches: cleanup [H3], canon/index/ledger prefilter when useful [D8, D13, D15], dedupe [H2], location filter [D5], routing [D2, D10], proxy prompt hygiene [H8].
|
|
84
87
|
5. Dispatch at most 2 tasks per round [H1]; wait for final outcomes, not just task ids [H5b].
|
|
85
88
|
6. Keep multi-job form-filling out of the orchestrator [H4].
|
|
86
89
|
7. Cross-check subagent facts against authoritative files [H7].
|
package/lib/jobforge-cache.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
resolveCacheDir,
|
|
11
11
|
verifyCache,
|
|
12
12
|
} from '@razroo/iso-cache';
|
|
13
|
+
import { canonicalizeJobForgeUrl } from './jobforge-canon.mjs';
|
|
13
14
|
|
|
14
15
|
export const CACHE_DIR = '.jobforge-cache';
|
|
15
16
|
export const JD_CACHE_NAMESPACE = 'jobforge.jd';
|
|
@@ -96,10 +97,14 @@ export function normalizeJobUrl(url) {
|
|
|
96
97
|
const text = String(url || '').trim();
|
|
97
98
|
if (!text) throw new Error('url is required');
|
|
98
99
|
try {
|
|
99
|
-
|
|
100
|
-
parsed.hash = '';
|
|
101
|
-
return parsed.toString();
|
|
100
|
+
return canonicalizeJobForgeUrl(text).canonical;
|
|
102
101
|
} catch {
|
|
103
|
-
|
|
102
|
+
try {
|
|
103
|
+
const parsed = new URL(text);
|
|
104
|
+
parsed.hash = '';
|
|
105
|
+
return parsed.toString();
|
|
106
|
+
} catch {
|
|
107
|
+
return text;
|
|
108
|
+
}
|
|
104
109
|
}
|
|
105
110
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import {
|
|
4
|
+
canonicalizeCompany,
|
|
5
|
+
canonicalizeCompanyRole,
|
|
6
|
+
canonicalizeEntity,
|
|
7
|
+
canonicalizeRole,
|
|
8
|
+
canonicalizeUrl,
|
|
9
|
+
compareCanon,
|
|
10
|
+
loadCanonConfig,
|
|
11
|
+
parseJson,
|
|
12
|
+
resolveProfile,
|
|
13
|
+
} from '@razroo/iso-canon';
|
|
14
|
+
|
|
15
|
+
export const CANON_CONFIG_FILE = 'templates/canon.json';
|
|
16
|
+
export const CANON_PROFILE = 'jobforge';
|
|
17
|
+
|
|
18
|
+
export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
|
|
19
|
+
return projectDir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function jobForgeCanonConfigPath(projectDir = resolveProjectDir()) {
|
|
23
|
+
return process.env.JOB_FORGE_CANON_CONFIG || join(projectDir, CANON_CONFIG_FILE);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function readJobForgeCanonConfig(projectDir = resolveProjectDir()) {
|
|
27
|
+
const path = jobForgeCanonConfigPath(projectDir);
|
|
28
|
+
return loadCanonConfig(parseJson(readFileSync(path, 'utf8'), path), path);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function jobForgeCanonProfile(projectDir = resolveProjectDir()) {
|
|
32
|
+
return resolveProfile(readJobForgeCanonConfig(projectDir), process.env.JOB_FORGE_CANON_PROFILE || CANON_PROFILE);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function canonicalizeJobForgeUrl(url, projectDir = resolveProjectDir()) {
|
|
36
|
+
return canonicalizeUrl(url, jobForgeCanonProfile(projectDir));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function canonicalizeJobForgeCompany(company, projectDir = resolveProjectDir()) {
|
|
40
|
+
return canonicalizeCompany(company, jobForgeCanonProfile(projectDir));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function canonicalizeJobForgeRole(role, projectDir = resolveProjectDir()) {
|
|
44
|
+
return canonicalizeRole(role, jobForgeCanonProfile(projectDir));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function canonicalizeJobForgeCompanyRole(company, role, projectDir = resolveProjectDir()) {
|
|
48
|
+
return canonicalizeCompanyRole(company, role, jobForgeCanonProfile(projectDir));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function canonicalizeJobForgeEntity(type, input, projectDir = resolveProjectDir()) {
|
|
52
|
+
return canonicalizeEntity(type, input, jobForgeCanonProfile(projectDir));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function compareJobForgeCanon(type, left, right, projectDir = resolveProjectDir()) {
|
|
56
|
+
return compareCanon(type, left, right, jobForgeCanonProfile(projectDir));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function jobForgeUrlKey(url, projectDir = resolveProjectDir()) {
|
|
60
|
+
return canonicalizeJobForgeUrl(url, projectDir).key;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function jobForgeCompanyRoleKey(company, role, projectDir = resolveProjectDir()) {
|
|
64
|
+
return canonicalizeJobForgeCompanyRole(company, role, projectDir).key;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function jobForgeApplicationSubject(company, role, projectDir = resolveProjectDir()) {
|
|
68
|
+
const companyKey = canonicalizeJobForgeCompany(company, projectDir).key.slice('company:'.length);
|
|
69
|
+
const roleKey = canonicalizeJobForgeRole(role, projectDir).key.slice('role:'.length);
|
|
70
|
+
return `application:${companyKey}:${roleKey}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function legacyCompanyRoleKey(company, role) {
|
|
74
|
+
return `company-role:${legacySlugPart(company)}:${legacySlugPart(role)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function legacyUrlKey(url) {
|
|
78
|
+
return `url:${String(url || '').trim()}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function legacySlugPart(value) {
|
|
82
|
+
const slug = String(value || 'unknown')
|
|
83
|
+
.toLowerCase()
|
|
84
|
+
.replace(/&/g, ' and ')
|
|
85
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
86
|
+
.replace(/^-+|-+$/g, '');
|
|
87
|
+
return slug || 'unknown';
|
|
88
|
+
}
|
package/lib/jobforge-index.mjs
CHANGED
|
@@ -6,8 +6,15 @@ import {
|
|
|
6
6
|
loadIndexConfig,
|
|
7
7
|
parseJson,
|
|
8
8
|
queryIndex,
|
|
9
|
+
recordId,
|
|
9
10
|
verifyIndex,
|
|
10
11
|
} from '@razroo/iso-index';
|
|
12
|
+
import {
|
|
13
|
+
jobForgeCompanyRoleKey,
|
|
14
|
+
jobForgeUrlKey,
|
|
15
|
+
legacyCompanyRoleKey,
|
|
16
|
+
legacyUrlKey,
|
|
17
|
+
} from './jobforge-canon.mjs';
|
|
11
18
|
|
|
12
19
|
export const INDEX_FILE = '.jobforge-index.json';
|
|
13
20
|
export const INDEX_CONFIG_FILE = 'templates/index.json';
|
|
@@ -35,7 +42,7 @@ export function readJobForgeIndexConfig(projectDir = resolveProjectDir()) {
|
|
|
35
42
|
|
|
36
43
|
export function buildJobForgeIndex(options = {}, projectDir = resolveProjectDir()) {
|
|
37
44
|
const config = readJobForgeIndexConfig(projectDir);
|
|
38
|
-
const index = buildIndex(config, { root: projectDir });
|
|
45
|
+
const index = canonicalizeJobForgeIndex(buildIndex(config, { root: projectDir }), projectDir);
|
|
39
46
|
const out = options.out || jobForgeIndexPath(projectDir);
|
|
40
47
|
if (options.write !== false) {
|
|
41
48
|
writeFileSync(out, `${JSON.stringify(index, null, 2)}\n`, 'utf8');
|
|
@@ -90,3 +97,72 @@ export function jobForgeIndexSummary(projectDir = resolveProjectDir()) {
|
|
|
90
97
|
configHash: index.configHash,
|
|
91
98
|
};
|
|
92
99
|
}
|
|
100
|
+
|
|
101
|
+
function canonicalizeJobForgeIndex(index, projectDir) {
|
|
102
|
+
const records = (index.records || []).map((record) => canonicalizeJobForgeIndexRecord(record, projectDir));
|
|
103
|
+
records.sort(compareRecords);
|
|
104
|
+
return {
|
|
105
|
+
...index,
|
|
106
|
+
records,
|
|
107
|
+
stats: {
|
|
108
|
+
...(index.stats || {}),
|
|
109
|
+
records: records.length,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function canonicalizeJobForgeIndexRecord(record, projectDir) {
|
|
115
|
+
const key = canonicalIndexKey(record, projectDir);
|
|
116
|
+
if (key === record.key) return record;
|
|
117
|
+
const updated = { ...record, key };
|
|
118
|
+
return { ...updated, id: recordId(updated) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function canonicalIndexKey(record, projectDir) {
|
|
122
|
+
if (isCompanyRoleRecord(record)) {
|
|
123
|
+
const { company, role } = companyRoleFields(record);
|
|
124
|
+
if (company && role) return safeCompanyRoleKey(company, role, projectDir);
|
|
125
|
+
}
|
|
126
|
+
if (isUrlRecord(record)) {
|
|
127
|
+
const url = record.fields?.url;
|
|
128
|
+
if (url) return safeUrlKey(url, projectDir);
|
|
129
|
+
}
|
|
130
|
+
return record.key;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isCompanyRoleRecord(record) {
|
|
134
|
+
return record.kind === 'jobforge.application' || record.kind === 'jobforge.tracker-addition';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function companyRoleFields(record) {
|
|
138
|
+
const fields = record.fields || {};
|
|
139
|
+
return {
|
|
140
|
+
company: fields.company || fields.Company,
|
|
141
|
+
role: fields.role || fields.Role,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isUrlRecord(record) {
|
|
146
|
+
return record.kind === 'jobforge.report.url' || record.kind === 'jobforge.pipeline.url' || record.kind === 'jobforge.scan.url';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function safeCompanyRoleKey(company, role, projectDir) {
|
|
150
|
+
try {
|
|
151
|
+
return jobForgeCompanyRoleKey(company, role, projectDir);
|
|
152
|
+
} catch {
|
|
153
|
+
return legacyCompanyRoleKey(company, role);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function safeUrlKey(url, projectDir) {
|
|
158
|
+
try {
|
|
159
|
+
return jobForgeUrlKey(url, projectDir);
|
|
160
|
+
} catch {
|
|
161
|
+
return legacyUrlKey(url);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function compareRecords(a, b) {
|
|
166
|
+
return `${a.kind}\0${a.key}\0${a.source?.path || ''}\0${a.source?.line || ''}\0${a.id}`
|
|
167
|
+
.localeCompare(`${b.kind}\0${b.key}\0${b.source?.path || ''}\0${b.source?.line || ''}\0${b.id}`);
|
|
168
|
+
}
|
package/lib/jobforge-ledger.mjs
CHANGED
|
@@ -8,6 +8,16 @@ import {
|
|
|
8
8
|
readLedger,
|
|
9
9
|
verifyLedger,
|
|
10
10
|
} from '@razroo/iso-ledger';
|
|
11
|
+
import {
|
|
12
|
+
jobForgeApplicationSubject,
|
|
13
|
+
jobForgeCompanyRoleKey,
|
|
14
|
+
jobForgeUrlKey,
|
|
15
|
+
legacyCompanyRoleKey,
|
|
16
|
+
legacySlugPart,
|
|
17
|
+
legacyUrlKey,
|
|
18
|
+
} from './jobforge-canon.mjs';
|
|
19
|
+
|
|
20
|
+
export { legacyCompanyRoleKey, legacyUrlKey };
|
|
11
21
|
|
|
12
22
|
export const LEDGER_DIR = '.jobforge-ledger';
|
|
13
23
|
export const LEDGER_FILE = 'events.jsonl';
|
|
@@ -89,7 +99,7 @@ export function recordTrackerMergeResult(addition, options = {}) {
|
|
|
89
99
|
|
|
90
100
|
export function buildApplicationEvent(type, app, options = {}) {
|
|
91
101
|
const projectDir = resolveProjectDir(options.projectDir);
|
|
92
|
-
const key = companyRoleKey(app.company, app.role);
|
|
102
|
+
const key = companyRoleKey(app.company, app.role, projectDir);
|
|
93
103
|
const sourceFile = options.sourceFile ? relativePath(projectDir, options.sourceFile) : '';
|
|
94
104
|
const idempotencyParts = [
|
|
95
105
|
options.idempotencyPrefix || type,
|
|
@@ -104,7 +114,7 @@ export function buildApplicationEvent(type, app, options = {}) {
|
|
|
104
114
|
return {
|
|
105
115
|
type,
|
|
106
116
|
key,
|
|
107
|
-
subject: applicationSubject(app.company, app.role),
|
|
117
|
+
subject: applicationSubject(app.company, app.role, projectDir),
|
|
108
118
|
idempotencyKey: idempotencyParts.join(':'),
|
|
109
119
|
data: compactObject({
|
|
110
120
|
num: numberOrString(app.num),
|
|
@@ -128,7 +138,7 @@ export function buildApplicationEvent(type, app, options = {}) {
|
|
|
128
138
|
|
|
129
139
|
export function buildPipelineEvent(item, options = {}) {
|
|
130
140
|
const projectDir = resolveProjectDir(options.projectDir);
|
|
131
|
-
const key = item.url ? urlKey(item.url) : `pipeline:${item.lineNumber || 'unknown'}`;
|
|
141
|
+
const key = item.url ? urlKey(item.url, projectDir) : `pipeline:${item.lineNumber || 'unknown'}`;
|
|
132
142
|
const sourceFile = options.sourceFile ? relativePath(projectDir, options.sourceFile) : 'data/pipeline.md';
|
|
133
143
|
const state = item.checked ? 'processed' : 'pending';
|
|
134
144
|
|
|
@@ -154,25 +164,33 @@ export function buildPipelineEvent(item, options = {}) {
|
|
|
154
164
|
};
|
|
155
165
|
}
|
|
156
166
|
|
|
157
|
-
export function companyRoleKey(company, role) {
|
|
158
|
-
|
|
167
|
+
export function companyRoleKey(company, role, projectDir = resolveProjectDir()) {
|
|
168
|
+
try {
|
|
169
|
+
return jobForgeCompanyRoleKey(company, role, projectDir);
|
|
170
|
+
} catch {
|
|
171
|
+
return legacyCompanyRoleKey(company, role);
|
|
172
|
+
}
|
|
159
173
|
}
|
|
160
174
|
|
|
161
|
-
export function applicationSubject(company, role) {
|
|
162
|
-
|
|
175
|
+
export function applicationSubject(company, role, projectDir = resolveProjectDir()) {
|
|
176
|
+
try {
|
|
177
|
+
return jobForgeApplicationSubject(company, role, projectDir);
|
|
178
|
+
} catch {
|
|
179
|
+
const key = legacyCompanyRoleKey(company, role).slice('company-role:'.length);
|
|
180
|
+
return `application:${key}`;
|
|
181
|
+
}
|
|
163
182
|
}
|
|
164
183
|
|
|
165
|
-
export function urlKey(url) {
|
|
166
|
-
|
|
184
|
+
export function urlKey(url, projectDir = resolveProjectDir()) {
|
|
185
|
+
try {
|
|
186
|
+
return jobForgeUrlKey(url, projectDir);
|
|
187
|
+
} catch {
|
|
188
|
+
return legacyUrlKey(url);
|
|
189
|
+
}
|
|
167
190
|
}
|
|
168
191
|
|
|
169
192
|
export function slugPart(value) {
|
|
170
|
-
|
|
171
|
-
.toLowerCase()
|
|
172
|
-
.replace(/&/g, ' and ')
|
|
173
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
174
|
-
.replace(/^-+|-+$/g, '');
|
|
175
|
-
return slug || 'unknown';
|
|
193
|
+
return legacySlugPart(value);
|
|
176
194
|
}
|
|
177
195
|
|
|
178
196
|
function relativePath(projectDir, value) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "job-forge",
|
|
3
|
-
"version": "2.14.
|
|
3
|
+
"version": "2.14.27",
|
|
4
4
|
"description": "AI-powered job search pipeline built on opencode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -55,6 +55,10 @@
|
|
|
55
55
|
"index:has": "node bin/job-forge.mjs index:has",
|
|
56
56
|
"index:verify": "node bin/job-forge.mjs index:verify",
|
|
57
57
|
"index:explain": "node bin/job-forge.mjs index:explain",
|
|
58
|
+
"canon:normalize": "node bin/job-forge.mjs canon:normalize",
|
|
59
|
+
"canon:key": "node bin/job-forge.mjs canon:key",
|
|
60
|
+
"canon:compare": "node bin/job-forge.mjs canon:compare",
|
|
61
|
+
"canon:explain": "node bin/job-forge.mjs canon:explain",
|
|
58
62
|
"migrate:plan": "node bin/job-forge.mjs migrate:plan",
|
|
59
63
|
"migrate:apply": "node bin/job-forge.mjs migrate:apply",
|
|
60
64
|
"migrate:check": "node bin/job-forge.mjs migrate:check",
|
|
@@ -127,6 +131,7 @@
|
|
|
127
131
|
"dependencies": {
|
|
128
132
|
"@razroo/iso-capabilities": "^0.1.0",
|
|
129
133
|
"@razroo/iso-cache": "^0.1.0",
|
|
134
|
+
"@razroo/iso-canon": "^0.1.0",
|
|
130
135
|
"@razroo/iso-context": "^0.1.0",
|
|
131
136
|
"@razroo/iso-contract": "^0.1.0",
|
|
132
137
|
"@razroo/iso-guard": "^0.1.0",
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { relative } from 'path';
|
|
4
|
+
import {
|
|
5
|
+
formatCanonResult,
|
|
6
|
+
formatCompareResult,
|
|
7
|
+
formatConfigSummary,
|
|
8
|
+
} from '@razroo/iso-canon';
|
|
9
|
+
import { PROJECT_DIR } from '../tracker-lib.mjs';
|
|
10
|
+
import {
|
|
11
|
+
canonicalizeJobForgeEntity,
|
|
12
|
+
compareJobForgeCanon,
|
|
13
|
+
jobForgeCanonConfigPath,
|
|
14
|
+
jobForgeCanonProfile,
|
|
15
|
+
} from '../lib/jobforge-canon.mjs';
|
|
16
|
+
|
|
17
|
+
const USAGE = `job-forge canon - deterministic identity keys for JobForge
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
job-forge canon:normalize <url|company|role> <value> [--json]
|
|
21
|
+
job-forge canon:normalize company-role --company <name> --role <title> [--json]
|
|
22
|
+
job-forge canon:key <url|company|role> <value> [--json]
|
|
23
|
+
job-forge canon:key company-role --company <name> --role <title> [--json]
|
|
24
|
+
job-forge canon:compare <url|company|role> <left> <right> [--json]
|
|
25
|
+
job-forge canon:compare company-role --left-company <name> --left-role <title> --right-company <name> --right-role <title> [--json]
|
|
26
|
+
job-forge canon:explain [--json]
|
|
27
|
+
job-forge canon:path
|
|
28
|
+
|
|
29
|
+
The policy is templates/canon.json. These commands are local, model-free, and
|
|
30
|
+
use the same keys that JobForge ledger/cache helpers use internally.`;
|
|
31
|
+
|
|
32
|
+
const [cmd = 'help', ...rawArgs] = process.argv.slice(2);
|
|
33
|
+
const opts = parseArgs(rawArgs);
|
|
34
|
+
|
|
35
|
+
if (opts.help || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
36
|
+
console.log(USAGE);
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
if (cmd === 'path') {
|
|
42
|
+
console.log(jobForgeCanonConfigPath(PROJECT_DIR));
|
|
43
|
+
} else if (cmd === 'normalize') {
|
|
44
|
+
normalize(opts, false);
|
|
45
|
+
} else if (cmd === 'key') {
|
|
46
|
+
normalize(opts, true);
|
|
47
|
+
} else if (cmd === 'compare') {
|
|
48
|
+
compare(opts);
|
|
49
|
+
} else if (cmd === 'explain') {
|
|
50
|
+
explain(opts);
|
|
51
|
+
} else {
|
|
52
|
+
console.error(`unknown canon command "${cmd}"\n`);
|
|
53
|
+
console.error(USAGE);
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseArgs(args) {
|
|
62
|
+
const opts = {
|
|
63
|
+
json: false,
|
|
64
|
+
help: false,
|
|
65
|
+
values: [],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
for (let i = 0; i < args.length; i++) {
|
|
69
|
+
const arg = args[i];
|
|
70
|
+
if (arg === '--json') {
|
|
71
|
+
opts.json = true;
|
|
72
|
+
} else if (arg === '--company') {
|
|
73
|
+
opts.company = valueAfter(args, ++i, '--company');
|
|
74
|
+
} else if (arg.startsWith('--company=')) {
|
|
75
|
+
opts.company = arg.slice('--company='.length);
|
|
76
|
+
} else if (arg === '--role') {
|
|
77
|
+
opts.role = valueAfter(args, ++i, '--role');
|
|
78
|
+
} else if (arg.startsWith('--role=')) {
|
|
79
|
+
opts.role = arg.slice('--role='.length);
|
|
80
|
+
} else if (arg === '--left-company') {
|
|
81
|
+
opts.leftCompany = valueAfter(args, ++i, '--left-company');
|
|
82
|
+
} else if (arg.startsWith('--left-company=')) {
|
|
83
|
+
opts.leftCompany = arg.slice('--left-company='.length);
|
|
84
|
+
} else if (arg === '--left-role') {
|
|
85
|
+
opts.leftRole = valueAfter(args, ++i, '--left-role');
|
|
86
|
+
} else if (arg.startsWith('--left-role=')) {
|
|
87
|
+
opts.leftRole = arg.slice('--left-role='.length);
|
|
88
|
+
} else if (arg === '--right-company') {
|
|
89
|
+
opts.rightCompany = valueAfter(args, ++i, '--right-company');
|
|
90
|
+
} else if (arg.startsWith('--right-company=')) {
|
|
91
|
+
opts.rightCompany = arg.slice('--right-company='.length);
|
|
92
|
+
} else if (arg === '--right-role') {
|
|
93
|
+
opts.rightRole = valueAfter(args, ++i, '--right-role');
|
|
94
|
+
} else if (arg.startsWith('--right-role=')) {
|
|
95
|
+
opts.rightRole = arg.slice('--right-role='.length);
|
|
96
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
97
|
+
opts.help = true;
|
|
98
|
+
} else if (arg.startsWith('--')) {
|
|
99
|
+
throw new Error(`unknown flag "${arg}"`);
|
|
100
|
+
} else {
|
|
101
|
+
opts.values.push(arg);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return opts;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalize(opts, keyOnly) {
|
|
109
|
+
const type = parseType(opts.values.shift(), keyOnly ? 'key' : 'normalize');
|
|
110
|
+
const input = normalizeInput(type, opts);
|
|
111
|
+
const result = canonicalizeJobForgeEntity(type, input, PROJECT_DIR);
|
|
112
|
+
if (opts.json) {
|
|
113
|
+
console.log(JSON.stringify(result, null, 2));
|
|
114
|
+
} else if (keyOnly) {
|
|
115
|
+
console.log(result.key);
|
|
116
|
+
} else {
|
|
117
|
+
console.log(formatCanonResult(result));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function compare(opts) {
|
|
122
|
+
const type = parseType(opts.values.shift(), 'compare');
|
|
123
|
+
const [left, right] = compareInputs(type, opts);
|
|
124
|
+
const result = compareJobForgeCanon(type, left, right, PROJECT_DIR);
|
|
125
|
+
if (opts.json) {
|
|
126
|
+
console.log(JSON.stringify(result, null, 2));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
console.log(formatCompareResult(result));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function explain(opts) {
|
|
133
|
+
const profile = jobForgeCanonProfile(PROJECT_DIR);
|
|
134
|
+
if (opts.json) {
|
|
135
|
+
console.log(JSON.stringify(profile, null, 2));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
console.log(`config: ${relativePath(jobForgeCanonConfigPath(PROJECT_DIR))}`);
|
|
139
|
+
console.log(formatConfigSummary({ version: 1, profiles: [profile] }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeInput(type, opts) {
|
|
143
|
+
if (type === 'company-role') {
|
|
144
|
+
if (!opts.company || !opts.role) throw new Error('company-role requires --company and --role');
|
|
145
|
+
return { company: opts.company, role: opts.role };
|
|
146
|
+
}
|
|
147
|
+
if (opts.values.length !== 1) throw new Error(`${type}: provide exactly one value; quote values containing spaces`);
|
|
148
|
+
return opts.values[0];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function compareInputs(type, opts) {
|
|
152
|
+
if (type === 'company-role') {
|
|
153
|
+
if (!opts.leftCompany || !opts.leftRole || !opts.rightCompany || !opts.rightRole) {
|
|
154
|
+
throw new Error('company-role compare requires --left-company, --left-role, --right-company, and --right-role');
|
|
155
|
+
}
|
|
156
|
+
return [
|
|
157
|
+
{ company: opts.leftCompany, role: opts.leftRole },
|
|
158
|
+
{ company: opts.rightCompany, role: opts.rightRole },
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
if (opts.values.length !== 2) throw new Error(`${type}: provide exactly two values; quote values containing spaces`);
|
|
162
|
+
return [opts.values[0], opts.values[1]];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseType(value, command) {
|
|
166
|
+
if (value === 'url' || value === 'company' || value === 'role' || value === 'company-role') return value;
|
|
167
|
+
throw new Error(`${command}: expected type url, company, role, or company-role`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function valueAfter(values, index, flag) {
|
|
171
|
+
const value = values[index];
|
|
172
|
+
if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function relativePath(path) {
|
|
177
|
+
return relative(PROJECT_DIR, path) || '.';
|
|
178
|
+
}
|
package/scripts/ledger.mjs
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
jobForgeLedgerPath,
|
|
17
17
|
jobForgeLedgerSummary,
|
|
18
18
|
ledgerExists,
|
|
19
|
+
legacyCompanyRoleKey,
|
|
20
|
+
legacyUrlKey,
|
|
19
21
|
readJobForgeLedger,
|
|
20
22
|
urlKey,
|
|
21
23
|
verifyJobForgeLedger,
|
|
@@ -201,8 +203,9 @@ function verify(opts) {
|
|
|
201
203
|
}
|
|
202
204
|
|
|
203
205
|
function has(opts) {
|
|
204
|
-
const filters =
|
|
205
|
-
const
|
|
206
|
+
const filters = queryFilterCandidates(opts);
|
|
207
|
+
const ledger = readJobForgeLedger(PROJECT_DIR);
|
|
208
|
+
const events = uniqueEvents(filters.flatMap((filter) => queryEvents(ledger, filter)));
|
|
206
209
|
if (opts.json) {
|
|
207
210
|
console.log(JSON.stringify({ match: events.length > 0, count: events.length, filters }, null, 2));
|
|
208
211
|
} else if (events.length > 0) {
|
|
@@ -237,6 +240,28 @@ function queryFilters(opts) {
|
|
|
237
240
|
return filters;
|
|
238
241
|
}
|
|
239
242
|
|
|
243
|
+
function queryFilterCandidates(opts) {
|
|
244
|
+
const primary = queryFilters(opts);
|
|
245
|
+
const candidates = [primary];
|
|
246
|
+
const legacy = { ...primary };
|
|
247
|
+
if (opts.url) legacy.key = legacyUrlKey(opts.url);
|
|
248
|
+
if (opts.company || opts.role) legacy.key = legacyCompanyRoleKey(opts.company, opts.role);
|
|
249
|
+
if (legacy.key && legacy.key !== primary.key) candidates.push(legacy);
|
|
250
|
+
return candidates;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function uniqueEvents(events) {
|
|
254
|
+
const seen = new Set();
|
|
255
|
+
const out = [];
|
|
256
|
+
for (const event of events) {
|
|
257
|
+
const id = event.id || event.idempotencyKey || JSON.stringify(event);
|
|
258
|
+
if (seen.has(id)) continue;
|
|
259
|
+
seen.add(id);
|
|
260
|
+
out.push(event);
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
240
265
|
function collectProjectEvents() {
|
|
241
266
|
const events = [];
|
|
242
267
|
const { entries } = readAllEntries();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"profiles": [
|
|
4
|
+
{
|
|
5
|
+
"name": "jobforge",
|
|
6
|
+
"url": {
|
|
7
|
+
"dropHash": true,
|
|
8
|
+
"stripWww": true,
|
|
9
|
+
"stripQueryParams": [
|
|
10
|
+
"utm_*",
|
|
11
|
+
"gh_src",
|
|
12
|
+
"source",
|
|
13
|
+
"ref",
|
|
14
|
+
"referrer",
|
|
15
|
+
"lever-source",
|
|
16
|
+
"ashby_jid"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"company": {
|
|
20
|
+
"aliases": {
|
|
21
|
+
"open ai": "openai",
|
|
22
|
+
"anthropic pbc": "anthropic",
|
|
23
|
+
"google llc": "google",
|
|
24
|
+
"meta platforms": "meta"
|
|
25
|
+
},
|
|
26
|
+
"suffixes": [
|
|
27
|
+
"inc",
|
|
28
|
+
"incorporated",
|
|
29
|
+
"llc",
|
|
30
|
+
"ltd",
|
|
31
|
+
"limited",
|
|
32
|
+
"corp",
|
|
33
|
+
"corporation",
|
|
34
|
+
"company",
|
|
35
|
+
"co",
|
|
36
|
+
"pbc",
|
|
37
|
+
"plc"
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
"role": {
|
|
41
|
+
"aliases": {
|
|
42
|
+
"fdse": "forward deployed software engineer",
|
|
43
|
+
"fullstack": "full stack",
|
|
44
|
+
"ml": "machine learning",
|
|
45
|
+
"sr": "senior",
|
|
46
|
+
"swe": "software engineer"
|
|
47
|
+
},
|
|
48
|
+
"stopWords": [
|
|
49
|
+
"hybrid",
|
|
50
|
+
"new york",
|
|
51
|
+
"onsite",
|
|
52
|
+
"remote",
|
|
53
|
+
"san francisco",
|
|
54
|
+
"united states",
|
|
55
|
+
"usa",
|
|
56
|
+
"us"
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
"match": {
|
|
60
|
+
"strong": 0.92,
|
|
61
|
+
"possible": 0.78
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"npx job-forge context:*",
|
|
14
14
|
"npx job-forge cache:*",
|
|
15
15
|
"npx job-forge index:*",
|
|
16
|
+
"npx job-forge canon:*",
|
|
16
17
|
"rg *"
|
|
17
18
|
],
|
|
18
19
|
"deny": [
|
|
@@ -60,7 +61,8 @@
|
|
|
60
61
|
"npx job-forge ledger:*",
|
|
61
62
|
"npx job-forge capabilities:*",
|
|
62
63
|
"npx job-forge cache:*",
|
|
63
|
-
"npx job-forge index:*"
|
|
64
|
+
"npx job-forge index:*",
|
|
65
|
+
"npx job-forge canon:*"
|
|
64
66
|
],
|
|
65
67
|
"deny": [
|
|
66
68
|
"task *"
|
|
@@ -100,6 +102,7 @@
|
|
|
100
102
|
"commands": {
|
|
101
103
|
"allow": [
|
|
102
104
|
"npx job-forge slugify *",
|
|
105
|
+
"npx job-forge canon:*",
|
|
103
106
|
"npx job-forge today",
|
|
104
107
|
"npx job-forge next-num"
|
|
105
108
|
]
|
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
"index:has": "job-forge index:has",
|
|
34
34
|
"index:query": "job-forge index:query",
|
|
35
35
|
"index:explain": "job-forge index:explain",
|
|
36
|
+
"canon:normalize": "job-forge canon:normalize",
|
|
37
|
+
"canon:key": "job-forge canon:key",
|
|
38
|
+
"canon:compare": "job-forge canon:compare",
|
|
39
|
+
"canon:explain": "job-forge canon:explain",
|
|
36
40
|
"migrate:plan": "job-forge migrate:plan",
|
|
37
41
|
"migrate:apply": "job-forge migrate:apply",
|
|
38
42
|
"migrate:check": "job-forge migrate:check",
|