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.
Files changed (66) hide show
  1. package/README.md +99 -12
  2. package/build/bin/cli.d.ts.map +1 -1
  3. package/build/bin/cli.js +85 -9
  4. package/build/bin/cli.js.map +1 -1
  5. package/build/src/dashboard-lite/render.d.ts +11 -11
  6. package/build/src/dashboard-lite/render.d.ts.map +1 -1
  7. package/build/src/dashboard-lite/render.js +139 -52
  8. package/build/src/dashboard-lite/render.js.map +1 -1
  9. package/build/src/dashboard-lite/server.d.ts +23 -7
  10. package/build/src/dashboard-lite/server.d.ts.map +1 -1
  11. package/build/src/dashboard-lite/server.js +106 -5
  12. package/build/src/dashboard-lite/server.js.map +1 -1
  13. package/build/src/empty-state.d.ts +16 -0
  14. package/build/src/empty-state.d.ts.map +1 -0
  15. package/build/src/empty-state.js +31 -0
  16. package/build/src/empty-state.js.map +1 -0
  17. package/build/src/pipeline-stats.d.ts +59 -0
  18. package/build/src/pipeline-stats.d.ts.map +1 -0
  19. package/build/src/pipeline-stats.js +66 -0
  20. package/build/src/pipeline-stats.js.map +1 -0
  21. package/build/src/sample-data.d.ts +49 -0
  22. package/build/src/sample-data.d.ts.map +1 -0
  23. package/build/src/sample-data.js +234 -0
  24. package/build/src/sample-data.js.map +1 -0
  25. package/build/src/schemas/career-schema.d.ts +15 -4
  26. package/build/src/schemas/career-schema.d.ts.map +1 -1
  27. package/build/src/schemas/career-schema.js +36 -2
  28. package/build/src/schemas/career-schema.js.map +1 -1
  29. package/build/src/server.d.ts +14 -1
  30. package/build/src/server.d.ts.map +1 -1
  31. package/build/src/server.js +4 -1
  32. package/build/src/server.js.map +1 -1
  33. package/build/src/storage/file-store.d.ts +25 -0
  34. package/build/src/storage/file-store.d.ts.map +1 -1
  35. package/build/src/storage/file-store.js +91 -6
  36. package/build/src/storage/file-store.js.map +1 -1
  37. package/build/src/tools/career-kb.d.ts +2 -0
  38. package/build/src/tools/career-kb.d.ts.map +1 -1
  39. package/build/src/tools/career-kb.js +155 -12
  40. package/build/src/tools/career-kb.js.map +1 -1
  41. package/build/src/tools/doctor.d.ts +115 -0
  42. package/build/src/tools/doctor.d.ts.map +1 -0
  43. package/build/src/tools/doctor.js +549 -0
  44. package/build/src/tools/doctor.js.map +1 -0
  45. package/build/src/tools/interview.d.ts.map +1 -1
  46. package/build/src/tools/interview.js +208 -1
  47. package/build/src/tools/interview.js.map +1 -1
  48. package/build/src/tools/opportunity.d.ts.map +1 -1
  49. package/build/src/tools/opportunity.js +103 -23
  50. package/build/src/tools/opportunity.js.map +1 -1
  51. package/build/src/tools/pipeline.d.ts +23 -1
  52. package/build/src/tools/pipeline.d.ts.map +1 -1
  53. package/build/src/tools/pipeline.js +111 -18
  54. package/build/src/tools/pipeline.js.map +1 -1
  55. package/build/src/tools/resume.d.ts.map +1 -1
  56. package/build/src/tools/resume.js +3 -2
  57. package/build/src/tools/resume.js.map +1 -1
  58. package/build/src/types/tool-args.d.ts +4 -1
  59. package/build/src/types/tool-args.d.ts.map +1 -1
  60. package/build/src/version.d.ts +9 -0
  61. package/build/src/version.d.ts.map +1 -1
  62. package/build/src/version.js +10 -1
  63. package/build/src/version.js.map +1 -1
  64. package/data/example/career/journal.yaml +4 -4
  65. package/data/example/pipeline/applications.yaml +66 -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
- const STAGE_ORDER = [
16
- "discovered", "applied", "screening", "interviewing",
17
- "offer", "negotiating", "accepted", "rejected", "withdrawn", "ghosted",
18
- ];
19
- const ACTIVE = [
20
- "discovered", "applied", "screening", "interviewing", "offer", "negotiating",
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: "#64748b", applied: "#0ea5e9", screening: "#8b5cf6",
24
- interviewing: "#f59e0b", offer: "#10b981", negotiating: "#14b8a6",
25
- accepted: "#059669", rejected: "#ef4444", withdrawn: "#9ca3af", ghosted: "#a1a1aa",
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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
- const t0 = today.getTime();
34
- const days = (iso) => (iso ? Math.round((new Date(iso).getTime() - t0) / 86400000) : NaN);
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
- const nextInterview = (app.interviewRounds ?? [])
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
- /** Render the full self-contained HTML document. */
88
- export function renderLiteDashboard(pipeline) {
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
- const presentStages = STAGE_ORDER.filter((st) => apps.some((a) => a.status === st) || ACTIVE.includes(st));
102
- const board = presentStages.map((st) => {
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">—</div>'}</div>
153
+ <div class="stack">${items.map(jobCard).join("") || '<div class="none">Nothing here yet</div>'}</div>
107
154
  </div>`;
108
- }).join("");
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
- :root{color-scheme:light;--bg:#f7f8fa;--card:#fff;--ink:#14161c;--muted:#6b7280;--line:#e6e8ee;--accent:#4f46e5;--accent-soft:#eef0fe;--shadow:0 1px 2px rgba(20,22,28,.06),0 4px 14px rgba(20,22,28,.05)}
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
- .grid2{display:grid;grid-template-columns:1.55fr 1fr;gap:16px;align-items:start}@media(max-width:900px){.grid2{grid-template-columns:1fr}}
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;height:230px;display:flex;align-items:flex-end;gap:10px}
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:#f0f1f5;border-radius:6px;height:22px;overflow:hidden}.bar .fill{height:100%;border-radius:6px}
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-auto-flow:column;grid-auto-columns:minmax(220px,1fr);gap:12px;overflow-x:auto;padding-bottom:8px}
149
- .col{background:#fbfbfd;border:1px solid var(--line);border-radius:12px;min-height:80px}
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:#eef0f4;color:var(--muted);border-radius:999px;font-size:11px;padding:1px 8px}
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:#fee2e2;color:#b91c1c}.p-medium{background:#fef3c7;color:#92400e}.p-low{background:#e5e7eb;color:#4b5563}
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{background:#4338ca}
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:#fff;padding:10px 16px;border-radius:10px;font-size:13px;opacity:0;transition:.2s;pointer-events:none}
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
- <h2>Pipeline by stage</h2>
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">Data stays local (<code>~/.career-compass</code>). Click any card to copy a prompt for Claude — that's where the work happens. Refresh the page to re-read from disk.</div>
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":"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"}
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
- * Zero-build "lite" dashboard server.
3
+ * Is this `Host` header one of ours?
4
4
  *
5
- * A dependency-free Node HTTP server that, on every request, re-reads the
6
- * pipeline YAML from disk and renders it via renderLiteDashboard(). Nothing is
7
- * cached or prerendered — refreshing the page always reflects the current state
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
- /** Start the lite dashboard on `port`, resolving once it's listening. */
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;AAIjD;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,IAAI,MAAM,CA2BlD;AAED,yEAAyE;AACzE,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,SAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAMxF"}
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
- const html = renderLiteDashboard(pipeline);
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
- /** Start the lite dashboard on `port`, resolving once it's listening. */
43
- export function startLiteDashboard(port, hostname = "localhost") {
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, () => resolve(server));
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;AAC5E,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAElD;;;;;;;;GAQG;AACH,MAAM,UAAU,yBAAyB;IACvC,OAAO,YAAY,CAAC,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;QACrC,yEAAyE;QACzE,MAAM,IAAI,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;QAC5C,IAAI,IAAI,KAAK,GAAG,IAAI,IAAI,KAAK,aAAa,EAAE,CAAC;YAC3C,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,cAAc,EAAE,YAAY,EAAE,CAAC,CAAC;YACrD,GAAG,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;YACrB,OAAO;QACT,CAAC;QACD,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,YAAY,EAAE,CAAC;YACtC,MAAM,IAAI,GAAG,mBAAmB,CAAC,QAAQ,CAAC,CAAC;YAC3C,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE;gBACjB,cAAc,EAAE,0BAA0B;gBAC1C,6DAA6D;gBAC7D,eAAe,EAAE,2BAA2B;aAC7C,CAAC,CAAC;YACH,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAChB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,OAAO,GAAG,kBAAkB,CAAC,GAAG,CAAC,CAAC;YACxC,MAAM,GAAG,GAAG,OAAO;gBACjB,CAAC,CAAC,uGAAwG,GAAa,CAAC,OAAO,EAAE;gBACjI,CAAC,CAAC,+BAAgC,GAAa,EAAE,OAAO,IAAI,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC;YAC5E,GAAG,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,EAAE,cAAc,EAAE,2BAA2B,EAAE,CAAC,CAAC;YACpF,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACf,CAAC;IACH,CAAC,CAAC,CAAC;AACL,CAAC;AAED,yEAAyE;AACzE,MAAM,UAAU,kBAAkB,CAAC,IAAY,EAAE,QAAQ,GAAG,WAAW;IACrE,MAAM,MAAM,GAAG,yBAAyB,EAAE,CAAC;IAC3C,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QAC7B,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,QAAQ,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;IACvD,CAAC,CAAC,CAAC;AACL,CAAC"}
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,UAAU,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AACxF,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAElD;;;;;;;;GAQG;AACH;;;;;;;;;;;;;GAaG;AACH,MAAM,iBAAiB,GAAG,IAAI,GAAG,CAAC,CAAC,WAAW,EAAE,WAAW,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC;AAE9E;;;;;;;;;;;;;;;GAeG;AACH,SAAS,UAAU,CAAC,IAAY;IAC9B,MAAM,CAAC,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IACpC,IAAI,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IAEpB,6EAA6E;IAC7E,IAAI,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QACtB,MAAM,SAAS,GAAG,8BAA8B,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACzD,OAAO,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACzC,CAAC;IAED,yEAAyE;IACzE,2EAA2E;IAC3E,wCAAwC;IACxC,IAAI,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;QAChC,OAAO,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAC5C,CAAC;IAED,MAAM,KAAK,GAAG,oBAAoB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC3C,OAAO,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AACjC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,IAAwB;IACpD,IAAI,CAAC,IAAI;QAAE,OAAO,KAAK,CAAC;IACxB,MAAM,QAAQ,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC;IAClC,OAAO,QAAQ,KAAK,IAAI,IAAI,iBAAiB,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;AAC9D,CAAC;AAED,MAAM,UAAU,yBAAyB;IACvC,OAAO,YAAY,CAAC,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;QACrC,2EAA2E;QAC3E,kEAAkE;QAClE,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;YACrC,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,cAAc,EAAE,2BAA2B,EAAE,CAAC,CAAC;YACpE,GAAG,CAAC,GAAG,CACL,2CAA2C,CAAC,GAAG,iBAAiB,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK;gBAC/E,kCAAkC,GAAG,CAAC,OAAO,CAAC,IAAI,IAAI,QAAQ,OAAO;gBACrE,yFAAyF,CAC5F,CAAC;YACF,OAAO;QACT,CAAC;QACD,yEAAyE;QACzE,MAAM,IAAI,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;QAC5C,IAAI,IAAI,KAAK,GAAG,IAAI,IAAI,KAAK,aAAa,EAAE,CAAC;YAC3C,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,cAAc,EAAE,YAAY,EAAE,CAAC,CAAC;YACrD,GAAG,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;YACrB,OAAO;QACT,CAAC;QACD,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,YAAY,EAAE,CAAC;YACtC,0EAA0E;YAC1E,sDAAsD;YACtD,MAAM,IAAI,GAAG,mBAAmB,CAAC,QAAQ,EAAE,UAAU,EAAE,CAAC,CAAC;YACzD,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE;gBACjB,cAAc,EAAE,0BAA0B;gBAC1C,6DAA6D;gBAC7D,eAAe,EAAE,2BAA2B;aAC7C,CAAC,CAAC;YACH,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAChB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,OAAO,GAAG,kBAAkB,CAAC,GAAG,CAAC,CAAC;YACxC,MAAM,GAAG,GAAG,OAAO;gBACjB,CAAC,CAAC,uGAAwG,GAAa,CAAC,OAAO,EAAE;gBACjI,CAAC,CAAC,+BAAgC,GAAa,EAAE,OAAO,IAAI,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC;YAC5E,GAAG,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,EAAE,cAAc,EAAE,2BAA2B,EAAE,CAAC,CAAC;YACpF,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACf,CAAC;IACH,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAY,EAAE,QAAQ,GAAG,WAAW;IACrE,MAAM,MAAM,GAAG,yBAAyB,EAAE,CAAC;IAC3C,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QAC7B,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,QAAQ,EAAE,GAAG,EAAE;YACjC,IAAI,QAAQ,KAAK,WAAW;gBAAE,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC;YACrD,wEAAwE;YACxE,oDAAoD;YACpD,MAAM,EAAE,GAAG,yBAAyB,EAAE,CAAC;YACvC,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,mCAAmC;YAC5E,EAAE,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE;gBAC1B,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,CAAC;gBACvC,OAAO,CAAC,MAAM,CAAC,CAAC;YAClB,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC"}
@@ -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"}