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.
- package/.cursor/rules/main.mdc +6 -3
- package/.opencode/skills/job-forge.md +7 -0
- package/AGENTS.md +6 -3
- package/CLAUDE.md +6 -3
- package/README.md +5 -1
- package/bin/create-job-forge.mjs +9 -1
- package/bin/job-forge.mjs +30 -0
- package/docs/ARCHITECTURE.md +6 -1
- package/docs/CUSTOMIZATION.md +13 -0
- package/docs/README.md +1 -1
- package/docs/SETUP.md +3 -0
- package/iso/commands/job-forge.md +7 -0
- package/iso/instructions.md +6 -3
- package/lib/jobforge-ledger.mjs +214 -0
- package/merge-tracker.mjs +23 -0
- package/package.json +7 -1
- package/scripts/ledger.mjs +359 -0
- package/scripts/telemetry.mjs +14 -0
- package/scripts/tracker-line.mjs +8 -0
- package/verify-pipeline.mjs +21 -0
package/.cursor/rules/main.mdc
CHANGED
|
@@ -12,7 +12,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
12
12
|
- [H1] Max 2 parallel `task` dispatches per message. For N jobs, run `ceil(N/2)` sequential rounds of 2. A round is not complete until both subagents return a final outcome (`APPLIED`, `APPLY FAILED`, `SKIP`, `Discarded`, or a written TSV path). A `task` tool result that only gives a session id / title is a launch acknowledgement, not completion. Applies in all modes, for all user phrasings ("urgent", "apply to 10 jobs now").
|
|
13
13
|
why: each subagent requires post-cleanup and racing more than 2 reliably loses at least one result. On 2026-04-25 the orchestrator launched round 2 while round 1 had only returned task ids, leaving four application subagents in flight and losing two provider recoveries
|
|
14
14
|
|
|
15
|
-
- [H2] Max 1 application per company+role. Before every `apply` dispatch, grep all four sources for the URL and for `company+role`: `data/pipeline.md`, all `data/applications/*.md` day files, `batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`. 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,
|
|
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
|
package/bin/create-job-forge.mjs
CHANGED
|
@@ -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: '>=
|
|
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`);
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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 |
|
package/docs/CUSTOMIZATION.md
CHANGED
|
@@ -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
|
package/iso/instructions.md
CHANGED
|
@@ -7,7 +7,7 @@ AI-powered job search pipeline: scans portals, evaluates offers, generates CVs v
|
|
|
7
7
|
- [H1] Max 2 parallel `task` dispatches per message. For N jobs, run `ceil(N/2)` sequential rounds of 2. A round is not complete until both subagents return a final outcome (`APPLIED`, `APPLY FAILED`, `SKIP`, `Discarded`, or a written TSV path). A `task` tool result that only gives a session id / title is a launch acknowledgement, not completion. Applies in all modes, for all user phrasings ("urgent", "apply to 10 jobs now").
|
|
8
8
|
why: each subagent requires post-cleanup and racing more than 2 reliably loses at least one result. On 2026-04-25 the orchestrator launched round 2 while round 1 had only returned task ids, leaving four application subagents in flight and losing two provider recoveries
|
|
9
9
|
|
|
10
|
-
- [H2] Max 1 application per company+role. Before every `apply` dispatch, grep all four sources for the URL and for `company+role`: `data/pipeline.md`, all `data/applications/*.md` day files, `batch/tracker-additions/*.tsv`, `batch/tracker-additions/merged/*.tsv`. 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.
|
|
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
|
+
}
|
package/scripts/telemetry.mjs
CHANGED
|
@@ -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}`);
|
package/scripts/tracker-line.mjs
CHANGED
|
@@ -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);
|
package/verify-pipeline.mjs
CHANGED
|
@@ -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`);
|