job-forge 2.14.15 → 2.14.16

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.
@@ -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`. 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`. If `.jobforge-ledger/events.jsonl` exists, `npx job-forge ledger:has --company "..." --role "..." --status Applied` may be used as a fast prefilter; a match is enough to drop that duplicate before dispatch. For candidates not rejected by the 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.
@@ -24,7 +24,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
24
24
  - [H5] Re-dispatch the same company only AFTER the previous subagent returns. Never fire the same `task` twice while the first is still in flight.
25
25
  why: two in-flight subagents for the same URL race on Geometra sessions and on tracker TSV writes, corrupting state and sometimes double-submitting
26
26
 
27
- - [H5b] Do not use `task` to poll task status. If OpenCode returns a task/session id without a final result, record the id, stop dispatching new rounds, and tell the user the round is still in flight. When the user asks to check later, inspect authoritative files (`batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`, day files, or `iso-trace`) rather than spawning a "check task status" subagent.
27
+ - [H5b] Do not use `task` to poll task status. If OpenCode returns a task/session id without a final result, record the id, stop dispatching new rounds, and tell the user the round is still in flight. When the user asks to check later, inspect authoritative files (`batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`, day files, `.jobforge-ledger/events.jsonl`, or `iso-trace`) rather than spawning a "check task status" subagent.
28
28
  why: OpenCode status prompts can be delivered into the target subagent as a new user message; a 2026-04-25 trace caused a subagent to call `task` recursively instead of finishing the application
29
29
 
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.
@@ -59,12 +59,15 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
59
59
  - [D7] For standalone `batch` runs, prefer `batch/batch-runner.sh` instead of hand-rolling the loop. It delegates to `@razroo/iso-orchestrator`, persists workflow records in `.jobforge-runs/`, caps bundle fan-out, and mutexes state/report-number writes. Use `JOBFORGE_LEGACY_BATCH_RUNNER=1` only as a fallback.
60
60
  why: the old Bash loop encoded resumability and parallelism manually; the iso-orchestrator path makes the durable control state inspectable and prevents report-number collisions under parallel bundles
61
61
 
62
+ - [D8] Use `job-forge ledger:*` for cheap local workflow-state checks when available. `iso-ledger` is not an MCP and adds no prompt/tool schema tokens; it records tracker TSV writes, merge outcomes, rebuilt tracker snapshots, and pipeline items in `.jobforge-ledger/events.jsonl`.
63
+ why: state-trace remains working memory, while iso-ledger is deterministic append-only workflow truth that can answer duplicate/status questions without loading growing markdown/TSV files into the model context
64
+
62
65
  ## Procedure
63
66
 
64
67
  1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
65
68
  2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
66
69
  3. Read the active mode file [D3]; decide inline vs delegated work [D1].
67
- 4. Prepare Geometra dispatches: cleanup [H3], dedupe [H2], location filter [D5], routing [D2], proxy prompt hygiene [H8].
70
+ 4. Prepare Geometra dispatches: cleanup [H3], ledger prefilter when present [D8], dedupe [H2], location filter [D5], routing [D2], proxy prompt hygiene [H8].
68
71
  5. Dispatch at most 2 tasks per round [H1]; wait for final outcomes, not just task ids [H5b].
69
72
  6. Keep multi-job form-filling out of the orchestrator [H4].
70
73
  7. Cross-check subagent facts against authoritative files [H7].
@@ -68,6 +68,10 @@ Or paste a JD directly to run the full pipeline.
68
68
  Token usage check (terminal, outside opencode):
69
69
  npx job-forge tokens --days 1 # today's sessions with input/cache breakdown
70
70
  npx job-forge tokens --session <id> # drill into one session for cache-bust hunting
71
+
72
+ Local workflow ledger (terminal, outside opencode):
73
+ npx job-forge ledger:status # .jobforge-ledger/events.jsonl summary
74
+ npx job-forge ledger:has --company "Acme" --role "Staff Engineer" --status Applied
71
75
  ```
72
76
 
73
77
  ---
@@ -142,6 +146,9 @@ Step 1 — Enumerate candidates
142
146
  - Build ordered list: candidates = [job_1, job_2, ..., job_N]
143
147
 
144
148
  Step 2 — Dedup against already-applied
149
+ - If .jobforge-ledger/events.jsonl exists, use npx job-forge ledger:has as a
150
+ fast prefilter for obvious company+role Applied duplicates. A ledger match
151
+ can be dropped before dispatch without loading tracker files into context.
145
152
  - For each candidate, grep all four sources for URL and company+role:
146
153
  data/pipeline.md, data/applications/*.md, batch/tracker-additions/*.tsv,
147
154
  batch/tracker-additions/merged/*.tsv
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`. 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`. If `.jobforge-ledger/events.jsonl` exists, `npx job-forge ledger:has --company "..." --role "..." --status Applied` may be used as a fast prefilter; a match is enough to drop that duplicate before dispatch. For candidates not rejected by the 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.
@@ -19,7 +19,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
19
19
  - [H5] Re-dispatch the same company only AFTER the previous subagent returns. Never fire the same `task` twice while the first is still in flight.
20
20
  why: two in-flight subagents for the same URL race on Geometra sessions and on tracker TSV writes, corrupting state and sometimes double-submitting
21
21
 
22
- - [H5b] Do not use `task` to poll task status. If OpenCode returns a task/session id without a final result, record the id, stop dispatching new rounds, and tell the user the round is still in flight. When the user asks to check later, inspect authoritative files (`batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`, day files, or `iso-trace`) rather than spawning a "check task status" subagent.
22
+ - [H5b] Do not use `task` to poll task status. If OpenCode returns a task/session id without a final result, record the id, stop dispatching new rounds, and tell the user the round is still in flight. When the user asks to check later, inspect authoritative files (`batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`, day files, `.jobforge-ledger/events.jsonl`, or `iso-trace`) rather than spawning a "check task status" subagent.
23
23
  why: OpenCode status prompts can be delivered into the target subagent as a new user message; a 2026-04-25 trace caused a subagent to call `task` recursively instead of finishing the application
24
24
 
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.
@@ -54,12 +54,15 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
54
54
  - [D7] For standalone `batch` runs, prefer `batch/batch-runner.sh` instead of hand-rolling the loop. It delegates to `@razroo/iso-orchestrator`, persists workflow records in `.jobforge-runs/`, caps bundle fan-out, and mutexes state/report-number writes. Use `JOBFORGE_LEGACY_BATCH_RUNNER=1` only as a fallback.
55
55
  why: the old Bash loop encoded resumability and parallelism manually; the iso-orchestrator path makes the durable control state inspectable and prevents report-number collisions under parallel bundles
56
56
 
57
+ - [D8] Use `job-forge ledger:*` for cheap local workflow-state checks when available. `iso-ledger` is not an MCP and adds no prompt/tool schema tokens; it records tracker TSV writes, merge outcomes, rebuilt tracker snapshots, and pipeline items in `.jobforge-ledger/events.jsonl`.
58
+ why: state-trace remains working memory, while iso-ledger is deterministic append-only workflow truth that can answer duplicate/status questions without loading growing markdown/TSV files into the model context
59
+
57
60
  ## Procedure
58
61
 
59
62
  1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
60
63
  2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
61
64
  3. Read the active mode file [D3]; decide inline vs delegated work [D1].
62
- 4. Prepare Geometra dispatches: cleanup [H3], dedupe [H2], location filter [D5], routing [D2], proxy prompt hygiene [H8].
65
+ 4. Prepare Geometra dispatches: cleanup [H3], ledger prefilter when present [D8], dedupe [H2], location filter [D5], routing [D2], proxy prompt hygiene [H8].
63
66
  5. Dispatch at most 2 tasks per round [H1]; wait for final outcomes, not just task ids [H5b].
64
67
  6. Keep multi-job form-filling out of the orchestrator [H4].
65
68
  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`. 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`. If `.jobforge-ledger/events.jsonl` exists, `npx job-forge ledger:has --company "..." --role "..." --status Applied` may be used as a fast prefilter; a match is enough to drop that duplicate before dispatch. For candidates not rejected by the 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.
@@ -19,7 +19,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
19
19
  - [H5] Re-dispatch the same company only AFTER the previous subagent returns. Never fire the same `task` twice while the first is still in flight.
20
20
  why: two in-flight subagents for the same URL race on Geometra sessions and on tracker TSV writes, corrupting state and sometimes double-submitting
21
21
 
22
- - [H5b] Do not use `task` to poll task status. If OpenCode returns a task/session id without a final result, record the id, stop dispatching new rounds, and tell the user the round is still in flight. When the user asks to check later, inspect authoritative files (`batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`, day files, or `iso-trace`) rather than spawning a "check task status" subagent.
22
+ - [H5b] Do not use `task` to poll task status. If OpenCode returns a task/session id without a final result, record the id, stop dispatching new rounds, and tell the user the round is still in flight. When the user asks to check later, inspect authoritative files (`batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`, day files, `.jobforge-ledger/events.jsonl`, or `iso-trace`) rather than spawning a "check task status" subagent.
23
23
  why: OpenCode status prompts can be delivered into the target subagent as a new user message; a 2026-04-25 trace caused a subagent to call `task` recursively instead of finishing the application
24
24
 
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.
@@ -54,12 +54,15 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
54
54
  - [D7] For standalone `batch` runs, prefer `batch/batch-runner.sh` instead of hand-rolling the loop. It delegates to `@razroo/iso-orchestrator`, persists workflow records in `.jobforge-runs/`, caps bundle fan-out, and mutexes state/report-number writes. Use `JOBFORGE_LEGACY_BATCH_RUNNER=1` only as a fallback.
55
55
  why: the old Bash loop encoded resumability and parallelism manually; the iso-orchestrator path makes the durable control state inspectable and prevents report-number collisions under parallel bundles
56
56
 
57
+ - [D8] Use `job-forge ledger:*` for cheap local workflow-state checks when available. `iso-ledger` is not an MCP and adds no prompt/tool schema tokens; it records tracker TSV writes, merge outcomes, rebuilt tracker snapshots, and pipeline items in `.jobforge-ledger/events.jsonl`.
58
+ why: state-trace remains working memory, while iso-ledger is deterministic append-only workflow truth that can answer duplicate/status questions without loading growing markdown/TSV files into the model context
59
+
57
60
  ## Procedure
58
61
 
59
62
  1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
60
63
  2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
61
64
  3. Read the active mode file [D3]; decide inline vs delegated work [D1].
62
- 4. Prepare Geometra dispatches: cleanup [H3], dedupe [H2], location filter [D5], routing [D2], proxy prompt hygiene [H8].
65
+ 4. Prepare Geometra dispatches: cleanup [H3], ledger prefilter when present [D8], dedupe [H2], location filter [D5], routing [D2], proxy prompt hygiene [H8].
63
66
  5. Dispatch at most 2 tasks per round [H1]; wait for final outcomes, not just task ids [H5b].
64
67
  6. Keep multi-job form-filling out of the orchestrator [H4].
65
68
  7. Cross-check subagent facts against authoritative files [H7].
package/README.md CHANGED
@@ -31,6 +31,8 @@ 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 an MCP-free local workflow ledger at `.jobforge-ledger/events.jsonl` when you use `job-forge ledger:*`, `tracker-line --write`, or `merge`. This is deterministic state for duplicate/status checks; it does not add prompt or tool-schema tokens.
35
+
34
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.
35
37
 
36
38
  Then fill in `cv.md`, `config/profile.yml`, and `portals.yml` with your personal data, paste a job URL into opencode, and JobForge evaluates + tracks it.
@@ -76,7 +78,7 @@ JobForge turns opencode into a full job search command center. Instead of manual
76
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/`. |
77
79
  | **Pipeline Integrity** | Automated merge, dedup, status normalization, health checks |
78
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. |
79
- | **Trace + Telemetry + Guard** | `job-forge trace:*` exposes local OpenCode transcripts, `job-forge telemetry:*` summarizes runs, and `job-forge guard:*` audits deterministic JobForge policy rules with `@razroo/iso-guard`. |
81
+ | **Trace + Telemetry + Guard + Ledger** | `job-forge trace:*` exposes local OpenCode transcripts, `job-forge telemetry:*` summarizes runs, `job-forge guard:*` audits deterministic policy rules, and `job-forge ledger:*` queries append-only workflow state without MCP/token overhead. |
80
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. |
81
83
 
82
84
  ## Usage
@@ -143,6 +145,7 @@ my-search/
143
145
  ├── portals.yml # companies to scan (personal)
144
146
  ├── config/profile.yml # your identity, target roles (personal)
145
147
  ├── data/ # applications, pipeline, scan history (personal, gitignored)
148
+ ├── .jobforge-ledger/ # append-only local workflow events (personal, gitignored)
146
149
  ├── reports/ # generated evaluation reports (personal, gitignored)
147
150
  ├── batch/{batch-input,batch-state}.tsv, tracker-additions/, logs/ # personal
148
151
  ├── .jobforge-runs/ # durable batch workflow records (generated)
@@ -190,6 +193,7 @@ JobForge/
190
193
  ├── batch/{batch-prompt.md,batch-runner.sh} # batch orchestrator
191
194
  ├── scripts/
192
195
  │ ├── batch-orchestrator.mjs # iso-orchestrator-backed batch control loop
196
+ │ ├── ledger.mjs # iso-ledger-backed workflow-state CLI
193
197
  │ ├── token-usage-report.mjs # opencode cost analyzer
194
198
  │ └── release/check-source.mjs # version gate for npm publish
195
199
  ├── tracker-lib.mjs / merge-tracker.mjs / dedup-tracker.mjs / verify-pipeline.mjs
@@ -119,6 +119,11 @@ const consumerPkg = {
119
119
  'telemetry:watch': 'job-forge telemetry:watch',
120
120
  'guard:audit': 'job-forge guard:audit',
121
121
  'guard:explain': 'job-forge guard:explain',
122
+ 'ledger:status': 'job-forge ledger:status',
123
+ 'ledger:rebuild': 'job-forge ledger:rebuild',
124
+ 'ledger:verify': 'job-forge ledger:verify',
125
+ 'ledger:has': 'job-forge ledger:has',
126
+ 'ledger:query': 'job-forge ledger:query',
122
127
  // One command to pull the latest harness and any locally-pinned MCP
123
128
  // packages. npm update is a no-op on packages not in package.json, so
124
129
  // listing @razroo/gmail-mcp + @geometra/mcp is safe for consumers that
@@ -128,7 +133,7 @@ const consumerPkg = {
128
133
  dependencies: {
129
134
  'job-forge': '^2.0.0',
130
135
  },
131
- engines: { node: '>=18' },
136
+ engines: { node: '>=20.6.0' },
132
137
  };
133
138
  write('package.json', JSON.stringify(consumerPkg, null, 2) + '\n');
134
139
 
@@ -218,6 +223,7 @@ Before doing any work, remember where things live in *this* project:
218
223
  | Application tracker | \`data/applications/YYYY-MM-DD.md\` | **Day-based**. One markdown table per day. **There is NO \`applications.md\` — do not look for it.** |
219
224
  | Inbox of pending URLs | \`data/pipeline.md\` | The queue for \`/job-forge pipeline\` |
220
225
  | Scanner dedup history | \`data/scan-history.tsv\` | Only touch in \`/job-forge scan\` |
226
+ | Local workflow ledger | \`.jobforge-ledger/events.jsonl\` | Deterministic append-only state; use \`job-forge ledger:*\` |
221
227
  | Scanner config | \`portals.yml\` (project root) | Company configs |
222
228
  | Profile / identity | \`config/profile.yml\` | Candidate name, email, target roles |
223
229
  | CV | \`cv.md\` (project root) | Markdown, source of truth |
@@ -305,6 +311,7 @@ data/applications.md
305
311
  data/pipeline.md
306
312
  data/scan-history.tsv
307
313
  data/token-usage.tsv
314
+ .jobforge-ledger/
308
315
  reports/
309
316
  !reports/.gitkeep
310
317
  batch/batch-state.tsv
@@ -361,6 +368,7 @@ job-forge sync # re-run if symlinks drift
361
368
  \`\`\`bash
362
369
  job-forge merge # merge batch/tracker-additions/*.tsv into the tracker
363
370
  job-forge verify # verify pipeline integrity
371
+ job-forge ledger:status # local deterministic workflow ledger status
364
372
  job-forge pdf cv.md out.pdf
365
373
  job-forge tokens --days 1 # per-session opencode token usage
366
374
  \`\`\`
package/bin/job-forge.mjs CHANGED
@@ -20,6 +20,7 @@
20
20
  * trace:* Inspect local agent transcripts via iso-trace
21
21
  * telemetry:* Summarize JobForge pipeline status from traces + tracker files
22
22
  * guard:* Audit JobForge trace policy with iso-guard
23
+ * ledger:* Query local deterministic workflow state via iso-ledger
23
24
  * sync Re-run the harness symlink sync (bin/sync.mjs)
24
25
  * help, --help Show this message
25
26
  */
@@ -74,6 +75,15 @@ const guardAliases = {
74
75
  'guard:explain': 'explain',
75
76
  };
76
77
 
78
+ const ledgerAliases = {
79
+ 'ledger:status': 'status',
80
+ 'ledger:rebuild': 'rebuild',
81
+ 'ledger:verify': 'verify',
82
+ 'ledger:has': 'has',
83
+ 'ledger:query': 'query',
84
+ 'ledger:path': 'path',
85
+ };
86
+
77
87
  const [, , cmd, ...rest] = process.argv;
78
88
 
79
89
  function printHelp() {
@@ -100,6 +110,10 @@ Commands:
100
110
  telemetry:watch Watch latest run status
101
111
  guard:audit Audit latest/local trace policy with iso-guard
102
112
  guard:explain Show the active iso-guard policy
113
+ ledger:status Show local workflow ledger status
114
+ ledger:rebuild Rebuild .jobforge-ledger/events.jsonl from tracker/pipeline files
115
+ ledger:has Check URL or company+role state without loading tracker files
116
+ ledger:verify Validate the local workflow ledger
103
117
  sync Re-create harness symlinks in the current project
104
118
 
105
119
  Deterministic helpers (prefer these over LLM-derived values):
@@ -128,6 +142,7 @@ Pass --help after a command to see its own flags, e.g.:
128
142
  job-forge telemetry:show ses_...
129
143
  job-forge guard:audit
130
144
  job-forge guard:explain
145
+ job-forge ledger:has --company "Acme" --role "Staff Engineer" --status Applied
131
146
 
132
147
  Project directory resolves to $JOB_FORGE_PROJECT or cwd.`);
133
148
  }
@@ -182,6 +197,21 @@ if (cmd === 'guard' || guardAliases[cmd]) {
182
197
  process.exit(result.status ?? 1);
183
198
  }
184
199
 
200
+ if (cmd === 'ledger' || ledgerAliases[cmd]) {
201
+ const ledgerArgs = cmd === 'ledger'
202
+ ? (rest.length === 0 ? ['help'] : rest)
203
+ : [ledgerAliases[cmd], ...rest];
204
+
205
+ const scriptPath = join(PKG_ROOT, 'scripts/ledger.mjs');
206
+ const result = spawnSync(process.execPath, [scriptPath, ...ledgerArgs], {
207
+ stdio: 'inherit',
208
+ cwd: PROJECT_DIR,
209
+ env: process.env,
210
+ });
211
+
212
+ process.exit(result.status ?? 1);
213
+ }
214
+
185
215
  const rel = commands[cmd];
186
216
  if (!rel) {
187
217
  console.error(`Unknown command: ${cmd}\n`);
@@ -17,6 +17,7 @@ my-search/
17
17
  ├── config/profile.yml # personal
18
18
  ├── portals.yml # personal
19
19
  ├── data/ # personal (gitignored)
20
+ ├── .jobforge-ledger/ # local workflow events (personal, gitignored)
20
21
  ├── reports/ # personal (gitignored)
21
22
  ├── AGENTS.md # personal overrides (opencode + codex)
22
23
  ├── CLAUDE.md # personal overrides (Claude Code); @-imports CLAUDE.harness.md
@@ -159,6 +160,7 @@ article-digest.md → Proof points for matching
159
160
  config/profile.yml → Candidate identity
160
161
  portals.yml → Scanner configuration
161
162
  data/pipeline.md → Pending URLs and `local:jds/...` inbox (see modes/pipeline.md)
163
+ .jobforge-ledger/events.jsonl → Append-only workflow events for cheap local duplicate/status checks
162
164
  jds/*.md → Saved job descriptions referenced from the pipeline (`local:jds/{file}`)
163
165
  templates/states.yml → Canonical status values
164
166
  templates/cv-template.html → PDF generation template
@@ -172,10 +174,11 @@ Create `data/pipeline.md` when you start using the URL inbox (`/job-forge pipeli
172
174
  - Reports: `{###}-{company-slug}-{YYYY-MM-DD}.md` (3-digit zero-padded)
173
175
  - PDFs: `cv-candidate-{company-slug}-{YYYY-MM-DD}.pdf`
174
176
  - Tracker TSVs: `batch/tracker-additions/{num}-{company-slug}.tsv` (one file per evaluation; merged files move under `batch/tracker-additions/merged/`)
177
+ - Ledger: `.jobforge-ledger/events.jsonl` (created by `job-forge ledger:rebuild`, `tracker-line --write`, or `merge`; gitignored personal state)
175
178
 
176
179
  ## Pipeline Integrity
177
180
 
178
- From the project root, `npx job-forge verify` (or `npm run verify`) runs `verify-pipeline.mjs`. When a tracker file exists, it validates canonical statuses (using `templates/states.yml` when that file is present and parseable), warns on probable duplicate company/role rows, checks that report column markdown links resolve to files in the repo, validates score column format (`X.X/5`, `N/A`, or `DUP`), rejects table rows with too few columns, flags markdown bold inside the score column, and warns if any `batch/tracker-additions/*.tsv` files are still waiting to be merged. It also compares state ids from `templates/states.yml` to an internal fallback list and warns when the two sets drift. **Fresh clone:** the command exits successfully when neither `data/applications.md` nor root `applications.md` exists yet; pending-TSV and states-drift checks still run so contributors see unmerged batch output early. Optional setup validation after you add `cv.md` and `config/profile.yml`: `npm run sync-check` (`cv-sync-check.mjs`).
181
+ From the project root, `npx job-forge verify` (or `npm run verify`) runs `verify-pipeline.mjs`. When a tracker file exists, it validates canonical statuses (using `templates/states.yml` when that file is present and parseable), warns on probable duplicate company/role rows, checks that report column markdown links resolve to files in the repo, validates score column format (`X.X/5`, `N/A`, or `DUP`), rejects table rows with too few columns, flags markdown bold inside the score column, and warns if any `batch/tracker-additions/*.tsv` files are still waiting to be merged. If `.jobforge-ledger/events.jsonl` exists, verify also validates the append-only ledger. It also compares state ids from `templates/states.yml` to an internal fallback list and warns when the two sets drift. **Fresh clone:** the command exits successfully when neither `data/applications.md` nor root `applications.md` exists yet; pending-TSV and states-drift checks still run so contributors see unmerged batch output early. Optional setup validation after you add `cv.md` and `config/profile.yml`: `npm run sync-check` (`cv-sync-check.mjs`).
179
182
 
180
183
  **`verify-pipeline.mjs` checks (same order as the script header):**
181
184
 
@@ -187,6 +190,7 @@ From the project root, `npx job-forge verify` (or `npm run verify`) runs `verify
187
190
  6. No unmerged `batch/tracker-additions/*.tsv` files (warns if any remain).
188
191
  7. Score column has no markdown bold.
189
192
  8. Warn when state ids in `templates/states.yml` drift from the script’s built-in fallback list (or when the file exists but ids failed to parse).
193
+ 9. Validate `.jobforge-ledger/events.jsonl` when present.
190
194
 
191
195
  When the tracker file is missing, checks 1–5 and 7 are skipped; checks 6 and 8 still run.
192
196
 
@@ -210,6 +214,7 @@ Scripts maintain data consistency. In a consumer project they're invoked via the
210
214
  | `scripts/trace.mjs` | `npx job-forge trace:list` / `trace:stats` / `trace:show` | Local transcript observability via `@razroo/iso-trace`; common commands default to OpenCode sessions for the consumer project |
211
215
  | `scripts/telemetry.mjs` | `npx job-forge telemetry:status` / `telemetry:show` | JobForge operational telemetry derived from OpenCode traces plus tracker TSV state |
212
216
  | `scripts/guard.mjs` | `npx job-forge guard:audit` / `guard:explain` | Deterministic `@razroo/iso-guard` policy audits over local OpenCode traces |
217
+ | `scripts/ledger.mjs` | `npx job-forge ledger:status` / `ledger:has` / `ledger:rebuild` | Deterministic `@razroo/iso-ledger` state over tracker, TSV, and pipeline files |
213
218
  | `tracker-lib.mjs` | _(library)_ | Shared helpers for reading/writing day-based tracker files — imported by merge/dedup/verify/normalize |
214
219
  | `bin/sync.mjs` | `npx job-forge sync` | Creates the harness symlinks in a consumer project (also runs as `postinstall`) |
215
220
  | `bin/create-job-forge.mjs` | `npx create-job-forge <dir>` | Scaffolds a new personal project |
@@ -125,6 +125,19 @@ npx job-forge telemetry:watch
125
125
 
126
126
  Telemetry is also local-only and passive. It reads OpenCode's SQLite DB and files under `batch/tracker-additions/`; agents do not need to remember to emit custom events.
127
127
 
128
+ ## JobForge ledger
129
+
130
+ The ledger is append-only local workflow state backed by `@razroo/iso-ledger`. It is not an MCP and does not add prompt, tool-schema, or state-trace tokens. Use it when you want a cheap deterministic check before loading growing tracker files:
131
+
132
+ ```bash
133
+ npx job-forge ledger:rebuild
134
+ npx job-forge ledger:status
135
+ npx job-forge ledger:has --company "Acme" --role "Staff Engineer" --status Applied
136
+ npx job-forge ledger:verify
137
+ ```
138
+
139
+ `tracker-line --write` records tracker-addition events, `merge` records add/update/skip outcomes, and `ledger:rebuild` backfills events from `data/applications/`, `batch/tracker-additions/`, `batch/tracker-additions/merged/`, and `data/pipeline.md`.
140
+
128
141
  ## JobForge guard audits
129
142
 
130
143
  Guard audits run deterministic `@razroo/iso-guard` policies over the same local OpenCode traces. The default policy lives at `templates/guards/jobforge-baseline.yaml` and checks rules that are reliable from transcript data, including max two task dispatches per assistant message, no task-status polling via `task`, no raw proxy configuration in task prompts, and no child session task recursion.
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`, `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`, `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
@@ -138,6 +138,9 @@ From your project root, these commands maintain the tracker and pipeline checks.
138
138
  | Show one JobForge run by session id/prefix | `npx job-forge telemetry:show <id>` | `npm run telemetry:show -- <id>` |
139
139
  | Audit latest JobForge trace policy | `npx job-forge guard:audit` | `npm run guard:audit` |
140
140
  | Show the active guard policy | `npx job-forge guard:explain` | `npm run guard:explain` |
141
+ | Show local workflow ledger status | `npx job-forge ledger:status` | `npm run ledger:status` |
142
+ | Rebuild local workflow ledger from tracker/pipeline files | `npx job-forge ledger:rebuild` | `npm run ledger:rebuild` |
143
+ | 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 ...` |
141
144
  | Re-create harness symlinks | `npx job-forge sync` | `npm run sync` |
142
145
  | Build optional dashboard TUI (Go on `PATH`) | `(cd node_modules/job-forge/dashboard && go build .)` | `npm run build:dashboard` (harness repo only) |
143
146
 
@@ -71,6 +71,10 @@ Or paste a JD directly to run the full pipeline.
71
71
  Token usage check (terminal, outside opencode):
72
72
  npx job-forge tokens --days 1 # today's sessions with input/cache breakdown
73
73
  npx job-forge tokens --session <id> # drill into one session for cache-bust hunting
74
+
75
+ Local workflow ledger (terminal, outside opencode):
76
+ npx job-forge ledger:status # .jobforge-ledger/events.jsonl summary
77
+ npx job-forge ledger:has --company "Acme" --role "Staff Engineer" --status Applied
74
78
  ```
75
79
 
76
80
  ---
@@ -145,6 +149,9 @@ Step 1 — Enumerate candidates
145
149
  - Build ordered list: candidates = [job_1, job_2, ..., job_N]
146
150
 
147
151
  Step 2 — Dedup against already-applied
152
+ - If .jobforge-ledger/events.jsonl exists, use npx job-forge ledger:has as a
153
+ fast prefilter for obvious company+role Applied duplicates. A ledger match
154
+ can be dropped before dispatch without loading tracker files into context.
148
155
  - For each candidate, grep all four sources for URL and company+role:
149
156
  data/pipeline.md, data/applications/*.md, batch/tracker-additions/*.tsv,
150
157
  batch/tracker-additions/merged/*.tsv
@@ -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`. 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`. If `.jobforge-ledger/events.jsonl` exists, `npx job-forge ledger:has --company "..." --role "..." --status Applied` may be used as a fast prefilter; a match is enough to drop that duplicate before dispatch. For candidates not rejected by the 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.
@@ -19,7 +19,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
19
19
  - [H5] Re-dispatch the same company only AFTER the previous subagent returns. Never fire the same `task` twice while the first is still in flight.
20
20
  why: two in-flight subagents for the same URL race on Geometra sessions and on tracker TSV writes, corrupting state and sometimes double-submitting
21
21
 
22
- - [H5b] Do not use `task` to poll task status. If OpenCode returns a task/session id without a final result, record the id, stop dispatching new rounds, and tell the user the round is still in flight. When the user asks to check later, inspect authoritative files (`batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`, day files, or `iso-trace`) rather than spawning a "check task status" subagent.
22
+ - [H5b] Do not use `task` to poll task status. If OpenCode returns a task/session id without a final result, record the id, stop dispatching new rounds, and tell the user the round is still in flight. When the user asks to check later, inspect authoritative files (`batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`, day files, `.jobforge-ledger/events.jsonl`, or `iso-trace`) rather than spawning a "check task status" subagent.
23
23
  why: OpenCode status prompts can be delivered into the target subagent as a new user message; a 2026-04-25 trace caused a subagent to call `task` recursively instead of finishing the application
24
24
 
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.
@@ -54,12 +54,15 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
54
54
  - [D7] For standalone `batch` runs, prefer `batch/batch-runner.sh` instead of hand-rolling the loop. It delegates to `@razroo/iso-orchestrator`, persists workflow records in `.jobforge-runs/`, caps bundle fan-out, and mutexes state/report-number writes. Use `JOBFORGE_LEGACY_BATCH_RUNNER=1` only as a fallback.
55
55
  why: the old Bash loop encoded resumability and parallelism manually; the iso-orchestrator path makes the durable control state inspectable and prevents report-number collisions under parallel bundles
56
56
 
57
+ - [D8] Use `job-forge ledger:*` for cheap local workflow-state checks when available. `iso-ledger` is not an MCP and adds no prompt/tool schema tokens; it records tracker TSV writes, merge outcomes, rebuilt tracker snapshots, and pipeline items in `.jobforge-ledger/events.jsonl`.
58
+ why: state-trace remains working memory, while iso-ledger is deterministic append-only workflow truth that can answer duplicate/status questions without loading growing markdown/TSV files into the model context
59
+
57
60
  ## Procedure
58
61
 
59
62
  1. Check `cv.md`, `profile.yml`, and `portals.yml`; onboard if any file is missing.
60
63
  2. Pick and name the mode from **Routing** [D6]. No match → ask; do not guess.
61
64
  3. Read the active mode file [D3]; decide inline vs delegated work [D1].
62
- 4. Prepare Geometra dispatches: cleanup [H3], dedupe [H2], location filter [D5], routing [D2], proxy prompt hygiene [H8].
65
+ 4. Prepare Geometra dispatches: cleanup [H3], ledger prefilter when present [D8], dedupe [H2], location filter [D5], routing [D2], proxy prompt hygiene [H8].
63
66
  5. Dispatch at most 2 tasks per round [H1]; wait for final outcomes, not just task ids [H5b].
64
67
  6. Keep multi-job form-filling out of the orchestrator [H4].
65
68
  7. Cross-check subagent facts against authoritative files [H7].
@@ -0,0 +1,214 @@
1
+ import { existsSync } from 'fs';
2
+ import { isAbsolute, join, relative } from 'path';
3
+ import {
4
+ appendEvent,
5
+ hasEvent,
6
+ materializeLedger,
7
+ queryEvents,
8
+ readLedger,
9
+ verifyLedger,
10
+ } from '@razroo/iso-ledger';
11
+
12
+ export const LEDGER_DIR = '.jobforge-ledger';
13
+ export const LEDGER_FILE = 'events.jsonl';
14
+
15
+ export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
16
+ return projectDir;
17
+ }
18
+
19
+ export function jobForgeLedgerPath(projectDir = resolveProjectDir()) {
20
+ return process.env.JOB_FORGE_LEDGER || join(projectDir, LEDGER_DIR, LEDGER_FILE);
21
+ }
22
+
23
+ export function jobForgeLedgerOptions(projectDir = resolveProjectDir()) {
24
+ return { path: jobForgeLedgerPath(projectDir) };
25
+ }
26
+
27
+ export function ledgerExists(projectDir = resolveProjectDir()) {
28
+ return existsSync(jobForgeLedgerPath(projectDir));
29
+ }
30
+
31
+ export function readJobForgeLedger(projectDir = resolveProjectDir()) {
32
+ if (!ledgerExists(projectDir)) return [];
33
+ return readLedger(jobForgeLedgerOptions(projectDir));
34
+ }
35
+
36
+ export function verifyJobForgeLedger(projectDir = resolveProjectDir()) {
37
+ return verifyLedger(jobForgeLedgerOptions(projectDir));
38
+ }
39
+
40
+ export function queryJobForgeLedger(options = {}, projectDir = resolveProjectDir()) {
41
+ return queryEvents(readJobForgeLedger(projectDir), options);
42
+ }
43
+
44
+ export function hasJobForgeEvent(options = {}, projectDir = resolveProjectDir()) {
45
+ return hasEvent(readJobForgeLedger(projectDir), options);
46
+ }
47
+
48
+ export function jobForgeLedgerSummary(projectDir = resolveProjectDir()) {
49
+ const events = readJobForgeLedger(projectDir);
50
+ const materialized = materializeLedger(events);
51
+ return {
52
+ path: jobForgeLedgerPath(projectDir),
53
+ exists: ledgerExists(projectDir),
54
+ events: events.length,
55
+ entities: materialized.entityCount,
56
+ latest: events.at(-1) || null,
57
+ };
58
+ }
59
+
60
+ export function appendJobForgeEvent(input, projectDir = resolveProjectDir()) {
61
+ return appendEvent(jobForgeLedgerOptions(projectDir), input);
62
+ }
63
+
64
+ export function recordTrackerAdditionWritten(addition, options = {}) {
65
+ const projectDir = resolveProjectDir(options.projectDir);
66
+ return appendJobForgeEvent(buildApplicationEvent('jobforge.tracker_addition.written', addition, {
67
+ projectDir,
68
+ sourceFile: options.sourceFile,
69
+ idempotencyPrefix: 'tracker-addition-written',
70
+ meta: options.meta,
71
+ }), projectDir);
72
+ }
73
+
74
+ export function recordTrackerMergeResult(addition, options = {}) {
75
+ const projectDir = resolveProjectDir(options.projectDir);
76
+ const outcome = options.outcome || 'processed';
77
+ return appendJobForgeEvent(buildApplicationEvent(`jobforge.tracker_merge.${outcome}`, addition, {
78
+ projectDir,
79
+ sourceFile: options.sourceFile,
80
+ idempotencyPrefix: `tracker-merge-${outcome}`,
81
+ data: {
82
+ outcome,
83
+ duplicateNum: jsonValue(options.duplicateNum),
84
+ reason: jsonValue(options.reason),
85
+ },
86
+ meta: options.meta,
87
+ }), projectDir);
88
+ }
89
+
90
+ export function buildApplicationEvent(type, app, options = {}) {
91
+ const projectDir = resolveProjectDir(options.projectDir);
92
+ const key = companyRoleKey(app.company, app.role);
93
+ const sourceFile = options.sourceFile ? relativePath(projectDir, options.sourceFile) : '';
94
+ const idempotencyParts = [
95
+ options.idempotencyPrefix || type,
96
+ sourceFile,
97
+ app.num,
98
+ app.date,
99
+ key,
100
+ app.status,
101
+ app.score,
102
+ ].filter((value) => value !== undefined && value !== null && String(value).length > 0);
103
+
104
+ return {
105
+ type,
106
+ key,
107
+ subject: applicationSubject(app.company, app.role),
108
+ idempotencyKey: idempotencyParts.join(':'),
109
+ data: compactObject({
110
+ num: numberOrString(app.num),
111
+ date: stringOrEmpty(app.date),
112
+ company: stringOrEmpty(app.company),
113
+ role: stringOrEmpty(app.role),
114
+ score: stringOrEmpty(app.score),
115
+ status: stringOrEmpty(app.status),
116
+ pdf: stringOrEmpty(app.pdf),
117
+ report: stringOrEmpty(app.report),
118
+ notes: stringOrEmpty(app.notes),
119
+ sourceFile,
120
+ ...compactObject(options.data || {}),
121
+ }),
122
+ meta: compactObject({
123
+ source: 'job-forge',
124
+ ...compactObject(options.meta || {}),
125
+ }),
126
+ };
127
+ }
128
+
129
+ export function buildPipelineEvent(item, options = {}) {
130
+ const projectDir = resolveProjectDir(options.projectDir);
131
+ const key = item.url ? urlKey(item.url) : `pipeline:${item.lineNumber || 'unknown'}`;
132
+ const sourceFile = options.sourceFile ? relativePath(projectDir, options.sourceFile) : 'data/pipeline.md';
133
+ const state = item.checked ? 'processed' : 'pending';
134
+
135
+ return {
136
+ type: 'jobforge.pipeline.item',
137
+ key,
138
+ subject: key,
139
+ idempotencyKey: `pipeline:${state}:${item.url || item.lineNumber || item.line || 'unknown'}`,
140
+ data: compactObject({
141
+ state,
142
+ checked: Boolean(item.checked),
143
+ url: stringOrEmpty(item.url),
144
+ company: stringOrEmpty(item.company),
145
+ role: stringOrEmpty(item.role),
146
+ line: stringOrEmpty(item.line),
147
+ lineNumber: numberOrString(item.lineNumber),
148
+ sourceFile,
149
+ }),
150
+ meta: compactObject({
151
+ source: 'job-forge',
152
+ ...compactObject(options.meta || {}),
153
+ }),
154
+ };
155
+ }
156
+
157
+ export function companyRoleKey(company, role) {
158
+ return `company-role:${slugPart(company)}:${slugPart(role)}`;
159
+ }
160
+
161
+ export function applicationSubject(company, role) {
162
+ return `application:${slugPart(company)}:${slugPart(role)}`;
163
+ }
164
+
165
+ export function urlKey(url) {
166
+ return `url:${String(url || '').trim()}`;
167
+ }
168
+
169
+ export function slugPart(value) {
170
+ const slug = String(value || 'unknown')
171
+ .toLowerCase()
172
+ .replace(/&/g, ' and ')
173
+ .replace(/[^a-z0-9]+/g, '-')
174
+ .replace(/^-+|-+$/g, '');
175
+ return slug || 'unknown';
176
+ }
177
+
178
+ function relativePath(projectDir, value) {
179
+ const text = String(value || '');
180
+ if (!text) return '';
181
+ const rel = relative(projectDir, text);
182
+ if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return rel.replace(/\\/g, '/');
183
+ return text.replace(/\\/g, '/');
184
+ }
185
+
186
+ function compactObject(obj) {
187
+ const out = {};
188
+ for (const [key, value] of Object.entries(obj || {})) {
189
+ const clean = jsonValue(value);
190
+ if (clean !== undefined) out[key] = clean;
191
+ }
192
+ return out;
193
+ }
194
+
195
+ function jsonValue(value) {
196
+ if (value === undefined) return undefined;
197
+ if (value === null) return null;
198
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
199
+ if (Array.isArray(value)) return value.map(jsonValue).filter((item) => item !== undefined);
200
+ if (typeof value === 'object') return compactObject(value);
201
+ return String(value);
202
+ }
203
+
204
+ function stringOrEmpty(value) {
205
+ if (value === undefined || value === null) return undefined;
206
+ const text = String(value);
207
+ return text.length > 0 ? text : undefined;
208
+ }
209
+
210
+ function numberOrString(value) {
211
+ if (value === undefined || value === null || value === '') return undefined;
212
+ const number = Number(value);
213
+ return Number.isFinite(number) && String(value).trim() !== '' ? number : String(value);
214
+ }
package/merge-tracker.mjs CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  import {
30
30
  DEFAULT_STATES, loadCanonicalStates, buildStatusDetectionRegex,
31
31
  } from './lib/canonical-states.mjs';
32
+ import { recordTrackerMergeResult } from './lib/jobforge-ledger.mjs';
32
33
 
33
34
  const ADDITIONS_DIR = join(PROJECT_DIR, 'batch/tracker-additions');
34
35
  const MERGED_DIR = join(ADDITIONS_DIR, 'merged');
@@ -288,6 +289,7 @@ let added = 0;
288
289
  let updated = 0;
289
290
  let skipped = 0;
290
291
  const newEntries = [];
292
+ const ledgerRecords = [];
291
293
 
292
294
  for (const file of tsvFiles) {
293
295
  const content = readFileSync(join(ADDITIONS_DIR, file), 'utf-8').trim();
@@ -359,12 +361,15 @@ for (const file of tsvFiles) {
359
361
  }
360
362
  }
361
363
  updated++;
364
+ ledgerRecords.push({ addition, outcome: 'updated', sourceFile: join(ADDITIONS_DIR, file), duplicateNum: duplicate.num, reason });
362
365
  } else if (statusRegresses) {
363
366
  console.log(`⏭️ Skip: ${addition.company} — ${addition.role} (existing #${duplicate.num} status ${duplicate.status} outranks new ${addition.status})`);
364
367
  skipped++;
368
+ ledgerRecords.push({ addition, outcome: 'skipped', sourceFile: join(ADDITIONS_DIR, file), duplicateNum: duplicate.num, reason: 'status-regression' });
365
369
  } else {
366
370
  console.log(`⏭️ Skip: ${addition.company} — ${addition.role} (existing #${duplicate.num} ${oldScore} >= new ${newScore})`);
367
371
  skipped++;
372
+ ledgerRecords.push({ addition, outcome: 'skipped', sourceFile: join(ADDITIONS_DIR, file), duplicateNum: duplicate.num, reason: 'no-improvement' });
368
373
  }
369
374
  } else {
370
375
  const entryNum = addition.num > maxNum ? addition.num : ++maxNum;
@@ -376,6 +381,7 @@ for (const file of tsvFiles) {
376
381
  });
377
382
  added++;
378
383
  console.log(`➕ Add #${entryNum}: ${addition.company} — ${addition.role} (${addition.score})`);
384
+ ledgerRecords.push({ addition: { ...addition, num: entryNum }, outcome: 'added', sourceFile: join(ADDITIONS_DIR, file), reason: 'new-entry' });
379
385
  }
380
386
  }
381
387
 
@@ -410,6 +416,23 @@ if (!DRY_RUN) {
410
416
  renameSync(join(ADDITIONS_DIR, file), join(MERGED_DIR, file));
411
417
  }
412
418
  console.log(`\n✅ Moved ${tsvFiles.length} TSVs to merged/`);
419
+
420
+ let ledgerEvents = 0;
421
+ for (const record of ledgerRecords) {
422
+ try {
423
+ const result = recordTrackerMergeResult(record.addition, {
424
+ projectDir: PROJECT_DIR,
425
+ sourceFile: record.sourceFile,
426
+ outcome: record.outcome,
427
+ duplicateNum: record.duplicateNum,
428
+ reason: record.reason,
429
+ });
430
+ if (result.appended) ledgerEvents++;
431
+ } catch (error) {
432
+ console.warn(`⚠️ Could not append ledger event for ${record.sourceFile}: ${error instanceof Error ? error.message : String(error)}`);
433
+ }
434
+ }
435
+ console.log(`🧾 Ledger: ${ledgerEvents} event(s) appended`);
413
436
  }
414
437
 
415
438
  console.log(`\n📊 Summary: +${added} added, 🔄${updated} updated, ⏭️${skipped} skipped`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-forge",
3
- "version": "2.14.15",
3
+ "version": "2.14.16",
4
4
  "description": "AI-powered job search pipeline built on opencode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,11 @@
27
27
  "telemetry:watch": "node bin/job-forge.mjs telemetry:watch",
28
28
  "guard:audit": "node bin/job-forge.mjs guard:audit",
29
29
  "guard:explain": "node bin/job-forge.mjs guard:explain",
30
+ "ledger:status": "node bin/job-forge.mjs ledger:status",
31
+ "ledger:rebuild": "node bin/job-forge.mjs ledger:rebuild",
32
+ "ledger:verify": "node bin/job-forge.mjs ledger:verify",
33
+ "ledger:has": "node bin/job-forge.mjs ledger:has",
34
+ "ledger:query": "node bin/job-forge.mjs ledger:query",
30
35
  "plan": "iso plan .",
31
36
  "lint:agentmd": "agentmd lint iso/instructions.md",
32
37
  "lint:modes": "isolint lint modes/",
@@ -92,6 +97,7 @@
92
97
  },
93
98
  "dependencies": {
94
99
  "@razroo/iso-guard": "^0.1.0",
100
+ "@razroo/iso-ledger": "^0.1.0",
95
101
  "@razroo/iso-orchestrator": "^0.1.0",
96
102
  "@razroo/iso-trace": "^0.4.0",
97
103
  "playwright": "^1.58.1"
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'fs';
4
+ import { dirname, join } from 'path';
5
+ import {
6
+ formatEvents,
7
+ formatVerifyResult,
8
+ queryEvents,
9
+ } from '@razroo/iso-ledger';
10
+ import { PROJECT_DIR, readAllEntries } from '../tracker-lib.mjs';
11
+ import {
12
+ appendJobForgeEvent,
13
+ buildApplicationEvent,
14
+ buildPipelineEvent,
15
+ companyRoleKey,
16
+ jobForgeLedgerPath,
17
+ jobForgeLedgerSummary,
18
+ ledgerExists,
19
+ readJobForgeLedger,
20
+ urlKey,
21
+ verifyJobForgeLedger,
22
+ } from '../lib/jobforge-ledger.mjs';
23
+
24
+ const USAGE = `job-forge ledger - local deterministic workflow state
25
+
26
+ Usage:
27
+ job-forge ledger:status [--json]
28
+ job-forge ledger:rebuild [--reset] [--json]
29
+ job-forge ledger:verify [--json]
30
+ job-forge ledger:has --url <url> [--json]
31
+ job-forge ledger:has --company <name> --role <role> [--status Applied] [--json]
32
+ job-forge ledger:query [--type <type>] [--key <key>] [--where field=value] [--limit N] [--json]
33
+ job-forge ledger:path
34
+
35
+ The ledger is stored at .jobforge-ledger/events.jsonl by default. It is local
36
+ personal workflow state, not an MCP and not prompt context.`;
37
+
38
+ const [cmd = 'help', ...rawArgs] = process.argv.slice(2);
39
+ const opts = parseArgs(rawArgs);
40
+
41
+ if (opts.help || cmd === 'help' || cmd === '--help' || cmd === '-h') {
42
+ console.log(USAGE);
43
+ process.exit(0);
44
+ }
45
+
46
+ try {
47
+ if (cmd === 'path') {
48
+ console.log(jobForgeLedgerPath(PROJECT_DIR));
49
+ } else if (cmd === 'status') {
50
+ status(opts);
51
+ } else if (cmd === 'rebuild') {
52
+ rebuild(opts);
53
+ } else if (cmd === 'verify') {
54
+ verify(opts);
55
+ } else if (cmd === 'has') {
56
+ has(opts);
57
+ } else if (cmd === 'query') {
58
+ query(opts);
59
+ } else {
60
+ console.error(`unknown ledger command "${cmd}"\n`);
61
+ console.error(USAGE);
62
+ process.exit(2);
63
+ }
64
+ } catch (error) {
65
+ console.error(error instanceof Error ? error.message : String(error));
66
+ process.exit(1);
67
+ }
68
+
69
+ function parseArgs(args) {
70
+ const opts = { where: {}, json: false, reset: false };
71
+ for (let i = 0; i < args.length; i++) {
72
+ const arg = args[i];
73
+ if (arg === '--json') {
74
+ opts.json = true;
75
+ } else if (arg === '--reset') {
76
+ opts.reset = true;
77
+ } else if (arg === '--url') {
78
+ opts.url = valueAfter(args, ++i, '--url');
79
+ } else if (arg.startsWith('--url=')) {
80
+ opts.url = arg.slice('--url='.length);
81
+ } else if (arg === '--company') {
82
+ opts.company = valueAfter(args, ++i, '--company');
83
+ } else if (arg.startsWith('--company=')) {
84
+ opts.company = arg.slice('--company='.length);
85
+ } else if (arg === '--role') {
86
+ opts.role = valueAfter(args, ++i, '--role');
87
+ } else if (arg.startsWith('--role=')) {
88
+ opts.role = arg.slice('--role='.length);
89
+ } else if (arg === '--status') {
90
+ opts.status = valueAfter(args, ++i, '--status');
91
+ } else if (arg.startsWith('--status=')) {
92
+ opts.status = arg.slice('--status='.length);
93
+ } else if (arg === '--type') {
94
+ opts.type = valueAfter(args, ++i, '--type');
95
+ } else if (arg.startsWith('--type=')) {
96
+ opts.type = arg.slice('--type='.length);
97
+ } else if (arg === '--key') {
98
+ opts.key = valueAfter(args, ++i, '--key');
99
+ } else if (arg.startsWith('--key=')) {
100
+ opts.key = arg.slice('--key='.length);
101
+ } else if (arg === '--where') {
102
+ addWhere(opts.where, valueAfter(args, ++i, '--where'));
103
+ } else if (arg.startsWith('--where=')) {
104
+ addWhere(opts.where, arg.slice('--where='.length));
105
+ } else if (arg === '--limit') {
106
+ opts.limit = Number(valueAfter(args, ++i, '--limit'));
107
+ } else if (arg.startsWith('--limit=')) {
108
+ opts.limit = Number(arg.slice('--limit='.length));
109
+ } else if (arg === '--help' || arg === '-h') {
110
+ opts.help = true;
111
+ } else {
112
+ throw new Error(`unknown flag "${arg}"`);
113
+ }
114
+ }
115
+ if (opts.status) opts.where.status = opts.status;
116
+ return opts;
117
+ }
118
+
119
+ function valueAfter(values, index, flag) {
120
+ const value = values[index];
121
+ if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
122
+ return value;
123
+ }
124
+
125
+ function addWhere(where, raw) {
126
+ const index = raw.indexOf('=');
127
+ if (index <= 0) throw new Error('--where must be field=value');
128
+ where[raw.slice(0, index)] = parsePrimitive(raw.slice(index + 1));
129
+ }
130
+
131
+ function parsePrimitive(value) {
132
+ if (value === 'true') return true;
133
+ if (value === 'false') return false;
134
+ if (value === 'null') return null;
135
+ const number = Number(value);
136
+ return Number.isFinite(number) && value.trim() !== '' ? number : value;
137
+ }
138
+
139
+ function status(opts) {
140
+ const summary = jobForgeLedgerSummary(PROJECT_DIR);
141
+ if (opts.json) {
142
+ console.log(JSON.stringify(summary, null, 2));
143
+ return;
144
+ }
145
+ if (!summary.exists) {
146
+ console.log(`ledger: missing (${relativeLedgerPath()})`);
147
+ console.log('run: job-forge ledger:rebuild');
148
+ return;
149
+ }
150
+ const verifyResult = verifyJobForgeLedger(PROJECT_DIR);
151
+ console.log(`ledger: ${relativeLedgerPath()}`);
152
+ console.log(`events: ${summary.events}`);
153
+ console.log(`entities: ${summary.entities}`);
154
+ console.log(`verify: ${verifyResult.ok ? 'PASS' : 'FAIL'} (${verifyResult.errors} errors, ${verifyResult.warnings} warnings)`);
155
+ if (summary.latest) {
156
+ console.log(`latest: ${summary.latest.type} @ ${summary.latest.at}`);
157
+ }
158
+ }
159
+
160
+ function rebuild(opts) {
161
+ const ledgerPath = jobForgeLedgerPath(PROJECT_DIR);
162
+ if (opts.reset && existsSync(ledgerPath)) rmSync(ledgerPath);
163
+ mkdirSync(dirname(ledgerPath), { recursive: true });
164
+
165
+ const results = [];
166
+ for (const event of collectProjectEvents()) {
167
+ results.push(appendJobForgeEvent(event, PROJECT_DIR));
168
+ }
169
+
170
+ const summary = {
171
+ path: ledgerPath,
172
+ eventsSeen: results.length,
173
+ appended: results.filter((result) => result.appended).length,
174
+ deduped: results.filter((result) => !result.appended).length,
175
+ };
176
+
177
+ if (opts.json) {
178
+ console.log(JSON.stringify(summary, null, 2));
179
+ return;
180
+ }
181
+ console.log(`ledger: ${relativeLedgerPath()}`);
182
+ console.log(`events: ${summary.appended} appended, ${summary.deduped} already present`);
183
+ }
184
+
185
+ function verify(opts) {
186
+ if (!ledgerExists(PROJECT_DIR)) {
187
+ if (opts.json) {
188
+ console.log(JSON.stringify({ ok: true, missing: true, path: jobForgeLedgerPath(PROJECT_DIR) }, null, 2));
189
+ } else {
190
+ console.log(`ledger: missing (${relativeLedgerPath()})`);
191
+ }
192
+ return;
193
+ }
194
+ const result = verifyJobForgeLedger(PROJECT_DIR);
195
+ if (opts.json) {
196
+ console.log(JSON.stringify(result, null, 2));
197
+ } else {
198
+ console.log(formatVerifyResult(result));
199
+ }
200
+ process.exit(result.errors > 0 ? 1 : 0);
201
+ }
202
+
203
+ function has(opts) {
204
+ const filters = queryFilters(opts);
205
+ const events = queryEvents(readJobForgeLedger(PROJECT_DIR), filters);
206
+ if (opts.json) {
207
+ console.log(JSON.stringify({ match: events.length > 0, count: events.length, filters }, null, 2));
208
+ } else if (events.length > 0) {
209
+ console.log(`MATCH (${events.length} event(s))`);
210
+ } else {
211
+ console.log('MISS');
212
+ }
213
+ process.exit(events.length > 0 ? 0 : 1);
214
+ }
215
+
216
+ function query(opts) {
217
+ const filters = queryFilters(opts);
218
+ const events = queryEvents(readJobForgeLedger(PROJECT_DIR), filters);
219
+ if (opts.json) {
220
+ console.log(JSON.stringify(events, null, 2));
221
+ return;
222
+ }
223
+ console.log(formatEvents(events));
224
+ }
225
+
226
+ function queryFilters(opts) {
227
+ const filters = {};
228
+ if (opts.type) filters.type = opts.type;
229
+ if (opts.key) filters.key = opts.key;
230
+ if (opts.url) filters.key = urlKey(opts.url);
231
+ if (opts.company || opts.role) {
232
+ if (!opts.company || !opts.role) throw new Error('--company and --role must be provided together');
233
+ filters.key = companyRoleKey(opts.company, opts.role);
234
+ }
235
+ if (Object.keys(opts.where || {}).length > 0) filters.where = opts.where;
236
+ if (Number.isFinite(opts.limit) && opts.limit > 0) filters.limit = opts.limit;
237
+ return filters;
238
+ }
239
+
240
+ function collectProjectEvents() {
241
+ const events = [];
242
+ const { entries } = readAllEntries();
243
+ for (const entry of entries) {
244
+ events.push(buildApplicationEvent('jobforge.application.tracker', entry, {
245
+ projectDir: PROJECT_DIR,
246
+ sourceFile: entry._sourceFile,
247
+ idempotencyPrefix: 'tracker-entry',
248
+ }));
249
+ }
250
+
251
+ for (const item of collectTrackerTsvs('batch/tracker-additions', 'pending')) {
252
+ events.push(buildApplicationEvent(`jobforge.tracker_addition.${item.state}`, item.addition, {
253
+ projectDir: PROJECT_DIR,
254
+ sourceFile: item.path,
255
+ idempotencyPrefix: `tracker-addition-${item.state}`,
256
+ data: { state: item.state },
257
+ }));
258
+ }
259
+
260
+ for (const item of collectTrackerTsvs('batch/tracker-additions/merged', 'merged')) {
261
+ events.push(buildApplicationEvent(`jobforge.tracker_addition.${item.state}`, item.addition, {
262
+ projectDir: PROJECT_DIR,
263
+ sourceFile: item.path,
264
+ idempotencyPrefix: `tracker-addition-${item.state}`,
265
+ data: { state: item.state },
266
+ }));
267
+ }
268
+
269
+ for (const item of collectPipelineItems()) {
270
+ events.push(buildPipelineEvent(item, {
271
+ projectDir: PROJECT_DIR,
272
+ sourceFile: join(PROJECT_DIR, 'data', 'pipeline.md'),
273
+ }));
274
+ }
275
+
276
+ return events;
277
+ }
278
+
279
+ function collectTrackerTsvs(relDir, state) {
280
+ const dir = join(PROJECT_DIR, relDir);
281
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
282
+ const out = [];
283
+ for (const name of readdirSync(dir).filter((file) => file.endsWith('.tsv')).sort()) {
284
+ const path = join(dir, name);
285
+ const addition = parseTsvContent(readFileSync(path, 'utf8'), name);
286
+ if (addition) out.push({ path, state, addition });
287
+ }
288
+ return out;
289
+ }
290
+
291
+ function parseTsvContent(content, filename) {
292
+ const text = content.trim();
293
+ if (!text) return null;
294
+ let parts;
295
+ if (text.startsWith('|')) {
296
+ parts = text.split('|').map((part) => part.trim()).filter(Boolean);
297
+ if (parts.length < 8) return null;
298
+ return {
299
+ num: parts[0],
300
+ date: parts[1],
301
+ company: parts[2],
302
+ role: parts[3],
303
+ score: parts[4],
304
+ status: parts[5],
305
+ pdf: parts[6],
306
+ report: parts[7],
307
+ notes: parts[8] || '',
308
+ };
309
+ }
310
+
311
+ parts = text.split('\t');
312
+ if (parts.length < 8) return null;
313
+ const col4 = parts[4].trim();
314
+ const col5 = parts[5].trim();
315
+ const col4LooksLikeScore = looksLikeScore(col4);
316
+ const col5LooksLikeScore = looksLikeScore(col5);
317
+ return {
318
+ num: parts[0],
319
+ date: parts[1],
320
+ company: parts[2],
321
+ role: parts[3],
322
+ status: col4LooksLikeScore && !col5LooksLikeScore ? col5 : col4,
323
+ score: col4LooksLikeScore && !col5LooksLikeScore ? col4 : col5,
324
+ pdf: parts[6],
325
+ report: parts[7],
326
+ notes: parts[8] || '',
327
+ sourceFile: filename,
328
+ };
329
+ }
330
+
331
+ function looksLikeScore(value) {
332
+ return /^\d+\.?\d*\/5$/.test(value) || value === 'N/A' || value === 'DUP';
333
+ }
334
+
335
+ function collectPipelineItems() {
336
+ const path = join(PROJECT_DIR, 'data', 'pipeline.md');
337
+ if (!existsSync(path)) return [];
338
+ const lines = readFileSync(path, 'utf8').split('\n');
339
+ const out = [];
340
+ lines.forEach((line, index) => {
341
+ const match = line.match(/^\s*-\s*\[([ xX])\]\s+([^|#\s]+)(.*)$/);
342
+ if (!match) return;
343
+ const rest = match[3] || '';
344
+ const fields = rest.split('|').map((field) => field.trim()).filter(Boolean);
345
+ out.push({
346
+ checked: match[1].toLowerCase() === 'x',
347
+ url: match[2].trim(),
348
+ company: fields[0] || '',
349
+ role: fields[1] || '',
350
+ line,
351
+ lineNumber: index + 1,
352
+ });
353
+ });
354
+ return out;
355
+ }
356
+
357
+ function relativeLedgerPath() {
358
+ return jobForgeLedgerPath(PROJECT_DIR).replace(`${PROJECT_DIR}/`, '');
359
+ }
@@ -4,6 +4,7 @@ import { spawnSync } from 'child_process';
4
4
  import { existsSync, readdirSync, statSync } from 'fs';
5
5
  import { join, resolve } from 'path';
6
6
  import { defaultOpenCodeDbPath, findSessionById, parseSinceCutoff } from '@razroo/iso-trace';
7
+ import { jobForgeLedgerSummary } from '../lib/jobforge-ledger.mjs';
7
8
 
8
9
  const PROJECT_DIR = process.env.JOB_FORGE_PROJECT || process.cwd();
9
10
  const DEFAULT_SINCE = '24h';
@@ -485,9 +486,21 @@ function sessionStatus({ taskCalls, children, childOutcomes, childProviderErrors
485
486
  function trackerStatus(projectDir) {
486
487
  const pendingDir = join(projectDir, 'batch', 'tracker-additions');
487
488
  const mergedDir = join(pendingDir, 'merged');
489
+ let ledger;
490
+ try {
491
+ ledger = jobForgeLedgerSummary(projectDir);
492
+ } catch (error) {
493
+ ledger = {
494
+ exists: true,
495
+ events: 0,
496
+ entities: 0,
497
+ error: error instanceof Error ? error.message : String(error),
498
+ };
499
+ }
488
500
  return {
489
501
  pending: listTsv(pendingDir),
490
502
  mergedCount: listTsv(mergedDir).length,
503
+ ledger,
491
504
  };
492
505
  }
493
506
 
@@ -636,6 +649,7 @@ function printStatus(telemetry) {
636
649
  console.log(`tasks: ${telemetry.tasks.total} (${telemetry.tasks.statusPolls} status-poll, ${telemetry.tasks.running} running)`);
637
650
  console.log(`children: ${telemetry.children.withOutcomes}/${telemetry.children.total} with outcomes`);
638
651
  console.log(`tracker: ${telemetry.tracker.pending.length} pending TSVs, ${telemetry.tracker.mergedCount} merged TSVs`);
652
+ console.log(`ledger: ${telemetry.tracker.ledger.error ? `error: ${telemetry.tracker.ledger.error}` : telemetry.tracker.ledger.exists ? `${telemetry.tracker.ledger.events} events` : 'missing'}`);
639
653
  console.log(`models: ${telemetry.models.slice(0, 3).map(modelLabel).join(', ') || 'none'}`);
640
654
  console.log(`errors: ${telemetry.providerErrors.length} root, ${telemetry.children.providerErrors} child provider errors, ${telemetry.children.toolErrors} child tool errors`);
641
655
  console.log(`issues: ${telemetry.policyIssues.length}`);
@@ -24,6 +24,7 @@
24
24
 
25
25
  import { writeFileSync, mkdirSync, existsSync } from 'fs';
26
26
  import { join } from 'path';
27
+ import { recordTrackerAdditionWritten } from '../lib/jobforge-ledger.mjs';
27
28
 
28
29
  const PROJECT_DIR = process.env.JOB_FORGE_PROJECT || process.cwd();
29
30
 
@@ -61,6 +62,13 @@ if (write) {
61
62
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
62
63
  const path = join(dir, `${num}.tsv`);
63
64
  writeFileSync(path, line + '\n', 'utf-8');
65
+ try {
66
+ recordTrackerAdditionWritten({
67
+ num, date, company, role, status, score: scoreField, pdf, report: reportLink, notes,
68
+ }, { projectDir: PROJECT_DIR, sourceFile: path });
69
+ } catch (error) {
70
+ console.warn(`warning: could not append tracker-line ledger event: ${error instanceof Error ? error.message : String(error)}`);
71
+ }
64
72
  console.log(path);
65
73
  } else {
66
74
  console.log(line);
@@ -15,6 +15,7 @@
15
15
  * 6. No pending TSVs in tracker-additions/ (runs even when tracker file is missing)
16
16
  * 7. No markdown bold in score column
17
17
  * 8. Drift warning if states.yml ids differ from the built-in fallback list
18
+ * 9. Ledger file verifies if .jobforge-ledger/events.jsonl exists
18
19
  *
19
20
  * Run: node verify-pipeline.mjs (from repo root; same as npm run verify)
20
21
  */
@@ -26,6 +27,7 @@ import {
26
27
  PROJECT_DIR, DATA_APPS_DIR, DATA_APPS_FILE, ROOT_APPS_FILE,
27
28
  usesDayFiles, readAllEntries, listDayFiles, dayFilePath,
28
29
  } from './tracker-lib.mjs';
30
+ import { jobForgeLedgerPath, ledgerExists, verifyJobForgeLedger } from './lib/jobforge-ledger.mjs';
29
31
 
30
32
  const ADDITIONS_DIR = join(PROJECT_DIR, 'batch/tracker-additions');
31
33
  const STATES_FILE = existsSync(join(PROJECT_DIR, 'templates/states.yml'))
@@ -127,6 +129,23 @@ function verifyStatesYamlDrift() {
127
129
  }
128
130
  }
129
131
 
132
+ function verifyLedgerIfPresent() {
133
+ if (!ledgerExists(PROJECT_DIR)) {
134
+ ok('Ledger not initialized');
135
+ return;
136
+ }
137
+ const result = verifyJobForgeLedger(PROJECT_DIR);
138
+ for (const issue of result.issues) {
139
+ const prefix = issue.line ? `ledger line ${issue.line}` : 'ledger';
140
+ const msg = `${prefix}: ${issue.code}: ${issue.message}`;
141
+ if (issue.severity === 'error') error(msg);
142
+ else warn(msg);
143
+ }
144
+ if (result.errors === 0) {
145
+ ok(`Ledger valid (${result.eventCount} events at ${relative(PROJECT_DIR, jobForgeLedgerPath(PROJECT_DIR))})`);
146
+ }
147
+ }
148
+
130
149
  // --- Read entries ---
131
150
  const { entries, source } = readAllEntries();
132
151
 
@@ -135,6 +154,7 @@ if (entries.length === 0) {
135
154
  console.log(' This is normal for a fresh setup.\n');
136
155
  checkPendingTrackerAdditions();
137
156
  verifyStatesYamlDrift();
157
+ verifyLedgerIfPresent();
138
158
  console.log('\n' + '='.repeat(50));
139
159
  console.log(`📊 Pipeline Health: ${errors} errors, ${warnings} warnings`);
140
160
  if (errors === 0 && warnings === 0) console.log('🟢 Pipeline is clean!');
@@ -254,6 +274,7 @@ for (const e of entries) {
254
274
  if (boldScores === 0) ok('No bold in scores');
255
275
 
256
276
  verifyStatesYamlDrift();
277
+ verifyLedgerIfPresent();
257
278
 
258
279
  console.log('\n' + '='.repeat(50));
259
280
  console.log(`📊 Pipeline Health: ${errors} errors, ${warnings} warnings`);