init
Release Please / release-please (push) Failing after 4m2s
Web CI / web typecheck + build (push) Successful in 3m39s
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 1h1m49s
CodeQL Analysis / Analyze (go) (push) Failing after 1h1m49s

This commit is contained in:
redlineo committed 2026-07-13 10:14:00 +03:00
commit d295c7f139
1527 files changed
+270726

No files matched your search

+359
View File
@@ -0,0 +1,359 @@
// The ONE action registry — the single source of truth for "what can be done".
// Both UI controls and the assistant dispatch the SAME entries via dispatch(),
// so the human UI and the agentic UI can never drift. Pure module (no React
// hooks): the caller assembles `ctx` from its hooks and passes it in.
//
// The model emits intents as a vendor-neutral text envelope (<<act:ID {json}>>),
// parsed CLIENT-SIDE; the registry validates args by hand (no zod dep) and gates
// side-effects. Resolution of "all the Anthropic ones" happens here, off the
// client pipeline snapshot — the model never sees or invents URLs.
import type { Application, InboxJob } from "@/lib/career-ops";
import type { Job } from "@/components/jobs/job-store";
export const AUTO_FIRE_MAX = 3; // fire ≤3 evaluations silently; confirm above that
export const BATCH_CAP = 12; // hard ceiling on a single fan-out
// Canonical states (templates/states.yml) — the web validates against the same set.
const CANON_STATUS = ["Evaluated", "Applied", "Responded", "Interview", "Offer", "Rejected", "Discarded", "SKIP"];
const TAB_VALUES = [
"INBOX", "ALL", "EVALUATED", "APPLIED", "RESPONDED", "INTERVIEW", "OFFER", "REJECTED", "DISCARDED", "SKIP",
] as const;
const SORT_VALUES = ["company", "role", "score", "status", "date"] as const;
export type StartJobInput = {
title: string;
subtitle?: string;
kind: string;
input: string;
page?: string;
batchId?: string;
};
export type ActionCtx = {
push: (path: string) => void; // router.push — section/detail change
replace: (path: string) => void; // router.replace — incremental filter tweak
startJob: (opts: StartJobInput) => string | null;
inbox: InboxJob[];
applications: Application[]; // tracker snapshot — resolve #n → company/role for confirms
jobForUrl: (url: string) => Job | undefined; // skip-if-done / retry logic
rememberFact: (fact: string) => void;
writeStatus: (n: string, status: string) => void; // UPDATE-only writeback via /api/status
setApplyField: (idOrLabel: string, value: string) => void; // edit an apply-proxy answer
startApply: (url: string) => void; // open the apply form-proxy for a posting URL
applyExplore?: (patch: Record<string, unknown>, opts?: { merge?: boolean; run?: boolean }) => void; // build a FREE discovery search
writeProfile?: (patch: Record<string, unknown>) => void; // merge-safe config/profile.yml write
writePortals?: (roles: string[], location?: string[]) => void; // merge-safe portals.yml title_filter write
};
export type ProfilePatch = {
name?: string;
email?: string;
location?: string;
roles?: string[];
compMin?: number;
compMax?: number;
currency?: string;
remote?: string;
seniority?: string;
};
// House-style hand validation (no zod). Keeps only well-formed, confident fields.
function coerceProfile(raw: Record<string, unknown>): ProfilePatch {
const out: ProfilePatch = {};
const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
const num = (v: unknown) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : undefined);
out.name = str(raw.name);
out.email = str(raw.email);
out.location = str(raw.location);
out.currency = str(raw.currency);
out.remote = str(raw.remote);
out.seniority = str(raw.seniority);
out.compMin = num(raw.compMin);
out.compMax = num(raw.compMax);
if (Array.isArray(raw.roles)) out.roles = raw.roles.filter((r): r is string => typeof r === "string" && r.trim().length > 0).map((r) => r.trim()).slice(0, 6);
return out;
}
export type DoneInfo = { jobIds?: string[]; batchId?: string; note?: string };
export type DispatchResult =
| ({ status: "done" } & DoneInfo)
| { status: "ignored"; note?: string }
| { status: "confirm"; summary: string; run: () => DoneInfo };
// ── helpers ──────────────────────────────────────────────────────────────
const isStr = (v: unknown): v is string => typeof v === "string" && v.length > 0;
const normCompany = (s: string) => s.toLowerCase().replace(/\s+/g, " ").trim();
// Allow only in-app routes (with optional :segment and ?query). Blocks anything
// that isn't one of the app's own sections — the model can't navigate offsite.
function isAllowedPath(p: string): boolean {
if (!p.startsWith("/")) return false;
if (/^(https?:)?\/\//i.test(p)) return false;
const path = p.split(/[?#]/)[0];
if (path === "/") return true;
return /^\/(explore|pipeline|portals|analytics|cv|config|apply|jobs)(\/[^/]+)?$/.test(path);
}
function genBatchId(): string {
return `batch-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
}
// ── actions ──────────────────────────────────────────────────────────────
type ActionDef = {
sideEffect: "none" | "spend" | "write";
// parse returns typed args or null (invalid → envelope ignored)
run: (raw: Record<string, unknown>, ctx: ActionCtx) => DispatchResult;
};
const ACTIONS: Record<string, ActionDef> = {
navigate: {
sideEffect: "none",
run: (raw, ctx) => {
const path = raw.path;
if (!isStr(path) || !isAllowedPath(path)) return { status: "ignored", note: "blocked navigation" };
ctx.push(path);
return { status: "done" };
},
},
filterPipeline: {
sideEffect: "none",
run: (raw, ctx) => {
const sp = new URLSearchParams();
const tab = typeof raw.tab === "string" ? raw.tab.toUpperCase() : "";
if ((TAB_VALUES as readonly string[]).includes(tab)) sp.set("tab", tab);
const min = typeof raw.min === "number" ? raw.min : parseFloat(String(raw.min ?? ""));
if (Number.isFinite(min) && min >= 0 && min <= 5) sp.set("min", String(min));
if (isStr(raw.q)) sp.set("q", String(raw.q).slice(0, 80));
const sort = typeof raw.sort === "string" ? raw.sort : "";
if ((SORT_VALUES as readonly string[]).includes(sort)) sp.set("sort", sort);
const dir = Number(raw.dir);
if (dir === 1 || dir === -1) sp.set("dir", String(dir));
const qs = sp.toString();
ctx.replace(`/pipeline${qs ? `?${qs}` : ""}`);
return { status: "done" };
},
},
evaluate: {
sideEffect: "spend",
run: (raw, ctx) => {
const url = raw.url;
if (!isStr(url) || !/^https?:\/\//i.test(url)) return { status: "ignored", note: "invalid url" };
const ex = ctx.jobForUrl(url);
if (ex && ex.status !== "error" && !raw.rerun) return { status: "ignored", note: "already evaluated" };
const id = ctx.startJob({
title: isStr(raw.title) ? String(raw.title) : "Evaluate",
subtitle: isStr(raw.subtitle) ? String(raw.subtitle) : undefined,
kind: "evaluate",
input: url,
page: "/pipeline",
});
return { status: "done", jobIds: id ? [id] : [] };
},
},
evaluateCompany: {
sideEffect: "spend",
run: (raw, ctx) => {
const company = raw.company;
if (!isStr(company)) return { status: "ignored", note: "missing company" };
const target = normCompany(company);
const rerun = raw.rerun === true;
const cap = Number.isFinite(Number(raw.max)) ? Math.min(BATCH_CAP, Number(raw.max)) : BATCH_CAP;
// statusScope: only 'inbox' is supported (Application rows carry no URL).
const matches = ctx.inbox.filter((j) => {
if (j.done) return false;
const c = normCompany(j.company);
return c === target || c.includes(target) || target.includes(c);
});
const pending = matches
.filter((j) => {
if (rerun) return true;
const ex = ctx.jobForUrl(j.url);
return !ex || ex.status === "error";
})
.slice(0, cap);
if (pending.length === 0) {
return {
status: "ignored",
note: matches.length > 0 ? `Already evaluated every ${company} posting.` : `No pending ${company} postings in your inbox.`,
};
}
const fire = (): DoneInfo => {
const batchId = pending.length > 1 ? genBatchId() : undefined;
const ids = pending
.map((j) =>
ctx.startJob({
title: `Evaluate · ${j.company}`,
subtitle: j.role,
kind: "evaluate",
input: j.url,
page: "/pipeline",
batchId,
}),
)
.filter((x): x is string => !!x);
return { jobIds: ids, batchId };
};
if (pending.length <= AUTO_FIRE_MAX) return { status: "done", ...fire() };
return {
status: "confirm",
summary: `Evaluate ${pending.length} ${company} postings? (~${pending.length} worker${pending.length > 1 ? "s" : ""})`,
run: fire,
};
},
},
explore: {
// FREE: opens the Explorer and builds a discovery search. Zero tokens — it
// never spends, so it bypasses the confirm gate. The provider clamps/validates.
sideEffect: "none",
run: (raw, ctx) => {
if (!ctx.applyExplore) return { status: "ignored", note: "explore unavailable here" };
const run = raw.run === true;
const merge = raw.merge === true;
ctx.push("/explore");
ctx.applyExplore(raw, { merge, run });
return { status: "done", note: run ? "Scanning the ATS network for fresh roles (free)…" : "Opened Explore with your filters." };
},
},
research: {
sideEffect: "spend",
run: (raw, ctx) => {
const target = raw.target;
if (!isStr(target)) return { status: "ignored", note: "missing target" };
const id = ctx.startJob({
title: isStr(raw.title) ? String(raw.title) : "Research",
kind: "research",
input: target,
page: "/pipeline",
});
return { status: "done", jobIds: id ? [id] : [] };
},
},
generatePdf: {
sideEffect: "spend",
run: (raw, ctx) => {
const n = String(raw.n ?? "").trim();
if (!n) return { status: "ignored", note: "need an application #" };
const app = ctx.applications.find((a) => a.n === n);
const id = ctx.startJob({ title: `CV PDF · ${app?.company ?? `#${n}`}`, subtitle: "tailored CV", kind: "pdf", input: n, page: `/pipeline/${n}` });
return { status: "done", jobIds: id ? [id] : [] };
},
},
setStatus: {
sideEffect: "write",
run: (raw, ctx) => {
const n = String(raw.n ?? "").trim();
const status = String(raw.status ?? "").trim();
const canon = CANON_STATUS.find((s) => s.toLowerCase() === status.toLowerCase());
if (!n || !canon) return { status: "ignored", note: "need an application # and a canonical status" };
const app = ctx.applications.find((a) => a.n === n);
const label = app ? `${app.company} · ${app.role}` : `#${n}`;
return {
status: "confirm",
summary: `Mark ${label} → ${canon}?`,
run: () => {
ctx.writeStatus(n, canon);
return { note: `Marked #${n} as ${canon}.` };
},
};
},
},
apply: {
sideEffect: "none",
run: (raw, ctx) => {
const url = raw.url;
if (!isStr(url) || !/^https?:\/\//i.test(url)) return { status: "ignored", note: "need an application form URL" };
ctx.startApply(url);
return { status: "done", note: "Opening the application form…" };
},
},
setApplyField: {
sideEffect: "none",
run: (raw, ctx) => {
const field = (raw.field ?? raw.label) as unknown;
const value = raw.value;
if (!isStr(field) || typeof value !== "string") return { status: "ignored", note: "need a field and a value" };
ctx.setApplyField(String(field), value);
return { status: "done", note: `Updated "${field}".` };
},
},
remember: {
sideEffect: "write",
run: (raw, ctx) => {
const fact = raw.fact;
if (!isStr(fact)) return { status: "ignored" };
ctx.rememberFact(String(fact).trim());
return { status: "done" };
},
},
// Propose the user's profile → on confirm, merge-safe write to config/profile.yml
// AND seed the scanner (portals.yml title_filter) so the very first scan has roles.
// DATA_CONTRACT: deep-merge only proposed keys; never clobber archetypes/narrative.
setProfile: {
sideEffect: "write",
run: (raw, ctx) => {
if (!ctx.writeProfile) return { status: "ignored", note: "profile write unavailable here" };
const p = coerceProfile(raw);
const has = Object.values(p).some((v) => (Array.isArray(v) ? v.length : v !== undefined));
if (!has) return { status: "ignored", note: "nothing to save" };
const bits = [p.roles?.length ? `roles: ${p.roles.join(", ")}` : "", p.location ? `in ${p.location}` : "", p.compMin && p.compMax ? `comp ${p.compMin}–${p.compMax}` : ""].filter(Boolean).join(" · ");
return {
status: "confirm",
summary: `Save your profile?${bits ? ` (${bits})` : ""}`,
run: () => {
ctx.writeProfile!(p as Record<string, unknown>);
if (p.roles?.length) ctx.writePortals?.(p.roles, p.location ? [p.location] : undefined);
return { note: "Profile saved — your matches will sharpen." };
},
};
},
},
setPortals: {
sideEffect: "write",
run: (raw, ctx) => {
if (!ctx.writePortals) return { status: "ignored", note: "portals write unavailable here" };
const roles = Array.isArray(raw.roles) ? raw.roles.filter((r): r is string => typeof r === "string" && r.trim().length > 0).map((r) => r.trim()) : [];
if (roles.length === 0) return { status: "ignored", note: "no roles" };
const location = Array.isArray(raw.location) ? raw.location.filter((l): l is string => typeof l === "string") : undefined;
return {
status: "confirm",
summary: `Set your scan targets to: ${roles.join(", ")}?`,
run: () => {
ctx.writePortals!(roles, location);
return { note: "Scan targets updated." };
},
};
},
},
};
export function actionExists(id: string): boolean {
return Object.prototype.hasOwnProperty.call(ACTIONS, id);
}
export function dispatch(id: string, rawArgs: Record<string, unknown>, ctx: ActionCtx): DispatchResult {
const def = ACTIONS[id];
if (!def) return { status: "ignored", note: `unknown action: ${id}` };
try {
return def.run(rawArgs ?? {}, ctx);
} catch {
return { status: "ignored", note: `could not run ${id}` };
}
}
+113
View File
@@ -0,0 +1,113 @@
import { pipelineSummary } from "@/lib/career-ops";
import { canonStatus, scoreNum } from "@/lib/format";
export const dynamic = "force-dynamic";
const STAGES: { key: string; label: string }[] = [
{ key: "EVALUATED", label: "Evaluated" },
{ key: "APPLIED", label: "Applied" },
{ key: "RESPONDED", label: "Responded" },
{ key: "INTERVIEW", label: "Interview" },
{ key: "OFFER", label: "Offer" },
{ key: "REJECTED", label: "Rejected" },
{ key: "DISCARDED", label: "Discarded" },
];
export default function Analytics() {
const { applications } = pipelineSummary();
const total = applications.length;
const stageCounts = STAGES.map((s) => ({
...s,
n: applications.filter((a) => canonStatus(a.status).includes(s.key)).length,
}));
const maxStage = Math.max(1, ...stageCounts.map((s) => s.n));
const scores = applications.map((a) => scoreNum(a.score)).filter((n) => !Number.isNaN(n));
const avg = scores.length ? scores.reduce((a, b) => a + b, 0) / scores.length : 0;
const buckets = [
{ label: "4.5 – 5.0", test: (n: number) => n >= 4.5 },
{ label: "4.0 – 4.4", test: (n: number) => n >= 4 && n < 4.5 },
{ label: "3.0 – 3.9", test: (n: number) => n >= 3 && n < 4 },
{ label: "< 3.0", test: (n: number) => n < 3 },
].map((b) => ({ label: b.label, n: scores.filter(b.test).length }));
const maxBucket = Math.max(1, ...buckets.map((b) => b.n));
const companyCounts = new Map<string, number>();
for (const a of applications) if (a.company) companyCounts.set(a.company, (companyCounts.get(a.company) ?? 0) + 1);
const topCompanies = [...companyCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10);
const maxCompany = Math.max(1, ...topCompanies.map((c) => c[1]));
const offers = stageCounts.find((s) => s.key === "OFFER")?.n ?? 0;
const interviews = stageCounts.find((s) => s.key === "INTERVIEW")?.n ?? 0;
return (
<div className="mx-auto max-w-4xl px-6 py-10">
<h1 className="font-display text-2xl tracking-tight text-landing">Analytics</h1>
<p className="mt-1 text-sm text-muted">Across {total} tracked evaluations.</p>
{/* headline stats */}
<div className="mt-6 grid gap-4 sm:grid-cols-4">
<Stat value={total} label="evaluated" />
<Stat value={avg ? avg.toFixed(2) : "—"} label="avg score" />
<Stat value={interviews} label="interviews" />
<Stat value={offers} label="offers" />
</div>
<Section title="Pipeline by stage">
{stageCounts.map((s) => (
<Bar key={s.key} label={s.label} value={s.n} pct={(s.n / maxStage) * 100} total={total} />
))}
</Section>
<Section title="Score distribution">
{buckets.map((b) => (
<Bar key={b.label} label={b.label} value={b.n} pct={(b.n / maxBucket) * 100} total={scores.length} />
))}
</Section>
<Section title="Top companies" id="companies">
{topCompanies.map(([name, n]) => (
<Bar key={name} label={name} value={n} pct={(n / maxCompany) * 100} />
))}
</Section>
</div>
);
}
function Stat({ value, label }: { value: number | string; label: string }) {
return (
<div className="rounded-2xl border border-border bg-surface/50 p-4">
<div className="text-3xl font-semibold tabular-nums">{value}</div>
<div className="mt-1 text-xs text-faint">{label}</div>
</div>
);
}
function Section({ title, children, id }: { title: string; children: React.ReactNode; id?: string }) {
return (
<section id={id} className="mt-10 scroll-mt-8">
<h2 className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">{title}</h2>
<div className="mt-4 space-y-2.5">{children}</div>
</section>
);
}
function Bar({ label, value, pct, total }: { label: string; value: number; pct: number; total?: number }) {
const share = total && total > 0 ? Math.round((value / total) * 100) : null;
return (
<div className="flex items-center gap-3">
<div className="w-32 shrink-0 truncate text-sm text-muted">{label}</div>
<div className="relative h-7 flex-1 overflow-hidden rounded-md bg-surface">
<div
className="h-full rounded-md bg-gradient-to-r from-brand/70 to-brand/40"
style={{ width: `${Math.max(pct, value > 0 ? 4 : 0)}%` }}
/>
</div>
<div className="w-20 shrink-0 text-right text-sm tabular-nums">
{value}
{share !== null && <span className="ml-1 text-xs text-faint">{share}%</span>}
</div>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { closeSession } from "@/lib/apply/session";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Explicitly close an apply session (the user hit "new" or left the page) so we
// free the off-screen browser tab promptly instead of waiting for the prune.
export async function POST(req: Request) {
let body: { sessionId?: string };
try {
body = await req.json();
} catch {
return Response.json({ ok: false }, { status: 400 });
}
if (body.sessionId) await closeSession(body.sessionId).catch(() => {});
return Response.json({ ok: true });
}
+73
View File
@@ -0,0 +1,73 @@
import { getSession, finalizeDrivenSession, extractCurrent, isApplicationFormFn, handoffSession } from "@/lib/apply/session";
import { driveSession } from "@/lib/apply/drive";
import { classifyEmpty } from "@/lib/apply/diagnose";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 300;
// Streamed agentic drive over an OPEN session: the AI drives the headed page to
// REACH a fillable application form (the user watches each step live), then we
// extract + finalize. NEVER submits (enforced in driveSession).
export async function POST(req: Request) {
let body: { sessionId?: string; cliId?: string; goal?: "reach" | "full"; answers?: { label: string; value: string }[] };
try {
body = await req.json();
} catch {
return Response.json({ error: "bad json" }, { status: 400 });
}
const { sessionId, cliId = "", goal = "reach", answers } = body;
const s = sessionId ? getSession(sessionId) : undefined;
if (!s) return Response.json({ error: "apply session not found (it may have expired)" }, { status: 404 });
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const emit = (o: unknown) => {
try {
controller.enqueue(encoder.encode(JSON.stringify(o) + "\n"));
} catch {
/* client gone */
}
};
try {
const page = s.page;
const isFormReady = async () => {
try {
return isApplicationFormFn((await extractCurrent(page, s.url)).form);
} catch {
return false;
}
};
const budget = goal === "full" ? 16 : 7;
const result = await driveSession(page, cliId, goal, isFormReady, (step) => emit({ t: "step", ...step }), budget, answers);
if (goal === "full") {
// The agent filled the real form — bring it to the front for the human
// to review + submit themselves. We never submit.
if (result.reached) await handoffSession(s.id).catch(() => {});
emit({ t: "done", filled: result.reached, turns: result.turns, reason: result.reason });
controller.close();
return;
}
if (result.reached) {
const fin = await finalizeDrivenSession(s.id, cliId);
if (fin) {
emit({ t: "done", reached: true, turns: result.turns, title: fin.title, fields: fin.fields, issues: fin.issues });
controller.close();
return;
}
}
// Didn't reach a real form → classify why for a clear message.
const why = await classifyEmpty(page, s.url).catch(() => ({ message: "Couldn't reach a fillable form on this page." }));
emit({ t: "error", reason: result.reason, message: result.reason === "stuck" ? result.steps.at(-1)?.detail || why.message : why.message });
} catch (e) {
emit({ t: "error", message: e instanceof Error ? e.message.slice(0, 160) : "drive failed" });
} finally {
controller.close();
}
},
});
return new Response(stream, { headers: { "Content-Type": "application/x-ndjson", "Cache-Control": "no-store" } });
}
+34
View File
@@ -0,0 +1,34 @@
import { fillSession, handoffSession, getSession } from "@/lib/apply/session";
import { resolveTailoredCv, companyFromTitle } from "@/lib/apply/cv";
import type { ApplyField } from "@/lib/apply/extract";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 120;
// Fill the real form behind the scenes (headed-but-off-screen), screenshotting
// each step for the "behind the scenes" strip, then bring the window to the front
// so the HUMAN reviews and submits. NEVER submits — there is no submit path here.
export async function POST(req: Request) {
let body: { sessionId?: string; answers?: Record<string, string>; fields?: ApplyField[]; handoff?: boolean; company?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "bad json" }, { status: 400 });
}
const { sessionId, answers = {}, fields = [], handoff, company } = body;
if (!sessionId) return Response.json({ error: "sessionId required" }, { status: 400 });
// Resolve the tailored CV server-side (never trust a client path): by the
// offer's company if known, else best-effort from the form title.
const session = getSession(sessionId);
const cvPath = resolveTailoredCv(company) ?? resolveTailoredCv(companyFromTitle(session?.title)) ?? undefined;
try {
const result = await fillSession(sessionId, answers, fields, cvPath);
if (handoff) await handoffSession(sessionId).catch(() => {});
return Response.json({ ...result, handedOff: !!handoff, cvAttached: !!cvPath });
} catch (e) {
return Response.json({ error: e instanceof Error ? e.message.slice(0, 200) : "fill failed" }, { status: 500 });
}
}
+221
View File
@@ -0,0 +1,221 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { resolveCli } from "@/lib/clis";
import { careerOpsRoot, readMemory } from "@/lib/career-ops";
import { getSession } from "@/lib/apply/session";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 320;
/**
* Pull a JSON object out of an LLM's text answer, tolerating code fences,
* trailing prose, and — crucially — TRUNCATION (the planner getting killed
* mid-output on a big form). When the object is incomplete we salvage the
* largest valid prefix so the fields that DID finish still come through.
*/
function extractJsonObject(text: string): { obj: Record<string, unknown> | null; truncated: boolean } {
const s = text.replace(/```(?:json)?/gi, "");
const start = s.indexOf("{");
if (start === -1) return { obj: null, truncated: false };
let depth = 0;
let inStr = false;
let esc = false;
let end = -1;
for (let i = start; i < s.length; i++) {
const c = s[i];
if (inStr) {
if (esc) esc = false;
else if (c === "\\") esc = true;
else if (c === '"') inStr = false;
} else if (c === '"') inStr = true;
else if (c === "{") depth++;
else if (c === "}") {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
if (end !== -1) {
try {
return { obj: JSON.parse(s.slice(start, end + 1)) as Record<string, unknown>, truncated: false };
} catch {
/* malformed even though balanced — fall through to salvage */
}
}
// Truncated / unbalanced: walk back from successive commas, close the JSON,
// and parse the largest prefix that is valid.
const frag = s.slice(start);
const open = (frag.match(/{/g) || []).length;
const close = (frag.match(/}/g) || []).length;
const pad = "}".repeat(Math.max(0, open - close));
for (let tryEnd = frag.length; tryEnd > 1; ) {
const cand = frag.slice(0, tryEnd).replace(/,\s*$/, "") + pad;
try {
return { obj: JSON.parse(cand) as Record<string, unknown>, truncated: true };
} catch {
const prevComma = frag.lastIndexOf(",", tryEnd - 1);
if (prevComma <= start) break;
tryEnd = prevComma;
}
}
return { obj: null, truncated: true };
}
// AI pre-fill (STREAMING NDJSON). The user's BYO CLI (read-only PLANNER — no
// browser access) drafts an answer per field from cv.md / profile / the job's
// report. We stream a live diagnostic log of every step (spawn, heartbeats,
// exit code/signal, parse outcome) so a stuck/empty prefill is observable on the
// page AND written to <root>/.career-ops-web/apply-prefill.log for debugging.
export async function POST(req: Request) {
let body: { sessionId?: string; cliId?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "bad json" }, { status: 400 });
}
const { sessionId, cliId } = body;
const t0 = Date.now();
const encoder = new TextEncoder();
const logPath = path.join(careerOpsRoot(), ".career-ops-web", "apply-prefill.log");
try {
fs.mkdirSync(path.dirname(logPath), { recursive: true });
} catch {
/* ignore */
}
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const emit = (obj: unknown) => {
try {
controller.enqueue(encoder.encode(JSON.stringify(obj) + "\n"));
} catch {
/* client gone */
}
};
const log = (m: string) => {
const el = Date.now() - t0;
emit({ t: "log", m, el });
try {
fs.appendFileSync(logPath, `${new Date(t0 + el).toISOString()} [+${(el / 1000).toFixed(1)}s] ${m}\n`);
} catch {
/* ignore */
}
};
const fail = (m: string, raw?: string) => {
log(`ERROR: ${m}`);
emit({ t: "error", m, raw });
controller.close();
};
try {
fs.appendFileSync(logPath, `\n===== prefill ${new Date(t0).toISOString()} session=${sessionId} cli=${cliId} =====\n`);
} catch {
/* ignore */
}
const s = sessionId ? getSession(sessionId) : undefined;
if (!s) return fail("apply session not found (it may have expired)");
const resolved = cliId ? resolveCli(cliId) : null;
if (!resolved) return fail(`CLI '${cliId}' not found on this machine`);
const { spec, binPath } = resolved;
const fieldsList = s.fields
.map((f) => `${f.id}\t${f.type}${f.required ? "*" : ""}\t${f.label}${f.options ? `\t[options: ${f.options.join(" | ")}]` : ""}`)
.join("\n");
const mem = readMemory().trim();
const prompt = `You are pre-filling a job application for the user (company/role: ${s.title}). Read cv.md and config/profile.yml; if a matching report for this company exists in reports/, read it too. Ground EVERY answer in the REAL candidate — never invent facts.${mem ? `\n\nDurable notes about the user:\n${mem}` : ""}
FIELDS (id ⇥ type ⇥ label ⇥ options):
${fieldsList}
For each field give the best answer:
- identity/contact (name, email, phone, github, linkedin, location) → from profile/cv.
- free-text (Why us?, cover-letter, "most impactful thing you've built", etc.) → a concise, honest, concrete answer in the candidate's own voice (no buzzwords, active voice, real metrics only). Keep each under ~120 words.
- select/radio → choose the best-matching option using the EXACT option text from the list.
- NEVER fill legal / visa / work-authorization / salary / demographic / sensitive fields → set needs_confirmation:true and value:"".
Output ONLY a compact JSON object mapping each field id → {"value": "...", "needs_confirmation": boolean}. No prose, no markdown, no code fence.`;
log(`Form: "${s.title}" · ${s.fields.length} fields · prompt ${prompt.length} chars · memory ${mem.length} chars`);
log(`Planner: ${cliId} (${binPath})`);
const isClaude = cliId === "claude";
// --strict-mcp-config with no --mcp-config = load ZERO MCP servers → much
// faster startup (skips the user's global playwright/gmail/linear/… servers
// the planner doesn't need; it only reads local files).
const args = isClaude
? ["-p", prompt, "--permission-mode", "acceptEdits", "--strict-mcp-config", "--allowedTools", "Read,Glob,Grep", "--disallowedTools", "Bash,Write,Edit,NotebookEdit,Task,WebFetch,WebSearch"]
: spec.args(prompt);
// Scale the timeout with form size (big forms = more drafting). Cap < maxDuration.
const killMs = Math.min(300_000, 150_000 + s.fields.length * 6_000);
log(`Spawning planner (timeout ${Math.round(killMs / 1000)}s)…`);
const result = await new Promise<{ buf: string; code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
// stdin = /dev/null so the CLI doesn't wait 3s for piped input.
const child = spawn(binPath, args, { cwd: careerOpsRoot(), env: process.env, stdio: ["ignore", "pipe", "pipe"] });
let buf = "";
let firstByteAt = 0;
const hb = setInterval(() => {
log(`…running ${Math.round((Date.now() - t0) / 1000)}s · ${buf.length} chars received`);
}, 4000);
child.stdout.on("data", (d: Buffer) => {
if (!firstByteAt) {
firstByteAt = Date.now();
log(`first output byte at ${Math.round((firstByteAt - t0) / 1000)}s`);
}
buf += d.toString();
});
child.stderr.on("data", (d: Buffer) => {
const e = d.toString().trim();
if (e) log(`stderr: ${e.slice(0, 160).replace(/\s+/g, " ")}`);
});
const killer = setTimeout(() => {
log("TIMEOUT reached → SIGTERM");
try {
child.kill("SIGTERM");
} catch {
/* ignore */
}
}, killMs);
child.on("close", (code, signal) => {
clearTimeout(killer);
clearInterval(hb);
resolve({ buf, code, signal });
});
child.on("error", (e) => {
clearTimeout(killer);
clearInterval(hb);
log(`spawn error: ${e.message}`);
resolve({ buf, code: null, signal: null });
});
});
log(`Planner exited code=${result.code} signal=${result.signal} · ${result.buf.length} chars total`);
log(`output head: ${result.buf.slice(0, 100).replace(/\s+/g, " ") || "(empty)"}`);
log(`output tail: ${result.buf.slice(-100).replace(/\s+/g, " ") || "(empty)"}`);
if (!result.buf.trim()) {
return fail(result.signal ? "planner was killed before producing any output (try again / smaller form)" : "planner produced no output (check the CLI works in this folder)");
}
const { obj, truncated } = extractJsonObject(result.buf);
if (!obj) {
return fail(
result.signal ? "planner was killed mid-answer (form too large/slow) — couldn't recover any fields" : "couldn't parse the planner's answer as JSON",
result.buf.slice(-300),
);
}
const count = Object.keys(obj).length;
log(`Parsed ${count} answers${truncated ? " (RECOVERED from truncated output — some fields may be missing)" : ""}`);
emit({ t: "done", answers: obj, truncated, count });
controller.close();
},
});
return new Response(stream, { headers: { "Content-Type": "application/x-ndjson", "Cache-Control": "no-store" } });
}
+26
View File
@@ -0,0 +1,26 @@
import { openSession } from "@/lib/apply/session";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 300; // the agentic drive + interpretation fallbacks spawn a planner
// Open a persistent apply session: headed-but-off-screen Chrome opens the real
// form, we extract + tag its fields. The session stays open for fill + handoff.
// cliId enables the agentic fallback (the AI interprets the live form) when
// deterministic extraction is low-confidence.
export async function POST(req: Request) {
let body: { url?: string; cliId?: string; agent?: boolean; _noApplyBtn?: boolean };
try {
body = await req.json();
} catch {
return Response.json({ error: "bad json" }, { status: 400 });
}
const url = (body.url ?? "").trim();
if (!/^https?:\/\//i.test(url)) return Response.json({ error: "A valid application URL (https://…) is required" }, { status: 400 });
try {
const session = await openSession(url, body.cliId, body.agent, body._noApplyBtn);
return Response.json(session);
} catch (e) {
return Response.json({ error: e instanceof Error ? e.message.slice(0, 200) : "could not open the form" }, { status: 500 });
}
}
+215
View File
@@ -0,0 +1,215 @@
import { spawn } from "node:child_process";
import { resolveCli } from "@/lib/clis";
import { careerOpsRoot, readMemory, doctorState } from "@/lib/career-ops";
export const runtime = "nodejs"; // child_process (spawn) requires the Node runtime
export const dynamic = "force-dynamic";
export const maxDuration = 120;
const SYSTEM_PREAMBLE = `You are the career-ops assistant — a proactive, friendly career co-pilot for a person who is actively job-hunting. You live inside their LOCAL career-ops web dashboard (a pipeline of evaluated jobs, A–F reports, their CV, analytics) and run on their own AI CLI.
YOUR MISSION: genuinely help THIS person land a great role. Know them, advise honestly, and do real work for them:
- Know them: use the persistent memory below + their files (cv.md, config/profile.yml, reports/, data/applications.md, and past worker logs in .career-ops-web/runs/{id}.md). Read them to be concrete.
- Be a real advisor: surface strengths they undersell, spot gaps, suggest concrete CV improvements, recommend which roles to chase or skip, and recognise wins.
YOU CAN ACT — you do it by emitting ACTION ENVELOPES inside your reply. An envelope is ONE line, on its own line (never inside a code fence):
<<act:ACTION_ID {"arg":"value"}>>
The args are a single JSON object. The dashboard parses the envelope and performs the action (you won't see its output) — so just say briefly what you're doing, then emit the envelope.
ACTIONS:
- navigate {"path":"/pipeline?tab=OFFER&min=4"} — take the user to a section. Valid paths: /, /pipeline, /portals, /analytics, /cv, /config, /apply, /pipeline/{n} (a report), /jobs/{id} (a worker). The path may carry a query string.
- filterPipeline {"tab":"OFFER","min":4,"q":"text","sort":"score","dir":-1} — filter the pipeline table in place. tab ∈ INBOX, ALL, EVALUATED, APPLIED, RESPONDED, INTERVIEW, OFFER, REJECTED, DISCARDED, SKIP; min = score floor 0–5.
- evaluate {"url":"https://…","title":"Evaluate · Acme","subtitle":"Role"} — spin ONE read-only evaluation worker on a SPECIFIC posting URL. Only when you actually have a real URL (e.g. from the page the user is on).
- evaluateCompany {"company":"Anthropic"} — evaluate ALL of the user's PENDING inbox postings for that company. Emit the COMPANY NAME ONLY — never URLs; the app resolves the concrete postings itself. Big batches ask the user to confirm first.
- research {"target":"https://… or 'my portfolio'","title":"Research · X"} — spin a read-only research worker.
- generatePdf {"n":"42"} — generate an ATS-optimized CV tailored to application #42 (runs the real pdf mode → output/ + marks the tracker PDF column). Spends tokens.
- setStatus {"n":"42","status":"Applied"} — move a tracked application to a new state (asks the user to confirm first). Canonical states: Evaluated, Applied, Responded, Interview, Offer, Rejected, Discarded, SKIP. Use the application number (the "#42" on its report page).
- apply {"url":"https://…"} — open the apply form-proxy for a posting URL (we re-render the real form in plain language; the user verifies and submits it themselves — never auto-submit).
- setApplyField {"field":"Why this role?","value":"<the answer>"} — write or revise an answer in the apply form the user is filling (only when an APPLY FORM is shown in your context). Use the field's label or id. When the user asks to make an answer shorter/sharper/etc, generate the new text and emit this.
- remember {"fact":"the concise fact"} — durably remember a preference/fact about the user (carries across sessions and across whichever CLI runs).
- setProfile {"name":"…","email":"…","location":"…","roles":["AI Engineer","ML Engineer"],"compMin":70000,"compMax":95000,"currency":"EUR","remote":"Remote (EU)","seniority":"Senior"} — PROPOSE the user's profile; the app shows a confirm card and ONLY on their OK writes config/profile.yml (merge-safe — it never clobbers their other fields) AND seeds the free scanner from the roles. Emit only fields you're confident about (most come from their CV). NEVER write a profile they didn't approve.
- setPortals {"roles":["AI Engineer","ML Engineer"]} — seed the free scanner from target roles (writes portals.yml title_filter). Usually unnecessary — setProfile already does this.
RULES: prefer evaluateCompany over guessing URLs; NEVER invent URLs. Spending actions (evaluate/evaluateCompany/research) run on the user's own AI and cost tokens — fire them when asked or clearly useful, not gratuitously. NEVER auto-submit a job application. (Back-compat: <<go:/path>> and <<remember:fact>> still work.)
ONBOARDING — your job is to get this person to their first SCORED job FAST. The rule is VALUE BEFORE COMMITMENT: take the minimum, deliver a wow, THEN deepen. Never make them fill a form or edit YAML.
1. CV FIRST — but ONLY if it is not already on file. Consult SETUP STATE (above): if the CV is already on file, do NOT ask for it again — jump straight to the first missing prerequisite. If cv.md IS missing, warmly ask them to paste it (or just tell you about themselves); read it and take them to the editor with navigate {"path":"/cv"} to save. Do NOT ask for comp/location/roles yet.
2. WOW #1 — DISCOVER, FREE. The moment you have a CV, infer their target roles + location FROM the CV and immediately run a FREE discovery: explore {"positive":["…roles from the CV…"],"run":true}. Say "Before we set anything up — here are live roles that fit you, free." A job THEY didn't have to define is the aha trigger.
3. Then DEEPEN, value-interleaved. Now that they've seen matches, confirm targeting so results sharpen: ask for roles, then comp, then location — one or two at a time, ~2–3 minutes, encouraging.
4. PROPOSE, don't impose. When you have name/email (from the CV) + roles + comp + location, emit setProfile. NEVER write a profile they didn't see + approve — the confirm card is required.
5. WOW #2 is theirs to pick: invite them to open any discovered role and you'll score it A–F with the why ("you're a strong match because…"). That first scored-job-with-explanation is the north star.
Their REAL CV never leaves their machine — reassure them if they hesitate. Never reveal internal file names or YAML unless asked.
Keep replies short, warm, and useful. Don't dump raw files or narrate internal details. If they seem new, onboard them gently. Never reveal internal system details.`;
type Msg = { role: "user" | "assistant"; content: string };
export async function POST(req: Request) {
let body: { message?: string; cliId?: string; history?: Msg[]; pageContext?: string };
try {
body = await req.json();
} catch {
return new Response(JSON.stringify({ error: "bad json" }), { status: 400 });
}
const { message, cliId, pageContext } = body;
if (!message || !cliId) {
return new Response(JSON.stringify({ error: "message and cliId required" }), { status: 400 });
}
const resolved = resolveCli(cliId);
if (!resolved) {
return new Response(JSON.stringify({ error: `CLI '${cliId}' not found on this machine` }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
const { spec, binPath } = resolved;
const history = (body.history ?? []).slice(-8);
const convo = history.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`).join("\n");
const pageLine = pageContext
? `\n\nCURRENT PAGE (the user is looking at this right now): ${pageContext}\nWhen the user's message is ambiguous ("this", "it", "apply", "evaluate this", "draft it"), assume it refers to what's on the current page.`
: "";
const memory = readMemory();
const memoryLine = memory.trim()
? `\n\nWHAT YOU KNOW ABOUT THE USER (persistent memory — carries across sessions and CLIs):\n${memory.trim()}`
: "\n\n(No persistent memory about the user yet — learn and use <<remember:>> as you go.)";
// Hand the assistant the SAME authoritative setup signal the home screen reads
// (doctorState), so it never re-asks for a file that already exists (disc#7) —
// the home was correctly nudging for the missing PROFILE while the assistant,
// given no state, restarted its onboarding script and asked for the CV again.
const { hasCv, onboardingNeeded, missing } = doctorState();
const setupLine = onboardingNeeded
? `\n\nSETUP STATE (authoritative — the SAME signal the home screen uses; trust it over guessing, and do NOT re-ask for anything already on file):\n- CV on file (cv.md): ${hasCv ? "YES — do NOT ask for it again; read it to be concrete" : "NO — this is the first thing to collect"}\n- Still missing: ${missing.length ? missing.join(", ") : "nothing"}\nWhen onboarding, START at the first item actually missing. If the CV is already on file, SKIP step 1 entirely and go straight to the next missing prerequisite (usually the profile — target roles, comp, location).`
: `\n\nSETUP STATE: this user is fully set up (CV + profile + scanner all on file). Do NOT run onboarding or ask for a CV — just help them with what they actually asked.`;
const prompt = `${SYSTEM_PREAMBLE}${setupLine}${memoryLine}${pageLine}\n\n--- Conversation ---\n${convo}\nUser: ${message}\nAssistant:`;
// Claude Code streams token-level deltas via stream-json + partial messages.
// Other CLIs: pass their stdout through raw.
// The chat CLI is READ-ONLY: all writes go through gated registry actions
// (remember → /api/memory, setStatus → /api/status), never the CLI editing
// files directly. Scope its tools so it can advise (read) but not blind-write.
const isClaude = cliId === "claude";
// allowedTools must be COMMA-separated; disallowedTools is the hard guardrail
// so the advisor can read (and WebFetch) but never blind-writes or shells out.
const args = isClaude
? [
"-p",
prompt,
"--output-format",
"stream-json",
"--verbose",
"--include-partial-messages",
"--permission-mode",
"acceptEdits",
"--allowedTools",
"Read,WebFetch,Glob,Grep",
"--disallowedTools",
"Bash,Write,Edit,NotebookEdit,Task",
]
: spec.args(prompt);
const child = spawn(binPath, args, { cwd: careerOpsRoot(), env: process.env });
const encoder = new TextEncoder();
// `closed` + kill timer in the OUTER scope so cancel() can flip `closed` before
// the child's late handlers run — otherwise they enqueue onto an already-closed
// controller and throw an uncaught "Controller is already closed" (see #1155).
let closed = false;
let killer: ReturnType<typeof setTimeout> | undefined;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let buf = "";
let emitted = false;
killer = setTimeout(() => {
try {
child.kill("SIGTERM");
} catch {
/* ignore */
}
}, 90_000);
const safeClose = () => {
if (!closed) {
closed = true;
if (killer) clearTimeout(killer);
try {
controller.close();
} catch {
/* already closed */
}
}
};
const safeEnqueue = (s: string): boolean => {
if (closed || !s) return false;
try {
controller.enqueue(encoder.encode(s));
return true;
} catch {
closed = true; // controller already closed underneath us — stop, never crash
return false;
}
};
const emit = (s: string) => {
if (safeEnqueue(s)) emitted = true;
};
child.stdout.on("data", (d: Buffer) => {
if (closed) return;
if (!isClaude) {
emit(d.toString());
return;
}
// line-buffered NDJSON → emit only assistant text deltas
buf += d.toString();
let nl: number;
while ((nl = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
try {
const obj = JSON.parse(line);
if (obj.type === "stream_event" && obj.event?.type === "content_block_delta") {
const text = obj.event.delta?.text;
if (typeof text === "string") emit(text);
}
} catch {
/* partial / non-json line — skip */
}
}
});
child.stderr.on("data", (d: Buffer) => {
const s = d.toString();
if (/error|not found|denied|fatal/i.test(s)) {
safeEnqueue(`\n[${spec.name}] ${s.trim()}\n`);
}
});
child.on("error", (e) => {
safeEnqueue(`\n[error launching ${spec.name}: ${e.message}]`);
safeClose();
});
child.on("close", () => {
if (!emitted) {
safeEnqueue("_(no output — is the CLI authenticated?)_");
}
safeClose();
});
},
cancel() {
closed = true;
if (killer) clearTimeout(killer);
try {
child.kill("SIGTERM");
} catch {
/* ignore */
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
});
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { detectClis } from "@/lib/clis";
export const dynamic = "force-dynamic";
// Detects which agnostic CLIs are installed on THIS machine (local-first). The
// web delegates career-ops to one of these in headless mode, on the user's own
// auth/tokens — no API key needed.
export async function GET() {
return NextResponse.json({ clis: detectClis() });
}
+45
View File
@@ -0,0 +1,45 @@
import { NextRequest } from "next/server";
import fs from "node:fs";
import path from "node:path";
import { careerOpsRoot } from "@/lib/career-ops";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Serve the tailored CV PDF the pdf mode wrote to output/cv-…-{company}-…pdf for
// a given offer (matched by company slug, newest first). Inline so it opens in
// the browser. Local-first: reads the user's own output/ dir.
export async function GET(req: NextRequest) {
const company = (req.nextUrl.searchParams.get("company") ?? "").trim();
if (!company) return new Response("company required", { status: 400 });
// Token-extract instead of replace-then-trim: same slug, and no `-+$`-style
// pattern that backtracks polynomially on adversarial input (CodeQL).
const slug = (company.toLowerCase().match(/[a-z0-9]+/g) ?? []).join("-");
const dir = path.join(careerOpsRoot(), "output");
// Match the slug at a token boundary (delimited by non-alphanumerics) so "Meta"
// doesn't serve "Metabase"'s tailored CV. The pdf mode names files cv-…-{slug}-….
const re = new RegExp(`(^|[^a-z0-9])${slug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z0-9]|$)`, "i");
let files: string[];
try {
files = fs
.readdirSync(dir)
.filter((f) => f.toLowerCase().endsWith(".pdf"))
.filter((f) => re.test(f.toLowerCase()));
} catch {
return new Response("no output directory", { status: 404 });
}
if (!files.length) return new Response("no tailored CV found for this offer", { status: 404 });
files.sort((a, b) => fs.statSync(path.join(dir, b)).mtimeMs - fs.statSync(path.join(dir, a)).mtimeMs);
const file = path.join(dir, files[0]);
try {
const buf = fs.readFileSync(file);
return new Response(new Uint8Array(buf), {
status: 200,
headers: { "Content-Type": "application/pdf", "Content-Disposition": `inline; filename="${files[0]}"`, "Cache-Control": "no-store" },
});
} catch {
return new Response("could not read the PDF", { status: 500 });
}
}
+234
View File
@@ -0,0 +1,234 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveCli } from "@/lib/clis";
import { careerOpsRoot } from "@/lib/career-ops";
// Parse a CV (pasted text or an uploaded PDF) into clean cv.md markdown by running
// the USER'S OWN CLI headless — the web never ships a heavyweight parser, and the
// real CV NEVER leaves the machine (local-first, PII-safe). This route is a
// PROPOSER: it produces candidate markdown only; the actual write to cv.md happens
// via the existing POST /api/cv after the user confirms (propose-then-confirm).
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 300;
// Prefer the CANONICAL core mode (single source of truth — CLI + web parse CVs
// identically); fall back to the inline prompt until modes/cv-ingest.md lands
// (exactly how the explore route handles a missing discover.md).
function readCanonicalMode(): string | null {
try {
return fs.readFileSync(path.join(careerOpsRoot(), "modes", "cv-ingest.md"), "utf8");
} catch {
return null;
}
}
function ingestPrompt(source: string): string {
const mode = readCanonicalMode();
if (mode) {
return `${mode}\n\n--- HEADLESS OUTPUT CONTRACT (the career-ops WEB is parsing your stream) ---\nFollow the mode above exactly. You are a PROPOSER running headless: emit ONLY the markdown between <<cv:start>> and <<cv:end>> (own lines, never in a code fence), then one <<cv:seed>>{...} line; or <<cv:error>>{"reason":"unreadable"} if you can't read it. Narrate one short line before <<cv:start>>.\n\n${source}`;
}
// Fallback mirrors the canonical examples/cv-example.md format (the SSOT the
// project ships) so a web-parsed CV is the same shape as a hand-written one.
return `You convert a person's CV into clean cv.md markdown that EXACTLY mirrors career-ops's reference format.
FORMAT (match exactly; omit a section if the source lacks it; INVENT NOTHING):
\`# CV -- {Full Name}\`
then bold contact lines directly under the title (no "Contact" section):
\`**Location:** …\` / \`**Email:** …\` / \`**LinkedIn:** …\` / \`**Portfolio:** …\` / \`**GitHub:** …\`
\`## Professional Summary\` — a 2-4 line summary, only from facts present.
\`## Work Experience\` — each role as: \`### {Company} -- {Location}\`, then \`**{Job Title}**\` on its own line, then \`{Start}-{End or Present}\` on its own line, then bullet points (preserve EVERY quantified achievement verbatim).
\`## Projects\` — flat bullets: \`- **{Name}** ({type}) -- {what + hero metric}\`.
\`## Education\` — flat bullets: \`- {Degree}, {Institution} ({year})\`.
\`## Skills\` — grouped bullets: \`- **{Category}:** {comma list}\`.
Use \`--\` (double hyphen), NEVER an em dash (ATS rule). Preserve every company/title/date/metric. Clean, don't rewrite — it's THEIR CV.
OUTPUT PROTOCOL:
- You are a PROPOSER: do NOT write any file. Emit ONLY the markdown wrapped EXACTLY between a line \`<<cv:start>>\` and a line \`<<cv:end>>\` (each on its own line, never inside a code fence).
- After \`<<cv:end>>\`, emit ONE more line: \`<<cv:seed>>{"title":"<their current/target role>","roles":["<3-5 role keywords>"],"location":"<their location or 'Remote'>"}\`
- If the source is unreadable or empty, emit ONLY: \`<<cv:error>>{"reason":"unreadable"}\` and stop.
- Narrate one short line BEFORE \`<<cv:start>>\` (e.g. "Reading your CV…").
${source}`;
}
const TEXT_SRC = (t: string) => `SOURCE (the user's CV, pasted as text — convert it):\n"""\n${t.slice(0, 24000)}\n"""`;
const FILE_SRC = (p: string) => `SOURCE: the user's CV is the file at this local path — READ it with your file/Read tool, then convert it:\n${p}`;
export async function POST(req: Request) {
const ctype = req.headers.get("content-type") || "";
let cliId = "";
let promptSource = "";
let tempFile: string | null = null;
try {
if (ctype.includes("application/json")) {
const body = (await req.json()) as { text?: string; cliId?: string };
cliId = body.cliId || "";
const text = (body.text || "").trim();
if (!text) return Response.json({ error: "empty cv text" }, { status: 400 });
promptSource = TEXT_SRC(text);
} else if (ctype.includes("multipart/form-data")) {
const form = await req.formData();
cliId = String(form.get("cliId") || "");
const file = form.get("file");
if (!(file instanceof File)) return Response.json({ error: "no file" }, { status: 400 });
// Reading a PDF/DOCX from a path needs the CLI's file tool, which only Claude
// is granted here. Tell non-Claude users plainly instead of failing opaquely.
if (cliId !== "claude" && /\.(pdf|docx)$/i.test(file.name)) {
return Response.json({ error: "PDF upload needs Claude Code — paste your CV text instead." }, { status: 400 });
}
const ext = (file.name.match(/\.[a-z0-9]+$/i)?.[0] || ".pdf").toLowerCase();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "career-ops-cv-"));
tempFile = path.join(dir, `cv${ext}`); // outside the repo, basename-only
fs.writeFileSync(tempFile, Buffer.from(await file.arrayBuffer()), { mode: 0o600 }); // PII → owner-only
promptSource = FILE_SRC(tempFile);
} else {
return Response.json({ error: "unsupported content-type" }, { status: 400 });
}
} catch {
return Response.json({ error: "bad request" }, { status: 400 });
}
const resolved = resolveCli(cliId);
if (!resolved) {
if (tempFile) cleanupTemp(tempFile);
return Response.json({ error: `CLI '${cliId}' not found on this machine` }, { status: 404 });
}
const { spec, binPath } = resolved;
const prompt = ingestPrompt(promptSource);
const isClaude = cliId === "claude";
const args = isClaude
? [
"-p",
prompt,
"--output-format",
"stream-json",
"--verbose",
"--include-partial-messages",
"--permission-mode",
"acceptEdits",
"--allowedTools",
"Read,Glob,Grep", // read the temp PDF; CANNOT write/edit/shell (proposer)
"--disallowedTools",
"Bash,Write,Edit,NotebookEdit,Task,WebFetch,WebSearch",
]
: spec.args(prompt);
let child;
try {
child = spawn(binPath, args, { cwd: careerOpsRoot(), env: process.env });
} catch (e) {
if (tempFile) cleanupTemp(tempFile); // never leak the CV temp if spawn throws sync
return Response.json({ error: e instanceof Error ? e.message : "failed to start the CLI" }, { status: 500 });
}
const encoder = new TextEncoder();
// The `closed` flag + kill timer live in the OUTER scope so the ReadableStream
// `cancel()` callback (fired on client disconnect / response teardown) can flip
// `closed` BEFORE the child's late close/error/stderr handlers run — otherwise
// they enqueue onto an already-closed controller and throw an UNCAUGHT
// "Invalid state: Controller is already closed" that crashes the server (#1155).
let closed = false;
let killer: ReturnType<typeof setTimeout> | undefined;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let buf = "";
let emitted = false;
killer = setTimeout(() => {
try {
child.kill("SIGTERM");
} catch {
/* ignore */
}
}, 240_000);
const safeClose = () => {
if (!closed) {
closed = true;
if (killer) clearTimeout(killer);
if (tempFile) cleanupTemp(tempFile);
try {
controller.close();
} catch {
/* already closed */
}
}
};
// Every write goes through here: guarded on `closed` AND try/catch'd, so a
// lost race with cancel()/close can never throw out of an EventEmitter cb.
const safeEnqueue = (s: string): boolean => {
if (closed || !s) return false;
try {
controller.enqueue(encoder.encode(s));
return true;
} catch {
closed = true; // controller already closed underneath us — stop, never crash
return false;
}
};
const emit = (s: string) => {
if (safeEnqueue(s)) emitted = true;
};
child.stdout.on("data", (d: Buffer) => {
if (closed) return;
if (!isClaude) {
emit(d.toString());
return;
}
buf += d.toString();
let nl: number;
while ((nl = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
try {
const obj = JSON.parse(line);
if (obj.type === "stream_event" && obj.event?.type === "content_block_delta") {
const text = obj.event.delta?.text;
if (typeof text === "string") emit(text);
}
} catch {
/* partial / non-json line */
}
}
});
child.stderr.on("data", (d: Buffer) => {
const s = d.toString();
if (/error|not found|denied|fatal/i.test(s)) safeEnqueue(`\n[${spec.name}] ${s.trim()}\n`);
});
child.on("error", (e) => {
safeEnqueue(`\n[error launching ${spec.name}: ${e.message}]`);
safeClose();
});
child.on("close", () => {
if (!emitted) safeEnqueue("<<cv:error>>{\"reason\":\"no-output\"}");
safeClose();
});
},
cancel() {
closed = true; // a consumer teardown must stop the child handlers from enqueuing
if (killer) clearTimeout(killer);
try {
child.kill("SIGTERM");
} catch {
/* ignore */
}
if (tempFile) cleanupTemp(tempFile);
},
});
return new Response(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no" },
});
}
function cleanupTemp(file: string) {
try {
fs.rmSync(path.dirname(file), { recursive: true, force: true });
} catch {
/* best-effort */
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import fs from "node:fs";
import path from "node:path";
import { careerOpsRoot } from "@/lib/career-ops";
import { atomicWriteWithBackup } from "@/lib/core/safe-write";
function cvPath() {
return path.join(careerOpsRoot(), "cv.md");
}
const MAX_CV_BYTES = 200_000;
export async function GET() {
try {
return NextResponse.json({ content: fs.readFileSync(cvPath(), "utf8"), exists: true });
} catch {
return NextResponse.json({ content: "", exists: false });
}
}
export async function POST(req: Request) {
let body: { content?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "bad json" }, { status: 400 });
}
if (typeof body.content !== "string") {
return NextResponse.json({ error: "content required" }, { status: 400 });
}
if (Buffer.byteLength(body.content, "utf8") > MAX_CV_BYTES) {
return NextResponse.json({ error: "CV is too large (over 200KB)" }, { status: 413 });
}
// DATA_CONTRACT: cv.md is user-layer and gitignored (no git recovery). Never
// blind-overwrite — snapshot the prior CV to a .bak first, write atomically.
try {
const bak = atomicWriteWithBackup(cvPath(), body.content);
return NextResponse.json({ ok: true, backedUp: !!bak });
} catch {
return NextResponse.json({ error: "write failed" }, { status: 500 });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { execFile } from "node:child_process";
import fs from "node:fs";
import { careerOpsRoot, rootScript } from "@/lib/career-ops";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Orchestrates the core's own cold-start check (doctor.mjs --json) — the SAME
// source of truth the CLI uses to decide onboarding. We never reimplement the
// prerequisite list; we read the core's verdict.
export async function GET() {
const root = careerOpsRoot();
const doctor = rootScript("doctor");
if (!fs.existsSync(doctor)) {
return Response.json({ available: false, onboardingNeeded: false, missing: [], warnings: [] });
}
const stdout = await new Promise<string>((resolve) => {
execFile("node", [doctor, "--json"], { cwd: root, timeout: 10_000 }, (_err, out) => resolve(out || ""));
});
try {
const last = stdout.trim().split("\n").pop() || "{}";
const j = JSON.parse(last);
return Response.json({ available: true, onboardingNeeded: !!j.onboardingNeeded, missing: j.missing ?? [], warnings: j.warnings ?? [] });
} catch {
return Response.json({ available: false, onboardingNeeded: false, missing: [], warnings: [] });
}
}
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest } from "next/server";
import { addOffersToPipeline } from "@/lib/core/pipeline";
import type { DiscoveredOffer } from "@/lib/explore";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Free + reversible: append chosen discovered offers to data/pipeline.md AND
// record them in data/scan-history.tsv, via the core's CANONICAL exported writers
// (no parallel writer). No tokens spent.
export async function POST(req: NextRequest) {
let offers: DiscoveredOffer[] = [];
try {
const body = (await req.json()) as { offers?: DiscoveredOffer[] };
offers = Array.isArray(body.offers) ? body.offers : [];
} catch {
return Response.json({ added: 0, error: "bad request" }, { status: 400 });
}
if (offers.length === 0) return Response.json({ added: 0 });
const result = await addOffersToPipeline(offers);
return Response.json(result);
}
+16
View File
@@ -0,0 +1,16 @@
import { assembleDedupContext } from "@/lib/core/discover";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// The client fetches this once before opening the AI stream and uses the set as a
// silent dedup backstop in the envelope parser (drops any AI candidate whose URL
// is already known). Keeps the stream itself pure text/plain.
export async function GET() {
try {
const { urls } = assembleDedupContext();
return Response.json({ urls: [...urls] });
} catch {
return Response.json({ urls: [] });
}
}
+178
View File
@@ -0,0 +1,178 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { resolveCli } from "@/lib/clis";
import { careerOpsRoot, readMemory } from "@/lib/career-ops";
import { assembleDedupContext } from "@/lib/core/discover";
// AI search orchestrates modes/discover.md by running the USER'S configured CLI
// headless (CLI-agnostic, like the assistant). Web hunting is slow → generous
// budget. The agent is a PROPOSER: Write/Edit/Bash are disabled so it structurally
// cannot persist; the only writes happen when the user later ADDs a candidate.
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 600;
const OUTPUT_CONTRACT = `
--- OUTPUT CONTRACT (the career-ops WEB is parsing your stream) ---
Follow modes/discover.md exactly. You are running headless for the web:
- You are a PROPOSER — never write a file (Write/Edit/Bash are disabled).
- Emit each candidate as ONE line, never inside a code fence:
<<offer:{"url":"…","title":"…","company":"…","location":"…","source":"ai-search","why":"…","postedHint":"…","ats":"…","verification":"unconfirmed"}>>
Valid JSON, one per line, the moment you're confident — stream them as you go.
- Between envelopes, narrate briefly (plain text) what you're searching — shown live as your reasoning.
- Be frugal (~3–6 searches, stop at a strong set). EVERY candidate is UNVERIFIED.
- Be a GENEROUS FINDER, not a judge: when a constraint (location, seniority, stage) can't be confirmed from the shallow signal, INCLUDE + flag the uncertainty in "why" — don't discard. NEVER score or judge fit; the A–F evaluation does that later, with the full JD.
- DEDUP: skip anything already known below; don't re-propose the user's existing companies.
`;
export async function POST(req: Request) {
let body: { query?: string; cliId?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "bad json" }, { status: 400 });
}
const query = (body.query || "").trim();
const cliId = body.cliId;
if (!query || !cliId) return Response.json({ error: "query and cliId required" }, { status: 400 });
const resolved = resolveCli(cliId);
if (!resolved) return Response.json({ error: `CLI '${cliId}' not found on this machine` }, { status: 404 });
const { spec, binPath } = resolved;
// Read the CANONICAL mode at request time — single source of truth, never a
// homegrown prompt. Missing (older core) → graceful 400 so the Scan tab stays usable.
let mode: string;
try {
mode = fs.readFileSync(path.join(careerOpsRoot(), "modes", "discover.md"), "utf8");
} catch {
return Response.json({ code: "MODE_MISSING", error: "AI search needs a newer career-ops — update to enable it." }, { status: 400 });
}
const { lines } = assembleDedupContext();
const memory = readMemory();
const memoryLine = memory.trim() ? `\n\nWHAT YOU KNOW ABOUT THE USER (persistent memory):\n${memory.trim()}` : "";
const knownBlock = lines.length ? `\n\n--- ALREADY KNOWN (dedup — do NOT propose these) ---\n${lines.join("\n")}` : "";
const prompt = `${mode}${OUTPUT_CONTRACT}${memoryLine}${knownBlock}\n\n--- USER INTENT ---\n${query}\n`;
const isClaude = cliId === "claude";
const args = isClaude
? [
"-p",
prompt,
"--output-format",
"stream-json",
"--verbose",
"--include-partial-messages",
"--permission-mode",
"acceptEdits",
"--allowedTools",
"Read,WebFetch,WebSearch,Glob,Grep", // WebSearch ADDED vs the read-only assistant
"--disallowedTools",
"Bash,Write,Edit,NotebookEdit,Task", // proposer-not-writer, by construction
]
: spec.args(prompt);
const child = spawn(binPath, args, { cwd: careerOpsRoot(), env: process.env });
const encoder = new TextEncoder();
// `closed` + kill timer in the OUTER scope so cancel() can flip `closed` before
// the child's late handlers run — otherwise they enqueue onto an already-closed
// controller and throw an uncaught "Controller is already closed" (see #1155).
let closed = false;
let killer: ReturnType<typeof setTimeout> | undefined;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let buf = "";
let emitted = false;
killer = setTimeout(() => {
try {
child.kill("SIGTERM");
} catch {
/* ignore */
}
}, 480_000);
const safeClose = () => {
if (!closed) {
closed = true;
if (killer) clearTimeout(killer);
try {
controller.close();
} catch {
/* already closed */
}
}
};
const safeEnqueue = (s: string): boolean => {
if (closed || !s) return false;
try {
controller.enqueue(encoder.encode(s));
return true;
} catch {
closed = true; // controller already closed underneath us — stop, never crash
return false;
}
};
const emit = (s: string) => {
if (safeEnqueue(s)) emitted = true;
};
child.stdout.on("data", (d: Buffer) => {
if (closed) return;
if (!isClaude) {
emit(d.toString());
return;
}
buf += d.toString();
let nl: number;
while ((nl = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
try {
const obj = JSON.parse(line);
if (obj.type === "stream_event" && obj.event?.type === "content_block_delta") {
const text = obj.event.delta?.text;
if (typeof text === "string") emit(text);
}
} catch {
/* partial / non-json line — skip */
}
}
});
child.stderr.on("data", (d: Buffer) => {
const s = d.toString();
if (/error|not found|denied|fatal/i.test(s)) {
safeEnqueue(`\n[${spec.name}] ${s.trim()}\n`);
}
});
child.on("error", (e) => {
safeEnqueue(`\n[error launching ${spec.name}: ${e.message}]`);
safeClose();
});
child.on("close", () => {
if (!emitted) safeEnqueue("_(no output — is the CLI authenticated?)_");
safeClose();
});
},
cancel() {
closed = true;
if (killer) clearTimeout(killer);
try {
child.kill("SIGTERM");
} catch {
/* ignore */
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
});
}
+60
View File
@@ -0,0 +1,60 @@
import { NextRequest } from "next/server";
import fs from "node:fs";
import { runDiscovery } from "@/lib/core/scan";
import { rootScript } from "@/lib/career-ops";
import { parseExplorePatch, DEFAULT_FILTERS, type DiscoveredOffer, type ScanEvent } from "@/lib/explore";
// Discovery is HTTP-bound across many ATS boards; give it room. It is FREE —
// zero LLM tokens (the scanner only does HTTP + JSON, and --dry-run writes nothing).
export const runtime = "nodejs";
export const maxDuration = 300;
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
let body: Record<string, unknown> = {};
try {
body = (await req.json()) as Record<string, unknown>;
} catch {
/* empty body → defaults */
}
const filters = parseExplorePatch(body, DEFAULT_FILTERS);
// Guard: a data-only checkout (or pre-onboarding) has no scanner. Fail soft.
if (!fs.existsSync(rootScript("scan-ats-full"))) {
return Response.json(
{ error: "The discovery scanner isn't available in this checkout yet." },
{ status: 400 },
);
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (obj: unknown) => {
try {
controller.enqueue(encoder.encode(JSON.stringify(obj) + "\n"));
} catch {
/* stream closed */
}
};
send({ kind: "start", ats: filters.ats, sinceDays: filters.sinceDays, limit: filters.limitPerAts, free: true } satisfies ScanEvent);
let offers: DiscoveredOffer[] = [];
try {
offers = await runDiscovery(filters, (e: ScanEvent) => send(e));
} catch (err) {
send({ kind: "error", message: err instanceof Error ? err.message : "discovery failed" } satisfies ScanEvent);
}
send({ kind: "done", count: offers.length, offers, cost: { tokens: 0, usd: 0 } } satisfies ScanEvent);
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "application/x-ndjson; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
});
}
+34
View File
@@ -0,0 +1,34 @@
import fs from "node:fs";
import path from "node:path";
import { careerOpsRoot } from "@/lib/career-ops";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Append-only follow-up log → data/follow-ups.md (NEVER clobber; the cadence
// calculator reads this to advance the schedule). Mirrors how the CLI records a
// follow-up. One dated line per logged follow-up.
export async function POST(req: Request) {
let body: { num?: string | number; company?: string; note?: string };
try {
body = (await req.json()) as { num?: string | number; company?: string; note?: string };
} catch {
return Response.json({ error: "bad json" }, { status: 400 });
}
const company = (body.company || "").toString().trim();
if (!company) return Response.json({ error: "company required" }, { status: 400 });
const today = new Date().toISOString().slice(0, 10);
const num = body.num != null ? `#${body.num} ` : "";
const note = (body.note || "Followed up").toString().replace(/[\r\n]+/g, " ").trim();
const line = `- ${today} · ${num}${company} — ${note}\n`;
const file = path.join(careerOpsRoot(), "data", "follow-ups.md");
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
if (!fs.existsSync(file)) fs.writeFileSync(file, "# Follow-ups\n\n", "utf8");
fs.appendFileSync(file, line, "utf8");
} catch (e) {
return Response.json({ error: e instanceof Error ? e.message : "write failed" }, { status: 500 });
}
return Response.json({ ok: true });
}
+28
View File
@@ -0,0 +1,28 @@
import { execFile } from "node:child_process";
import fs from "node:fs";
import { careerOpsRoot, rootScript } from "@/lib/career-ops";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// The DEMAND loop: surface follow-ups due, via the core's own
// followup-cadence.mjs --json (the SAME calculator the CLI uses) — we never
// reimplement the cadence logic, we read its verdict (mirrors /api/doctor).
export async function GET() {
const script = rootScript("followup-cadence");
if (!fs.existsSync(script)) return Response.json({ available: false, metadata: null, entries: [] });
const stdout = await new Promise<string>((resolve) => {
execFile("node", [script, "--json"], { cwd: careerOpsRoot(), timeout: 12_000 }, (_e, out) => resolve(out || ""));
});
try {
const start = stdout.indexOf("{");
const j = JSON.parse(stdout.slice(start));
const entries = Array.isArray(j.entries) ? j.entries : [];
// Overdue first; cap for the home (full list lives in the tracker).
const overdue = entries.filter((e: { status?: string }) => /overdue|urgent/i.test(String(e.status))).slice(0, 8);
const top = (overdue.length ? overdue : entries).slice(0, 6);
return Response.json({ available: true, metadata: j.metadata ?? null, entries: top });
} catch {
return Response.json({ available: false, metadata: null, entries: [] });
}
}
+108
View File
@@ -0,0 +1,108 @@
import { NextRequest } from "next/server";
import fs from "node:fs/promises";
import path from "node:path";
import { careerOpsRoot } from "@/lib/career-ops";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Localhost logo proxy + on-disk cache, FOREVER per key. Honors local-first: the
// browser never talks to Google directly. Accepts `?domain=` (exact) OR
// `?company=` (a name → we guess a handful of likely domains and resolve once).
// Each key is fetched at most once ever, then served from .career-ops-web/logo-cache
// (a hit, or an empty sentinel for a known miss). On any miss → 404 so the
// client's <img onError> falls back to the offline monogram. Because the cache is
// keyed by company, once a company's logo resolves it's instant for that card AND
// every other card (this search or any future one), forever.
const DOMAIN_RE = /^[a-z0-9.-]{1,253}\.[a-z]{2,}$/i;
function cacheDir(): string {
return path.join(careerOpsRoot(), ".career-ops-web", "logo-cache");
}
/** Plausible domains for a company name, cheapest/likeliest first. */
function companyDomains(company: string): string[] {
const paren = company.match(/\(([A-Za-z0-9]{2,12})\)/)?.[1]; // "… (5WPR)"
// [^()] (not [^)]) keeps the match unambiguous — no polynomial backtracking on
// adversarial inputs full of unclosed parens (CodeQL js/polynomial-redos).
const base = company.replace(/\([^()]*\)/g, "").trim();
const compact = base.toLowerCase().replace(/&/g, "and").replace(/[^a-z0-9]/g, "");
const firstWord = base.toLowerCase().split(/\s+/)[0].replace(/[^a-z0-9]/g, "");
const stems = [...new Set([compact, paren?.toLowerCase(), firstWord].filter((s): s is string => !!s && s.length >= 2 && s.length <= 30))];
const out: string[] = [];
for (const t of [".com", ".ai", ".io", ".co"]) for (const s of stems) out.push(s + t);
return out.slice(0, 5);
}
/** Fetch a real favicon for one domain (Google's tokenless service). Returns the
* bytes, or null for a miss (Google serves a tiny globe placeholder for misses). */
async function fetchFavicon(domain: string): Promise<ArrayBuffer | null> {
try {
const res = await fetch(`https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`, {
headers: { Accept: "image/*" },
signal: AbortSignal.timeout(3500),
redirect: "follow",
});
if (!res.ok) return null;
const ab = await res.arrayBuffer();
return ab.byteLength > 220 ? ab : null;
} catch {
return null;
}
}
export async function GET(req: NextRequest) {
const sp = req.nextUrl.searchParams;
const domain = (sp.get("domain") ?? "").trim().toLowerCase();
const company = (sp.get("company") ?? "").trim();
let key: string;
let candidates: string[];
if (domain) {
if (!DOMAIN_RE.test(domain) || domain.includes("..")) return new Response("bad domain", { status: 400 });
key = domain.replace(/[^a-z0-9.-]/g, "_");
candidates = [domain];
} else if (company) {
const slug = company.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 40);
if (!slug) return new Response("bad company", { status: 400 });
key = `co_${slug}`;
candidates = companyDomains(company);
if (candidates.length === 0) return new Response("no logo", { status: 404 });
} else {
return new Response("need domain or company", { status: 400 });
}
// `key` is already sanitized above, but enforce containment anyway: a cache
// path must never resolve outside the cache dir (defense in depth).
const file = path.resolve(cacheDir(), `${key}.png`);
if (!file.startsWith(path.resolve(cacheDir()) + path.sep)) return new Response("bad key", { status: 400 });
// 1) serve from disk cache forever (hit, or empty sentinel = known miss)
try {
const buf = await fs.readFile(file);
if (buf.byteLength > 0) {
return new Response(new Uint8Array(buf), { status: 200, headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=604800" } });
}
return new Response("no logo", { status: 404 });
} catch {
/* not cached yet → resolve below */
}
// 2) resolve once: first candidate domain that yields a real favicon wins
let bytes: ArrayBuffer | null = null;
for (const d of candidates) {
bytes = await fetchFavicon(d);
if (bytes) break;
}
try {
await fs.mkdir(cacheDir(), { recursive: true });
await fs.writeFile(file, bytes ? Buffer.from(bytes) : new Uint8Array(0)); // sentinel on miss → never refetch
} catch {
/* best-effort */
}
if (!bytes) return new Response("no logo", { status: 404 });
return new Response(bytes, { status: 200, headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=604800" } });
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { readMemory, rememberFact } from "@/lib/career-ops";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
return NextResponse.json({ memory: readMemory() });
}
// Append a durable fact the assistant learned about the user. Written to the
// CANONICAL modes/_profile.md (single source of truth) so the CLI/TUI see it too
// — never a web-only memory store.
export async function POST(req: Request) {
let b: { fact?: string };
try {
b = await req.json();
} catch {
return NextResponse.json({ error: "bad json" }, { status: 400 });
}
const fact = (b.fact ?? "").toString();
if (!fact.trim()) return NextResponse.json({ error: "fact required" }, { status: 400 });
const result = rememberFact(fact);
if (result === "error") return NextResponse.json({ error: "write failed" }, { status: 500 });
return NextResponse.json({ ok: true, deduped: result === "deduped" });
}
+17
View File
@@ -0,0 +1,17 @@
import { pipelineSummary } from "@/lib/career-ops";
export const runtime = "nodejs";
export const dynamic = "force-dynamic"; // always read fresh local files
// Exposes the user's pipeline (inbox + tracker) to the client so the assistant
// can resolve "all the Anthropic ones" to concrete postings CLIENT-SIDE — the
// model only ever emits a company name, never URLs (no hallucination, no tokens).
export async function GET() {
const s = pipelineSummary();
return Response.json({
inbox: s.inbox,
applications: s.applications,
root: s.root,
rootExists: s.rootExists,
});
}
+58
View File
@@ -0,0 +1,58 @@
import fs from "node:fs";
import path from "node:path";
import yaml from "js-yaml";
import { careerOpsRoot } from "@/lib/career-ops";
import { atomicWriteWithBackup } from "@/lib/core/safe-write";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Merge-safe writer for portals.yml's title_filter (a USER-LAYER file). Replaces
// ONLY title_filter.positive (the role keywords the free scanner matches), seeding
// from templates/portals.example.yml on first create, and PRESERVING tracked_companies
// + every other block. Atomic write, confirm-gated (setProfile/setPortals). This is
// what loads the very first home scan once the user confirms their target roles.
function isObj(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === "object" && !Array.isArray(v);
}
export async function POST(req: Request) {
let body: { roles?: string[]; location?: string[] };
try {
body = (await req.json()) as { roles?: string[]; location?: string[] };
} catch {
return Response.json({ error: "bad json" }, { status: 400 });
}
const roles = (Array.isArray(body.roles) ? body.roles : []).map((r) => String(r).trim()).filter(Boolean).slice(0, 24);
if (roles.length === 0) return Response.json({ error: "no roles" }, { status: 400 });
const root = careerOpsRoot();
const file = path.join(root, "portals.yml");
let doc: Record<string, unknown> = {};
try {
doc = (yaml.load(fs.readFileSync(file, "utf8")) as Record<string, unknown>) || {};
} catch {
try {
doc = (yaml.load(fs.readFileSync(path.join(root, "templates", "portals.example.yml"), "utf8")) as Record<string, unknown>) || {};
} catch {
doc = {};
}
}
const tf = isObj(doc.title_filter) ? { ...doc.title_filter } : {};
tf.positive = roles; // replace ONLY the positive keywords; keep negative/etc.
doc.title_filter = tf;
if (Array.isArray(body.location) && body.location.length) {
const lf = isObj(doc.location_filter) ? { ...doc.location_filter } : {};
lf.allow = body.location.map((l) => String(l).trim()).filter(Boolean);
doc.location_filter = lf;
}
try {
atomicWriteWithBackup(file, yaml.dump(doc, { lineWidth: 100, noRefs: true }));
} catch (e) {
return Response.json({ error: e instanceof Error ? e.message : "write failed" }, { status: 500 });
}
return Response.json({ ok: true, roles: roles.length });
}
+46
View File
@@ -0,0 +1,46 @@
import { execFile } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { careerOpsRoot, rootScript } from "@/lib/career-ops";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 120;
// Orchestrates the core's verify-portals.mjs (#1016) — the SAME ATS-slug
// validator the CLI uses. Catches the silent 404s that quietly drop a company
// from every future scan (= lost offers). We parse its console output; we do NOT
// reimplement the validation.
const STATUS: Record<string, "live" | "empty" | "broken" | "skipped"> = {
"✅": "live",
"🟡": "empty",
"❌": "broken",
"➖": "skipped",
};
export async function GET() {
const root = careerOpsRoot();
const verifyPortals = rootScript("verify-portals");
if (!fs.existsSync(verifyPortals)) {
return Response.json({ available: false, configured: false, companies: [] });
}
if (!fs.existsSync(path.join(root, "portals.yml"))) {
return Response.json({ available: true, configured: false, companies: [] });
}
const stdout = await new Promise<string>((resolve) => {
execFile(
"node",
[verifyPortals],
{ cwd: root, timeout: 110_000, maxBuffer: 4 * 1024 * 1024 },
(_e, out, err) => resolve((out || "") + (err || "")),
);
});
const companies: { name: string; status: string; detail: string }[] = [];
for (const line of stdout.split("\n")) {
const m = line.match(/^\s*(✅|🟡|❌|➖)\s+(.+?)\s+—\s+(.*)$/);
if (m) companies.push({ name: m[2].trim(), status: STATUS[m[1]] ?? "unknown", detail: m[3].trim() });
}
return Response.json({ available: true, configured: true, companies });
}
+101
View File
@@ -0,0 +1,101 @@
import fs from "node:fs";
import path from "node:path";
import yaml from "js-yaml";
import { careerOpsRoot } from "@/lib/career-ops";
import { atomicWriteWithBackup } from "@/lib/core/safe-write";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Merge-safe writer for config/profile.yml (a USER-LAYER file — DATA_CONTRACT:
// never clobber the user's archetypes/narrative/proof-points). On first create we
// seed from config/profile.example.yml; on an existing file we deep-merge ONLY the
// proposed keys, write atomically (temp + rename), and only ever via the confirm-
// gated setProfile action. The web orchestrates the real file — no parallel store.
type ProfilePatch = {
name?: string;
email?: string;
location?: string;
roles?: string[];
compMin?: number;
compMax?: number;
currency?: string;
remote?: string;
};
function isObj(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === "object" && !Array.isArray(v);
}
/** Deep-merge src onto dst (objects recurse; arrays/scalars replace). Non-mutating. */
function deepMerge(dst: unknown, src: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = isObj(dst) ? { ...dst } : {};
for (const [k, v] of Object.entries(src)) {
out[k] = isObj(v) ? deepMerge(out[k], v) : v;
}
return out;
}
function patchToProfile(p: ProfilePatch): Record<string, unknown> {
const out: Record<string, unknown> = {};
const candidate: Record<string, unknown> = {};
if (p.name) candidate.full_name = p.name;
if (p.email) candidate.email = p.email;
if (p.location) candidate.location = p.location;
if (Object.keys(candidate).length) out.candidate = candidate;
if (p.roles?.length) out.target_roles = { primary: p.roles.slice(0, 6) };
const comp: Record<string, unknown> = {};
if (p.compMin && p.compMax) comp.target_range = `${p.compMin}-${p.compMax}`;
if (p.currency) comp.currency = p.currency;
if (p.remote) comp.location_flexibility = p.remote;
if (Object.keys(comp).length) out.compensation = comp;
// seniority intentionally not written (no canonical home in profile.yml);
// archetypes/narrative live in modes/_profile.md — this writer never touches them.
return out;
}
export async function POST(req: Request) {
let patch: ProfilePatch;
try {
patch = (await req.json()) as ProfilePatch;
} catch {
return Response.json({ error: "bad json" }, { status: 400 });
}
const proposed = patchToProfile(patch);
if (Object.keys(proposed).length === 0) return Response.json({ error: "nothing to write" }, { status: 400 });
const root = careerOpsRoot();
const file = path.join(root, "config", "profile.yml");
let base: Record<string, unknown> = {};
let seeded = false;
// DATA-LOSS GUARD (maintainer, bug-class #649/#704/#920/#958): distinguish
// "no profile yet" (safe to seed from the example) from "profile EXISTS but is
// malformed" (NEVER overwrite — that would silently destroy the user's data).
if (!fs.existsSync(file)) {
try {
base = (yaml.load(fs.readFileSync(path.join(root, "config", "profile.example.yml"), "utf8")) as Record<string, unknown>) || {};
seeded = Object.keys(base).length > 0;
} catch {
base = {};
}
} else {
let parsed: unknown;
try {
parsed = yaml.load(fs.readFileSync(file, "utf8"));
} catch {
return Response.json({ error: "config/profile.yml exists but is not valid YAML — refusing to overwrite it." }, { status: 409 });
}
base = isObj(parsed) ? (parsed as Record<string, unknown>) : {};
}
const merged = deepMerge(base, proposed);
try {
// Back up the prior profile before the first normalized write (yaml.dump
// reformats — comments are not preserved; the .bak is the safety net).
atomicWriteWithBackup(file, yaml.dump(merged, { lineWidth: 100, noRefs: true }));
} catch (e) {
return Response.json({ error: e instanceof Error ? e.message : "write failed" }, { status: 500 });
}
return Response.json({ ok: true, seeded });
}
+62
View File
@@ -0,0 +1,62 @@
import fs from "node:fs";
import path from "node:path";
import { careerOpsRoot, doctorState, readApplications, readInbox, trackerCanDelete } from "@/lib/career-ops";
import { scannerSupportsJson } from "@/lib/core/scan";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// STRUCTURAL diagnostics for the in-app bug reporter: shapes, counts and
// capability probes only — never file contents. A malformed tracker shows up
// here as "12 candidate rows, 5 parsed", which is exactly what triage needs,
// without a single company name leaving the machine.
function lineCount(rel: string, predicate: (l: string) => boolean): number {
try {
return fs
.readFileSync(path.join(careerOpsRoot(), rel), "utf8")
.split("\n")
.filter(predicate).length;
} catch {
return 0;
}
}
function dirCount(rel: string, ext: string): number {
try {
return fs.readdirSync(path.join(careerOpsRoot(), rel)).filter((f) => f.endsWith(ext)).length;
} catch {
return 0;
}
}
export async function GET() {
const doctor = doctorState();
// "candidate" = a line that LOOKS like a row; parsed = what the tolerant
// reader accepted. A gap between the two is the data-contract fingerprint.
const inboxCandidates = lineCount("data/pipeline.md", (l) => /^\s*-\s*\[[ xX]\]/.test(l));
const trackerCandidates = lineCount(
"data/applications.md",
(l) => l.trim().startsWith("|") && !/^\|\s*#\s*\|/.test(l.trim()) && !/^\|\s*:?-{2,}/.test(l.trim()),
);
return Response.json({
runtime: { node: process.version, platform: process.platform, arch: process.arch },
setup: {
phase: doctor.phase,
missing: doctor.missing, // system prereq FILENAMES only (cv.md, portals.yml…)
hasCv: doctor.hasCv,
hasData: doctor.hasData,
},
data: {
inbox: { candidates: inboxCandidates, parsed: readInbox().length },
tracker: { candidates: trackerCandidates, parsed: readApplications().length },
reports: dirCount("reports", ".md"),
pdfs: dirCount("output", ".pdf"),
followupsFile: fs.existsSync(path.join(careerOpsRoot(), "data", "follow-ups.md")),
},
capabilities: {
scanJson: scannerSupportsJson(),
trackerDelete: trackerCanDelete(),
},
});
}
+265
View File
@@ -0,0 +1,265 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { resolveCli } from "@/lib/clis";
import { careerOpsRoot, readMemory } from "@/lib/career-ops";
import { acquireTrackerWrite, releaseTrackerWrite } from "@/lib/core/run-registry";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 800; // a real oferta evaluation / pdf-mode CV tailoring + render is heavy and multi-step
// The web ORCHESTRATES the real career-ops engine — it does NOT reimplement it.
// kind "evaluate" runs the REAL modes/oferta.md and persists the canonical
// artifacts (A–F report + tracker row) via the SAME scripts the CLI uses
// (reserve-report-num.mjs → reports/ → batch/tracker-additions/ → merge-tracker.mjs),
// so a web evaluation is byte-identical to a CLI one (single source of truth, no
// drift). kind "research" stays read-only. Streams progress as NDJSON events.
function buildPrompt(kind: string, input: string, memory: string, today: string): string {
const mem = memory.trim() ? `\n\nDurable notes about the user (from their profile):\n${memory.trim()}\n` : "";
if (kind === "research") {
return `You are investigating the user's OWN work / portfolio to surface job-search-relevant strengths, headless. Investigate the target (use WebFetch for URLs; read local files if referenced) and report: what it is, why it is impressive, and how to leverage it in their job search — which roles/claims it supports and how to frame it on a CV. Be specific, honest, and encouraging.${mem}
End with EXACTLY one final line: VERDICT: {0-5 signal strength}/5 — {why it helps their search, ≤12 words}
Target: ${input}`;
}
if (kind === "pdf") {
return `You are generating the user's ATS-optimized, TAILORED CV PDF for application #${input}, headless, on their machine. Run the REAL career-ops "pdf" mode — follow modes/pdf.md EXACTLY (do not improvise a format).
1. Read modes/pdf.md, cv.md, config/profile.yml, and the evaluation report at reports/${input}-*.md (for the JD keywords + analysis).
2. Tailor the CV per modes/pdf.md: inject the JD's keywords into the summary + first bullets, reorder experience by relevance, build the competency grid, pick the top 3–4 projects. NEVER invent skills — only reword REAL experience using the JD's vocabulary.
3. Fill templates/cv-template.html's {{...}} placeholders with the tailored content; write the HTML to /tmp/cv-{candidate}-{company}.html (candidate = the profile name in kebab-case).
4. Render the PDF: \`node generate-pdf.mjs /tmp/cv-{candidate}-{company}.html output/cv-{candidate}-{company}-${today}.pdf --format={letter for US/Canada companies, else a4}\`.
5. Update the tracker: in data/applications.md, change the PDF column for row #${input} from ❌ to ✅.
Do not submit anything anywhere.
End with EXACTLY one final line: VERDICT: {5 if the PDF was written, else 1}/5 — {the output/ path, ≤12 words}`;
}
if (kind === "fix-portal") {
return `A company's job-portal ATS slug is BROKEN — career-ops can no longer scan it, so it silently disappears from every future scan. Repair it (headless, on the user's machine):
1. Run \`node verify-portals.mjs --add "${input}"\` — it probes Greenhouse/Ashby/Lever for the company's correct ATS slug and prints the suggested ats + slug.
2. Open portals.yml, find the "${input}" entry under tracked_companies, and update its careers_url (and any api/slug field) to the suggested WORKING ATS URL. Change ONLY this one company; preserve all other YAML structure, comments and formatting exactly.
3. Re-run \`node verify-portals.mjs\` and confirm "${input}" now shows ✅ live (not ❌).
If NO slug variant resolves, say so clearly and leave portals.yml unchanged. Never touch any other company.
End with EXACTLY one final line: VERDICT: {5 if now live, else 1}/5 — {what you changed, ≤12 words}`;
}
// evaluate (default) — run the REAL oferta mode + persist canonically
return `You are running the OFFICIAL career-ops job evaluation, HEADLESS, on the user's own machine. Today is ${today}. Run the REAL career-ops evaluation — do NOT improvise your own scoring.
1. Read modes/oferta.md and follow it EXACTLY (blocks A–F, G posting-legitimacy, and the Machine Summary). Ground the fit in THIS person: read cv.md, config/profile.yml and modes/_profile.md. Use WebFetch to read the posting (you are headless — Playwright is unavailable, so use WebFetch and mark the report header "Verification: unconfirmed (batch mode)").
2. Persist the result CANONICALLY so the web and the CLI share ONE source of truth:
a. Reserve a report number: run \`node reserve-report-num.mjs\` — its stdout is a 3-digit number (e.g. 035).
b. Write the full report to reports/{num}-{company-slug}-${today}.md (company-slug = company lowercased, non-alphanumerics → hyphens).
c. Append ONE row of 9 TAB-separated columns to batch/tracker-additions/{num}-{company-slug}.tsv, in THIS exact order (real \\t tabs, status BEFORE score):
{num}\t${today}\t{Company}\t{Role}\t{CanonicalStatus e.g. Evaluated}\t{score}/5\t❌\t[{num}](reports/{num}-{company-slug}-${today}.md)\t{one-line note}
d. Merge into the tracker: run \`node merge-tracker.mjs\` (it dedupes by company+role+report-num, validates the status, and writes data/applications.md — NEVER edit applications.md by hand).
3. NEVER submit an application, fill no forms, contact no one. This is evaluation + persistence ONLY.${mem}
After everything above is written and merged, output EXACTLY one final line, nothing after it:
VERDICT: {score}/5 — {reason in 12 words or fewer}
Posting URL: ${input}`;
}
export async function POST(req: Request) {
let body: { kind?: string; input?: string; cliId?: string };
try {
body = await req.json();
} catch {
return new Response(JSON.stringify({ error: "bad json" }), { status: 400 });
}
const { kind = "evaluate", input, cliId } = body;
if (!input || !cliId) {
return new Response(JSON.stringify({ error: "input and cliId required" }), { status: 400 });
}
const resolved = resolveCli(cliId);
if (!resolved) {
return new Response(JSON.stringify({ error: `CLI '${cliId}' not found` }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
const { spec, binPath } = resolved;
// These run the REAL core (modes/scripts), not just data — fail clearly if the
// root is incomplete instead of faking it.
const needsScript: Record<string, string> = { evaluate: "modes/oferta.md", "fix-portal": "verify-portals.mjs", pdf: "generate-pdf.mjs" };
const required = needsScript[kind];
if (required && !fs.existsSync(path.join(careerOpsRoot(), required))) {
return new Response(
JSON.stringify({
error: `This needs a complete career-ops checkout (${required}). CAREER_OPS_ROOT has data only — point it at a full checkout.`,
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
// An A–F score is meaningless without a CV to score against — the CLI would
// hallucinate a fit narrative and still emit a VERDICT. Require cv.md first.
if ((kind === "evaluate" || kind === "pdf") && !fs.existsSync(path.join(careerOpsRoot(), "cv.md"))) {
return new Response(
JSON.stringify({ error: "Add your CV first so I can score this against you — drop it on the home page." }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const today = new Date().toISOString().slice(0, 10);
const prompt = buildPrompt(kind, input, readMemory(), today);
const isClaude = cliId === "claude";
// Tool scope by kind (comma-separated lists; disallowedTools is the hard
// guardrail). 'evaluate' runs the REAL mode + persists canonical artifacts →
// it needs Write + Bash (reserve-report-num / merge-tracker / write the
// report). 'research' stays read-only. Task (sub-agents) is always blocked
// (runaway cost). NEVER auto-submits — that is a prompt-level guarantee.
const tools =
kind === "evaluate" || kind === "fix-portal" || kind === "pdf"
? { allowed: "Read,WebFetch,WebSearch,Write,Edit,Bash,Glob,Grep", disallowed: "Task,NotebookEdit" }
: { allowed: "Read,WebFetch,WebSearch,Glob,Grep", disallowed: "Bash,Write,Edit,NotebookEdit,Task" };
const args = isClaude
? ["-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages",
"--permission-mode", "acceptEdits",
"--allowedTools", tools.allowed,
"--disallowedTools", tools.disallowed]
: spec.args(prompt);
// For write-needing kinds, snapshot reports/ so we can verify the worker
// actually persisted (non-Claude CLIs lack Write auth and silently no-op).
const reportsDir = path.join(careerOpsRoot(), "reports");
const countReports = () => {
try {
return fs.readdirSync(reportsDir).filter((f) => f.endsWith(".md")).length;
} catch {
return 0;
}
};
const persists = kind === "evaluate";
const reportsBefore = persists ? countReports() : 0;
// Tracker-mutating runs hold a write token so a row delete can't race their merge
// (tracker.mjs delete doesn't yet share a lock with merge-tracker — see run-registry).
const writeToken = kind === "evaluate" || kind === "pdf" ? acquireTrackerWrite() : null;
const child = spawn(binPath, args, { cwd: careerOpsRoot(), env: process.env });
const enc = new TextEncoder();
// `closed` + kill timer in the OUTER scope so cancel() (client disconnect) can
// flip `closed` before the child's late handlers run, and send() is try/catch'd —
// otherwise a late enqueue onto a closed controller throws uncaught (see #1155).
let closed = false;
let killer: ReturnType<typeof setTimeout> | undefined;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let buf = "";
let emittedText = false; // any assistant text delta → the CLI actually ran
let sawError = false;
let lastTokens = 0; // per-run token cost from the Claude result event (#6) — local only
let lastCostUsd: number | null = null;
// pdf-mode tailors a full CV + renders it — give it more headroom.
const killMs = kind === "pdf" ? 720_000 : 285_000;
killer = setTimeout(() => {
try { child.kill("SIGTERM"); } catch { /* ignore */ }
}, killMs);
const send = (obj: unknown) => {
if (closed) return;
try { controller.enqueue(enc.encode(JSON.stringify(obj) + "\n")); } catch { closed = true; }
};
const close = () => {
if (!closed) {
closed = true;
if (killer) clearTimeout(killer);
if (writeToken !== null) releaseTrackerWrite(writeToken);
try { controller.close(); } catch { /* */ }
}
};
child.stdout.on("data", (d: Buffer) => {
if (closed) return;
if (!isClaude) {
emittedText = true;
send({ type: "text", text: d.toString() });
return;
}
buf += d.toString();
let nl: number;
while ((nl = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
try {
const ev = JSON.parse(line);
if (ev.type === "stream_event") {
const e = ev.event;
if (e?.type === "content_block_start" && e.content_block?.type === "tool_use") {
send({ type: "tool", name: e.content_block.name });
} else if (e?.type === "content_block_delta" && e.delta?.text) {
emittedText = true;
send({ type: "text", text: e.delta.text });
}
} else if (ev.type === "system" && ev.subtype === "init") {
send({ type: "status", label: "Agent ready" });
} else if (ev.type === "result") {
// Capture the per-run cost; the authoritative "done" is sent on close
// (so the honesty gate decides done-vs-error first). Tokens = the same
// formula /api/usage uses: input + output + cache-creation.
const u = ev.usage || {};
lastTokens = (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_creation_input_tokens || 0);
if (typeof ev.total_cost_usd === "number") lastCostUsd = ev.total_cost_usd;
}
} catch {
/* partial line */
}
}
});
child.stderr.on("data", (d: Buffer) => {
const s = d.toString();
// Widened: auth/login/quota failures are the most common real error and
// the old narrow regex missed them (silent false "success").
if (/error|denied|fatal|not found|unauthorized|forbidden|auth|login|credential|api[ -]?key|quota|rate limit|not authenticated/i.test(s)) {
sawError = true;
send({ type: "error", msg: s.trim().slice(0, 200) });
}
});
child.on("error", (e) => { send({ type: "error", msg: e.message }); close(); });
child.on("close", (code) => {
const wroteReport = countReports() > reportsBefore;
const cleanExit = code === 0; // non-zero OR null (killed/signal) = NOT clean
// Honesty gate (#9): a green "done" with a parsed score requires a CLEAN exit,
// real output, AND (for evaluations) a report actually written. Anything else
// is surfaced — an errored run must never be banked as a confident score.
if (!emittedText && !sawError && !cleanExit) {
send({ type: "error", msg: "The CLI exited with an error — is it installed and authenticated?" });
} else if (!emittedText && !sawError) {
send({ type: "error", msg: "The CLI produced no output — is it installed and authenticated? (career-ops is best on Claude Code.)" });
} else if (persists && !wroteReport) {
// The worker ran but never wrote the report/tracker row (e.g. a CLI
// without file-write authorization) — surface it instead of a fake score.
send({ type: "error", msg: "This evaluation didn't save a report, so it's not in your tracker. Full evaluation is verified on Claude Code." });
} else if (!cleanExit || sawError) {
// Produced output (maybe even a report) but did NOT finish cleanly — flag it
// instead of recording a confident score off a half-finished run.
send({ type: "error", msg: "This run hit an error before finishing, so it isn't recorded as a confident result — re-run it to verify." });
} else {
send({ type: "done", tokens: lastTokens, costUsd: lastCostUsd });
}
close();
});
},
cancel() {
closed = true;
if (killer) clearTimeout(killer);
if (writeToken !== null) releaseTrackerWrite(writeToken);
try { child.kill("SIGTERM"); } catch { /* ignore */ }
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
});
}
+59
View File
@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import fs from "node:fs";
import path from "node:path";
import { careerOpsRoot } from "@/lib/career-ops";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Body = {
id?: string;
title?: string;
subtitle?: string;
page?: string;
input?: string;
result?: { score: number | null; summary: string };
steps?: { kind: string; label: string }[];
output?: string;
};
// Persist a finished worker's log as markdown under a web-managed dir so the CLI
// assistant can read past runs ("what did we find on that Anthropic role?").
export async function POST(req: Request) {
let b: Body;
try {
b = await req.json();
} catch {
return NextResponse.json({ error: "bad json" }, { status: 400 });
}
if (!b.id) return NextResponse.json({ error: "id required" }, { status: 400 });
const dir = path.join(careerOpsRoot(), ".career-ops-web", "runs");
try {
fs.mkdirSync(dir, { recursive: true });
} catch {
return NextResponse.json({ error: "mkdir failed" }, { status: 500 });
}
const safeId = String(b.id).replace(/[^a-z0-9_-]/gi, "");
const steps = (b.steps ?? []).map((s) => `- ${s.kind === "tool" ? `🔧 ${s.label}` : s.label}`).join("\n");
const verdict = b.result?.score != null ? `${b.result.score}/5 — ${b.result.summary || ""}` : "—";
const md = `# Web run · ${b.title || b.id}
- id: ${b.id}
- page: ${b.page || "-"}
- input: ${b.input || "-"}
- verdict: ${verdict}
## Steps
${steps}
## Output
${b.output || ""}
`;
try {
fs.writeFileSync(path.join(dir, `${safeId}.md`), md);
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "write failed" }, { status: 500 });
}
}
+74
View File
@@ -0,0 +1,74 @@
import { NextResponse } from "next/server";
import fs from "node:fs";
import path from "node:path";
import { careerOpsRoot } from "@/lib/career-ops";
import { canonicalizeStatus } from "@/lib/core/states";
import { atomicWrite } from "@/lib/core/safe-write";
// Writeback: UPDATE the status cell of an EXISTING tracker row only. Never adds
// rows — per the core data contract, new rows go through the TSV + merge flow.
// HARDENED: validate against the 8 canonical states (states.yml SSOT); reject any
// value with table-breaking chars (| \r \n **) that would scramble the row; detect
// the Status column from the header (8- and 9-col layouts); atomic write.
export async function POST(req: Request) {
let body: { n?: string; status?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "bad json" }, { status: 400 });
}
const { n, status } = body;
if (!n || typeof status !== "string" || !status.trim()) {
return NextResponse.json({ error: "n and status required" }, { status: 400 });
}
if (/[|\r\n*]/.test(status)) {
return NextResponse.json({ error: "invalid status (table-breaking characters)" }, { status: 400 });
}
const canon = canonicalizeStatus(status);
if (!canon) {
return NextResponse.json({ error: `not a canonical status: ${status}` }, { status: 400 });
}
const file = path.join(careerOpsRoot(), "data", "applications.md");
let md: string;
try {
md = fs.readFileSync(file, "utf8");
} catch {
return NextResponse.json({ error: "tracker not found" }, { status: 404 });
}
const lines = md.split("\n");
// Find the Status column index from the header row (robust to 8- vs 9-col).
let statusIdx = 6;
for (const l of lines) {
if (!l.trim().startsWith("|")) continue;
const cells = l.split("|").map((c) => c.trim().toLowerCase());
const idx = cells.findIndex((c) => c === "status");
if (idx > 0) {
statusIdx = idx;
break;
}
if (/^:?-{2,}:?$/.test(cells[1] ?? "")) break; // hit the separator → no header match, keep default
}
let changed = false;
for (let i = 0; i < lines.length; i++) {
if (!lines[i].trim().startsWith("|")) continue;
const parts = lines[i].split("|");
if (parts.length < 8) continue;
if (parts[1].trim() !== String(n)) continue;
if (statusIdx >= parts.length - 1) continue; // guard malformed row
parts[statusIdx] = ` ${canon} `;
lines[i] = parts.join("|");
changed = true;
break;
}
if (!changed) return NextResponse.json({ error: "row not found" }, { status: 404 });
try {
atomicWrite(file, lines.join("\n"));
} catch {
return NextResponse.json({ error: "write failed" }, { status: 500 });
}
return NextResponse.json({ ok: true, status: canon });
}
+105
View File
@@ -0,0 +1,105 @@
import { spawn } from "node:child_process";
import { careerOpsRoot, rootScript, trackerCanDelete } from "@/lib/career-ops";
import { isTrackerWriting } from "@/lib/core/run-registry";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Remove ONE application row from applications.md by orchestrating the core
// write-gate `tracker.mjs delete --num N` (#1200) — we NEVER hand-edit the file
// (atomic write + SQLite reindex + orphan-report report all live in the script).
// This is the disc#9 fix: a bogus row (e.g. an evaluation that errored mid-run)
// must be removable. `--dry-run` previews; the real delete is irreversible, so the
// UI confirms first.
// One delete at a time (single local process). Real deletes only — dry-runs write
// nothing and can overlap.
let deleting = false;
function parseOrphan(stderr: string): string | null {
// dry-run: "(report file would be orphaned: <path>)"
// real: "Note: report file may now be orphaned — <path>"
const m = stderr.match(/orphaned[:—-]+\s*([^\n)]+)\)?\s*$/im);
return m ? m[1].trim() : null;
}
export async function POST(req: Request) {
let body: { n?: string | number; dryRun?: boolean };
try {
body = await req.json();
} catch {
return Response.json({ error: "bad json" }, { status: 400 });
}
const num = String(body.n ?? "").trim();
if (!/^\d+$/.test(num)) {
return Response.json({ error: "a numeric application number is required" }, { status: 400 });
}
const dryRun = !!body.dryRun;
if (!trackerCanDelete()) {
return Response.json(
{ error: "Removing a tracker row needs a newer career-ops — update to delete rows from here." },
{ status: 400 },
);
}
// Serialize: the delete must not run while an evaluation is writing the tracker
// (tracker.mjs delete doesn't share a lock with merge-tracker yet).
if (isTrackerWriting()) {
return Response.json(
{ error: "An evaluation is updating your tracker right now — try again in a moment." },
{ status: 409 },
);
}
if (!dryRun && deleting) {
return Response.json({ error: "Another delete is already in progress — try again in a moment." }, { status: 409 });
}
if (!dryRun) deleting = true;
const args = [rootScript("tracker"), "delete", "--num", num];
if (dryRun) args.push("--dry-run");
try {
const result = await new Promise<{ code: number | null; err: string }>((resolve) => {
let err = "";
let child;
try {
child = spawn(process.execPath, args, { cwd: careerOpsRoot(), env: process.env });
} catch (e) {
resolve({ code: 1, err: e instanceof Error ? e.message : "failed to start tracker.mjs" });
return;
}
child.stderr.on("data", (d: Buffer) => {
err += d.toString();
});
child.stdout.on("data", () => {
/* delete reports to stderr; drain stdout so the pipe never stalls */
});
const killer = setTimeout(() => {
try {
child.kill("SIGTERM");
} catch {
/* ignore */
}
}, 30_000);
child.on("error", (e) => {
clearTimeout(killer);
resolve({ code: 1, err: e.message });
});
child.on("close", (code) => {
clearTimeout(killer);
resolve({ code, err });
});
});
if (result.code !== 0) {
const notFound = /No application numbered/i.test(result.err);
return Response.json(
{ error: result.err.trim().split("\n")[0] || "delete failed" },
{ status: notFound ? 404 : 400 },
);
}
return Response.json({ ok: true, dryRun, orphanReport: parseOrphan(result.err) });
} finally {
if (!dryRun) deleting = false;
}
}
+86
View File
@@ -0,0 +1,86 @@
import { NextResponse } from "next/server";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Reads Claude Code's per-message usage across ALL projects (~/.claude/projects/
// **/*.jsonl) and sums tokens in the rolling 5h and 7d windows — the same scope
// as the account's rate-limit windows. Cached 60s (the read is heavy).
type Usage = { window5h: { tokens: number; messages: number }; window7d: { tokens: number; messages: number }; computedAt: number };
let cache: { at: number; data: Usage } | null = null;
function projectsDir(): string {
return path.join(os.homedir(), ".claude", "projects");
}
function* walkJsonl(dir: string): Generator<string> {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory()) yield* walkJsonl(p);
else if (e.isFile() && e.name.endsWith(".jsonl")) yield p;
}
}
function compute(): Usage {
const now = Date.now();
const w5 = now - 5 * 3600 * 1000;
const w7 = now - 7 * 24 * 3600 * 1000;
const cutoffMtime = w7 - 3600 * 1000;
let t5 = 0;
let t7 = 0;
let m5 = 0;
let m7 = 0;
for (const file of walkJsonl(projectsDir())) {
let st: fs.Stats;
try {
st = fs.statSync(file);
} catch {
continue;
}
if (st.mtimeMs < cutoffMtime) continue;
let content: string;
try {
content = fs.readFileSync(file, "utf8");
} catch {
continue;
}
for (const line of content.split("\n")) {
if (!line.includes('"usage"')) continue;
let d: { message?: { usage?: Record<string, number> }; timestamp?: string };
try {
d = JSON.parse(line);
} catch {
continue;
}
const u = d.message?.usage;
const ts = d.timestamp ? Date.parse(d.timestamp) : NaN;
if (!u || Number.isNaN(ts) || ts < w7) continue;
const tok = (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_creation_input_tokens || 0);
t7 += tok;
m7 += 1;
if (ts >= w5) {
t5 += tok;
m5 += 1;
}
}
}
return { window5h: { tokens: t5, messages: m5 }, window7d: { tokens: t7, messages: m7 }, computedAt: now };
}
export async function GET() {
if (cache && Date.now() - cache.at < 60_000) return NextResponse.json(cache.data);
const data = compute();
cache = { at: Date.now(), data };
return NextResponse.json(data);
}
+54
View File
@@ -0,0 +1,54 @@
import fs from "node:fs";
import path from "node:path";
import { execSync } from "node:child_process";
// The WEB build's own version + channel (NOT the user's data checkout) — read from
// the repo's VERSION (parent of the web/ cwd). The channel is derived from a
// pre-release suffix (`-rc`/`-beta`) so the UI can show a beta banner + the bug
// reporter can tag the right release. Invisible to stable installs (the updater
// reads VERSION from `main`, which stays stable while the bundle lives on a branch).
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function readVersion(): string {
const candidates = [path.join(process.cwd(), "..", "VERSION"), path.join(process.cwd(), "VERSION")];
for (const p of candidates) {
try {
const v = fs.readFileSync(p, "utf8").split(/\s+/)[0].trim();
if (v) return v;
} catch {
/* next candidate */
}
}
return "";
}
function shortSha(): string {
try {
return execSync("git rev-parse --short HEAD", { cwd: process.cwd(), stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
} catch {
return "";
}
}
function webVersion(): string {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8"));
return typeof pkg.version === "string" ? pkg.version : "";
} catch {
return "";
}
}
export async function GET() {
const coreVersion = readVersion();
const web = webVersion();
const m = coreVersion.match(/-(rc|beta|alpha|next)\b/i);
// Channel precedence: an explicit core pre-release suffix wins (RC installs);
// otherwise the web component's own maturity decides — pre-1.0 on main IS the
// alpha (release-please versions web/ independently), and the banner/bug-report
// stay visible until the web graduates to 1.0.
const channel = m ? m[1].toLowerCase() : web && /^0\./.test(web) ? "alpha" : "stable";
const version = web ? `web ${web}` : coreVersion;
return Response.json({ version, coreVersion, channel, sha: shortSha() });
}
+71
View File
@@ -0,0 +1,71 @@
import fs from "node:fs";
import path from "node:path";
import { careerOpsRoot, readApplications } from "@/lib/career-ops";
import type { DiscoveredOffer } from "@/lib/explore";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// The SUPPLY loop, ZERO tokens: "new matches this week" = roles surfaced by past
// free scans (data/scan-history.tsv) in the last N days that the user hasn't
// evaluated yet. No scan runs here — it reads the history a past scan already
// wrote, so the home stays instant + free (directly answers the #1 token-cost
// complaint). cols: url, first_seen, portal, title, company, status, location.
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
export async function GET(req: Request) {
const days = Math.min(30, Math.max(1, Number(new URL(req.url).searchParams.get("days")) || 7));
const cutoff = Date.now() - days * 86_400_000;
let rows: string[];
try {
rows = fs.readFileSync(path.join(careerOpsRoot(), "data", "scan-history.tsv"), "utf8").split("\n");
} catch {
return Response.json({ offers: [], count: 0 });
}
// Companies already evaluated → don't resurface as "new".
const evaluated = new Set(readApplications().map((a) => norm(a.company)).filter(Boolean));
const toOffer = (c: string[]): DiscoveredOffer | null => {
const [url, firstSeen, portal, title, company, status, location] = c;
if (!url || !/^https?:\/\//i.test(url)) return null;
if (status && /skipped|expired/i.test(status)) return null;
if (company && evaluated.has(norm(company))) return null;
return {
url,
company: (company || "").trim(),
title: (title || "").trim(),
location: (location || "").trim(),
postedAt: /^\d{4}-\d{2}-\d{2}$/.test(firstSeen || "") ? firstSeen : "",
ats: (portal || "").replace(/-full$/, "").trim() || "other",
source: "whats-new",
};
};
const seen = new Set<string>();
const offers: DiscoveredOffer[] = [];
let anyDated = false;
// Pass 1: recent-by-date (the supply loop).
for (let i = rows.length - 1; i >= 1 && offers.length < 24; i--) {
const c = rows[i].split("\t");
const t = Date.parse(c[1] || "");
if (Number.isFinite(t)) anyDated = true;
if (!Number.isFinite(t) || t < cutoff) continue;
const o = toOffer(c);
if (!o || seen.has(o.url)) continue;
seen.add(o.url);
offers.push(o);
}
// Fallback for LEGACY scan.mjs histories with no parseable first_seen (every row
// would be dropped → a false "all caught up"). Show the most-recent-by-append-order
// un-evaluated rows instead, so the supply loop still surfaces something.
if (offers.length === 0 && !anyDated) {
for (let i = rows.length - 1; i >= 1 && offers.length < 12; i--) {
const o = toOffer(rows[i].split("\t"));
if (!o || seen.has(o.url)) continue;
seen.add(o.url);
offers.push(o);
}
}
return Response.json({ offers, count: offers.length });
}
+28
View File
@@ -0,0 +1,28 @@
import { Send } from "lucide-react";
import { ApplyView } from "@/components/apply-view";
import { ApplyBackdropMount } from "@/components/apply/apply-backdrop-mount";
export const dynamic = "force-dynamic";
export default function ApplyPage() {
return (
<div className="relative min-h-screen">
{/* full-viewport blurred form wallpaper (behind everything) */}
<ApplyBackdropMount />
<div className="relative z-10 mx-auto max-w-3xl px-6 py-8">
<div className="flex items-center gap-3">
<Send className="size-6 text-brand" />
<h1 className="font-display text-2xl tracking-tight text-landing">Apply</h1>
</div>
<p className="mt-1.5 max-w-xl text-sm text-muted">
career-ops reads the real application form on your machine and re-renders it here in plain language, pre-filled
from your CV. You verify every answer — then it fills the real form behind the scenes and you submit it yourself.
It never submits for you.
</p>
<div className="mt-6">
<ApplyView />
</div>
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { ConfigForm } from "@/components/config-form";
export default function ConfigPage() {
return <ConfigForm />;
}
+7
View File
@@ -0,0 +1,7 @@
import { CvEditor } from "@/components/cv-editor";
export const dynamic = "force-dynamic";
export default function CvPage() {
return <CvEditor />;
}
+27
View File
@@ -0,0 +1,27 @@
import fs from "node:fs";
import { ExplorerView } from "@/components/explore/explorer-view";
import { seedExploreFilters } from "@/lib/core/portals";
import { readInbox, readApplications, careerOpsRoot } from "@/lib/career-ops";
import { DEFAULT_FILTERS } from "@/lib/explore";
// Read live data at request time so a bare checkout (or `next build` with no
// CAREER_OPS_ROOT) never fails — discovery seeds are best-effort.
export const dynamic = "force-dynamic";
export default function ExplorePage() {
let seed: { filters: typeof DEFAULT_FILTERS; seededFrom: string[] } = { filters: DEFAULT_FILTERS, seededFrom: [] };
try {
seed = seedExploreFilters();
} catch {
/* bare checkout → defaults */
}
let rootExists = false;
try {
rootExists = fs.existsSync(careerOpsRoot());
} catch {
/* ignore */
}
return (
<ExplorerView seed={seed} inboxSnapshot={readInbox()} appsSnapshot={readApplications()} rootExists={rootExists} />
);
}
+191
View File
@@ -0,0 +1,191 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
/* ──────────────────────────────────────────────────────────────────────────
Design tokens — inherited from career-ops-docs. Brand burnt-orange #dd7627
(mode-invariant), zinc neutrals, light + dark (Fumadocs neutral values).
────────────────────────────────────────────────────────────────────────── */
@theme {
--color-brand: hsl(26 73% 51%);
--color-brand-200: hsl(33 100% 60%); /* hover / lighter */
--color-brand-foreground: #ffffff;
--color-brand-soft: hsl(26 73% 51% / 0.12);
--color-brand-secondary: #fc7744;
--font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-serif: var(--font-instrument-serif), ui-serif, Georgia, serif;
--font-display: var(--font-instrument-serif), ui-serif, Georgia, serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
}
/* Semantic surfaces reference runtime vars so they swap with the theme class. */
@theme inline {
--color-background: var(--bg);
--color-surface: var(--surface);
--color-surface-hover: var(--surface-hover);
--color-border: var(--border);
--color-foreground: var(--fg);
--color-muted: var(--muted);
--color-faint: var(--faint);
--color-landing: var(--landing); /* editorial olive (light) / cream (dark) — docs landing-foreground */
}
:root {
--bg: #f7f6f3; /* warm off-white (docs stone tone) */
--surface: #ffffff;
--surface-hover: #efeeea;
--border: hsl(40 9% 86%);
--fg: hsl(30 10% 11%); /* warm near-black, high legibility */
--muted: hsl(35 9% 34%); /* darker than before for readable body text */
--faint: hsl(35 7% 42%); /* AA: ~4.6:1 on #fff (was 50% ≈ 3.9:1, failed small text) */
--landing: #59592a; /* olive editorial headline */
--dot: 40 8% 42% / 0.09;
--pre-bg: #f2f1ed;
color-scheme: light;
}
.dark {
--bg: #0a0a0a;
--surface: #161616;
--surface-hover: #232323;
--border: #262626;
--fg: #fafafa;
--muted: #a1a1aa;
--faint: #8b8b95; /* AA: ~4.6:1 on #161616 (was #71717a ≈ 3.7:1, failed small text) */
--landing: #e4e2d0; /* cream editorial headline */
--dot: 240 5% 65% / 0.10;
--pre-bg: #111111;
color-scheme: dark;
}
body {
background: var(--bg);
color: var(--fg);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
html {
scrollbar-gutter: stable;
}
@layer utilities {
.font-serif-italic {
font-family: var(--font-instrument-serif-italic), var(--font-serif);
font-style: italic;
}
/* Ambient dot grid — adapts to the theme via --dot. Pure CSS, 0 DOM. */
.dot-bg {
background-image: radial-gradient(circle, hsl(var(--dot)) 1px, transparent 1px);
background-size: 24px 24px;
}
}
/* ── Animations ported from the docs home ───────────────────────────────── */
@keyframes fade-in-delayed {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-fade-in-delayed { animation: fade-in-delayed 700ms ease 200ms both; }
@keyframes cli-cursor-pulse {
0%, 50% { opacity: 1; }
50.01%, 100% { opacity: 0; }
}
.cli-cursor { animation: cli-cursor-pulse 1s infinite; }
@keyframes terminal-popup-in {
from { opacity: 0; transform: translateY(-2.5rem); }
to { opacity: 1; transform: translateY(0); }
}
.animate-terminal-popup { animation: terminal-popup-in 300ms ease both; }
/* Worker-pill indeterminate progress bar — a brand sweep (hero aesthetic). */
@keyframes job-progress {
0% { transform: translateX(-110%); }
100% { transform: translateX(320%); }
}
.job-indeterminate { position: relative; overflow: hidden; }
.job-indeterminate::after {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 35%;
background: linear-gradient(90deg, transparent, var(--color-brand), transparent);
animation: job-progress 1.3s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.cli-cursor,
.animate-terminal-popup,
.animate-fade-in-delayed {
animation: none;
}
.job-indeterminate::after { animation: none; width: 100%; opacity: 0.35; }
}
/* ── Report / CV markdown prose (theme-aware) ───────────────────────────── */
.report-prose {
color: color-mix(in srgb, var(--fg) 88%, transparent);
font-size: 0.95rem;
line-height: 1.7;
}
.report-prose > :first-child { margin-top: 0; }
.report-prose h1, .report-prose h2, .report-prose h3, .report-prose h4 {
color: var(--fg);
letter-spacing: -0.01em;
line-height: 1.25;
}
.report-prose h1 { font-family: var(--font-display); font-size: 1.7rem; margin: 2rem 0 0.75rem; }
.report-prose h2 {
font-family: var(--font-display);
font-size: 1.3rem;
margin: 2.25rem 0 0.75rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--border);
}
.report-prose h3 { font-weight: 600; font-size: 1.05rem; margin: 1.6rem 0 0.5rem; }
.report-prose h4 { font-weight: 600; font-size: 0.95rem; color: var(--muted); margin: 1.25rem 0 0.4rem; }
.report-prose p { margin: 0.9rem 0; }
.report-prose strong { color: var(--fg); font-weight: 600; }
.report-prose a { color: var(--color-brand); text-decoration: none; }
.report-prose a:hover { text-decoration: underline; }
.report-prose code {
font-family: var(--font-mono);
font-size: 0.85em;
color: var(--color-brand);
background: var(--surface-hover);
padding: 0.1em 0.35em;
border-radius: 0.3rem;
}
.report-prose pre {
background: var(--pre-bg);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1rem;
overflow-x: auto;
margin: 1rem 0;
}
.report-prose pre code { background: none; color: var(--fg); padding: 0; }
.report-prose ul, .report-prose ol { margin: 0.9rem 0; padding-left: 1.4rem; }
.report-prose ul { list-style: disc; }
.report-prose ol { list-style: decimal; }
.report-prose li { margin: 0.35rem 0; }
.report-prose li::marker { color: var(--faint); }
.report-prose blockquote {
border-left: 3px solid var(--color-brand);
padding-left: 1rem;
margin: 1rem 0;
color: var(--muted);
}
.report-prose hr { border: none; border-top: 1px solid var(--border); margin: 1.75rem 0; }
.report-prose table { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin: 1rem 0; }
.report-prose thead { border-bottom: 1px solid var(--border); }
.report-prose th { text-align: left; font-weight: 600; color: var(--fg); padding: 0.5rem 0.75rem; vertical-align: top; }
.report-prose td {
padding: 0.55rem 0.75rem;
vertical-align: top;
border-top: 1px solid var(--border);
word-break: break-word;
}
.report-prose tbody tr:hover { background: color-mix(in srgb, var(--fg) 3%, transparent); }
.report-prose img { border-radius: 0.5rem; }
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 96 96" width="96" height="96">
<title>career-ops</title>
<rect width="96" height="96" rx="14" fill="#D5742E"/>
<path d="M 32.407 64.702Q 28.975 64.702 26.206 62.401Q 23.437 60.100 21.799 55.810Q 20.161 51.520 20.161 45.592Q 20.161 38.962 22.150 34.048Q 24.139 29.134 27.571 26.443Q 31.003 23.752 35.215 23.752Q 38.803 23.752 40.831 25.156Q 41.611 25.702 41.611 26.872L 41.767 36.388Q 41.767 37.558 40.831 37.558Q 39.973 37.558 39.739 36.544Q 38.647 32.254 37.633 29.914Q 36.619 27.574 35.527 26.677Q 34.435 25.780 33.109 25.780Q 31.549 25.780 29.872 27.574Q 28.195 29.368 27.064 33.385Q 25.933 37.402 25.933 43.954Q 25.933 52.300 28.078 56.512Q 30.223 60.724 33.655 60.724Q 36.385 60.724 38.218 58.462Q 40.051 56.200 41.377 50.272Q 41.611 49.414 42.313 49.414Q 43.249 49.414 43.015 50.974Q 42.157 56.434 40.636 59.437Q 39.115 62.440 37.048 63.571Q 34.981 64.702 32.407 64.702Z" fill="#FFFFFF"/>
<path d="M 61.643 64.702Q 57.665 64.702 54.506 62.011Q 51.347 59.320 49.475 54.640Q 47.603 49.960 47.603 44.188Q 47.603 38.416 49.475 33.814Q 51.347 29.212 54.545 26.482Q 57.743 23.752 61.643 23.752Q 65.621 23.752 68.780 26.482Q 71.939 29.212 73.811 33.814Q 75.683 38.416 75.683 44.188Q 75.683 49.960 73.850 54.640Q 72.017 59.320 68.819 62.011Q 65.621 64.702 61.643 64.702ZM 61.643 62.752Q 69.755 62.752 69.755 44.188Q 69.755 25.702 61.643 25.702Q 53.531 25.702 53.531 44.188Q 53.531 62.752 61.643 62.752Z" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+89
View File
@@ -0,0 +1,89 @@
"use client";
import { use } from "react";
import Link from "next/link";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ArrowLeft, Loader2, Wrench, CircleDot, Check, X } from "lucide-react";
import { useJobs } from "@/components/jobs/job-store";
import { HeroGlow } from "@/components/hero-glow";
import { Badge } from "@/components/ui/badge";
export default function JobPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const { jobs } = useJobs();
const job = jobs.find((j) => j.id === id);
if (!job) {
return (
<div className="mx-auto max-w-3xl px-6 py-10">
<Link href="/pipeline" className="inline-flex items-center gap-1.5 text-sm text-muted transition-colors hover:text-brand">
<ArrowLeft className="size-4" /> Pipeline
</Link>
<p className="mt-8 text-sm text-muted">
This worker is no longer in memory (it finished earlier or the page was reloaded).
</p>
</div>
);
}
return (
<div className="mx-auto max-w-3xl px-6 py-8">
<Link href="/pipeline" className="inline-flex items-center gap-1.5 text-sm text-muted transition-colors hover:text-brand">
<ArrowLeft className="size-4" /> Pipeline
</Link>
<section className="dot-bg relative mt-5 overflow-hidden rounded-2xl border border-border bg-surface/40 px-6 py-7">
{job.status === "running" && <HeroGlow />}
<div className="relative z-10">
<p className="flex items-center gap-2 font-mono text-xs uppercase tracking-[0.18em] text-faint">
{job.status === "running" ? (
<><Loader2 className="size-3 animate-spin text-brand" /> working</>
) : job.status === "done" ? (
<><Check className="size-3 text-emerald-500" /> done</>
) : (
<><X className="size-3 text-red-400" /> error</>
)}
</p>
<h1 className="mt-2 font-display text-2xl tracking-tight text-landing">{job.title}</h1>
{job.subtitle && <p className="mt-1 text-sm text-muted">{job.subtitle}</p>}
{job.result?.score != null && (
<div className="mt-3 flex flex-wrap items-center gap-2.5">
<Badge tone={job.result.tone}>{job.result.score}/5</Badge>
{job.result.summary && <span className="text-sm text-muted">{job.result.summary}</span>}
</div>
)}
</div>
</section>
<ol className="mt-6 space-y-2">
{job.steps.map((s, i) => (
<li key={i} className="flex items-start gap-2.5 text-sm">
{s.kind === "tool" ? (
<Wrench className="mt-0.5 size-3.5 shrink-0 text-brand" />
) : (
<CircleDot className="mt-0.5 size-3.5 shrink-0 text-faint" />
)}
<span className={s.kind === "tool" ? "font-medium" : "text-muted"}>
{s.kind === "tool" ? `Using ${s.label}` : s.label}
</span>
</li>
))}
{job.status === "running" && (
<li className="flex items-center gap-2.5 text-sm text-muted">
<Loader2 className="size-3.5 animate-spin text-brand" /> thinking…
</li>
)}
</ol>
{job.text && (
<div className="mt-8">
<h2 className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Output</h2>
<div className="report-prose mt-3 rounded-2xl border border-border bg-surface/40 p-5">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{job.text}</ReactMarkdown>
</div>
</div>
)}
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import Link from "next/link";
import { Check, AlertTriangle, Loader2, Trash2 } from "lucide-react";
import { useJobs } from "@/components/jobs/job-store";
import { pillTone } from "@/components/jobs/worker-pills";
import { cn } from "@/lib/cn";
const TONE_CHIP = {
good: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
warn: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
bad: "bg-red-500/15 text-red-700 dark:text-red-400",
muted: "bg-surface-hover text-muted",
} as const;
export default function JobsHistory() {
const { jobs, clearFinished } = useJobs();
return (
<div className="mx-auto max-w-4xl px-6 py-10">
<div className="flex items-end justify-between">
<div>
<h1 className="font-display text-2xl tracking-tight text-landing">Workers</h1>
<p className="mt-1 text-sm text-muted">
Every evaluation you ran — a persistent log. <span className="tabular-nums">{jobs.length}</span> total.
</p>
</div>
{jobs.some((j) => j.status !== "running") && (
<button
onClick={clearFinished}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-surface px-3 py-1.5 text-xs text-muted transition-colors hover:bg-surface-hover hover:text-foreground"
>
<Trash2 className="size-3.5" /> Clear finished
</button>
)}
</div>
{jobs.length === 0 ? (
<div className="mt-8 rounded-2xl border border-dashed border-border bg-surface/30 px-6 py-12 text-center text-sm text-muted">
No workers yet. Hit <span className="text-foreground">Evaluate</span> on an inbox posting to spin one up.
</div>
) : (
<ul className="mt-6 divide-y divide-border overflow-hidden rounded-2xl border border-border bg-surface/40">
{jobs.map((j) => {
const tone = pillTone(j);
return (
<li key={j.id}>
<Link href={`/jobs/${j.id}`} className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-hover">
{j.status === "running" ? (
<Loader2 className="size-4 shrink-0 animate-spin text-brand" />
) : j.status === "error" ? (
<AlertTriangle className="size-4 shrink-0 text-red-400" />
) : (
<Check className="size-4 shrink-0 text-emerald-500" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{j.title}</div>
{(j.subtitle || j.result?.summary) && (
<div className="truncate text-xs text-muted">{j.result?.summary || j.subtitle}</div>
)}
</div>
{j.result?.score != null && (
<span className={cn("shrink-0 rounded-md px-1.5 py-0.5 text-xs font-semibold tabular-nums", TONE_CHIP[tone])}>
{j.result.score}/5
</span>
)}
<span className="hidden shrink-0 text-xs capitalize text-faint sm:block">{j.status}</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { Metadata, Viewport } from "next";
import { inter, instrumentSerif, instrumentSerifItalic } from "@/lib/fonts";
import { AppShell } from "@/components/app-shell";
import "./globals.css";
export const metadata: Metadata = {
title: "career-ops — official web experience",
description: "The official, local-first web experience for career-ops.",
// Home-screen / standalone (iOS): let our theme-color flow up to the status bar
// + Dynamic Island; safe-area insets handle the layout.
appleWebApp: { capable: true, statusBarStyle: "black-translucent", title: "career-ops" },
};
export const viewport: Viewport = {
// viewport-fit=cover → env(safe-area-inset-*) become non-zero so the header can
// sit flush under the notch / Dynamic Island.
viewportFit: "cover",
// Default (corrected to the real theme before paint by THEME_SCRIPT, then kept
// in sync by the theme toggle). Dark flows seamlessly into the black island.
themeColor: "#0a0a0a",
};
// Before paint: set the theme class AND tint the browser chrome (theme-color) to
// match — so Safari's status bar / URL bar unify with the header instead of a
// jarring light seam. Matches --bg (light #f7f6f3 / dark #0a0a0a).
const THEME_SCRIPT = `(function(){try{var t=localStorage.getItem('career-ops:theme');var d=t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches);if(d)document.documentElement.classList.add('dark');var m=document.querySelector('meta[name="theme-color"]');if(!m){m=document.createElement('meta');m.setAttribute('name','theme-color');document.head.appendChild(m);}m.setAttribute('content',d?'#0a0a0a':'#f7f6f3');}catch(e){document.documentElement.classList.add('dark');}})();`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html
lang="en"
suppressHydrationWarning
className={`${inter.variable} ${instrumentSerif.variable} ${instrumentSerifItalic.variable}`}
>
<body className="font-sans antialiased">
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
<AppShell>{children}</AppShell>
</body>
</html>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { pipelineSummary, doctorState } from "@/lib/career-ops";
import { OnboardingBanner } from "@/components/onboarding-banner";
import { FirstRunHome } from "@/components/home/first-run-home";
import { TodayDashboard } from "@/components/home/today-dashboard";
export const dynamic = "force-dynamic"; // always read fresh local files at request time (never at build — CI has no user data)
export default function Home() {
const { phase, onboardingNeeded } = doctorState();
// First run (truly empty install): the CV-upload takeover IS the home — value
// before commitment. The full dashboard returns once they have a CV or any data.
if (phase === "first-run") return <FirstRunHome />;
const { inbox, applications } = pipelineSummary();
// Established / in-between: the dual-loop retention dashboard. Show the setup
// banner whenever ANY prereq is missing (mirrors the core doctor.mjs), so a
// portals-missing user is nudged rather than told "all caught up".
return (
<>
{onboardingNeeded && <OnboardingBanner />}
<TodayDashboard applications={applications} inbox={inbox} inBetween={phase === "in-between"} />
</>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { notFound } from "next/navigation";
import { readReport, findApplication, trackerCanDelete } from "@/lib/career-ops";
import { ReportView } from "@/components/report-view";
export const dynamic = "force-dynamic";
export default async function ReportPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const app = findApplication(id);
const report = readReport(id);
if (!app && !report) notFound();
return <ReportView id={id} app={app} report={report?.content ?? null} file={report?.file ?? null} canDelete={trackerCanDelete()} />;
}
+14
View File
@@ -0,0 +1,14 @@
import { Suspense } from "react";
import { pipelineSummary } from "@/lib/career-ops";
import { PipelineView } from "@/components/pipeline-view";
export const dynamic = "force-dynamic"; // always read fresh local files
export default function PipelinePage() {
const { inbox, applications } = pipelineSummary();
return (
<Suspense>
<PipelineView applications={applications} inbox={inbox} />
</Suspense>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { Radar } from "lucide-react";
import { PortalsView } from "@/components/portals-view";
export const dynamic = "force-dynamic";
export default function PortalsPage() {
return (
<div className="mx-auto max-w-3xl px-6 py-8">
<div className="flex items-center gap-3">
<Radar className="size-6 text-brand" />
<h1 className="font-display text-2xl tracking-tight text-landing">Portals</h1>
</div>
<p className="mt-1.5 max-w-xl text-sm text-muted">
The companies the scanner watches for new roles (<code className="text-foreground">portals.yml</code>). Run a
health check to catch ATS slugs that have quietly broken — a 404 means that company silently disappears from
every future scan.
</p>
<div className="mt-6">
<PortalsView />
</div>
</div>
);
}