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
@@ -0,0 +1,115 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ /**
3
+ * `check_setup` — the tool you run when Career Compass feels broken.
4
+ *
5
+ * The one real user this package has described her setup as "rough around the
6
+ * edges" and assumed the roughness was her fault. It wasn't: she was on a stale
7
+ * install, and the fix turned out to be "ask Claude to pull from git." Nothing
8
+ * in the product could have told her that. Every other tool here answers a
9
+ * career question; none of them answer "is this thing actually working?", so
10
+ * the only diagnostic available was the user's own patience.
11
+ *
12
+ * So this reports the whole install in one pass — version, data directory,
13
+ * Career KB contents, pipeline integrity, leftover temp files, dashboard — and
14
+ * every finding that isn't already fine carries the single next command. The
15
+ * output is deliberately one compact block: a health check that needs a
16
+ * follow-up question has failed at its job.
17
+ */
18
+ /**
19
+ * `unknown` is a first-class status, not a failure. The update check cannot
20
+ * succeed offline, and rendering that as ❌ would teach users to ignore ❌.
21
+ */
22
+ export type FindingStatus = "ok" | "warn" | "problem" | "unknown";
23
+ export interface Finding {
24
+ label: string;
25
+ status: FindingStatus;
26
+ detail: string;
27
+ /** The one command or sentence that resolves it. Omitted when nothing to do. */
28
+ fix?: string;
29
+ }
30
+ export type UpdateCheckResult = {
31
+ ok: true;
32
+ latest: string;
33
+ } | {
34
+ ok: false;
35
+ reason: string;
36
+ };
37
+ /** Injected so tests never touch the network. See {@link registerDoctorTools}. */
38
+ export type UpdateChecker = () => Promise<UpdateCheckResult>;
39
+ /** Where the update check asks. Exported so the privacy test can assert it. */
40
+ export declare const REGISTRY_URL = "https://registry.npmjs.org/career-compass-mcp/latest";
41
+ /**
42
+ * Ask the public npm registry which version is current.
43
+ *
44
+ * This is the first and only outbound request Career Compass has ever made, in
45
+ * a product whose entire pitch is that nothing leaves your machine — so the
46
+ * shape of it matters more than the feature does:
47
+ *
48
+ * - It is an unauthenticated GET for a public package name. No headers that
49
+ * identify the user, no query string, no body, no cookies. The registry
50
+ * learns that someone asked about `career-compass-mcp`, which is the same
51
+ * thing `npm view` tells it.
52
+ * - `/latest` rather than the full packument: one small JSON document instead
53
+ * of every version's metadata.
54
+ * - It fails soft. A timeout, a DNS failure, an offline laptop, a corporate
55
+ * proxy returning HTML — all resolve to `{ ok: false }` with a human reason,
56
+ * never a thrown error. Being offline is not a problem with your install, and
57
+ * a health check that reports it as one is worse than no health check.
58
+ * - It is skippable: `checkForUpdates: false` never constructs the request.
59
+ *
60
+ * Disclosed in PRIVACY.md under "Update checks".
61
+ */
62
+ export declare function checkNpmForUpdate(timeoutMs?: number): Promise<UpdateCheckResult>;
63
+ /**
64
+ * Semver precedence, enough of it to answer "am I behind?" without a dependency.
65
+ *
66
+ * Returns a negative number when `a` precedes `b`, positive when it follows,
67
+ * 0 when equal, and `null` when either side is unparseable — which is a real
68
+ * case (`PKG_VERSION` is "unknown" if package.json can't be read) and must not
69
+ * silently compare as "up to date".
70
+ */
71
+ export declare function compareVersions(a: string, b: string): number | null;
72
+ export type DashboardProbeResult = {
73
+ reachable: true;
74
+ isCareerCompass: boolean;
75
+ } | {
76
+ reachable: false;
77
+ reason: string;
78
+ };
79
+ /** Injected so tests are deterministic. See {@link registerDoctorTools}. */
80
+ export type DashboardProbe = (port: number) => Promise<DashboardProbeResult>;
81
+ /**
82
+ * Probe the local dashboard.
83
+ *
84
+ * Loopback only, so this never leaves the machine and is not the network call
85
+ * PRIVACY.md's update-check section is about. A closed port refuses instantly,
86
+ * so the timeout only matters when something is listening but wedged.
87
+ */
88
+ export declare function probeLocalDashboard(port: number, timeoutMs?: number): Promise<DashboardProbeResult>;
89
+ /**
90
+ * The command to open the dashboard on *this* install's data folder.
91
+ *
92
+ * The dashboard reads `CAREER_DATA_PATH`, and there is no `--data` flag — so a
93
+ * bare `npx career-compass-mcp dashboard` serves whatever that variable says in
94
+ * the shell the user happens to be typing in, which is not the one the MCP
95
+ * server was launched with. This tool has just printed the real folder two
96
+ * lines above; recommending a command that ignores it sent anyone with a custom
97
+ * folder to an empty board and a freshly created `~/.career-compass`.
98
+ *
99
+ * Both shells, because the manifest declares win32 alongside darwin and linux
100
+ * and a `VAR=value command` prefix is a syntax error in PowerShell. The prefix
101
+ * is omitted entirely when the folder is the default one, where it would be
102
+ * noise that obscures the actual command.
103
+ */
104
+ export declare function dashboardCommand(dataDir: string, port: number): string;
105
+ /** Matches the CLI's default so a user who ran `dashboard` bare is found. */
106
+ export declare const DEFAULT_DASHBOARD_PORT = 3141;
107
+ export declare function renderReport(findings: Finding[], freshInstall: boolean): string;
108
+ export interface DoctorDeps {
109
+ /** Defaults to {@link checkNpmForUpdate}. Tests inject a stub — the suite must never hit the network. */
110
+ checkForUpdate?: UpdateChecker;
111
+ /** Defaults to {@link probeLocalDashboard}. */
112
+ probeDashboard?: DashboardProbe;
113
+ }
114
+ export declare function registerDoctorTools(server: McpServer, deps?: DoctorDeps): void;
115
+ //# sourceMappingURL=doctor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../../src/tools/doctor.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAIzE;;;;;;;;;;;;;;;GAeG;AAIH;;;GAGG;AACH,MAAM,MAAM,aAAa,GAAG,IAAI,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,CAAC;AAElE,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,aAAa,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,gFAAgF;IAChF,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAWD,MAAM,MAAM,iBAAiB,GACzB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC5B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAElC,kFAAkF;AAClF,MAAM,MAAM,aAAa,GAAG,MAAM,OAAO,CAAC,iBAAiB,CAAC,CAAC;AAE7D,+EAA+E;AAC/E,eAAO,MAAM,YAAY,yDAAkD,CAAC;AAE5E;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,iBAAiB,CAAC,SAAS,SAAO,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAsBpF;AAmBD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA8BnE;AAiSD,MAAM,MAAM,oBAAoB,GAC5B;IAAE,SAAS,EAAE,IAAI,CAAC;IAAC,eAAe,EAAE,OAAO,CAAA;CAAE,GAC7C;IAAE,SAAS,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzC,4EAA4E;AAC5E,MAAM,MAAM,cAAc,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,oBAAoB,CAAC,CAAC;AAE7E;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,SAAO,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAcvG;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAQtE;AA4BD,6EAA6E;AAC7E,eAAO,MAAM,sBAAsB,OAAO,CAAC;AAE3C,wBAAgB,YAAY,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,YAAY,EAAE,OAAO,GAAG,MAAM,CAqC/E;AAID,MAAM,WAAW,UAAU;IACzB,yGAAyG;IACzG,cAAc,CAAC,EAAE,aAAa,CAAC;IAC/B,+CAA+C;IAC/C,cAAc,CAAC,EAAE,cAAc,CAAC;CACjC;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,SAAS,EAAE,IAAI,GAAE,UAAe,GAAG,IAAI,CAoFlF"}
@@ -0,0 +1,549 @@
1
+ import { z } from "zod";
2
+ import { access, readdir, readFile } from "fs/promises";
3
+ import { constants as FS } from "fs";
4
+ import { homedir } from "os";
5
+ import { join, resolve } from "path";
6
+ import { parse as parseYaml } from "yaml";
7
+ import { getDataDir, loadPipeline, isCorruptDataError, CAREER_SECTIONS } from "../storage/file-store.js";
8
+ import { PKG_NAME, PKG_VERSION } from "../version.js";
9
+ const GLYPH = {
10
+ ok: "✅",
11
+ warn: "⚠️",
12
+ problem: "❌",
13
+ unknown: "ℹ️",
14
+ };
15
+ /** Where the update check asks. Exported so the privacy test can assert it. */
16
+ export const REGISTRY_URL = `https://registry.npmjs.org/${PKG_NAME}/latest`;
17
+ /**
18
+ * Ask the public npm registry which version is current.
19
+ *
20
+ * This is the first and only outbound request Career Compass has ever made, in
21
+ * a product whose entire pitch is that nothing leaves your machine — so the
22
+ * shape of it matters more than the feature does:
23
+ *
24
+ * - It is an unauthenticated GET for a public package name. No headers that
25
+ * identify the user, no query string, no body, no cookies. The registry
26
+ * learns that someone asked about `career-compass-mcp`, which is the same
27
+ * thing `npm view` tells it.
28
+ * - `/latest` rather than the full packument: one small JSON document instead
29
+ * of every version's metadata.
30
+ * - It fails soft. A timeout, a DNS failure, an offline laptop, a corporate
31
+ * proxy returning HTML — all resolve to `{ ok: false }` with a human reason,
32
+ * never a thrown error. Being offline is not a problem with your install, and
33
+ * a health check that reports it as one is worse than no health check.
34
+ * - It is skippable: `checkForUpdates: false` never constructs the request.
35
+ *
36
+ * Disclosed in PRIVACY.md under "Update checks".
37
+ */
38
+ export async function checkNpmForUpdate(timeoutMs = 3000) {
39
+ try {
40
+ const response = await fetch(REGISTRY_URL, {
41
+ signal: AbortSignal.timeout(timeoutMs),
42
+ headers: { accept: "application/json" },
43
+ redirect: "follow",
44
+ });
45
+ if (!response.ok) {
46
+ return { ok: false, reason: `the npm registry answered HTTP ${response.status}` };
47
+ }
48
+ const body = (await response.json());
49
+ if (typeof body.version !== "string") {
50
+ return { ok: false, reason: "the npm registry response had no version field" };
51
+ }
52
+ return { ok: true, latest: body.version };
53
+ }
54
+ catch (error) {
55
+ const name = error?.name;
56
+ if (name === "TimeoutError" || name === "AbortError") {
57
+ return { ok: false, reason: `no answer from the npm registry within ${timeoutMs}ms` };
58
+ }
59
+ return { ok: false, reason: "could not reach the npm registry (offline, or a proxy is in the way)" };
60
+ }
61
+ }
62
+ function parseVersion(raw) {
63
+ // Drop build metadata: semver says it takes no part in precedence.
64
+ const withoutBuild = raw.trim().replace(/^v/, "").split("+")[0];
65
+ const [corePart, ...preParts] = withoutBuild.split("-");
66
+ const core = corePart.split(".").map((n) => Number(n));
67
+ if (core.length !== 3 || core.some((n) => !Number.isInteger(n) || n < 0))
68
+ return null;
69
+ const prerelease = preParts.join("-");
70
+ return { core, prerelease: prerelease ? prerelease.split(".") : [] };
71
+ }
72
+ /**
73
+ * Semver precedence, enough of it to answer "am I behind?" without a dependency.
74
+ *
75
+ * Returns a negative number when `a` precedes `b`, positive when it follows,
76
+ * 0 when equal, and `null` when either side is unparseable — which is a real
77
+ * case (`PKG_VERSION` is "unknown" if package.json can't be read) and must not
78
+ * silently compare as "up to date".
79
+ */
80
+ export function compareVersions(a, b) {
81
+ const left = parseVersion(a);
82
+ const right = parseVersion(b);
83
+ if (!left || !right)
84
+ return null;
85
+ for (let i = 0; i < 3; i++) {
86
+ if (left.core[i] !== right.core[i])
87
+ return left.core[i] - right.core[i];
88
+ }
89
+ // 1.0.0-rc.1 precedes 1.0.0: a version WITH a prerelease is the earlier one.
90
+ if (left.prerelease.length === 0 && right.prerelease.length === 0)
91
+ return 0;
92
+ if (left.prerelease.length === 0)
93
+ return 1;
94
+ if (right.prerelease.length === 0)
95
+ return -1;
96
+ for (let i = 0; i < Math.max(left.prerelease.length, right.prerelease.length); i++) {
97
+ const l = left.prerelease[i];
98
+ const r = right.prerelease[i];
99
+ if (l === undefined)
100
+ return -1;
101
+ if (r === undefined)
102
+ return 1;
103
+ const lNum = /^\d+$/.test(l);
104
+ const rNum = /^\d+$/.test(r);
105
+ if (lNum && rNum) {
106
+ if (Number(l) !== Number(r))
107
+ return Number(l) - Number(r);
108
+ }
109
+ else if (lNum !== rNum) {
110
+ return lNum ? -1 : 1; // numeric identifiers rank below alphanumeric ones
111
+ }
112
+ else if (l !== r) {
113
+ return l < r ? -1 : 1;
114
+ }
115
+ }
116
+ return 0;
117
+ }
118
+ function versionFinding(result) {
119
+ if (result === null) {
120
+ return {
121
+ label: "Version",
122
+ status: "unknown",
123
+ detail: `v${PKG_VERSION} installed. Update check skipped, as you asked.`,
124
+ fix: "Run this again with checkForUpdates: true to compare against the npm registry.",
125
+ };
126
+ }
127
+ if (!result.ok) {
128
+ return {
129
+ label: "Version",
130
+ status: "unknown",
131
+ detail: `v${PKG_VERSION} installed. Could not check for a newer one — ${result.reason}.`,
132
+ fix: "Nothing to do; this is a network condition, not a problem with your install.",
133
+ };
134
+ }
135
+ const order = compareVersions(PKG_VERSION, result.latest);
136
+ if (order === null) {
137
+ return {
138
+ label: "Version",
139
+ status: "unknown",
140
+ detail: `This install reports its version as "${PKG_VERSION}", which isn't a version number, so it can't be compared against v${result.latest} on npm.`,
141
+ fix: "Reinstall the package — a missing or unreadable package.json usually means a truncated download.",
142
+ };
143
+ }
144
+ if (order < 0) {
145
+ return {
146
+ label: "Version",
147
+ status: "warn",
148
+ detail: `v${PKG_VERSION} installed; v${result.latest} is the current release on npm.`,
149
+ fix: `Ask Claude: "update career-compass-mcp to ${result.latest}" — see the Upgrading section of the README for your install type (Claude Desktop bundle, npm, or source).`,
150
+ };
151
+ }
152
+ if (order > 0) {
153
+ return {
154
+ label: "Version",
155
+ status: "ok",
156
+ detail: `v${PKG_VERSION} installed, ahead of v${result.latest} on npm — you're running from source or a prerelease.`,
157
+ };
158
+ }
159
+ return {
160
+ label: "Version",
161
+ status: "ok",
162
+ detail: `v${PKG_VERSION} is the current release.`,
163
+ };
164
+ }
165
+ // ─── Data directory ───────────────────────────────────────────────────────────
166
+ async function dataDirFinding(dataDir) {
167
+ try {
168
+ await access(dataDir, FS.F_OK);
169
+ }
170
+ catch {
171
+ return {
172
+ label: "Data directory",
173
+ status: "problem",
174
+ detail: `${dataDir} does not exist.`,
175
+ fix: "Restart your MCP client — the server creates this directory on startup. If it keeps failing, CAREER_DATA_PATH points somewhere you cannot create.",
176
+ };
177
+ }
178
+ try {
179
+ // A permission check, not a write: this tool declares readOnlyHint, and a
180
+ // probe file that "cleans up after itself" would still be a write a host
181
+ // was told wouldn't happen. On Windows this can read as writable when a
182
+ // deeper ACL would refuse, so a passing check is a floor, not a guarantee.
183
+ await access(dataDir, FS.W_OK);
184
+ }
185
+ catch {
186
+ return {
187
+ label: "Data directory",
188
+ status: "problem",
189
+ detail: `${dataDir} exists but is not writable, so nothing you save can be stored.`,
190
+ fix: "Fix the folder's permissions, or point CAREER_DATA_PATH at a directory you own.",
191
+ };
192
+ }
193
+ return {
194
+ label: "Data directory",
195
+ status: "ok",
196
+ detail: `${dataDir} exists and is writable.`,
197
+ };
198
+ }
199
+ /**
200
+ * Inspect each career section file directly rather than through
201
+ * `loadCareerData()`.
202
+ *
203
+ * The loader merges everything and fails closed on a bad profile, which is the
204
+ * right behavior for a tool doing real work and the wrong behavior for a
205
+ * diagnostic: one unparseable file would take the whole report down and tell
206
+ * the user nothing about the other five. Reading them one at a time is the
207
+ * point — "which file is the broken one" is the answer they came for.
208
+ */
209
+ async function readSectionStates(careerDir) {
210
+ const sections = [...CAREER_SECTIONS, "journal"];
211
+ return Promise.all(sections.map(async (section) => {
212
+ const path = join(careerDir, `${section}.yaml`);
213
+ let raw;
214
+ try {
215
+ raw = await readFile(path, "utf-8");
216
+ }
217
+ catch {
218
+ return { section, present: false, count: 0, unreadable: false };
219
+ }
220
+ try {
221
+ const parsed = parseYaml(raw);
222
+ if (parsed === null || parsed === undefined) {
223
+ return { section, present: true, count: 0, unreadable: false };
224
+ }
225
+ const count = Array.isArray(parsed) ? parsed.length : 1;
226
+ return { section, present: true, count, unreadable: false };
227
+ }
228
+ catch {
229
+ return { section, present: true, count: 0, unreadable: true };
230
+ }
231
+ }));
232
+ }
233
+ function careerKbFindings(states) {
234
+ const findings = [];
235
+ const unreadable = states.filter((s) => s.unreadable);
236
+ if (unreadable.length > 0) {
237
+ findings.push({
238
+ label: "Career KB files",
239
+ status: "problem",
240
+ detail: `${unreadable.map((s) => `${s.section}.yaml`).join(", ")} exist but are not valid YAML, so every tool that reads your Career KB will refuse to run rather than overwrite them.`,
241
+ fix: "Open each one and fix the YAML, or restore the timestamped .bak sitting next to it.",
242
+ });
243
+ }
244
+ const profile = states.find((s) => s.section === "profile");
245
+ const populated = states.filter((s) => !s.unreadable && s.count > 0);
246
+ // The journal is written by `capture_insight` as you go, not something a user
247
+ // sits down and fills in, so its emptiness is never a gap worth nagging about.
248
+ const emptyOrMissing = states.filter((s) => !s.unreadable && s.count === 0 && s.section !== "journal");
249
+ const inventory = populated
250
+ .map((s) => `${s.section} (${s.count})`)
251
+ .join(", ");
252
+ // Nothing saved anywhere: a genuinely new install, and the only case that
253
+ // should be told there is no career data here.
254
+ if (populated.length === 0) {
255
+ findings.push({
256
+ label: "Career KB",
257
+ status: "warn",
258
+ detail: "Nothing saved yet, so `tailor_resume`, `generate_cover_letter`, `explore_opportunity`, and `prepare_interview` have nothing to work from. This is the normal state of a fresh install.",
259
+ fix: 'Paste in your resume and say "save this to my Career KB" — Claude extracts the structure and writes it with `save_career_section`.',
260
+ });
261
+ return findings;
262
+ }
263
+ // Profile missing but other sections written. This used to return the
264
+ // fresh-install message above, which told someone who had already saved their
265
+ // experience and skills that there was "no career data" here — the exact
266
+ // self-blame this tool exists to prevent, aimed at a user who had done the
267
+ // work. The profile is genuinely load-bearing (every KB-backed tool loads it
268
+ // first and bails without it), so it still leads — but it is reported as the
269
+ // one missing piece, next to what is already there.
270
+ if (!profile?.present || profile.unreadable || profile.count === 0) {
271
+ findings.push({
272
+ label: "Career KB",
273
+ status: "warn",
274
+ detail: `Saved: ${inventory}. But profile.yaml is ${profile?.unreadable ? "unreadable" : "missing"}, and ` +
275
+ "`tailor_resume`, `generate_cover_letter`, `explore_opportunity`, and `prepare_interview` all load " +
276
+ "the profile before anything else — so they report no career data even though the rest of your KB is here.",
277
+ fix: profile?.unreadable
278
+ ? "Fix profile.yaml, or restore the timestamped .bak beside it — that alone unblocks every tool above."
279
+ : "Run `save_career_section` with section 'profile' — that alone unblocks every tool above.",
280
+ });
281
+ return findings;
282
+ }
283
+ if (emptyOrMissing.length > 0) {
284
+ findings.push({
285
+ label: "Career KB",
286
+ status: "warn",
287
+ detail: `Populated: ${inventory}. Still empty: ${emptyOrMissing.map((s) => s.section).join(", ")}.`,
288
+ fix: `Fill the gaps with \`save_career_section\` — every filled section sharpens resume tailoring and interview prep.`,
289
+ });
290
+ }
291
+ else {
292
+ findings.push({
293
+ label: "Career KB",
294
+ status: "ok",
295
+ detail: `All sections populated: ${inventory}.`,
296
+ });
297
+ }
298
+ return findings;
299
+ }
300
+ // ─── Pipeline ─────────────────────────────────────────────────────────────────
301
+ async function pipelineFinding(dataDir, port) {
302
+ try {
303
+ const pipeline = await loadPipeline();
304
+ const total = pipeline.applications.length;
305
+ if (total === 0) {
306
+ return {
307
+ label: "Pipeline",
308
+ status: "warn",
309
+ detail: "No applications tracked yet.",
310
+ fix: "Add the first one with `pipeline_add`, then watch it move:\n" +
311
+ dashboardCommand(dataDir, port),
312
+ };
313
+ }
314
+ const active = pipeline.applications.filter((a) => a.status !== "rejected" && a.status !== "withdrawn" && a.status !== "accepted").length;
315
+ return {
316
+ label: "Pipeline",
317
+ status: "ok",
318
+ detail: `Parses cleanly — ${total} application${total === 1 ? "" : "s"}, ${active} still active.`,
319
+ };
320
+ }
321
+ catch (error) {
322
+ if (isCorruptDataError(error)) {
323
+ return {
324
+ label: "Pipeline",
325
+ status: "problem",
326
+ detail: `${error.filePath} exists but cannot be parsed, so the pipeline tools and the dashboard both refuse to run rather than overwrite it.`,
327
+ fix: "Fix the YAML, or restore the timestamped .bak next to it.",
328
+ };
329
+ }
330
+ return {
331
+ label: "Pipeline",
332
+ status: "problem",
333
+ detail: `Could not read the pipeline: ${error.message}`,
334
+ fix: "Check that your CAREER_DATA_PATH directory is readable.",
335
+ };
336
+ }
337
+ }
338
+ // ─── Orphaned temp files ──────────────────────────────────────────────────────
339
+ /**
340
+ * Leftover `.tmp` files from an interrupted atomic write.
341
+ *
342
+ * `atomicWriteYaml` writes `.<name>.<uuid>.tmp` and renames it over the target.
343
+ * If the process dies between those two steps the temp file survives, holding
344
+ * data that never landed. Harmless to the tools — nothing reads them — but they
345
+ * accumulate silently, and one of them may be the write a user thinks they made.
346
+ */
347
+ async function orphanFinding(dataDir) {
348
+ const dirs = [join(dataDir, "career"), join(dataDir, "pipeline")];
349
+ const orphans = [];
350
+ for (const dir of dirs) {
351
+ let entries;
352
+ try {
353
+ entries = await readdir(dir);
354
+ }
355
+ catch {
356
+ continue;
357
+ }
358
+ for (const entry of entries) {
359
+ if (entry.endsWith(".tmp"))
360
+ orphans.push(join(dir, entry));
361
+ }
362
+ }
363
+ if (orphans.length === 0) {
364
+ return { label: "Temp files", status: "ok", detail: "No leftover .tmp files." };
365
+ }
366
+ return {
367
+ label: "Temp files",
368
+ status: "warn",
369
+ detail: `${orphans.length} leftover .tmp file${orphans.length === 1 ? "" : "s"} from an interrupted write: ${orphans.slice(0, 3).join(", ")}${orphans.length > 3 ? ", …" : ""}`,
370
+ fix: "Nothing reads these — delete them. If one holds a write you thought you made, copy the contents out first.",
371
+ };
372
+ }
373
+ /**
374
+ * Probe the local dashboard.
375
+ *
376
+ * Loopback only, so this never leaves the machine and is not the network call
377
+ * PRIVACY.md's update-check section is about. A closed port refuses instantly,
378
+ * so the timeout only matters when something is listening but wedged.
379
+ */
380
+ export async function probeLocalDashboard(port, timeoutMs = 1500) {
381
+ try {
382
+ const response = await fetch(`http://127.0.0.1:${port}/`, {
383
+ signal: AbortSignal.timeout(timeoutMs),
384
+ });
385
+ const body = await response.text();
386
+ return { reachable: true, isCareerCompass: body.includes("Career Compass") };
387
+ }
388
+ catch (error) {
389
+ const name = error?.name;
390
+ if (name === "TimeoutError" || name === "AbortError") {
391
+ return { reachable: false, reason: "timed out" };
392
+ }
393
+ return { reachable: false, reason: "nothing is listening" };
394
+ }
395
+ }
396
+ /**
397
+ * The command to open the dashboard on *this* install's data folder.
398
+ *
399
+ * The dashboard reads `CAREER_DATA_PATH`, and there is no `--data` flag — so a
400
+ * bare `npx career-compass-mcp dashboard` serves whatever that variable says in
401
+ * the shell the user happens to be typing in, which is not the one the MCP
402
+ * server was launched with. This tool has just printed the real folder two
403
+ * lines above; recommending a command that ignores it sent anyone with a custom
404
+ * folder to an empty board and a freshly created `~/.career-compass`.
405
+ *
406
+ * Both shells, because the manifest declares win32 alongside darwin and linux
407
+ * and a `VAR=value command` prefix is a syntax error in PowerShell. The prefix
408
+ * is omitted entirely when the folder is the default one, where it would be
409
+ * noise that obscures the actual command.
410
+ */
411
+ export function dashboardCommand(dataDir, port) {
412
+ const flags = port === DEFAULT_DASHBOARD_PORT ? "" : ` --port ${port}`;
413
+ const command = `npx career-compass-mcp dashboard${flags}`;
414
+ if (resolve(dataDir) === resolve(join(homedir(), ".career-compass")))
415
+ return command;
416
+ return (`PowerShell: $env:CAREER_DATA_PATH="${dataDir}"; ${command}\n` +
417
+ `bash/zsh: CAREER_DATA_PATH="${dataDir}" ${command}`);
418
+ }
419
+ function dashboardFinding(dataDir, port, result) {
420
+ if (!result.reachable) {
421
+ return {
422
+ label: "Dashboard",
423
+ status: "ok",
424
+ detail: `Not running on port ${port} (${result.reason}). That's normal — it only runs while you have it open.`,
425
+ fix: `Open it on the folder above:\n${dashboardCommand(dataDir, port)}`,
426
+ };
427
+ }
428
+ if (!result.isCareerCompass) {
429
+ return {
430
+ label: "Dashboard",
431
+ status: "warn",
432
+ detail: `Something is listening on port ${port}, but it isn't the Career Compass dashboard.`,
433
+ fix: `Start the dashboard on a free port:\n${dashboardCommand(dataDir, port + 1)}`,
434
+ };
435
+ }
436
+ return {
437
+ label: "Dashboard",
438
+ status: "ok",
439
+ detail: `Running at http://localhost:${port}.`,
440
+ };
441
+ }
442
+ // ─── Report ───────────────────────────────────────────────────────────────────
443
+ /** Matches the CLI's default so a user who ran `dashboard` bare is found. */
444
+ export const DEFAULT_DASHBOARD_PORT = 3141;
445
+ export function renderReport(findings, freshInstall) {
446
+ const lines = ["# Career Compass — Setup Check", ""];
447
+ for (const f of findings) {
448
+ lines.push(`${GLYPH[f.status]} **${f.label}** — ${f.detail}`);
449
+ if (f.fix) {
450
+ // A fix may run to several lines when it spells out a command per shell.
451
+ // Continuation lines are indented under the arrow rather than falling
452
+ // back to column zero, where they read as a new finding.
453
+ const [first, ...rest] = f.fix.split("\n");
454
+ lines.push(` → ${first}`);
455
+ for (const line of rest)
456
+ lines.push(` ${line}`);
457
+ }
458
+ }
459
+ const problems = findings.filter((f) => f.status === "problem").length;
460
+ const warnings = findings.filter((f) => f.status === "warn").length;
461
+ lines.push("");
462
+ if (freshInstall) {
463
+ // A brand-new install is not a broken one, and listing its empty sections as
464
+ // faults is how a first-run experience teaches someone the tool is failing.
465
+ lines.push("**Getting started.** The install itself is fine — there's just no career data in it yet. " +
466
+ "Paste your resume into this conversation and ask me to save it; I'll extract the structure " +
467
+ "and write it with `save_career_section`. After that, add a role you're chasing with " +
468
+ "`pipeline_add`, and everything else here has something to work with.");
469
+ }
470
+ else if (problems > 0) {
471
+ lines.push(`**${problems} thing${problems === 1 ? "" : "s"} to fix**${warnings > 0 ? `, plus ${warnings} worth a look` : ""}. Start with the ❌ above.`);
472
+ }
473
+ else if (warnings > 0) {
474
+ lines.push(`**Nothing is broken.** ${warnings} thing${warnings === 1 ? "" : "s"} above would make Career Compass work better.`);
475
+ }
476
+ else {
477
+ lines.push("**Everything checks out.**");
478
+ }
479
+ return lines.join("\n");
480
+ }
481
+ export function registerDoctorTools(server, deps = {}) {
482
+ const checkForUpdate = deps.checkForUpdate ?? checkNpmForUpdate;
483
+ const probeDashboard = deps.probeDashboard ?? probeLocalDashboard;
484
+ server.registerTool("check_setup", {
485
+ title: "Check Career Compass Setup",
486
+ // Reads files and asks the npm registry for a version number. It writes
487
+ // nothing — the data-directory check is a permission probe, not a test
488
+ // write — so a host may run it without prompting, which is the point:
489
+ // "why isn't this working" should not itself require a permission step.
490
+ // openWorldHint is true because of the registry call, and that is the
491
+ // honest answer even though the call sends nothing about the user.
492
+ annotations: {
493
+ readOnlyHint: true,
494
+ destructiveHint: false,
495
+ idempotentHint: true,
496
+ openWorldHint: true,
497
+ },
498
+ description: "Health-check this Career Compass install and report everything at once: whether a newer version has shipped, whether your data directory exists and is writable, which Career KB sections are filled in, whether the pipeline file parses, leftover temp files, and whether the dashboard is running. Every finding comes with the one command that fixes it. Run this first whenever something seems wrong, or right after an install or upgrade.",
499
+ inputSchema: {
500
+ checkForUpdates: z
501
+ .boolean()
502
+ .default(true)
503
+ .describe("Whether to ask the public npm registry which version is current. This is the only outbound network call Career Compass ever makes: an unauthenticated GET for the package name, sending nothing about you or your data. Set false to run the check entirely offline."),
504
+ dashboardPort: z
505
+ .number()
506
+ .int()
507
+ .min(1)
508
+ .max(65535)
509
+ .default(DEFAULT_DASHBOARD_PORT)
510
+ .describe("Which loopback port to check for a running dashboard. Matches `career-compass-mcp dashboard --port`; the default is 3141."),
511
+ },
512
+ }, async ({ checkForUpdates, dashboardPort }) => {
513
+ const dataDir = getDataDir();
514
+ const careerDir = join(dataDir, "career");
515
+ // Both of these reach outside the process, and a diagnostic is most needed
516
+ // exactly when things are failing — so a rejection from either must cost
517
+ // its own finding, never the whole report. `checkNpmForUpdate` and
518
+ // `probeLocalDashboard` already resolve rather than throw; this is the
519
+ // belt to their braces, and it holds for an override that is less careful.
520
+ const [update, sections, pipeline, orphans, dashboard] = await Promise.all([
521
+ checkForUpdates
522
+ ? checkForUpdate().catch((error) => ({
523
+ ok: false,
524
+ reason: `the update check itself failed (${error?.message ?? String(error)})`,
525
+ }))
526
+ : Promise.resolve(null),
527
+ readSectionStates(careerDir),
528
+ pipelineFinding(dataDir, dashboardPort),
529
+ orphanFinding(dataDir),
530
+ probeDashboard(dashboardPort).catch(() => ({ reachable: false, reason: "the check could not run" })),
531
+ ]);
532
+ const findings = [
533
+ versionFinding(update),
534
+ await dataDirFinding(dataDir),
535
+ ...careerKbFindings(sections),
536
+ pipeline,
537
+ orphans,
538
+ dashboardFinding(dataDir, dashboardPort, dashboard),
539
+ ];
540
+ // "Fresh" means nothing has ever been saved — not merely that the profile
541
+ // is absent. Keying this off the profile alone closed the report with
542
+ // getting-started guidance for someone who had already written their
543
+ // experience and skills, which reads as the tool not seeing their work.
544
+ const hasAnyCareerData = sections.some((s) => s.count > 0);
545
+ const freshInstall = !hasAnyCareerData && !findings.some((f) => f.status === "problem");
546
+ return { content: [{ type: "text", text: renderReport(findings, freshInstall) }] };
547
+ });
548
+ }
549
+ //# sourceMappingURL=doctor.js.map