job-forge 2.14.20 → 2.14.22

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.
@@ -30,7 +30,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
30
30
  - [H6] Application outcomes flow through `batch/tracker-additions/*.tsv`, not `data/pipeline.md`. After any multi-apply run, the orchestrator MUST run `npx job-forge merge` then `npx job-forge verify` before ending the session.
31
31
  why: `pipeline.md` is the URL inbox (`[ ]` pending → `[x]` processed); `data/applications/YYYY-MM-DD.md` is the outcome log; the TSV pathway is the only safe bridge because `merge` handles column order and duplicate detection
32
32
 
33
- - [H7] Load-bearing facts passed to downstream subagents must originate from a file, not from prior subagent prose. Authoritative sources: `data/pipeline.md`, `data/scan-history.tsv`, `batch/scan-output-*.md`, `reports/{num}-*.md` with `**URL:**` / `**Score:**` headers, `batch/tracker-additions/*.tsv`.
33
+ - [H7] Load-bearing facts passed to downstream subagents must originate from a file, not from prior subagent prose. Authoritative sources: `data/pipeline.md`, `data/scan-history.tsv`, `batch/scan-output-*.md`, `reports/{num}-*.md` with `**URL:**` / `**Score:**` headers, `batch/tracker-additions/*.tsv`, cached JD content returned by `npx job-forge cache:get --url ...`.
34
34
  why: 2026-04-18 scan subagent returned 30 fabricated Greenhouse IDs in prose (plausible-looking, non-existent); orchestrator dispatched 30 downstream subagents that all 404'd. Subagents can hallucinate IDs, scores, and confirmation text — round-trip through a file or don't trust the value
35
35
 
36
36
  - [H8] Never paste proxy values from `config/profile.yml` into `task` prompts, status text, or summaries. If a proxy is configured, tell the subagent exactly: "Proxy is configured; read `config/profile.yml` and pass its top-level `proxy:` object to every `geometra_connect` call." Do not transcribe `server`, `username`, `password`, or `bypass`, even if you just read them from disk.
@@ -71,11 +71,14 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
71
71
  - [D11] Treat `templates/context.json` as the source of truth for mode/reference context bundles. Use `npx job-forge context:plan <mode>` or `npx job-forge context:check <mode>` when changing or validating what a mode loads; do not paste the full context matrix into prompts.
72
72
  why: deterministic context bundles prevent reference-file drift and accidental token bloat without adding MCP/tool-schema tokens
73
73
 
74
+ - [D12] Use `job-forge cache:*` for deterministic local artifact reuse when available. For URL inputs, check `npx job-forge cache:has --url "..."` / `cache:get` before browser or network JD fetches; after a successful fetch, store the exact JD text with `npx job-forge cache:put --url "..." --ttl 14d --input @file` when it is already on disk.
75
+ why: `iso-cache` is not an MCP and adds no prompt/tool-schema tokens; it avoids repeated JD fetch/render passes and lets future sessions reuse stable content from `.jobforge-cache/`
76
+
74
77
  ## Procedure
75
78
 
76
79
  1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
77
80
  2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
78
- 3. Read the active mode file [D3]; use context bundle checks when changing context loads [D11]; decide inline vs delegated work [D1].
81
+ 3. Read the active mode file [D3]; use context bundle checks when changing context loads [D11]; check cached artifacts before URL/JD refetches [D12]; decide inline vs delegated work [D1].
79
82
  4. Prepare Geometra dispatches: cleanup [H3], ledger prefilter when present [D8], dedupe [H2], location filter [D5], routing [D2, D10], proxy prompt hygiene [H8].
80
83
  5. Dispatch at most 2 tasks per round [H1]; wait for final outcomes, not just task ids [H5b].
81
84
  6. Keep multi-job form-filling out of the orchestrator [H4].
package/AGENTS.md CHANGED
@@ -25,7 +25,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
25
25
  - [H6] Application outcomes flow through `batch/tracker-additions/*.tsv`, not `data/pipeline.md`. After any multi-apply run, the orchestrator MUST run `npx job-forge merge` then `npx job-forge verify` before ending the session.
26
26
  why: `pipeline.md` is the URL inbox (`[ ]` pending → `[x]` processed); `data/applications/YYYY-MM-DD.md` is the outcome log; the TSV pathway is the only safe bridge because `merge` handles column order and duplicate detection
27
27
 
28
- - [H7] Load-bearing facts passed to downstream subagents must originate from a file, not from prior subagent prose. Authoritative sources: `data/pipeline.md`, `data/scan-history.tsv`, `batch/scan-output-*.md`, `reports/{num}-*.md` with `**URL:**` / `**Score:**` headers, `batch/tracker-additions/*.tsv`.
28
+ - [H7] Load-bearing facts passed to downstream subagents must originate from a file, not from prior subagent prose. Authoritative sources: `data/pipeline.md`, `data/scan-history.tsv`, `batch/scan-output-*.md`, `reports/{num}-*.md` with `**URL:**` / `**Score:**` headers, `batch/tracker-additions/*.tsv`, cached JD content returned by `npx job-forge cache:get --url ...`.
29
29
  why: 2026-04-18 scan subagent returned 30 fabricated Greenhouse IDs in prose (plausible-looking, non-existent); orchestrator dispatched 30 downstream subagents that all 404'd. Subagents can hallucinate IDs, scores, and confirmation text — round-trip through a file or don't trust the value
30
30
 
31
31
  - [H8] Never paste proxy values from `config/profile.yml` into `task` prompts, status text, or summaries. If a proxy is configured, tell the subagent exactly: "Proxy is configured; read `config/profile.yml` and pass its top-level `proxy:` object to every `geometra_connect` call." Do not transcribe `server`, `username`, `password`, or `bypass`, even if you just read them from disk.
@@ -66,11 +66,14 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
66
66
  - [D11] Treat `templates/context.json` as the source of truth for mode/reference context bundles. Use `npx job-forge context:plan <mode>` or `npx job-forge context:check <mode>` when changing or validating what a mode loads; do not paste the full context matrix into prompts.
67
67
  why: deterministic context bundles prevent reference-file drift and accidental token bloat without adding MCP/tool-schema tokens
68
68
 
69
+ - [D12] Use `job-forge cache:*` for deterministic local artifact reuse when available. For URL inputs, check `npx job-forge cache:has --url "..."` / `cache:get` before browser or network JD fetches; after a successful fetch, store the exact JD text with `npx job-forge cache:put --url "..." --ttl 14d --input @file` when it is already on disk.
70
+ why: `iso-cache` is not an MCP and adds no prompt/tool-schema tokens; it avoids repeated JD fetch/render passes and lets future sessions reuse stable content from `.jobforge-cache/`
71
+
69
72
  ## Procedure
70
73
 
71
74
  1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
72
75
  2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
73
- 3. Read the active mode file [D3]; use context bundle checks when changing context loads [D11]; decide inline vs delegated work [D1].
76
+ 3. Read the active mode file [D3]; use context bundle checks when changing context loads [D11]; check cached artifacts before URL/JD refetches [D12]; decide inline vs delegated work [D1].
74
77
  4. Prepare Geometra dispatches: cleanup [H3], ledger prefilter when present [D8], dedupe [H2], location filter [D5], routing [D2, D10], proxy prompt hygiene [H8].
75
78
  5. Dispatch at most 2 tasks per round [H1]; wait for final outcomes, not just task ids [H5b].
76
79
  6. Keep multi-job form-filling out of the orchestrator [H4].
package/CLAUDE.md CHANGED
@@ -25,7 +25,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
25
25
  - [H6] Application outcomes flow through `batch/tracker-additions/*.tsv`, not `data/pipeline.md`. After any multi-apply run, the orchestrator MUST run `npx job-forge merge` then `npx job-forge verify` before ending the session.
26
26
  why: `pipeline.md` is the URL inbox (`[ ]` pending → `[x]` processed); `data/applications/YYYY-MM-DD.md` is the outcome log; the TSV pathway is the only safe bridge because `merge` handles column order and duplicate detection
27
27
 
28
- - [H7] Load-bearing facts passed to downstream subagents must originate from a file, not from prior subagent prose. Authoritative sources: `data/pipeline.md`, `data/scan-history.tsv`, `batch/scan-output-*.md`, `reports/{num}-*.md` with `**URL:**` / `**Score:**` headers, `batch/tracker-additions/*.tsv`.
28
+ - [H7] Load-bearing facts passed to downstream subagents must originate from a file, not from prior subagent prose. Authoritative sources: `data/pipeline.md`, `data/scan-history.tsv`, `batch/scan-output-*.md`, `reports/{num}-*.md` with `**URL:**` / `**Score:**` headers, `batch/tracker-additions/*.tsv`, cached JD content returned by `npx job-forge cache:get --url ...`.
29
29
  why: 2026-04-18 scan subagent returned 30 fabricated Greenhouse IDs in prose (plausible-looking, non-existent); orchestrator dispatched 30 downstream subagents that all 404'd. Subagents can hallucinate IDs, scores, and confirmation text — round-trip through a file or don't trust the value
30
30
 
31
31
  - [H8] Never paste proxy values from `config/profile.yml` into `task` prompts, status text, or summaries. If a proxy is configured, tell the subagent exactly: "Proxy is configured; read `config/profile.yml` and pass its top-level `proxy:` object to every `geometra_connect` call." Do not transcribe `server`, `username`, `password`, or `bypass`, even if you just read them from disk.
@@ -66,11 +66,14 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
66
66
  - [D11] Treat `templates/context.json` as the source of truth for mode/reference context bundles. Use `npx job-forge context:plan <mode>` or `npx job-forge context:check <mode>` when changing or validating what a mode loads; do not paste the full context matrix into prompts.
67
67
  why: deterministic context bundles prevent reference-file drift and accidental token bloat without adding MCP/tool-schema tokens
68
68
 
69
+ - [D12] Use `job-forge cache:*` for deterministic local artifact reuse when available. For URL inputs, check `npx job-forge cache:has --url "..."` / `cache:get` before browser or network JD fetches; after a successful fetch, store the exact JD text with `npx job-forge cache:put --url "..." --ttl 14d --input @file` when it is already on disk.
70
+ why: `iso-cache` is not an MCP and adds no prompt/tool-schema tokens; it avoids repeated JD fetch/render passes and lets future sessions reuse stable content from `.jobforge-cache/`
71
+
69
72
  ## Procedure
70
73
 
71
74
  1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
72
75
  2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
73
- 3. Read the active mode file [D3]; use context bundle checks when changing context loads [D11]; decide inline vs delegated work [D1].
76
+ 3. Read the active mode file [D3]; use context bundle checks when changing context loads [D11]; check cached artifacts before URL/JD refetches [D12]; decide inline vs delegated work [D1].
74
77
  4. Prepare Geometra dispatches: cleanup [H3], ledger prefilter when present [D8], dedupe [H2], location filter [D5], routing [D2, D10], proxy prompt hygiene [H8].
75
78
  5. Dispatch at most 2 tasks per round [H1]; wait for final outcomes, not just task ids [H5b].
76
79
  6. Keep multi-job form-filling out of the orchestrator [H4].
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`, and `.jobforge-ledger/events.jsonl` records deterministic duplicate/status events via `@razroo/iso-ledger`. None of these add always-on prompt or tool-schema tokens.
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`, `.jobforge-ledger/events.jsonl` records duplicate/status events via `@razroo/iso-ledger`, and `.jobforge-cache/` stores reusable JD/artifact content via `@razroo/iso-cache`. 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** | `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, and `job-forge context:*` plans mode/reference context bundles without MCP/tool-schema overhead. |
81
+ | **Trace + Telemetry + Guard + Contract + Ledger + Capabilities + Context + Cache** | `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, and `job-forge cache:*` reuses fetched JD/artifact content 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
@@ -146,6 +146,7 @@ my-search/
146
146
  ├── config/profile.yml # your identity, target roles (personal)
147
147
  ├── data/ # applications, pipeline, scan history (personal, gitignored)
148
148
  ├── .jobforge-ledger/ # append-only local workflow events (personal, gitignored)
149
+ ├── .jobforge-cache/ # content-addressed local JD/artifact cache (personal, gitignored)
149
150
  ├── reports/ # generated evaluation reports (personal, gitignored)
150
151
  ├── batch/{batch-input,batch-state}.tsv, tracker-additions/, logs/ # personal
151
152
  ├── .jobforge-runs/ # durable batch workflow records (generated)
@@ -197,6 +198,7 @@ JobForge/
197
198
  │ ├── ledger.mjs # iso-ledger-backed workflow-state CLI
198
199
  │ ├── capabilities.mjs # iso-capabilities-backed role policy CLI
199
200
  │ ├── context.mjs # iso-context-backed context bundle CLI
201
+ │ ├── cache.mjs # iso-cache-backed local artifact cache CLI
200
202
  │ ├── token-usage-report.mjs # opencode cost analyzer
201
203
  │ └── release/check-source.mjs # version gate for npm publish
202
204
  ├── tracker-lib.mjs / merge-tracker.mjs / dedup-tracker.mjs / verify-pipeline.mjs
package/bin/job-forge.mjs CHANGED
@@ -23,6 +23,7 @@
23
23
  * ledger:* Query local deterministic workflow state via iso-ledger
24
24
  * capabilities:* Query role capability policy via iso-capabilities
25
25
  * context:* Query/render deterministic context bundles via iso-context
26
+ * cache:* Reuse local deterministic artifacts via iso-cache
26
27
  * sync Re-run the harness symlink sync (bin/sync.mjs)
27
28
  * help, --help Show this message
28
29
  */
@@ -103,6 +104,18 @@ const contextAliases = {
103
104
  'context:path': 'path',
104
105
  };
105
106
 
107
+ const cacheAliases = {
108
+ 'cache:key': 'key',
109
+ 'cache:status': 'status',
110
+ 'cache:has': 'has',
111
+ 'cache:get': 'get',
112
+ 'cache:put': 'put',
113
+ 'cache:list': 'list',
114
+ 'cache:verify': 'verify',
115
+ 'cache:prune': 'prune',
116
+ 'cache:path': 'path',
117
+ };
118
+
106
119
  const [, , cmd, ...rest] = process.argv;
107
120
 
108
121
  function printHelp() {
@@ -142,6 +155,12 @@ Commands:
142
155
  context:plan Estimate files/tokens for one context bundle
143
156
  context:check Fail if a context bundle exceeds its budget
144
157
  context:render Render context bundle content as markdown/json
158
+ cache:status Show local artifact cache status
159
+ cache:key Print deterministic cache key for a job URL
160
+ cache:has Check whether a job URL or cache key is cached
161
+ cache:get Read cached JD/artifact content
162
+ cache:put Store JD/artifact content
163
+ cache:verify Validate local artifact cache integrity
145
164
  sync Re-create harness symlinks in the current project
146
165
 
147
166
  Deterministic helpers (prefer these over LLM-derived values):
@@ -175,6 +194,9 @@ Pass --help after a command to see its own flags, e.g.:
175
194
  job-forge capabilities:check general-free --tool browser --mcp geometra --command "npx job-forge merge" --filesystem write
176
195
  job-forge context:plan apply
177
196
  job-forge context:check apply --budget 23000
197
+ job-forge cache:has --url https://example.test/jobs/123
198
+ job-forge cache:get --url https://example.test/jobs/123
199
+ job-forge cache:put --url https://example.test/jobs/123 --input @jds/example.md
178
200
 
179
201
  Project directory resolves to $JOB_FORGE_PROJECT or cwd.`);
180
202
  }
@@ -274,6 +296,21 @@ if (cmd === 'context' || contextAliases[cmd]) {
274
296
  process.exit(result.status ?? 1);
275
297
  }
276
298
 
299
+ if (cmd === 'cache' || cacheAliases[cmd]) {
300
+ const cacheArgs = cmd === 'cache'
301
+ ? (rest.length === 0 ? ['help'] : rest)
302
+ : [cacheAliases[cmd], ...rest];
303
+
304
+ const scriptPath = join(PKG_ROOT, 'scripts/cache.mjs');
305
+ const result = spawnSync(process.execPath, [scriptPath, ...cacheArgs], {
306
+ stdio: 'inherit',
307
+ cwd: PROJECT_DIR,
308
+ env: process.env,
309
+ });
310
+
311
+ process.exit(result.status ?? 1);
312
+ }
313
+
277
314
  const rel = commands[cmd];
278
315
  if (!rel) {
279
316
  console.error(`Unknown command: ${cmd}\n`);
package/docs/SETUP.md CHANGED
@@ -128,6 +128,7 @@ From your project root, these commands maintain the tracker and pipeline checks.
128
128
  | Inspect tracker row contract | `npx iso-contract explain jobforge.tracker-row --contracts templates/contracts.json` | _(none)_ |
129
129
  | Inspect role capabilities | `npx job-forge capabilities:explain general-free` | `npm run capabilities:explain -- general-free` |
130
130
  | Inspect context bundle budget | `npx job-forge context:plan apply` | `npm run context:plan -- apply` |
131
+ | Inspect local JD/artifact cache | `npx job-forge cache:status` | `npm run cache:status` |
131
132
  | Map status column to canonical labels | `npx job-forge normalize` | `npm run normalize` |
132
133
  | Merge duplicate company/role rows | `npx job-forge dedup` | `npm run dedup` |
133
134
  | Generate ATS-optimized CV PDF | `npx job-forge pdf` | `npm run pdf` |
@@ -144,6 +145,7 @@ From your project root, these commands maintain the tracker and pipeline checks.
144
145
  | Show local workflow ledger status | `npx job-forge ledger:status` | `npm run ledger:status` |
145
146
  | Rebuild local workflow ledger from tracker/pipeline files | `npx job-forge ledger:rebuild` | `npm run ledger:rebuild` |
146
147
  | Check duplicate/status event without loading tracker files | `npx job-forge ledger:has --company "Acme" --role "Staff Engineer" --status Applied` | `npm run ledger:has -- --company ...` |
148
+ | Check/reuse cached JD content | `npx job-forge cache:has --url <url>` / `npx job-forge cache:get --url <url>` | `npm run cache:has -- --url ...` |
147
149
  | Re-create harness symlinks | `npx job-forge sync` | `npm run sync` |
148
150
  | Build optional dashboard TUI (Go on `PATH`) | `(cd node_modules/job-forge/dashboard && go build .)` | `npm run build:dashboard` (harness repo only) |
149
151
 
@@ -25,7 +25,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
25
25
  - [H6] Application outcomes flow through `batch/tracker-additions/*.tsv`, not `data/pipeline.md`. After any multi-apply run, the orchestrator MUST run `npx job-forge merge` then `npx job-forge verify` before ending the session.
26
26
  why: `pipeline.md` is the URL inbox (`[ ]` pending → `[x]` processed); `data/applications/YYYY-MM-DD.md` is the outcome log; the TSV pathway is the only safe bridge because `merge` handles column order and duplicate detection
27
27
 
28
- - [H7] Load-bearing facts passed to downstream subagents must originate from a file, not from prior subagent prose. Authoritative sources: `data/pipeline.md`, `data/scan-history.tsv`, `batch/scan-output-*.md`, `reports/{num}-*.md` with `**URL:**` / `**Score:**` headers, `batch/tracker-additions/*.tsv`.
28
+ - [H7] Load-bearing facts passed to downstream subagents must originate from a file, not from prior subagent prose. Authoritative sources: `data/pipeline.md`, `data/scan-history.tsv`, `batch/scan-output-*.md`, `reports/{num}-*.md` with `**URL:**` / `**Score:**` headers, `batch/tracker-additions/*.tsv`, cached JD content returned by `npx job-forge cache:get --url ...`.
29
29
  why: 2026-04-18 scan subagent returned 30 fabricated Greenhouse IDs in prose (plausible-looking, non-existent); orchestrator dispatched 30 downstream subagents that all 404'd. Subagents can hallucinate IDs, scores, and confirmation text — round-trip through a file or don't trust the value
30
30
 
31
31
  - [H8] Never paste proxy values from `config/profile.yml` into `task` prompts, status text, or summaries. If a proxy is configured, tell the subagent exactly: "Proxy is configured; read `config/profile.yml` and pass its top-level `proxy:` object to every `geometra_connect` call." Do not transcribe `server`, `username`, `password`, or `bypass`, even if you just read them from disk.
@@ -66,11 +66,14 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
66
66
  - [D11] Treat `templates/context.json` as the source of truth for mode/reference context bundles. Use `npx job-forge context:plan <mode>` or `npx job-forge context:check <mode>` when changing or validating what a mode loads; do not paste the full context matrix into prompts.
67
67
  why: deterministic context bundles prevent reference-file drift and accidental token bloat without adding MCP/tool-schema tokens
68
68
 
69
+ - [D12] Use `job-forge cache:*` for deterministic local artifact reuse when available. For URL inputs, check `npx job-forge cache:has --url "..."` / `cache:get` before browser or network JD fetches; after a successful fetch, store the exact JD text with `npx job-forge cache:put --url "..." --ttl 14d --input @file` when it is already on disk.
70
+ why: `iso-cache` is not an MCP and adds no prompt/tool-schema tokens; it avoids repeated JD fetch/render passes and lets future sessions reuse stable content from `.jobforge-cache/`
71
+
69
72
  ## Procedure
70
73
 
71
74
  1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
72
75
  2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
73
- 3. Read the active mode file [D3]; use context bundle checks when changing context loads [D11]; decide inline vs delegated work [D1].
76
+ 3. Read the active mode file [D3]; use context bundle checks when changing context loads [D11]; check cached artifacts before URL/JD refetches [D12]; decide inline vs delegated work [D1].
74
77
  4. Prepare Geometra dispatches: cleanup [H3], ledger prefilter when present [D8], dedupe [H2], location filter [D5], routing [D2, D10], proxy prompt hygiene [H8].
75
78
  5. Dispatch at most 2 tasks per round [H1]; wait for final outcomes, not just task ids [H5b].
76
79
  6. Keep multi-job form-filling out of the orchestrator [H4].
@@ -0,0 +1,105 @@
1
+ import { existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import {
4
+ cacheKey,
5
+ hasCacheEntry,
6
+ listCacheEntries,
7
+ pruneCache,
8
+ putCacheEntry,
9
+ readCacheContent,
10
+ resolveCacheDir,
11
+ verifyCache,
12
+ } from '@razroo/iso-cache';
13
+
14
+ export const CACHE_DIR = '.jobforge-cache';
15
+ export const JD_CACHE_NAMESPACE = 'jobforge.jd';
16
+ export const JD_CACHE_VERSION = '1';
17
+ export const JD_CACHE_KIND = 'jd';
18
+ export const DEFAULT_JD_TTL_MS = 14 * 24 * 60 * 60 * 1000;
19
+
20
+ export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
21
+ return projectDir;
22
+ }
23
+
24
+ export function jobForgeCacheDir(projectDir = resolveProjectDir()) {
25
+ return process.env.JOB_FORGE_CACHE || join(projectDir, CACHE_DIR);
26
+ }
27
+
28
+ export function jobForgeCacheSummary(projectDir = resolveProjectDir()) {
29
+ const root = resolveCacheDir(jobForgeCacheDir(projectDir));
30
+ const entries = listCacheEntries(root, { includeExpired: true });
31
+ return {
32
+ root,
33
+ exists: existsSync(root),
34
+ entries: entries.length,
35
+ active: entries.filter((entry) => !entry.expiresAt || new Date(entry.expiresAt).getTime() > Date.now()).length,
36
+ };
37
+ }
38
+
39
+ export function jobDescriptionCacheKey(url) {
40
+ return cacheKey({
41
+ namespace: JD_CACHE_NAMESPACE,
42
+ version: JD_CACHE_VERSION,
43
+ parts: { url: normalizeJobUrl(url) },
44
+ });
45
+ }
46
+
47
+ export function putJobDescriptionCache(url, content, options = {}, projectDir = resolveProjectDir()) {
48
+ const normalizedUrl = normalizeJobUrl(url);
49
+ return putCacheEntry(
50
+ jobForgeCacheDir(projectDir),
51
+ jobDescriptionCacheKey(normalizedUrl),
52
+ content,
53
+ {
54
+ kind: options.kind || JD_CACHE_KIND,
55
+ contentType: options.contentType || 'text/markdown',
56
+ ttlMs: options.expiresAt ? undefined : (options.ttlMs ?? DEFAULT_JD_TTL_MS),
57
+ expiresAt: options.expiresAt,
58
+ metadata: {
59
+ url: normalizedUrl,
60
+ source: options.source || 'job-forge',
61
+ ...(options.metadata || {}),
62
+ },
63
+ },
64
+ );
65
+ }
66
+
67
+ export function readJobDescriptionCache(url, options = {}, projectDir = resolveProjectDir()) {
68
+ return readCacheContent(jobForgeCacheDir(projectDir), jobDescriptionCacheKey(url), options);
69
+ }
70
+
71
+ export function hasJobDescriptionCache(url, options = {}, projectDir = resolveProjectDir()) {
72
+ return hasCacheEntry(jobForgeCacheDir(projectDir), jobDescriptionCacheKey(url), options);
73
+ }
74
+
75
+ export function readJobForgeCache(key, options = {}, projectDir = resolveProjectDir()) {
76
+ return readCacheContent(jobForgeCacheDir(projectDir), key, options);
77
+ }
78
+
79
+ export function putJobForgeCache(key, content, options = {}, projectDir = resolveProjectDir()) {
80
+ return putCacheEntry(jobForgeCacheDir(projectDir), key, content, options);
81
+ }
82
+
83
+ export function listJobForgeCache(options = {}, projectDir = resolveProjectDir()) {
84
+ return listCacheEntries(jobForgeCacheDir(projectDir), options);
85
+ }
86
+
87
+ export function verifyJobForgeCache(projectDir = resolveProjectDir()) {
88
+ return verifyCache(jobForgeCacheDir(projectDir));
89
+ }
90
+
91
+ export function pruneJobForgeCache(options = {}, projectDir = resolveProjectDir()) {
92
+ return pruneCache(jobForgeCacheDir(projectDir), options);
93
+ }
94
+
95
+ export function normalizeJobUrl(url) {
96
+ const text = String(url || '').trim();
97
+ if (!text) throw new Error('url is required');
98
+ try {
99
+ const parsed = new URL(text);
100
+ parsed.hash = '';
101
+ return parsed.toString();
102
+ } catch {
103
+ return text;
104
+ }
105
+ }
@@ -21,7 +21,9 @@ Fetch the JD content once. If the input is a **URL** (not pasted JD text), fetch
21
21
 
22
22
  **If the input is JD text** (not a URL): use it directly, no fetching needed.
23
23
 
24
- **Local artifacts before Step 0 methods:** Grep `reports/` for the URL or stable company+role slug; if a report already embeds the full JD, Read it and skip network fetch entirely. If the pipeline row or `jds/` references `local:jds/{file}`, Read that file first. This stacks with the rule above: one fetch per URL per session, and **zero** if the JD is already on disk.
24
+ **Local artifacts before Step 0 methods:** Grep `reports/` for the URL or stable company+role slug; if a report already embeds the full JD, Read it and skip network fetch entirely. If the pipeline row or `jds/` references `local:jds/{file}`, Read that file first. For URL inputs, run `npx job-forge cache:has --url "{url}"` and then `npx job-forge cache:get --url "{url}"` on a hit; cached JD text is authoritative local content and avoids a browser/network fetch. This stacks with the rule above: one fetch per URL per session, and **zero** if the JD is already on disk or in `.jobforge-cache/`.
25
+
26
+ **Cache after a successful fetch:** If the JD text is written to `jds/` or embedded in a report, store that exact text with `npx job-forge cache:put --url "{url}" --ttl 14d --input @path/to/jd.md`. Do not refetch just to populate the cache.
25
27
 
26
28
  ## Step 1 — Run Evaluation A-F
27
29
  Execute exactly as in the `offer` mode (read `modes/offer.md` for all blocks A-F).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-forge",
3
- "version": "2.14.20",
3
+ "version": "2.14.22",
4
4
  "description": "AI-powered job search pipeline built on opencode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,6 +41,14 @@
41
41
  "context:plan": "node bin/job-forge.mjs context:plan",
42
42
  "context:check": "node bin/job-forge.mjs context:check",
43
43
  "context:render": "node bin/job-forge.mjs context:render",
44
+ "cache:key": "node bin/job-forge.mjs cache:key",
45
+ "cache:has": "node bin/job-forge.mjs cache:has",
46
+ "cache:get": "node bin/job-forge.mjs cache:get",
47
+ "cache:put": "node bin/job-forge.mjs cache:put",
48
+ "cache:status": "node bin/job-forge.mjs cache:status",
49
+ "cache:list": "node bin/job-forge.mjs cache:list",
50
+ "cache:verify": "node bin/job-forge.mjs cache:verify",
51
+ "cache:prune": "node bin/job-forge.mjs cache:prune",
44
52
  "plan": "iso plan .",
45
53
  "lint:agentmd": "agentmd lint iso/instructions.md",
46
54
  "lint:modes": "isolint lint modes/",
@@ -58,7 +66,9 @@
58
66
  "bin/",
59
67
  "iso/",
60
68
  "models.yaml",
61
- ".claude/",
69
+ ".claude/settings.json",
70
+ ".claude/iso-route.resolved.json",
71
+ ".claude/agents/",
62
72
  ".cursor/mcp.json",
63
73
  ".cursor/rules/",
64
74
  ".cursor/iso-route.md",
@@ -106,6 +116,7 @@
106
116
  },
107
117
  "dependencies": {
108
118
  "@razroo/iso-capabilities": "^0.1.0",
119
+ "@razroo/iso-cache": "^0.1.0",
109
120
  "@razroo/iso-context": "^0.1.0",
110
121
  "@razroo/iso-contract": "^0.1.0",
111
122
  "@razroo/iso-guard": "^0.1.0",
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, writeFileSync } from 'fs';
4
+ import {
5
+ formatCacheEntries,
6
+ formatPruneResult,
7
+ formatVerifyResult,
8
+ } from '@razroo/iso-cache';
9
+ import { PROJECT_DIR } from '../tracker-lib.mjs';
10
+ import {
11
+ DEFAULT_JD_TTL_MS,
12
+ JD_CACHE_KIND,
13
+ jobDescriptionCacheKey,
14
+ jobForgeCacheDir,
15
+ jobForgeCacheSummary,
16
+ listJobForgeCache,
17
+ pruneJobForgeCache,
18
+ putJobDescriptionCache,
19
+ putJobForgeCache,
20
+ readJobDescriptionCache,
21
+ readJobForgeCache,
22
+ verifyJobForgeCache,
23
+ } from '../lib/jobforge-cache.mjs';
24
+
25
+ const USAGE = `job-forge cache - local deterministic artifact cache
26
+
27
+ Usage:
28
+ job-forge cache:key --url <url> [--json]
29
+ job-forge cache:status [--json]
30
+ job-forge cache:has (--url <url> | --key <key>) [--allow-expired] [--json]
31
+ job-forge cache:get (--url <url> | --key <key>) [--allow-expired] [--output <file>] [--json]
32
+ job-forge cache:put (--url <url> | --key <key>) --input <text|@file|-> [--ttl 14d] [--kind <kind>] [--content-type <type>] [--meta <json|@file>] [--json]
33
+ job-forge cache:list [--kind <kind>] [--include-expired] [--json]
34
+ job-forge cache:verify [--json]
35
+ job-forge cache:prune [--expired] [--dry-run] [--json]
36
+ job-forge cache:path
37
+
38
+ Default path is .jobforge-cache/ unless JOB_FORGE_CACHE is set. This is local
39
+ project state, not an MCP and not prompt context.`;
40
+
41
+ const [cmd = 'help', ...rawArgs] = process.argv.slice(2);
42
+ const opts = parseArgs(rawArgs);
43
+
44
+ if (opts.help || cmd === 'help' || cmd === '--help' || cmd === '-h') {
45
+ console.log(USAGE);
46
+ process.exit(0);
47
+ }
48
+
49
+ try {
50
+ if (cmd === 'path') {
51
+ console.log(jobForgeCacheDir(PROJECT_DIR));
52
+ } else if (cmd === 'key') {
53
+ key(opts);
54
+ } else if (cmd === 'status') {
55
+ status(opts);
56
+ } else if (cmd === 'has') {
57
+ has(opts);
58
+ } else if (cmd === 'get') {
59
+ get(opts);
60
+ } else if (cmd === 'put') {
61
+ put(opts);
62
+ } else if (cmd === 'list') {
63
+ list(opts);
64
+ } else if (cmd === 'verify') {
65
+ verify(opts);
66
+ } else if (cmd === 'prune') {
67
+ prune(opts);
68
+ } else {
69
+ console.error(`unknown cache command "${cmd}"\n`);
70
+ console.error(USAGE);
71
+ process.exit(2);
72
+ }
73
+ } catch (error) {
74
+ console.error(error instanceof Error ? error.message : String(error));
75
+ process.exit(1);
76
+ }
77
+
78
+ function parseArgs(args) {
79
+ const opts = {
80
+ json: false,
81
+ help: false,
82
+ allowExpired: false,
83
+ includeExpired: false,
84
+ dryRun: false,
85
+ expired: false,
86
+ ttlMs: DEFAULT_JD_TTL_MS,
87
+ metadata: {},
88
+ };
89
+
90
+ for (let i = 0; i < args.length; i++) {
91
+ const arg = args[i];
92
+ if (arg === '--json') {
93
+ opts.json = true;
94
+ } else if (arg === '--url') {
95
+ opts.url = valueAfter(args, ++i, '--url');
96
+ } else if (arg.startsWith('--url=')) {
97
+ opts.url = arg.slice('--url='.length);
98
+ } else if (arg === '--key') {
99
+ opts.key = valueAfter(args, ++i, '--key');
100
+ } else if (arg.startsWith('--key=')) {
101
+ opts.key = arg.slice('--key='.length);
102
+ } else if (arg === '--input') {
103
+ opts.input = valueAfter(args, ++i, '--input');
104
+ } else if (arg.startsWith('--input=')) {
105
+ opts.input = arg.slice('--input='.length);
106
+ } else if (arg === '--output') {
107
+ opts.output = valueAfter(args, ++i, '--output');
108
+ } else if (arg.startsWith('--output=')) {
109
+ opts.output = arg.slice('--output='.length);
110
+ } else if (arg === '--kind') {
111
+ opts.kind = valueAfter(args, ++i, '--kind');
112
+ } else if (arg.startsWith('--kind=')) {
113
+ opts.kind = arg.slice('--kind='.length);
114
+ } else if (arg === '--content-type') {
115
+ opts.contentType = valueAfter(args, ++i, '--content-type');
116
+ } else if (arg.startsWith('--content-type=')) {
117
+ opts.contentType = arg.slice('--content-type='.length);
118
+ } else if (arg === '--ttl') {
119
+ opts.ttlMs = parseDuration(valueAfter(args, ++i, '--ttl'));
120
+ } else if (arg.startsWith('--ttl=')) {
121
+ opts.ttlMs = parseDuration(arg.slice('--ttl='.length));
122
+ } else if (arg === '--expires-at') {
123
+ opts.expiresAt = valueAfter(args, ++i, '--expires-at');
124
+ opts.ttlMs = undefined;
125
+ } else if (arg.startsWith('--expires-at=')) {
126
+ opts.expiresAt = arg.slice('--expires-at='.length);
127
+ opts.ttlMs = undefined;
128
+ } else if (arg === '--meta') {
129
+ opts.metadata = parseMetadata(valueAfter(args, ++i, '--meta'));
130
+ } else if (arg.startsWith('--meta=')) {
131
+ opts.metadata = parseMetadata(arg.slice('--meta='.length));
132
+ } else if (arg === '--allow-expired') {
133
+ opts.allowExpired = true;
134
+ } else if (arg === '--include-expired') {
135
+ opts.includeExpired = true;
136
+ } else if (arg === '--dry-run') {
137
+ opts.dryRun = true;
138
+ } else if (arg === '--expired') {
139
+ opts.expired = true;
140
+ } else if (arg === '--help' || arg === '-h') {
141
+ opts.help = true;
142
+ } else {
143
+ throw new Error(`unknown flag "${arg}"`);
144
+ }
145
+ }
146
+
147
+ return opts;
148
+ }
149
+
150
+ function valueAfter(values, index, flag) {
151
+ const value = values[index];
152
+ if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
153
+ return value;
154
+ }
155
+
156
+ function key(opts) {
157
+ const keyValue = keyFromOptions(opts);
158
+ if (opts.json) {
159
+ console.log(JSON.stringify({ key: keyValue, url: opts.url || null }, null, 2));
160
+ return;
161
+ }
162
+ console.log(keyValue);
163
+ }
164
+
165
+ function status(opts) {
166
+ const summary = jobForgeCacheSummary(PROJECT_DIR);
167
+ if (opts.json) {
168
+ console.log(JSON.stringify(summary, null, 2));
169
+ return;
170
+ }
171
+ console.log(`cache: ${summary.root}`);
172
+ console.log(`exists: ${summary.exists ? 'yes' : 'no'}`);
173
+ console.log(`entries: ${summary.active} active, ${summary.entries - summary.active} expired`);
174
+ }
175
+
176
+ function has(opts) {
177
+ const hit = read(opts);
178
+ if (opts.json) {
179
+ console.log(JSON.stringify({ hit: Boolean(hit?.hit), stale: Boolean(hit?.stale), key: keyFromOptions(opts) }, null, 2));
180
+ } else {
181
+ console.log(hit?.hit ? `HIT${hit.stale ? ' stale' : ''}` : 'MISS');
182
+ }
183
+ process.exit(hit?.hit ? 0 : 1);
184
+ }
185
+
186
+ function get(opts) {
187
+ const hit = read(opts);
188
+ if (!hit?.hit || hit.content === undefined) {
189
+ if (opts.json) {
190
+ console.log(JSON.stringify({ hit: false, stale: Boolean(hit?.stale), key: keyFromOptions(opts) }, null, 2));
191
+ } else {
192
+ console.log('MISS');
193
+ }
194
+ process.exit(1);
195
+ }
196
+ if (opts.output) {
197
+ writeFileSync(opts.output, hit.content, 'utf8');
198
+ }
199
+ if (opts.json) {
200
+ console.log(JSON.stringify({ hit: true, stale: hit.stale, entry: hit.entry, content: opts.output ? undefined : hit.content }, null, 2));
201
+ } else if (opts.output) {
202
+ console.log(`WROTE ${opts.output}`);
203
+ } else {
204
+ process.stdout.write(hit.content);
205
+ if (!hit.content.endsWith('\n')) process.stdout.write('\n');
206
+ }
207
+ }
208
+
209
+ function put(opts) {
210
+ if (!opts.input) throw new Error('cache:put requires --input <text|@file|->');
211
+ const content = readInput(opts.input);
212
+ const entry = opts.url
213
+ ? putJobDescriptionCache(opts.url, content, {
214
+ kind: opts.kind || JD_CACHE_KIND,
215
+ contentType: opts.contentType,
216
+ ttlMs: opts.ttlMs,
217
+ expiresAt: opts.expiresAt,
218
+ metadata: opts.metadata,
219
+ }, PROJECT_DIR)
220
+ : putJobForgeCache(requiredKey(opts), content, {
221
+ kind: opts.kind,
222
+ contentType: opts.contentType,
223
+ ttlMs: opts.expiresAt ? undefined : opts.ttlMs,
224
+ expiresAt: opts.expiresAt,
225
+ metadata: opts.metadata,
226
+ }, PROJECT_DIR);
227
+ if (opts.json) {
228
+ console.log(JSON.stringify(entry, null, 2));
229
+ return;
230
+ }
231
+ console.log(`STORED ${entry.key} ${entry.contentHash}`);
232
+ }
233
+
234
+ function list(opts) {
235
+ const entries = listJobForgeCache({
236
+ kind: opts.kind,
237
+ includeExpired: opts.includeExpired,
238
+ }, PROJECT_DIR);
239
+ if (opts.json) {
240
+ console.log(JSON.stringify(entries, null, 2));
241
+ return;
242
+ }
243
+ console.log(formatCacheEntries(entries));
244
+ }
245
+
246
+ function verify(opts) {
247
+ const result = verifyJobForgeCache(PROJECT_DIR);
248
+ if (opts.json) {
249
+ console.log(JSON.stringify(result, null, 2));
250
+ } else {
251
+ console.log(formatVerifyResult(result));
252
+ }
253
+ process.exit(result.ok ? 0 : 1);
254
+ }
255
+
256
+ function prune(opts) {
257
+ const result = pruneJobForgeCache({
258
+ expired: opts.expired || undefined,
259
+ dryRun: opts.dryRun,
260
+ }, PROJECT_DIR);
261
+ if (opts.json) {
262
+ console.log(JSON.stringify(result, null, 2));
263
+ return;
264
+ }
265
+ console.log(formatPruneResult(result));
266
+ }
267
+
268
+ function read(opts) {
269
+ if (opts.url) {
270
+ return readJobDescriptionCache(opts.url, { allowExpired: opts.allowExpired }, PROJECT_DIR);
271
+ }
272
+ return readJobForgeCache(requiredKey(opts), { allowExpired: opts.allowExpired }, PROJECT_DIR);
273
+ }
274
+
275
+ function keyFromOptions(opts) {
276
+ if (opts.url) return jobDescriptionCacheKey(opts.url);
277
+ return requiredKey(opts);
278
+ }
279
+
280
+ function requiredKey(opts) {
281
+ if (!opts.key) throw new Error('expected --url or --key');
282
+ return opts.key;
283
+ }
284
+
285
+ function readInput(input) {
286
+ if (input === '-') return readFileSync(0, 'utf8');
287
+ if (input.startsWith('@')) return readFileSync(input.slice(1), 'utf8');
288
+ return input;
289
+ }
290
+
291
+ function parseMetadata(raw) {
292
+ const text = raw.startsWith('@') ? readFileSync(raw.slice(1), 'utf8') : raw;
293
+ const parsed = JSON.parse(text);
294
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
295
+ throw new Error('--meta must be a JSON object');
296
+ }
297
+ return parsed;
298
+ }
299
+
300
+ function parseDuration(raw) {
301
+ const match = /^(\d+)(ms|s|m|h|d)?$/.exec(String(raw).trim());
302
+ if (!match) throw new Error('--ttl must be a duration like 14d, 2h, 30m, 10s, or 500ms');
303
+ const value = Number(match[1]);
304
+ const unit = match[2] || 'ms';
305
+ const multipliers = {
306
+ ms: 1,
307
+ s: 1000,
308
+ m: 60 * 1000,
309
+ h: 60 * 60 * 1000,
310
+ d: 24 * 60 * 60 * 1000,
311
+ };
312
+ return value * multipliers[unit];
313
+ }
@@ -11,6 +11,7 @@
11
11
  "npx job-forge ledger:*",
12
12
  "npx job-forge capabilities:*",
13
13
  "npx job-forge context:*",
14
+ "npx job-forge cache:*",
14
15
  "rg *"
15
16
  ],
16
17
  "deny": [
@@ -56,7 +57,8 @@
56
57
  "npx job-forge merge",
57
58
  "npx job-forge verify",
58
59
  "npx job-forge ledger:*",
59
- "npx job-forge capabilities:*"
60
+ "npx job-forge capabilities:*",
61
+ "npx job-forge cache:*"
60
62
  ],
61
63
  "deny": [
62
64
  "task *"
@@ -9,11 +9,20 @@
9
9
  "description": "Shared JobForge orchestration contract.",
10
10
  "tokenBudget": 4000,
11
11
  "files": [
12
+ {
13
+ "path": "AGENTS.harness.md",
14
+ "maxTokens": 3500,
15
+ "required": false,
16
+ "notes": [
17
+ "Consumer-project symlink to the shared harness contract."
18
+ ]
19
+ },
12
20
  {
13
21
  "path": "iso/instructions.md",
14
22
  "maxTokens": 3500,
23
+ "required": false,
15
24
  "notes": [
16
- "Shared contract; mode/reference files should be selected through narrower bundles."
25
+ "Harness-source fallback; mode/reference files should be selected through narrower bundles."
17
26
  ]
18
27
  }
19
28
  ]