job-forge 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.codex/config.toml +8 -0
  2. package/.cursor/mcp.json +21 -0
  3. package/.cursor/rules/main.mdc +519 -0
  4. package/.mcp.json +21 -0
  5. package/.opencode/agents/general-free.md +85 -0
  6. package/.opencode/agents/general-paid.md +39 -0
  7. package/.opencode/agents/glm-minimal.md +50 -0
  8. package/.opencode/skills/job-forge.md +185 -0
  9. package/AGENTS.md +514 -0
  10. package/CLAUDE.md +514 -0
  11. package/LICENSE +21 -0
  12. package/README.md +195 -0
  13. package/batch/README.md +60 -0
  14. package/batch/batch-prompt.md +399 -0
  15. package/batch/batch-runner.sh +673 -0
  16. package/bin/create-job-forge.mjs +375 -0
  17. package/bin/job-forge.mjs +120 -0
  18. package/bin/sync.mjs +141 -0
  19. package/config/profile.example.yml +67 -0
  20. package/cv-sync-check.mjs +128 -0
  21. package/dedup-tracker.mjs +201 -0
  22. package/docs/ARCHITECTURE.md +220 -0
  23. package/docs/CUSTOMIZATION.md +101 -0
  24. package/docs/MODEL-ROUTING.md +195 -0
  25. package/docs/README.md +54 -0
  26. package/docs/SETUP.md +186 -0
  27. package/docs/demo.gif +0 -0
  28. package/fonts/dm-sans-latin-ext.woff2 +0 -0
  29. package/fonts/dm-sans-latin.woff2 +0 -0
  30. package/fonts/space-grotesk-latin-ext.woff2 +0 -0
  31. package/fonts/space-grotesk-latin.woff2 +0 -0
  32. package/generate-pdf.mjs +168 -0
  33. package/iso/agents/general-free.md +90 -0
  34. package/iso/agents/general-paid.md +44 -0
  35. package/iso/agents/glm-minimal.md +55 -0
  36. package/iso/commands/job-forge.md +188 -0
  37. package/iso/config.json +7 -0
  38. package/iso/instructions.md +514 -0
  39. package/iso/mcp.json +15 -0
  40. package/merge-tracker.mjs +377 -0
  41. package/modes/README.md +30 -0
  42. package/modes/_shared-calibration.md +26 -0
  43. package/modes/_shared.md +272 -0
  44. package/modes/apply.md +257 -0
  45. package/modes/auto-pipeline.md +70 -0
  46. package/modes/batch.md +110 -0
  47. package/modes/compare.md +23 -0
  48. package/modes/contact.md +82 -0
  49. package/modes/deep.md +99 -0
  50. package/modes/followup.md +68 -0
  51. package/modes/negotiation.md +146 -0
  52. package/modes/offer.md +199 -0
  53. package/modes/pdf.md +121 -0
  54. package/modes/pipeline.md +83 -0
  55. package/modes/project.md +30 -0
  56. package/modes/rejection.md +92 -0
  57. package/modes/scan.md +185 -0
  58. package/modes/tracker.md +31 -0
  59. package/modes/training.md +27 -0
  60. package/normalize-statuses.mjs +152 -0
  61. package/opencode.json +28 -0
  62. package/package.json +78 -0
  63. package/scripts/add-tags.mjs +894 -0
  64. package/scripts/cursor-agent-loop.sh +211 -0
  65. package/scripts/cursor-agent-stream-format.py +134 -0
  66. package/scripts/next-num.mjs +33 -0
  67. package/scripts/release/check-source.mjs +37 -0
  68. package/scripts/render-report-header.mjs +78 -0
  69. package/scripts/session-report.mjs +129 -0
  70. package/scripts/slugify.mjs +27 -0
  71. package/scripts/today.mjs +20 -0
  72. package/scripts/token-usage-report.mjs +315 -0
  73. package/scripts/tracker-line.mjs +67 -0
  74. package/scripts/verify-greenhouse-urls.mjs +195 -0
  75. package/templates/cv-template.html +395 -0
  76. package/templates/portals.example.yml +3140 -0
  77. package/templates/states.yml +62 -0
  78. package/tracker-lib.mjs +257 -0
  79. package/verify-pipeline.mjs +267 -0
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env bash
2
+ # Re-exec under bash when invoked as `sh` (often dash on Linux). This script uses
3
+ # bash-only syntax ([[, arrays, PIPESTATUS, BASH_SOURCE).
4
+ if [ -z "${BASH_VERSION+x}" ]; then
5
+ exec /usr/bin/env bash "$0" "$@"
6
+ fi
7
+
8
+ # Drive Cursor Agent CLI in a loop (non-interactive). Each iteration is one agent
9
+ # session that explores the repo, picks the next best improvement, implements it,
10
+ # runs a small quality gate, commits, and optionally pushes.
11
+ #
12
+ # Task selection (humans/agents):
13
+ # - Read CONTRIBUTING.md ("What to Contribute") and docs/SETUP.md, docs/ARCHITECTURE.md,
14
+ # docs/CUSTOMIZATION.md for direction.
15
+ # - Search for open work (repo root): rg 'TODO|FIXME|HACK' . --glob '*.mjs' --glob '*.go' --glob '*.md' --glob '*.sh'
16
+ # - Improve modes/*.md, .mjs utilities, templates/, examples/, or dashboard/ (Go TUI)
17
+ # in one cohesive slice per iteration.
18
+ # - Do not commit personal artifacts (cv.md, config/profile.yml, data/applications.md,
19
+ # reports/, user portals.yml) — see CONTRIBUTING.md and .gitignore.
20
+ #
21
+ # Quality gate (repo root):
22
+ # npm run verify
23
+ # npm run build:dashboard # same as (cd dashboard && go build .)
24
+ # verify-pipeline.mjs exits 0 when applications.md is missing (fresh clone); still
25
+ # run it. Dashboard build requires Go on PATH when touching dashboard/.
26
+ # Do not require npm run sync-check (cv-sync-check.mjs) in the gate: it expects
27
+ # personal cv.md and profile.yml and fails on a clean template checkout.
28
+ #
29
+ # Self-improve: when this script or its header comments are stale vs CONTRIBUTING.md
30
+ # or real workflows, a minimal accurate edit here is fair game for an iteration.
31
+ #
32
+ # Prerequisites:
33
+ # - Cursor Agent CLI: https://cursor.com/install (`agent` on PATH)
34
+ # - Auth: `agent login` or CURSOR_API_KEY
35
+ # - Node.js for npm run verify
36
+ # - Go (when working on dashboard/)
37
+ # - For push: configured remote; new branches may need `git push -u origin HEAD` once
38
+ #
39
+ # Environment (optional):
40
+ # CURSOR_AGENT_ITERATIONS Max agent runs (default: 100)
41
+ # CURSOR_AGENT_PUSH If 1, git push after each iteration (default: 1). 0 = no push.
42
+ # CURSOR_AGENT_FORCE_SHELL If 1, pass --force for agent shell (default: 1). Set 0 for prompts.
43
+ # CURSOR_AGENT_WORKSPACE Repo root (default: git top-level from cwd)
44
+ # CURSOR_AGENT_MODEL Passed as --model (default: composer-2)
45
+ # CURSOR_AGENT_EXTRA Extra instructions appended to the built-in prompt
46
+ # CURSOR_AGENT_VERBOSE If 1, stream agent progress via stream-json (default: 1). 0 = text only
47
+ # CURSOR_AGENT_GIT_AUTHOR_NAME Git author/committer name (default: Charlie Greenman)
48
+ # CURSOR_AGENT_GIT_AUTHOR_EMAIL Git author/committer email (default: GitHub noreply for this repo)
49
+ #
50
+ # Usage:
51
+ # ./scripts/cursor-agent-loop.sh
52
+ # CURSOR_AGENT_ITERATIONS=3 ./scripts/cursor-agent-loop.sh
53
+ # CURSOR_AGENT_PUSH=0 CURSOR_AGENT_VERBOSE=0 ./scripts/cursor-agent-loop.sh
54
+ #
55
+ set -euo pipefail
56
+
57
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
58
+ STREAM_FORMATTER="${SCRIPT_DIR}/cursor-agent-stream-format.py"
59
+
60
+ ITERATIONS="${CURSOR_AGENT_ITERATIONS:-100}"
61
+ PUSH="${CURSOR_AGENT_PUSH:-1}"
62
+ FORCE_SHELL="${CURSOR_AGENT_FORCE_SHELL:-1}"
63
+ VERBOSE="${CURSOR_AGENT_VERBOSE:-1}"
64
+ WORKSPACE="${CURSOR_AGENT_WORKSPACE:-}"
65
+ MODEL="${CURSOR_AGENT_MODEL:-composer-2}"
66
+ EXTRA="${CURSOR_AGENT_EXTRA:-}"
67
+
68
+ if ! command -v agent >/dev/null 2>&1; then
69
+ echo "error: 'agent' not found. Install Cursor Agent CLI: https://cursor.com/install" >&2
70
+ exit 1
71
+ fi
72
+
73
+ if [[ "$VERBOSE" == "1" ]]; then
74
+ if ! command -v python3 >/dev/null 2>&1; then
75
+ echo "warning: python3 not found; install Python 3 or set CURSOR_AGENT_VERBOSE=0" >&2
76
+ VERBOSE=0
77
+ elif [[ ! -f "$STREAM_FORMATTER" ]]; then
78
+ echo "warning: missing ${STREAM_FORMATTER}; set CURSOR_AGENT_VERBOSE=0" >&2
79
+ VERBOSE=0
80
+ fi
81
+ fi
82
+
83
+ if [[ -z "$WORKSPACE" ]]; then
84
+ WORKSPACE="$(git rev-parse --show-toplevel 2>/dev/null)" || {
85
+ echo "error: not inside a git repository (set CURSOR_AGENT_WORKSPACE)" >&2
86
+ exit 1
87
+ }
88
+ fi
89
+
90
+ cd "$WORKSPACE"
91
+
92
+ current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
93
+ if [[ "$PUSH" == "1" && "$current_branch" == "main" ]]; then
94
+ echo "warning: will push to main after each iteration; use a feature branch or CURSOR_AGENT_PUSH=0 if unintended." >&2
95
+ fi
96
+
97
+ case "$ITERATIONS" in
98
+ '' | *[!0-9]*)
99
+ echo "error: CURSOR_AGENT_ITERATIONS must be a positive integer (got: ${ITERATIONS})" >&2
100
+ exit 1
101
+ ;;
102
+ esac
103
+ if [[ "$ITERATIONS" -lt 1 ]]; then
104
+ echo "error: CURSOR_AGENT_ITERATIONS must be >= 1" >&2
105
+ exit 1
106
+ fi
107
+
108
+ if [[ "$PUSH" == "1" ]]; then
109
+ PUSH_TEXT="After a successful commit, do not run git push; the host script runs git push immediately after this agent exits."
110
+ else
111
+ PUSH_TEXT="Do not run git push."
112
+ fi
113
+
114
+ GIT_AUTHOR_NAME="${CURSOR_AGENT_GIT_AUTHOR_NAME:-Charlie Greenman}"
115
+ GIT_AUTHOR_EMAIL="${CURSOR_AGENT_GIT_AUTHOR_EMAIL:-CharlieGreenman@users.noreply.github.com}"
116
+ export GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL
117
+ export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
118
+ export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
119
+
120
+ build_prompt() {
121
+ cat <<EOF
122
+ You are working on JobForge, an AI-powered job-search pipeline (Claude Code modes, Node utilities, optional Go dashboard). Respect CLAUDE.md, CONTRIBUTING.md, and .cursor rules if present.
123
+
124
+ Single iteration — do exactly one cohesive, meaningful slice of work:
125
+
126
+ 1. Explore the repo. Read CLAUDE.md, CONTRIBUTING.md, and skim docs/ (SETUP, ARCHITECTURE, CUSTOMIZATION). Understand modes/, batch/, templates/, and dashboard/ when relevant.
127
+
128
+ 2. Decide what to work on. Priority order:
129
+ a) CONTRIBUTING.md "What to Contribute" and obvious gaps in docs or examples.
130
+ b) Search for concrete hooks: e.g. \`rg 'TODO|FIXME|HACK' . --glob '*.mjs' --glob '*.go' --glob '*.md' --glob '*.sh'\` (repo root; adjust as needed).
131
+ c) One subsystem per iteration: a mode file in modes/, a script (*.mjs), template/example polish, or dashboard (Go) — not a repo-wide refactor.
132
+ d) Self-improve this loop: if scripts/cursor-agent-loop.sh or its header is wrong or missing heuristics, fix it minimally when that is highest leverage.
133
+
134
+ Do NOT commit or "fix" user-specific files: cv.md, config/profile.yml, portals.yml with personal data, data/applications.md, reports/, or other private paths called out in CONTRIBUTING / .gitignore. Upstream improvements should stay generic.
135
+
136
+ Pick something concrete. Do NOT claim there is nothing to do.
137
+
138
+ 3. Implement with minimal scope. Match existing style (markdown tone, Node ESM, Go conventions in dashboard/). Scripts should handle missing files gracefully (see CONTRIBUTING).
139
+
140
+ 4. Run the quality gate from the repo root:
141
+ npm run verify
142
+ npm run build:dashboard
143
+ If you did not change dashboard/, the go build still must pass — fix any breakage you introduced. If Go is missing from the environment, say so clearly and stop rather than skipping silently.
144
+
145
+ 5. If you made real changes: git add only what belongs to this task, then commit with author and message rules:
146
+ - Author must be exactly: ${GIT_AUTHOR_NAME} <${GIT_AUTHOR_EMAIL}>
147
+ Use: git commit --author="${GIT_AUTHOR_NAME} <${GIT_AUTHOR_EMAIL}>" -m "type: short description"
148
+ (GIT_AUTHOR_*/GIT_COMMITTER_* may already be exported for this session; the result must still show that author.)
149
+ - Subject/body: conventional prefix (feat:/fix:/chore:/docs:/test:/refactor:). Describe the change only.
150
+ - Do not mention Cursor, Cursor Agent, ChatGPT, Claude, automation, bots, or "AI" / "generated" in the commit message.
151
+ ${PUSH_TEXT}
152
+
153
+ 6. Do not force-push. Do not rewrite published history.
154
+
155
+ 7. End your response with a final line: DONE
156
+
157
+ ${EXTRA}
158
+ EOF
159
+ }
160
+
161
+ agent_cmd=(agent -p --trust --workspace "$WORKSPACE")
162
+ if [[ "$VERBOSE" == "1" ]]; then
163
+ agent_cmd+=(--output-format stream-json --stream-partial-output)
164
+ else
165
+ agent_cmd+=(--output-format text)
166
+ fi
167
+ if [[ "$FORCE_SHELL" == "1" ]]; then
168
+ agent_cmd+=(--force)
169
+ fi
170
+ agent_cmd+=(--model "$MODEL")
171
+
172
+ i=1
173
+ while true; do
174
+ if [[ "$i" -gt "$ITERATIONS" ]]; then
175
+ break
176
+ fi
177
+
178
+ echo "=== cursor-agent-loop: iteration $i of ${ITERATIONS} ===" >&2
179
+ prompt="$(build_prompt)"
180
+ agent_status=0
181
+ if [[ "$VERBOSE" == "1" ]]; then
182
+ set +e
183
+ "${agent_cmd[@]}" "$prompt" | python3 "$STREAM_FORMATTER"
184
+ pipe_statuses=("${PIPESTATUS[@]}")
185
+ set -e
186
+ agent_status=${pipe_statuses[0]}
187
+ fmt_status=${pipe_statuses[1]:-0}
188
+ if [[ "$fmt_status" -ne 0 ]]; then
189
+ echo "error: stream formatter exited non-zero ($fmt_status) on iteration $i" >&2
190
+ exit "$fmt_status"
191
+ fi
192
+ fi
193
+ if [[ "$VERBOSE" != "1" ]]; then
194
+ set +e
195
+ "${agent_cmd[@]}" "$prompt"
196
+ agent_status=$?
197
+ set -e
198
+ fi
199
+ if [[ "$agent_status" -ne 0 ]]; then
200
+ echo "error: agent exited non-zero ($agent_status) on iteration $i" >&2
201
+ exit "$agent_status"
202
+ fi
203
+
204
+ if [[ "$PUSH" == "1" ]]; then
205
+ git push
206
+ fi
207
+
208
+ i=$((i + 1))
209
+ done
210
+
211
+ echo "=== cursor-agent-loop: finished ${ITERATIONS} iteration(s) ===" >&2
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Pretty-print Cursor Agent CLI stream-json (NDJSON) for humans.
4
+
5
+ Reads one JSON object per line from stdin; prints assistant text to stdout
6
+ and progress (session, tools) to stderr. See:
7
+ https://cursor.com/docs/cli/reference/output-format
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+
15
+
16
+ def _assistant_text(obj: dict) -> str:
17
+ parts: list[str] = []
18
+ for block in (obj.get("message") or {}).get("content") or []:
19
+ if isinstance(block, dict) and block.get("type") == "text":
20
+ parts.append(str(block.get("text") or ""))
21
+ return "".join(parts)
22
+
23
+
24
+ def _describe_tool_call(tool_call: dict, *, completed: bool) -> str:
25
+ if not isinstance(tool_call, dict) or not tool_call:
26
+ return "?"
27
+
28
+ if "readToolCall" in tool_call:
29
+ inner = tool_call["readToolCall"]
30
+ args = inner.get("args") or {}
31
+ path = args.get("path", "?")
32
+ if completed:
33
+ res = inner.get("result") or {}
34
+ succ = (res.get("success") or {}) if isinstance(res, dict) else {}
35
+ lines = succ.get("totalLines")
36
+ if lines is not None:
37
+ return f"read {path} ({lines} lines)"
38
+ return f"read {path} (done)"
39
+ return f"read {path}"
40
+
41
+ if "writeToolCall" in tool_call:
42
+ inner = tool_call["writeToolCall"]
43
+ args = inner.get("args") or {}
44
+ path = args.get("path", "?")
45
+ if completed:
46
+ res = inner.get("result") or {}
47
+ succ = (res.get("success") or {}) if isinstance(res, dict) else {}
48
+ lines = succ.get("linesCreated")
49
+ if lines is not None:
50
+ return f"write {path} (+{lines} lines)"
51
+ return f"write {path} (done)"
52
+ return f"write {path}"
53
+
54
+ fn = tool_call.get("function")
55
+ if isinstance(fn, dict):
56
+ name = fn.get("name") or "function"
57
+ raw = fn.get("arguments")
58
+ if isinstance(raw, str):
59
+ arg_s = raw.replace("\n", " ").strip()
60
+ if len(arg_s) > 160:
61
+ arg_s = arg_s[:160] + "…"
62
+ else:
63
+ arg_s = json.dumps(raw, default=str) if raw is not None else ""
64
+ if len(arg_s) > 160:
65
+ arg_s = arg_s[:160] + "…"
66
+ return f"{name} {arg_s}".strip()
67
+
68
+ for key, inner in tool_call.items():
69
+ if isinstance(inner, dict) and key.endswith("ToolCall"):
70
+ label = key[: -len("ToolCall")] or key
71
+ args = inner.get("args")
72
+ if isinstance(args, dict):
73
+ return f"{label} {json.dumps(args, default=str)[:200]}"
74
+ return f"{label} …"
75
+
76
+ return json.dumps(tool_call, default=str)[:200]
77
+
78
+
79
+ def main() -> None:
80
+ for line in sys.stdin:
81
+ line = line.strip()
82
+ if not line:
83
+ continue
84
+ try:
85
+ o = json.loads(line)
86
+ except json.JSONDecodeError:
87
+ print(line, file=sys.stderr, flush=True)
88
+ continue
89
+
90
+ t = o.get("type")
91
+ if t == "thinking":
92
+ continue
93
+ if t == "system":
94
+ sid = str(o.get("session_id") or "")
95
+ sid_short = (sid[:8] + "…") if len(sid) > 8 else sid
96
+ print(
97
+ f"\n[cursor-agent] session {sid_short} model={o.get('model', '?')!s} cwd={o.get('cwd', '')}",
98
+ file=sys.stderr,
99
+ flush=True,
100
+ )
101
+ elif t == "user":
102
+ txt = _assistant_text(o)
103
+ if len(txt) > 600:
104
+ txt = txt[:600] + "…"
105
+ print(f"\n[cursor-agent] --- prompt ({len(txt)} chars) ---\n{txt}\n", file=sys.stderr, flush=True)
106
+ elif t == "assistant":
107
+ sys.stdout.write(_assistant_text(o))
108
+ sys.stdout.flush()
109
+ elif t == "tool_call":
110
+ sub = o.get("subtype")
111
+ tc = o.get("tool_call") if isinstance(o.get("tool_call"), dict) else {}
112
+ desc = _describe_tool_call(tc, completed=(sub == "completed"))
113
+ if sub == "started":
114
+ print(f"\n[cursor-agent] tool ▶ {desc}", file=sys.stderr, flush=True)
115
+ elif sub == "completed":
116
+ print(f"[cursor-agent] tool ✓ {desc}", file=sys.stderr, flush=True)
117
+ else:
118
+ print(f"[cursor-agent] tool {sub!s} {desc}", file=sys.stderr, flush=True)
119
+ elif t == "result":
120
+ dur = o.get("duration_ms")
121
+ err = o.get("is_error")
122
+ sub = o.get("subtype")
123
+ print(
124
+ f"\n[cursor-agent] result subtype={sub!s} duration_ms={dur!s} is_error={err!s}",
125
+ file=sys.stderr,
126
+ flush=True,
127
+ )
128
+ else:
129
+ # Forward-compatible: show unknown types lightly
130
+ print(f"[cursor-agent] event type={t!s}", file=sys.stderr, flush=True)
131
+
132
+
133
+ if __name__ == "__main__":
134
+ main()
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * next-num — print the next sequential report number (3-digit zero-padded).
4
+ *
5
+ * Reads reports/ and returns max(existing) + 1. Used by agents instead of
6
+ * having the model figure this out by listing + parsing filenames.
7
+ *
8
+ * Usage:
9
+ * job-forge next-num # prints e.g. "521"
10
+ * job-forge next-num --padded # prints e.g. "521" (default, already padded)
11
+ * job-forge next-num --raw # prints e.g. "521" without padding
12
+ */
13
+
14
+ import { readdirSync, existsSync } from 'fs';
15
+ import { join } from 'path';
16
+
17
+ const PROJECT_DIR = process.env.JOB_FORGE_PROJECT || process.cwd();
18
+ const REPORTS_DIR = join(PROJECT_DIR, 'reports');
19
+ const RAW = process.argv.includes('--raw');
20
+
21
+ let max = 0;
22
+ if (existsSync(REPORTS_DIR)) {
23
+ for (const f of readdirSync(REPORTS_DIR)) {
24
+ if (!f.endsWith('.md')) continue;
25
+ const m = f.match(/^(\d+)-/);
26
+ if (!m) continue;
27
+ const n = parseInt(m[1], 10);
28
+ if (n > max) max = n;
29
+ }
30
+ }
31
+
32
+ const next = max + 1;
33
+ console.log(RAW ? String(next) : String(next).padStart(3, '0'));
@@ -0,0 +1,37 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { fileURLToPath } from 'node:url';
3
+ import path from 'node:path';
4
+
5
+ const version = process.argv[2];
6
+
7
+ if (!version) {
8
+ console.error('Usage: node scripts/release/check-source.mjs <version>');
9
+ process.exit(1);
10
+ }
11
+
12
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
13
+ const pkgPath = path.join(root, 'package.json');
14
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
15
+
16
+ if (pkg.version !== version) {
17
+ console.error(
18
+ `package.json version ${pkg.version ?? 'unknown'} does not match release tag ${version}. ` +
19
+ `Bump "version" in package.json, commit, and retag before publishing.`,
20
+ );
21
+ process.exit(1);
22
+ }
23
+
24
+ for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
25
+ const deps = pkg[section];
26
+ if (!deps) continue;
27
+ for (const [name, spec] of Object.entries(deps)) {
28
+ if (typeof spec === 'string' && spec.startsWith('file:')) {
29
+ console.error(
30
+ `${section}["${name}"] is "${spec}" — file: deps are published verbatim and break consumers.`,
31
+ );
32
+ process.exit(1);
33
+ }
34
+ }
35
+ }
36
+
37
+ console.log(`${pkg.name}: ${pkg.version}`);
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * render-report-header — output the canonical report.md header + `## Score`
4
+ * section from a score JSON object. Saves the model from re-emitting the
5
+ * standard boilerplate (header fields, JSON fencing, section labels) on
6
+ * every evaluation.
7
+ *
8
+ * Typical usage from an agent:
9
+ *
10
+ * # 1. Emit the score JSON per _shared.md
11
+ * # 2. Save it to /tmp/score.json (or pipe via stdin)
12
+ * # 3. Generate the header + score section:
13
+ * node scripts/render-report-header.mjs < /tmp/score.json > /tmp/header.md
14
+ * # 4. Append Blocks A-F + Extracted Keywords to /tmp/header.md
15
+ * # 5. Write the final file to reports/{num}-{slug}-{date}.md
16
+ *
17
+ * Input: JSON on stdin (or --score-json <path>) matching the schema in
18
+ * `modes/_shared.md` → "Score Emission — EMIT-ONCE JSON".
19
+ *
20
+ * Output (to stdout): markdown starting with the `# Evaluation:` line and
21
+ * ending right before `## A) Role Summary` (which the caller appends).
22
+ */
23
+
24
+ import { readFileSync } from 'fs';
25
+
26
+ function arg(name) {
27
+ const i = process.argv.indexOf(`--${name}`);
28
+ if (i < 0 || i + 1 >= process.argv.length) return null;
29
+ return process.argv[i + 1];
30
+ }
31
+
32
+ const scoreFile = arg('score-json');
33
+ let raw;
34
+ if (scoreFile) {
35
+ raw = readFileSync(scoreFile, 'utf-8');
36
+ } else if (!process.stdin.isTTY) {
37
+ raw = readFileSync(0, 'utf-8');
38
+ } else {
39
+ console.error('usage: render-report-header [--score-json <path>] (or pipe JSON via stdin)');
40
+ process.exit(2);
41
+ }
42
+
43
+ let score;
44
+ try {
45
+ score = JSON.parse(raw);
46
+ } catch (e) {
47
+ console.error(`invalid score JSON: ${e.message}`);
48
+ process.exit(2);
49
+ }
50
+
51
+ const required = ['report_num', 'company', 'role', 'archetype', 'url', 'date', 'weighted_total'];
52
+ const missing = required.filter(k => score[k] === undefined);
53
+ if (missing.length) {
54
+ console.error(`score JSON is missing required fields: ${missing.join(', ')}`);
55
+ process.exit(2);
56
+ }
57
+
58
+ const pdfNote = score.pdf_threshold_met ? 'pending' : 'below threshold — no PDF';
59
+
60
+ process.stdout.write(`# Evaluation: ${score.company} — ${score.role}
61
+
62
+ **Date:** ${score.date}
63
+ **Archetype:** ${score.archetype}
64
+ **Score:** ${Number(score.weighted_total).toFixed(1)}/5
65
+ **URL:** ${score.url}
66
+ **PDF:** ${pdfNote}
67
+
68
+ ---
69
+
70
+ ## Score
71
+
72
+ \`\`\`json
73
+ ${JSON.stringify(score, null, 2)}
74
+ \`\`\`
75
+
76
+ ---
77
+
78
+ `);
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * session-report — print a token/cost summary for the most-recent session(s)
4
+ * and WARN if any session exceeded the cost budget.
5
+ *
6
+ * Ideal for a shell wrapper around `opencode`:
7
+ * opencode "$@"
8
+ * job-forge session-report --since-minutes 30
9
+ *
10
+ * Or at the end of a batch run (already wired into batch-runner.sh).
11
+ *
12
+ * Exits 0 always; prints warnings to stderr, summary to stdout.
13
+ *
14
+ * Usage:
15
+ * job-forge session-report # last 1 day
16
+ * job-forge session-report --since-minutes 30 # only sessions started in last 30 min
17
+ * job-forge session-report --warn-at 1.00 # warn threshold in dollars (default 1.00)
18
+ * job-forge session-report --log # also append to data/token-usage.tsv
19
+ */
20
+
21
+ import { execSync } from 'child_process';
22
+ import { existsSync, appendFileSync, writeFileSync } from 'fs';
23
+ import { join } from 'path';
24
+
25
+ const PROJECT_DIR = process.env.JOB_FORGE_PROJECT || process.cwd();
26
+ const OPENCODE = process.env.OPENCODE_BIN || '/Users/charlie/.opencode/bin/opencode';
27
+
28
+ function arg(name, def = null) {
29
+ const i = process.argv.indexOf(`--${name}`);
30
+ if (i < 0 || i + 1 >= process.argv.length) return def;
31
+ return process.argv[i + 1];
32
+ }
33
+
34
+ const SINCE_MIN = parseInt(arg('since-minutes', '1440'), 10); // default 24h
35
+ const WARN_AT = parseFloat(arg('warn-at', '1.00'));
36
+ const LOG = process.argv.includes('--log');
37
+
38
+ const cutoffMs = Date.now() - SINCE_MIN * 60_000;
39
+
40
+ function query(sql) {
41
+ const cmd = `cd "${PROJECT_DIR}" && "${OPENCODE}" db "${sql.replace(/"/g, '\\"')}" --format json 2>/dev/null`;
42
+ try {
43
+ const out = execSync(cmd, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }).trim();
44
+ return out ? JSON.parse(out) : [];
45
+ } catch { return []; }
46
+ }
47
+
48
+ function fmtNum(n) {
49
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
50
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
51
+ return String(n);
52
+ }
53
+ function fmtCost(n) { return `$${n.toFixed(4)}`; }
54
+
55
+ const sessions = query(`
56
+ SELECT
57
+ s.id,
58
+ s.title,
59
+ s.time_created,
60
+ SUM(json_extract(m.data, '$.tokens.input')) as total_input,
61
+ SUM(json_extract(m.data, '$.tokens.output')) as total_output,
62
+ SUM(json_extract(m.data, '$.tokens.cache.read')) as total_cache_read,
63
+ SUM(json_extract(m.data, '$.cost')) as total_cost,
64
+ COUNT(CASE WHEN json_extract(m.data, '$.tokens.input') > 0 THEN 1 END) as msg_count
65
+ FROM session s
66
+ JOIN message m ON m.session_id = s.id
67
+ WHERE s.time_created >= ${cutoffMs}
68
+ AND json_extract(m.data, '$.role') = 'assistant'
69
+ GROUP BY s.id
70
+ ORDER BY s.time_created DESC
71
+ `);
72
+
73
+ if (!sessions.length) {
74
+ console.log(`No sessions in the last ${SINCE_MIN} minutes.`);
75
+ process.exit(0);
76
+ }
77
+
78
+ let totalCost = 0;
79
+ let totalMsgs = 0;
80
+ let warned = 0;
81
+ const expensive = [];
82
+
83
+ console.log(`\nSessions in the last ${SINCE_MIN} minutes (${sessions.length}):`);
84
+ console.log('─'.repeat(80));
85
+ for (const s of sessions) {
86
+ totalCost += s.total_cost || 0;
87
+ totalMsgs += s.msg_count || 0;
88
+ if ((s.total_cost || 0) > WARN_AT) {
89
+ warned++;
90
+ expensive.push(s);
91
+ }
92
+ const title = (s.title || '(untitled)').slice(0, 50);
93
+ console.log(
94
+ ` ${fmtCost(s.total_cost || 0).padStart(9)} ${title.padEnd(52)} ${fmtNum(s.total_input || 0).padStart(6)} in, ${fmtNum(s.total_cache_read || 0).padStart(6)} cache, ${String(s.msg_count || 0).padStart(3)} msgs`
95
+ );
96
+ }
97
+ console.log('─'.repeat(80));
98
+ console.log(` Total: ${fmtCost(totalCost)} across ${totalMsgs} messages\n`);
99
+
100
+ if (warned) {
101
+ console.error(`⚠️ ${warned} session(s) exceeded $${WARN_AT.toFixed(2)} budget:`);
102
+ for (const s of expensive) {
103
+ console.error(` ${fmtCost(s.total_cost || 0)} ${(s.title || '(untitled)').slice(0, 60)} (id: ${s.id})`);
104
+ }
105
+ console.error(` Drill in with: npx job-forge tokens --session <id>`);
106
+ console.error(` If a single session > $1 shows high cache_creation on most messages,`);
107
+ console.error(` the orchestrator likely didn't delegate — tighten AGENTS.md routing.\n`);
108
+ }
109
+
110
+ if (LOG) {
111
+ const logFile = join(PROJECT_DIR, 'data', 'token-usage.tsv');
112
+ const header = 'date\ttime\tsession_id\ttitle\tmessages\tinput_tokens\tcache_read\tcost\n';
113
+ if (!existsSync(logFile)) writeFileSync(logFile, header, 'utf-8');
114
+ for (const s of sessions) {
115
+ const d = new Date(s.time_created);
116
+ const date = d.toISOString().slice(0, 10);
117
+ const time = d.toISOString().slice(11, 19);
118
+ const row = [
119
+ date, time, s.id,
120
+ (s.title || '').replace(/\t/g, ' '),
121
+ s.msg_count || 0,
122
+ s.total_input || 0,
123
+ s.total_cache_read || 0,
124
+ (s.total_cost || 0).toFixed(4),
125
+ ].join('\t') + '\n';
126
+ appendFileSync(logFile, row);
127
+ }
128
+ console.log(`Logged ${sessions.length} session(s) to ${logFile}`);
129
+ }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * slugify — convert a company/role name to a filename-safe slug.
4
+ *
5
+ * Rule: lowercase, strip non-alphanumerics (collapse to single hyphens),
6
+ * trim leading/trailing hyphens. Matches the convention used across
7
+ * reports/{num}-{slug}-{date}.md.
8
+ *
9
+ * Usage:
10
+ * job-forge slugify "Anthropic, PBC" # → anthropic-pbc
11
+ * job-forge slugify "OpenAI Co. — Austin" # → openai-co-austin
12
+ */
13
+
14
+ const input = process.argv.slice(2).join(' ').trim();
15
+ if (!input) {
16
+ console.error('usage: job-forge slugify <name>');
17
+ process.exit(2);
18
+ }
19
+
20
+ const slug = input
21
+ .toLowerCase()
22
+ .normalize('NFKD')
23
+ .replace(/[\u0300-\u036f]/g, '')
24
+ .replace(/[^a-z0-9]+/g, '-')
25
+ .replace(/^-+|-+$/g, '');
26
+
27
+ console.log(slug);
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * today — print today's date in YYYY-MM-DD (local time).
4
+ *
5
+ * Useful because agents sometimes lose track of the current date during
6
+ * long sessions, or because the shell `date` command differs across
7
+ * macOS/Linux. This guarantees the exact format used in tracker day
8
+ * files and report filenames.
9
+ *
10
+ * Usage:
11
+ * job-forge today # → 2026-04-15
12
+ * job-forge today --utc # → 2026-04-15 (UTC day — useful for batch logs)
13
+ */
14
+
15
+ const UTC = process.argv.includes('--utc');
16
+ const now = new Date();
17
+ const y = UTC ? now.getUTCFullYear() : now.getFullYear();
18
+ const m = (UTC ? now.getUTCMonth() : now.getMonth()) + 1;
19
+ const d = UTC ? now.getUTCDate() : now.getDate();
20
+ console.log(`${y}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}`);