career-compass-mcp 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -12
- package/build/bin/cli.d.ts.map +1 -1
- package/build/bin/cli.js +85 -9
- package/build/bin/cli.js.map +1 -1
- package/build/src/dashboard-lite/render.d.ts +11 -11
- package/build/src/dashboard-lite/render.d.ts.map +1 -1
- package/build/src/dashboard-lite/render.js +139 -52
- package/build/src/dashboard-lite/render.js.map +1 -1
- package/build/src/dashboard-lite/server.d.ts +23 -7
- package/build/src/dashboard-lite/server.d.ts.map +1 -1
- package/build/src/dashboard-lite/server.js +106 -5
- package/build/src/dashboard-lite/server.js.map +1 -1
- package/build/src/empty-state.d.ts +16 -0
- package/build/src/empty-state.d.ts.map +1 -0
- package/build/src/empty-state.js +31 -0
- package/build/src/empty-state.js.map +1 -0
- package/build/src/pipeline-stats.d.ts +59 -0
- package/build/src/pipeline-stats.d.ts.map +1 -0
- package/build/src/pipeline-stats.js +66 -0
- package/build/src/pipeline-stats.js.map +1 -0
- package/build/src/sample-data.d.ts +49 -0
- package/build/src/sample-data.d.ts.map +1 -0
- package/build/src/sample-data.js +234 -0
- package/build/src/sample-data.js.map +1 -0
- package/build/src/schemas/career-schema.d.ts +15 -4
- package/build/src/schemas/career-schema.d.ts.map +1 -1
- package/build/src/schemas/career-schema.js +36 -2
- package/build/src/schemas/career-schema.js.map +1 -1
- package/build/src/server.d.ts +14 -1
- package/build/src/server.d.ts.map +1 -1
- package/build/src/server.js +4 -1
- package/build/src/server.js.map +1 -1
- package/build/src/storage/file-store.d.ts +25 -0
- package/build/src/storage/file-store.d.ts.map +1 -1
- package/build/src/storage/file-store.js +91 -6
- package/build/src/storage/file-store.js.map +1 -1
- package/build/src/tools/career-kb.d.ts +2 -0
- package/build/src/tools/career-kb.d.ts.map +1 -1
- package/build/src/tools/career-kb.js +155 -12
- package/build/src/tools/career-kb.js.map +1 -1
- package/build/src/tools/doctor.d.ts +115 -0
- package/build/src/tools/doctor.d.ts.map +1 -0
- package/build/src/tools/doctor.js +549 -0
- package/build/src/tools/doctor.js.map +1 -0
- package/build/src/tools/interview.d.ts.map +1 -1
- package/build/src/tools/interview.js +208 -1
- package/build/src/tools/interview.js.map +1 -1
- package/build/src/tools/opportunity.d.ts.map +1 -1
- package/build/src/tools/opportunity.js +103 -23
- package/build/src/tools/opportunity.js.map +1 -1
- package/build/src/tools/pipeline.d.ts +23 -1
- package/build/src/tools/pipeline.d.ts.map +1 -1
- package/build/src/tools/pipeline.js +111 -18
- package/build/src/tools/pipeline.js.map +1 -1
- package/build/src/tools/resume.d.ts.map +1 -1
- package/build/src/tools/resume.js +3 -2
- package/build/src/tools/resume.js.map +1 -1
- package/build/src/types/tool-args.d.ts +4 -1
- package/build/src/types/tool-args.d.ts.map +1 -1
- package/build/src/version.d.ts +9 -0
- package/build/src/version.d.ts.map +1 -1
- package/build/src/version.js +10 -1
- package/build/src/version.js.map +1 -1
- package/data/example/career/journal.yaml +4 -4
- package/data/example/pipeline/applications.yaml +66 -66
- package/package.json +5 -2
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { STATUS_ORDER, statusRank } from "../schemas/career-schema.js";
|
|
2
|
+
import { ACTIVE_STATUSES, computeStats } from "../pipeline-stats.js";
|
|
1
3
|
/**
|
|
2
4
|
* Zero-build "lite" dashboard.
|
|
3
5
|
*
|
|
@@ -12,17 +14,40 @@
|
|
|
12
14
|
* than dispatching it. In Cowork, the artifact variant wires the same prompts to
|
|
13
15
|
* sendPrompt() instead.
|
|
14
16
|
*/
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
// The board's column order is the funnel order declared on the schema, shared
|
|
18
|
+
// with `pipeline_view sortBy=status` so the two surfaces cannot disagree about
|
|
19
|
+
// which stage comes first.
|
|
20
|
+
const STAGE_ORDER = STATUS_ORDER;
|
|
21
|
+
// The same live-stage list the stats use, so the chart cannot show a stage the
|
|
22
|
+
// "Active" KPI is not counting.
|
|
23
|
+
const ACTIVE = ACTIVE_STATUSES;
|
|
24
|
+
/**
|
|
25
|
+
* Stage colours as a temperature ramp, not a rainbow.
|
|
26
|
+
*
|
|
27
|
+
* These were the stock Tailwind hues — slate, sky, violet, amber, emerald, red.
|
|
28
|
+
* Six fully-saturated colours from three different families, dropped into a page
|
|
29
|
+
* built on warm neutrals and one clay accent: the board and the chart read as a
|
|
30
|
+
* different product than the frame around them, and in dark mode the violet and
|
|
31
|
+
* sky went neon.
|
|
32
|
+
*
|
|
33
|
+
* The replacement encodes the funnel instead of decorating it. Cool ash at the
|
|
34
|
+
* top where nothing has happened yet, warming through brass and clay as a
|
|
35
|
+
* conversation heats up, resolving to green when it lands. Everything is
|
|
36
|
+
* desaturated enough to sit inside a warm neutral system and to hold contrast
|
|
37
|
+
* on both the cream and the bark background. Terminal states are muted on
|
|
38
|
+
* purpose — a rejection should not be the brightest thing on the page.
|
|
39
|
+
*/
|
|
22
40
|
const STAGE_COLOR = {
|
|
23
|
-
discovered: "#
|
|
24
|
-
|
|
25
|
-
|
|
41
|
+
discovered: "#8e887d", // ash — noticed, not acted on
|
|
42
|
+
applied: "#7c8fa1", // cool steel — sent, waiting
|
|
43
|
+
screening: "#a2894f", // brass — someone replied
|
|
44
|
+
interviewing: "#c2603c", // clay — the product's accent, the hottest live stage
|
|
45
|
+
offer: "#4f8a6d", // sage — it landed
|
|
46
|
+
negotiating: "#3f7f74", // deep teal — landed, still moving
|
|
47
|
+
accepted: "#3d7a52", // forest — done, good
|
|
48
|
+
rejected: "#a85a4a", // muted brick — done, not good. Deliberately not red.
|
|
49
|
+
withdrawn: "#8a8278", // faded ash — done, your call
|
|
50
|
+
ghosted: "#6f6a63", // dim — done, no answer
|
|
26
51
|
};
|
|
27
52
|
function esc(s) {
|
|
28
53
|
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
@@ -30,8 +55,20 @@ function esc(s) {
|
|
|
30
55
|
/** Derive next actions from follow-up dates, upcoming interviews, and expiring offers. */
|
|
31
56
|
export function deriveNextActions(apps, today = new Date()) {
|
|
32
57
|
const out = [];
|
|
33
|
-
|
|
34
|
-
|
|
58
|
+
// Compare CALENDAR DAYS in the user's own timezone, not timestamps.
|
|
59
|
+
// `new Date("2026-07-24")` is parsed as UTC midnight, while `Date.now()` is
|
|
60
|
+
// local — so west of UTC a follow-up due today came out negative and rendered
|
|
61
|
+
// as "overdue by 1d", and east of UTC tomorrow's reminder fired a day early.
|
|
62
|
+
// A date-only field has no time in it; treating it as one is the bug.
|
|
63
|
+
const midnight = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
|
|
64
|
+
const days = (iso) => {
|
|
65
|
+
if (!iso)
|
|
66
|
+
return NaN;
|
|
67
|
+
const [y, m, d] = iso.slice(0, 10).split("-").map(Number);
|
|
68
|
+
if (!y || !m || !d)
|
|
69
|
+
return NaN;
|
|
70
|
+
return Math.round((new Date(y, m - 1, d).getTime() - midnight) / 86400000);
|
|
71
|
+
};
|
|
35
72
|
for (const app of apps) {
|
|
36
73
|
if (["accepted", "rejected", "withdrawn", "ghosted"].includes(app.status))
|
|
37
74
|
continue;
|
|
@@ -42,7 +79,13 @@ export function deriveNextActions(apps, today = new Date()) {
|
|
|
42
79
|
else if (fu <= 2)
|
|
43
80
|
out.push({ app, label: `Follow-up due ${fu === 0 ? "today" : `in ${fu}d`} — ${app.company}`, urgency: "soon" });
|
|
44
81
|
}
|
|
45
|
-
|
|
82
|
+
// A future-dated round on an application that has already reached offer or
|
|
83
|
+
// negotiating is a leftover, not a plan — the process moved past it.
|
|
84
|
+
// Filtering rounds by date alone put "Interview in 2d" beside an offer
|
|
85
|
+
// under review, in the demo, on the panel that is supposed to say what to
|
|
86
|
+
// do next.
|
|
87
|
+
const stillInterviewing = statusRank(app.status) <= statusRank("interviewing");
|
|
88
|
+
const nextInterview = !stillInterviewing ? undefined : (app.interviewRounds ?? [])
|
|
46
89
|
.map((r) => days(r.date)).filter((d) => !Number.isNaN(d) && d >= 0).sort((a, b) => a - b)[0];
|
|
47
90
|
if (nextInterview != null && nextInterview <= 7) {
|
|
48
91
|
out.push({ app, label: `Interview ${nextInterview === 0 ? "today" : `in ${nextInterview}d`} — ${app.company}`, urgency: nextInterview <= 2 ? "soon" : "info" });
|
|
@@ -56,20 +99,6 @@ export function deriveNextActions(apps, today = new Date()) {
|
|
|
56
99
|
const rank = { overdue: 0, soon: 1, info: 2 };
|
|
57
100
|
return out.sort((a, b) => rank[a.urgency] - rank[b.urgency]);
|
|
58
101
|
}
|
|
59
|
-
export function computeStats(apps) {
|
|
60
|
-
const total = apps.length;
|
|
61
|
-
const active = apps.filter((a) => ACTIVE.includes(a.status)).length;
|
|
62
|
-
const inConversation = apps.filter((a) => ["screening", "interviewing"].includes(a.status)).length;
|
|
63
|
-
const offers = apps.filter((a) => ["offer", "negotiating", "accepted"].includes(a.status)).length;
|
|
64
|
-
const applied = apps.filter((a) => a.status !== "discovered").length;
|
|
65
|
-
const responded = apps.filter((a) => !["discovered", "applied", "ghosted"].includes(a.status)).length;
|
|
66
|
-
const ghosted = apps.filter((a) => a.status === "ghosted").length;
|
|
67
|
-
return {
|
|
68
|
-
total, active, inConversation, offers,
|
|
69
|
-
responseRate: applied ? Math.round((responded / applied) * 100) : 0,
|
|
70
|
-
ghostRate: applied ? Math.round((ghosted / applied) * 100) : 0,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
102
|
function kpi(n, label, foot = "") {
|
|
74
103
|
return `<div class="kpi"><div class="n">${esc(n)}</div><div class="l">${esc(label)}</div>${foot ? `<div class="foot">${esc(foot)}</div>` : ""}</div>`;
|
|
75
104
|
}
|
|
@@ -84,8 +113,17 @@ function jobCard(a) {
|
|
|
84
113
|
<div class="meta">${pr}${exc}</div>
|
|
85
114
|
</div>`;
|
|
86
115
|
}
|
|
87
|
-
/**
|
|
88
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Render the full self-contained HTML document.
|
|
118
|
+
*
|
|
119
|
+
* `dataDir` is passed in rather than read here so this module stays free of the
|
|
120
|
+
* store (and of node-only APIs — the Next dashboard imports from this tree).
|
|
121
|
+
* When it is absent the footer simply does not name a folder, which is the one
|
|
122
|
+
* honest thing to say without knowing it: the footer used to print
|
|
123
|
+
* `~/.career-compass` unconditionally, so anyone running with CAREER_DATA_PATH
|
|
124
|
+
* set was told their data was somewhere it wasn't.
|
|
125
|
+
*/
|
|
126
|
+
export function renderLiteDashboard(pipeline, dataDir) {
|
|
89
127
|
const apps = [...(pipeline.applications ?? [])].sort((a, b) => (b.dateUpdated ?? "").localeCompare(a.dateUpdated ?? ""));
|
|
90
128
|
const s = computeStats(apps);
|
|
91
129
|
const actions = deriveNextActions(apps);
|
|
@@ -98,14 +136,37 @@ export function renderLiteDashboard(pipeline) {
|
|
|
98
136
|
kpi(`${s.responseRate}%`, "Response rate"),
|
|
99
137
|
kpi(`${s.ghostRate}%`, "Ghost rate"),
|
|
100
138
|
].join("");
|
|
101
|
-
|
|
102
|
-
|
|
139
|
+
// Only show a stage that has something in it, plus the handful of early
|
|
140
|
+
// stages that read as "nothing here yet" rather than as clutter. Rendering
|
|
141
|
+
// every active stage regardless meant a small pipeline still produced six
|
|
142
|
+
// columns, several of them just "0 —".
|
|
143
|
+
// The live funnel is the thing you scan every day; closed applications are
|
|
144
|
+
// reference. Giving them equal column weight pushed the board onto a second
|
|
145
|
+
// ragged row and made "Rejected" as loud as "Interviewing". Terminal stages
|
|
146
|
+
// now collapse behind one disclosure.
|
|
147
|
+
const CLOSED = ["accepted", "rejected", "withdrawn", "ghosted"];
|
|
148
|
+
const ALWAYS_SHOW = ["applied", "screening", "interviewing"];
|
|
149
|
+
const stageCol = (st) => {
|
|
103
150
|
const items = apps.filter((a) => a.status === st);
|
|
104
151
|
return `<div class="col">
|
|
105
152
|
<div class="h"><span class="sw" style="background:${STAGE_COLOR[st]}"></span> ${st} <span class="count">${items.length}</span></div>
|
|
106
|
-
<div class="stack">${items.map(jobCard).join("") || '<div class="none"
|
|
153
|
+
<div class="stack">${items.map(jobCard).join("") || '<div class="none">Nothing here yet</div>'}</div>
|
|
107
154
|
</div>`;
|
|
108
|
-
}
|
|
155
|
+
};
|
|
156
|
+
const liveStages = STAGE_ORDER.filter((st) => !CLOSED.includes(st) && (apps.some((a) => a.status === st) || ALWAYS_SHOW.includes(st)));
|
|
157
|
+
const closedStages = STAGE_ORDER.filter((st) => CLOSED.includes(st) && apps.some((a) => a.status === st));
|
|
158
|
+
const closedCount = apps.filter((a) => CLOSED.includes(a.status)).length;
|
|
159
|
+
const board = liveStages.map(stageCol).join("");
|
|
160
|
+
const closedBoard = closedCount
|
|
161
|
+
? `<details class="closed">
|
|
162
|
+
<summary>Closed <span class="count">${closedCount}</span>
|
|
163
|
+
<span class="dots">${closedStages
|
|
164
|
+
.map((st) => `<span class="sw" style="background:${STAGE_COLOR[st]}" title="${st}"></span>`)
|
|
165
|
+
.join("")}</span>
|
|
166
|
+
</summary>
|
|
167
|
+
<div class="board">${closedStages.map(stageCol).join("")}</div>
|
|
168
|
+
</details>`
|
|
169
|
+
: "";
|
|
109
170
|
const actionsHtml = actions.length
|
|
110
171
|
? actions.map((x) => `<div class="action ${x.urgency}"><span class="dot"></span><div class="t"><b>${esc(x.label)}</b><span>${esc(x.app.role)}</span></div></div>`).join("")
|
|
111
172
|
: `<div class="none-lg">✅ Nothing overdue. Follow-ups, upcoming interviews, and expiring offers surface here.</div>`;
|
|
@@ -123,8 +184,13 @@ export function renderLiteDashboard(pipeline) {
|
|
|
123
184
|
<html lang="en"><head>
|
|
124
185
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
125
186
|
<title>Career Compass — Pipeline</title>
|
|
187
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%231f1e1d'/%3E%3Ccircle cx='16' cy='16' r='10' fill='%23c2603c'/%3E%3Cpath d='M20.5 10.5 L14.5 17.5 L11.5 21.5 L17.5 14.5 Z' fill='%231f1e1d'/%3E%3C/svg%3E">
|
|
126
188
|
<style>
|
|
127
|
-
|
|
189
|
+
/* Warm neutrals + clay accent, so the page sits inside Claude rather than
|
|
190
|
+
next to it, and follows the host's light/dark preference instead of
|
|
191
|
+
blinding anyone running Claude Desktop in dark mode. */
|
|
192
|
+
:root{color-scheme:light dark;--bg:#faf9f5;--card:#fff;--ink:#1f1e1d;--muted:#6b675f;--line:#e8e5dd;--accent:#c2603c;--accent-soft:#f6ece7;--sunk:#f5f3ed;--shadow:0 1px 2px rgba(31,30,29,.05),0 4px 14px rgba(31,30,29,.04)}
|
|
193
|
+
@media(prefers-color-scheme:dark){:root{--bg:#1f1e1d;--card:#262624;--ink:#f2efe8;--muted:#a5a096;--line:#37352f;--accent:#e08560;--accent-soft:#33241d;--sunk:#221f1d;--shadow:0 1px 2px rgba(0,0,0,.3),0 4px 14px rgba(0,0,0,.22)}}
|
|
128
194
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased}
|
|
129
195
|
.wrap{max-width:1180px;margin:0 auto;padding:20px 20px 64px}
|
|
130
196
|
header.top{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:18px}
|
|
@@ -133,35 +199,58 @@ h1{font-size:20px;margin:0 0 2px;letter-spacing:-.01em}.sub{color:var(--muted);f
|
|
|
133
199
|
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:20px}
|
|
134
200
|
.kpi{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px;box-shadow:var(--shadow)}
|
|
135
201
|
.kpi .n{font-size:26px;font-weight:700;letter-spacing:-.02em}.kpi .l{color:var(--muted);font-size:12px;margin-top:2px}.kpi .foot{font-size:11.5px;margin-top:6px;color:var(--muted)}
|
|
136
|
-
|
|
202
|
+
/* minmax(0,…) is load-bearing, not decoration. A grid item's default
|
|
203
|
+
min-width is auto — its min-content — so a bare 1fr track is a *floor*,
|
|
204
|
+
not a cap: one long unbreakable token in a company name or a follow-up
|
|
205
|
+
label grows its own rail past its share, and the sibling rail is squeezed
|
|
206
|
+
to whatever is left. Measured on the sample pipeline at 1512px, the rails
|
|
207
|
+
went 1263px / 175px, the chart track collapsed to 3px, and the page ran
|
|
208
|
+
135px off the right edge. minmax(0,1fr) lets the item shrink; the
|
|
209
|
+
overflow-wrap below is what keeps the token itself inside the card once
|
|
210
|
+
the box stops growing to fit it. */
|
|
211
|
+
.grid2{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;align-items:start}@media(max-width:900px){.grid2{grid-template-columns:minmax(0,1fr)}}
|
|
212
|
+
.grid2>*,.board>*{min-width:0}
|
|
213
|
+
.action .t b,.jc .co,.jc .ro{overflow-wrap:anywhere}
|
|
137
214
|
.panel{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}
|
|
138
215
|
.panel h2{font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:0;padding:14px 16px;border-bottom:1px solid var(--line)}
|
|
139
|
-
.panel .body{padding:14px 16px}.chart-box{position:relative;
|
|
216
|
+
.panel .body{padding:14px 16px}.chart-box{position:relative;display:flex;gap:10px}
|
|
140
217
|
.bar-row{display:flex;flex-direction:column;gap:9px;width:100%}
|
|
141
218
|
.bar{display:flex;align-items:center;gap:10px}.bar .lab{width:96px;font-size:12px;color:var(--muted);text-align:right;flex:0 0 auto}
|
|
142
|
-
.bar .track{flex:1;background
|
|
219
|
+
.bar .track{flex:1;background:var(--sunk);border-radius:6px;height:22px;overflow:hidden}
|
|
220
|
+
/* display:block is load-bearing. These are <span>s, and width has no effect on
|
|
221
|
+
an inline box — so every bar in this chart rendered as an empty track. */
|
|
222
|
+
.bar .fill{display:block;height:100%;border-radius:6px;min-width:3px;transition:width .3s ease}
|
|
143
223
|
.bar .val{font-size:12px;font-weight:600;width:22px}
|
|
144
224
|
.action{display:flex;gap:10px;padding:11px 0;border-bottom:1px dashed var(--line)}.action:last-child{border-bottom:0}
|
|
145
225
|
.action .dot{width:8px;height:8px;border-radius:50%;margin-top:6px;flex:0 0 auto;background:var(--accent)}
|
|
146
226
|
.action.overdue .dot{background:#ef4444}.action.soon .dot{background:#f59e0b}
|
|
147
227
|
.action .t{flex:1}.action .t b{display:block;font-size:13.5px}.action .t span{color:var(--muted);font-size:12px}
|
|
148
|
-
.board{display:grid;grid-
|
|
149
|
-
.col{background
|
|
228
|
+
.board{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:12px;align-items:start}
|
|
229
|
+
.col{background:var(--sunk);border:1px solid var(--line);border-radius:12px;min-height:80px}
|
|
150
230
|
.col .h{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line);font-weight:600;font-size:12.5px;text-transform:capitalize}
|
|
151
|
-
.col .h .sw{width:9px;height:9px;border-radius:50%}.col .h .count{margin-left:auto;background
|
|
231
|
+
.col .h .sw{width:9px;height:9px;border-radius:50%}.col .h .count{margin-left:auto;background:var(--sunk);color:var(--muted);border:1px solid var(--line);border-radius:999px;font-size:11px;padding:1px 8px}
|
|
152
232
|
.col .stack{padding:10px;display:flex;flex-direction:column;gap:9px}.none{color:var(--muted);font-size:11.5px;padding:4px 2px}.none-lg{color:var(--muted);font-size:13px}
|
|
153
233
|
.jc{background:var(--card);border:1px solid var(--line);border-left:3px solid var(--stage,#ccc);border-radius:10px;padding:10px 11px;cursor:pointer;transition:.12s}
|
|
154
234
|
.jc:hover{box-shadow:var(--shadow);transform:translateY(-1px)}.jc .co{font-weight:650;font-size:13px}.jc .ro{color:var(--muted);font-size:12px;margin-top:1px}
|
|
155
235
|
.jc .meta{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;align-items:center}
|
|
156
|
-
.pill{font-size:10.5px;padding:2px 7px;border-radius:999px;font-weight:600}.p-high{background
|
|
236
|
+
.pill{font-size:10.5px;padding:2px 7px;border-radius:999px;font-weight:600}.p-high{background:color-mix(in srgb,#ef4444 16%,transparent);color:#c04a3a}.p-medium{background:color-mix(in srgb,#f59e0b 18%,transparent);color:#a06a1b}.p-low{background:color-mix(in srgb,var(--muted) 16%,transparent);color:var(--muted)}
|
|
157
237
|
.exc{font-size:11px;color:var(--muted);margin-left:auto}
|
|
158
238
|
.btn{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:9px;padding:7px 12px;font-size:12.5px;font-weight:600;cursor:pointer;transition:.12s}
|
|
159
|
-
.btn:hover{border-color:var(--accent);color:var(--accent)}.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}.btn.primary:hover{
|
|
239
|
+
.btn:hover{border-color:var(--accent);color:var(--accent)}.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}.btn.primary:hover{filter:brightness(.93)}
|
|
160
240
|
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 14px}
|
|
161
241
|
.state{text-align:center;padding:48px 20px;color:var(--muted)}.state h3{color:var(--ink);font-size:16px;margin:0 0 6px}.state .btns{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin-top:16px}
|
|
162
242
|
.foot-note{color:var(--muted);font-size:11.5px;margin-top:22px;text-align:center}
|
|
163
|
-
#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%) translateY(20px);background:var(--ink);color
|
|
243
|
+
#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%) translateY(20px);background:var(--ink);color:var(--card);padding:10px 16px;border-radius:10px;font-size:13px;opacity:0;transition:.2s;pointer-events:none}
|
|
164
244
|
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
|
245
|
+
.closed{margin-top:12px;border-top:1px solid var(--line);padding-top:10px}
|
|
246
|
+
.closed summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:600;color:var(--muted);padding:4px 2px;border-radius:8px}
|
|
247
|
+
.closed summary::-webkit-details-marker{display:none}
|
|
248
|
+
.closed summary::before{content:"▸";display:inline-block;transition:transform .15s;color:var(--muted);font-size:11px}
|
|
249
|
+
.closed[open] summary::before{transform:rotate(90deg)}
|
|
250
|
+
.closed summary:hover{color:var(--ink)}
|
|
251
|
+
.closed .dots{display:inline-flex;gap:4px;margin-left:2px}
|
|
252
|
+
.closed .dots .sw{width:8px;height:8px;border-radius:50%}
|
|
253
|
+
.closed .board{margin-top:10px}
|
|
165
254
|
.hidden{display:none!important}
|
|
166
255
|
</style></head>
|
|
167
256
|
<body><div class="wrap">
|
|
@@ -176,17 +265,15 @@ h1{font-size:20px;margin:0 0 2px;letter-spacing:-.01em}.sub{color:var(--muted);f
|
|
|
176
265
|
</div>
|
|
177
266
|
<div class="kpis">${kpis}</div>
|
|
178
267
|
${apps.length === 0 ? emptyState : `
|
|
268
|
+
<div class="panel" style="margin-bottom:16px">
|
|
269
|
+
<h2>Pipeline by stage</h2>
|
|
270
|
+
<div class="body"><div class="board">${board}</div>${closedBoard}</div>
|
|
271
|
+
</div>
|
|
179
272
|
<div class="grid2">
|
|
180
|
-
<div class="panel">
|
|
181
|
-
|
|
182
|
-
<div class="body"><div class="board">${board}</div></div>
|
|
183
|
-
</div>
|
|
184
|
-
<div style="display:flex;flex-direction:column;gap:16px">
|
|
185
|
-
<div class="panel"><h2>Next actions</h2><div class="body">${actionsHtml}</div></div>
|
|
186
|
-
<div class="panel"><h2>Stage distribution</h2><div class="body"><div id="chart" class="chart-box"></div></div></div>
|
|
187
|
-
</div>
|
|
273
|
+
<div class="panel"><h2>Next actions</h2><div class="body">${actionsHtml}</div></div>
|
|
274
|
+
<div class="panel"><h2>Stage distribution</h2><div class="body"><div id="chart" class="chart-box"></div></div></div>
|
|
188
275
|
</div>`}
|
|
189
|
-
<div class="foot-note"
|
|
276
|
+
<div class="foot-note">${dataDir ? `Data stays local (<code>${esc(dataDir)}</code>).` : "Data stays local on this machine."} Click any card to copy a prompt for Claude — that's where the work happens. Refresh the page to re-read from disk.</div>
|
|
190
277
|
</div>
|
|
191
278
|
<div id="toast"></div>
|
|
192
279
|
<script>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"render.js","sourceRoot":"","sources":["../../../src/dashboard-lite/render.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"render.js","sourceRoot":"","sources":["../../../src/dashboard-lite/render.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
import { type Server } from "http";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Is this `Host` header one of ours?
|
|
4
4
|
*
|
|
5
|
-
* A
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* of ~/.career-compass, mirroring the force-dynamic guarantee of the Next.js
|
|
9
|
-
* dashboard. Ships in the npm package (pure JS, no framework, no build).
|
|
5
|
+
* A missing header is refused too. HTTP/1.1 requires it and every browser sends
|
|
6
|
+
* it, so its absence is either a hand-written request or an attempt to skip the
|
|
7
|
+
* check — neither is a case worth serving a career history to.
|
|
10
8
|
*/
|
|
9
|
+
export declare function isAllowedHost(host: string | undefined): boolean;
|
|
11
10
|
export declare function createLiteDashboardServer(): Server;
|
|
12
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Start the lite dashboard on `port`, resolving once it's listening.
|
|
13
|
+
*
|
|
14
|
+
* Binds the literal IPv4 loopback rather than the name "localhost". Passing a
|
|
15
|
+
* name lets Node resolve it, and on Windows that returns `::1` first — so the
|
|
16
|
+
* server bound IPv6-only, and every client that tried `127.0.0.1` got
|
|
17
|
+
* ECONNREFUSED while the CLI cheerfully printed a `http://localhost` URL that
|
|
18
|
+
* happened to work on the author's machine. Which of the two a given browser
|
|
19
|
+
* picks is not something we get to decide, so binding the name is a coin flip
|
|
20
|
+
* on someone else's computer.
|
|
21
|
+
*
|
|
22
|
+
* `127.0.0.1` is the address every client tries, so it is the one that must
|
|
23
|
+
* work. A second listener on `::1` is attempted for hosts where `localhost`
|
|
24
|
+
* resolves IPv6-first; failure there is ignored, because IPv4 alone is a
|
|
25
|
+
* working dashboard and a machine with no IPv6 loopback is not an error.
|
|
26
|
+
*
|
|
27
|
+
* Both are loopback: the dashboard is never reachable from the network.
|
|
28
|
+
*/
|
|
13
29
|
export declare function startLiteDashboard(port: number, hostname?: string): Promise<Server>;
|
|
14
30
|
//# sourceMappingURL=server.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../src/dashboard-lite/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,MAAM,EAAE,MAAM,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../src/dashboard-lite/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,MAAM,EAAE,MAAM,MAAM,CAAC;AAkEjD;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAI/D;AAED,wBAAgB,yBAAyB,IAAI,MAAM,CAwClD;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,SAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAgBxF"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createServer } from "http";
|
|
2
|
-
import { loadPipeline, isCorruptDataError } from "../storage/file-store.js";
|
|
2
|
+
import { getDataDir, loadPipeline, isCorruptDataError } from "../storage/file-store.js";
|
|
3
3
|
import { renderLiteDashboard } from "./render.js";
|
|
4
4
|
/**
|
|
5
5
|
* Zero-build "lite" dashboard server.
|
|
@@ -10,8 +10,79 @@ import { renderLiteDashboard } from "./render.js";
|
|
|
10
10
|
* of ~/.career-compass, mirroring the force-dynamic guarantee of the Next.js
|
|
11
11
|
* dashboard. Ships in the npm package (pure JS, no framework, no build).
|
|
12
12
|
*/
|
|
13
|
+
/**
|
|
14
|
+
* Host names this server will answer to. Everything else is refused.
|
|
15
|
+
*
|
|
16
|
+
* Binding loopback stops the *network* from reaching the dashboard; it does not
|
|
17
|
+
* stop a web page. Any site the user visits can point a hostname it controls at
|
|
18
|
+
* 127.0.0.1 and have the browser issue same-origin requests to this server —
|
|
19
|
+
* DNS rebinding. The one thing that distinguishes those requests from a real
|
|
20
|
+
* one is the `Host` header, which carries the attacker's name rather than a
|
|
21
|
+
* loopback name, so checking it is the whole defense.
|
|
22
|
+
*
|
|
23
|
+
* The dashboard serves a user's entire job search — companies, salaries,
|
|
24
|
+
* contacts, interview notes — from a machine-local origin with no auth, which
|
|
25
|
+
* is exactly the shape of target rebinding exists to reach.
|
|
26
|
+
*/
|
|
27
|
+
const ALLOWED_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
28
|
+
/**
|
|
29
|
+
* Split the hostname out of a `Host` header value, or reject the value.
|
|
30
|
+
*
|
|
31
|
+
* `Host` is `name[":" port]`, but a bracketed IPv6 literal (`[::1]:3141`) and a
|
|
32
|
+
* bare one (`::1`, which some clients send) both contain colons, so a naive
|
|
33
|
+
* split on ":" turns `[::1]` into `[`.
|
|
34
|
+
*
|
|
35
|
+
* Everything after the name is checked rather than discarded. Taking the left
|
|
36
|
+
* half of the first colon and asking no questions about the right half let
|
|
37
|
+
* `localhost:evil.com` and `[::1]evil.com` read as loopback. Nothing can put
|
|
38
|
+
* those bytes on the wire from a browser — `Host` is built from the URL
|
|
39
|
+
* authority, so a rebound page still sends its own name — but a parser that
|
|
40
|
+
* answers a question it was not asked is one refactor away from mattering.
|
|
41
|
+
* Only `:<digits>` is a port; anything else makes the header malformed, and
|
|
42
|
+
* malformed is not loopback.
|
|
43
|
+
*/
|
|
44
|
+
function hostnameOf(host) {
|
|
45
|
+
const h = host.trim().toLowerCase();
|
|
46
|
+
if (!h)
|
|
47
|
+
return null;
|
|
48
|
+
// Bracketed IPv6 literal: nothing may follow the closing bracket but a port.
|
|
49
|
+
if (h.startsWith("[")) {
|
|
50
|
+
const bracketed = /^(\[[0-9a-f:.]+\])(?::\d+)?$/.exec(h);
|
|
51
|
+
return bracketed ? bracketed[1] : null;
|
|
52
|
+
}
|
|
53
|
+
// More than one colon and no brackets: a bare IPv6 literal, which cannot
|
|
54
|
+
// carry a port — the whole value is the hostname, so every character of it
|
|
55
|
+
// has to be one an address can contain.
|
|
56
|
+
if (h.split(":").length - 1 > 1) {
|
|
57
|
+
return /^[0-9a-f:.]+$/.test(h) ? h : null;
|
|
58
|
+
}
|
|
59
|
+
const named = /^([^:]+)(?::\d+)?$/.exec(h);
|
|
60
|
+
return named ? named[1] : null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Is this `Host` header one of ours?
|
|
64
|
+
*
|
|
65
|
+
* A missing header is refused too. HTTP/1.1 requires it and every browser sends
|
|
66
|
+
* it, so its absence is either a hand-written request or an attempt to skip the
|
|
67
|
+
* check — neither is a case worth serving a career history to.
|
|
68
|
+
*/
|
|
69
|
+
export function isAllowedHost(host) {
|
|
70
|
+
if (!host)
|
|
71
|
+
return false;
|
|
72
|
+
const hostname = hostnameOf(host);
|
|
73
|
+
return hostname !== null && ALLOWED_HOSTNAMES.has(hostname);
|
|
74
|
+
}
|
|
13
75
|
export function createLiteDashboardServer() {
|
|
14
76
|
return createServer(async (req, res) => {
|
|
77
|
+
// Checked before anything else, including the path: a rejected origin must
|
|
78
|
+
// not learn which paths exist or how the server responds to them.
|
|
79
|
+
if (!isAllowedHost(req.headers.host)) {
|
|
80
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
81
|
+
res.end(`Refused: this dashboard only answers to ${[...ALLOWED_HOSTNAMES].join(", ")}.\n` +
|
|
82
|
+
`The request arrived with Host: ${req.headers.host ?? "(none)"}.\n\n` +
|
|
83
|
+
`It binds loopback and is meant to be opened at http://localhost:<port> on this machine.`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
15
86
|
// Only serve the dashboard at "/"; everything else 404s (favicon, etc.).
|
|
16
87
|
const path = (req.url ?? "/").split("?")[0];
|
|
17
88
|
if (path !== "/" && path !== "/index.html") {
|
|
@@ -21,7 +92,9 @@ export function createLiteDashboardServer() {
|
|
|
21
92
|
}
|
|
22
93
|
try {
|
|
23
94
|
const pipeline = await loadPipeline();
|
|
24
|
-
|
|
95
|
+
// Read per request, like the pipeline itself: the footer names the folder
|
|
96
|
+
// this page is actually serving, not the default one.
|
|
97
|
+
const html = renderLiteDashboard(pipeline, getDataDir());
|
|
25
98
|
res.writeHead(200, {
|
|
26
99
|
"content-type": "text/html; charset=utf-8",
|
|
27
100
|
// Never cache: the whole point is a live read of local data.
|
|
@@ -39,12 +112,40 @@ export function createLiteDashboardServer() {
|
|
|
39
112
|
}
|
|
40
113
|
});
|
|
41
114
|
}
|
|
42
|
-
/**
|
|
43
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Start the lite dashboard on `port`, resolving once it's listening.
|
|
117
|
+
*
|
|
118
|
+
* Binds the literal IPv4 loopback rather than the name "localhost". Passing a
|
|
119
|
+
* name lets Node resolve it, and on Windows that returns `::1` first — so the
|
|
120
|
+
* server bound IPv6-only, and every client that tried `127.0.0.1` got
|
|
121
|
+
* ECONNREFUSED while the CLI cheerfully printed a `http://localhost` URL that
|
|
122
|
+
* happened to work on the author's machine. Which of the two a given browser
|
|
123
|
+
* picks is not something we get to decide, so binding the name is a coin flip
|
|
124
|
+
* on someone else's computer.
|
|
125
|
+
*
|
|
126
|
+
* `127.0.0.1` is the address every client tries, so it is the one that must
|
|
127
|
+
* work. A second listener on `::1` is attempted for hosts where `localhost`
|
|
128
|
+
* resolves IPv6-first; failure there is ignored, because IPv4 alone is a
|
|
129
|
+
* working dashboard and a machine with no IPv6 loopback is not an error.
|
|
130
|
+
*
|
|
131
|
+
* Both are loopback: the dashboard is never reachable from the network.
|
|
132
|
+
*/
|
|
133
|
+
export function startLiteDashboard(port, hostname = "127.0.0.1") {
|
|
44
134
|
const server = createLiteDashboardServer();
|
|
45
135
|
return new Promise((resolve, reject) => {
|
|
46
136
|
server.once("error", reject);
|
|
47
|
-
server.listen(port, hostname, () =>
|
|
137
|
+
server.listen(port, hostname, () => {
|
|
138
|
+
if (hostname !== "127.0.0.1")
|
|
139
|
+
return resolve(server);
|
|
140
|
+
// Best-effort dual-stack. Its lifetime is tied to the primary server so
|
|
141
|
+
// `close()` on the returned handle tears down both.
|
|
142
|
+
const v6 = createLiteDashboardServer();
|
|
143
|
+
v6.once("error", () => resolve(server)); // no IPv6 loopback: IPv4 is enough
|
|
144
|
+
v6.listen(port, "::1", () => {
|
|
145
|
+
server.once("close", () => v6.close());
|
|
146
|
+
resolve(server);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
48
149
|
});
|
|
49
150
|
}
|
|
50
151
|
//# sourceMappingURL=server.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.js","sourceRoot":"","sources":["../../../src/dashboard-lite/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAe,MAAM,MAAM,CAAC;AACjD,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../../../src/dashboard-lite/server.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one message a user sees before they have any career data.
|
|
3
|
+
*
|
|
4
|
+
* There used to be four different ones, and the only one carrying an
|
|
5
|
+
* instruction gave two wrong facts in twenty words: it named `data/career/`,
|
|
6
|
+
* a repo-relative path that exists nowhere on an installed user's machine, and
|
|
7
|
+
* it told them to run `ingest_document`, which by design never writes anything.
|
|
8
|
+
* So the first thing a new user saw was a dead end that also lied about where
|
|
9
|
+
* their files live.
|
|
10
|
+
*
|
|
11
|
+
* This names the actual resolved directory — the server knows it — and offers
|
|
12
|
+
* a first move that works: `save_career_section`, which is the only tool that
|
|
13
|
+
* writes the Career KB.
|
|
14
|
+
*/
|
|
15
|
+
export declare function noCareerDataMessage(): string;
|
|
16
|
+
//# sourceMappingURL=empty-state.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"empty-state.d.ts","sourceRoot":"","sources":["../../src/empty-state.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,IAAI,MAAM,CAa5C"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { getDataDir } from "./storage/file-store.js";
|
|
3
|
+
/**
|
|
4
|
+
* The one message a user sees before they have any career data.
|
|
5
|
+
*
|
|
6
|
+
* There used to be four different ones, and the only one carrying an
|
|
7
|
+
* instruction gave two wrong facts in twenty words: it named `data/career/`,
|
|
8
|
+
* a repo-relative path that exists nowhere on an installed user's machine, and
|
|
9
|
+
* it told them to run `ingest_document`, which by design never writes anything.
|
|
10
|
+
* So the first thing a new user saw was a dead end that also lied about where
|
|
11
|
+
* their files live.
|
|
12
|
+
*
|
|
13
|
+
* This names the actual resolved directory — the server knows it — and offers
|
|
14
|
+
* a first move that works: `save_career_section`, which is the only tool that
|
|
15
|
+
* writes the Career KB.
|
|
16
|
+
*/
|
|
17
|
+
export function noCareerDataMessage() {
|
|
18
|
+
const careerDir = join(getDataDir(), "career");
|
|
19
|
+
return [
|
|
20
|
+
"**No career data yet.** Career Compass reads plain YAML from:",
|
|
21
|
+
"",
|
|
22
|
+
` ${careerDir}`,
|
|
23
|
+
"",
|
|
24
|
+
"Nothing is there so far, which is why this tool has nothing to work with.",
|
|
25
|
+
"The fastest way to start: paste in a resume and ask me to save it — I'll",
|
|
26
|
+
"extract the structure and write it with `save_career_section`. You can also",
|
|
27
|
+
"drop your own YAML into that folder directly; it's yours, and it never",
|
|
28
|
+
"leaves this machine.",
|
|
29
|
+
].join("\n");
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=empty-state.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"empty-state.js","sourceRoot":"","sources":["../../src/empty-state.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAErD;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,mBAAmB;IACjC,MAAM,SAAS,GAAG,IAAI,CAAC,UAAU,EAAE,EAAE,QAAQ,CAAC,CAAC;IAC/C,OAAO;QACL,+DAA+D;QAC/D,EAAE;QACF,OAAO,SAAS,EAAE;QAClB,EAAE;QACF,2EAA2E;QAC3E,0EAA0E;QAC1E,6EAA6E;QAC7E,wEAAwE;QACxE,sBAAsB;KACvB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC"}
|