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.
- package/.codex/config.toml +8 -0
- package/.cursor/mcp.json +21 -0
- package/.cursor/rules/main.mdc +519 -0
- package/.mcp.json +21 -0
- package/.opencode/agents/general-free.md +85 -0
- package/.opencode/agents/general-paid.md +39 -0
- package/.opencode/agents/glm-minimal.md +50 -0
- package/.opencode/skills/job-forge.md +185 -0
- package/AGENTS.md +514 -0
- package/CLAUDE.md +514 -0
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/batch/README.md +60 -0
- package/batch/batch-prompt.md +399 -0
- package/batch/batch-runner.sh +673 -0
- package/bin/create-job-forge.mjs +375 -0
- package/bin/job-forge.mjs +120 -0
- package/bin/sync.mjs +141 -0
- package/config/profile.example.yml +67 -0
- package/cv-sync-check.mjs +128 -0
- package/dedup-tracker.mjs +201 -0
- package/docs/ARCHITECTURE.md +220 -0
- package/docs/CUSTOMIZATION.md +101 -0
- package/docs/MODEL-ROUTING.md +195 -0
- package/docs/README.md +54 -0
- package/docs/SETUP.md +186 -0
- package/docs/demo.gif +0 -0
- package/fonts/dm-sans-latin-ext.woff2 +0 -0
- package/fonts/dm-sans-latin.woff2 +0 -0
- package/fonts/space-grotesk-latin-ext.woff2 +0 -0
- package/fonts/space-grotesk-latin.woff2 +0 -0
- package/generate-pdf.mjs +168 -0
- package/iso/agents/general-free.md +90 -0
- package/iso/agents/general-paid.md +44 -0
- package/iso/agents/glm-minimal.md +55 -0
- package/iso/commands/job-forge.md +188 -0
- package/iso/config.json +7 -0
- package/iso/instructions.md +514 -0
- package/iso/mcp.json +15 -0
- package/merge-tracker.mjs +377 -0
- package/modes/README.md +30 -0
- package/modes/_shared-calibration.md +26 -0
- package/modes/_shared.md +272 -0
- package/modes/apply.md +257 -0
- package/modes/auto-pipeline.md +70 -0
- package/modes/batch.md +110 -0
- package/modes/compare.md +23 -0
- package/modes/contact.md +82 -0
- package/modes/deep.md +99 -0
- package/modes/followup.md +68 -0
- package/modes/negotiation.md +146 -0
- package/modes/offer.md +199 -0
- package/modes/pdf.md +121 -0
- package/modes/pipeline.md +83 -0
- package/modes/project.md +30 -0
- package/modes/rejection.md +92 -0
- package/modes/scan.md +185 -0
- package/modes/tracker.md +31 -0
- package/modes/training.md +27 -0
- package/normalize-statuses.mjs +152 -0
- package/opencode.json +28 -0
- package/package.json +78 -0
- package/scripts/add-tags.mjs +894 -0
- package/scripts/cursor-agent-loop.sh +211 -0
- package/scripts/cursor-agent-stream-format.py +134 -0
- package/scripts/next-num.mjs +33 -0
- package/scripts/release/check-source.mjs +37 -0
- package/scripts/render-report-header.mjs +78 -0
- package/scripts/session-report.mjs +129 -0
- package/scripts/slugify.mjs +27 -0
- package/scripts/today.mjs +20 -0
- package/scripts/token-usage-report.mjs +315 -0
- package/scripts/tracker-line.mjs +67 -0
- package/scripts/verify-greenhouse-urls.mjs +195 -0
- package/templates/cv-template.html +395 -0
- package/templates/portals.example.yml +3140 -0
- package/templates/states.yml +62 -0
- package/tracker-lib.mjs +257 -0
- 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')}`);
|