career-compass-mcp 2.3.0 → 2.4.1
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 +98 -12
- package/build/bin/cli.d.ts.map +1 -1
- package/build/bin/cli.js +70 -7
- 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 +40 -26
- package/build/src/dashboard-lite/render.js.map +1 -1
- package/build/src/dashboard-lite/server.d.ts +5 -6
- package/build/src/dashboard-lite/server.d.ts.map +1 -1
- package/build/src/dashboard-lite/server.js +75 -2
- package/build/src/dashboard-lite/server.js.map +1 -1
- package/build/src/manifest-format.d.ts +56 -0
- package/build/src/manifest-format.d.ts.map +1 -0
- package/build/src/manifest-format.js +67 -0
- package/build/src/manifest-format.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 +10 -0
- package/build/src/storage/file-store.d.ts.map +1 -1
- package/build/src/storage/file-store.js +72 -5
- package/build/src/storage/file-store.js.map +1 -1
- package/build/src/tools/career-kb.d.ts +26 -0
- package/build/src/tools/career-kb.d.ts.map +1 -1
- package/build/src/tools/career-kb.js +138 -13
- 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 +206 -0
- 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 +101 -22
- 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 +110 -17
- package/build/src/tools/pipeline.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 +7 -7
- package/package.json +5 -2
|
@@ -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.
|
|
@@ -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,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How `manifest.json` is written to disk — the encoding, not the content.
|
|
3
|
+
*
|
|
4
|
+
* `manifest.json` is generated from the live server by
|
|
5
|
+
* `scripts/gen-manifest-tools.mjs`, and it is checked in with every non-ASCII
|
|
6
|
+
* character as a `\uXXXX` escape. Both forms are valid JSON and decode
|
|
7
|
+
* identically, so the choice is purely about diffs: the descriptions are full
|
|
8
|
+
* of em dashes, and letting them land as literals buries the first real copy
|
|
9
|
+
* change under a screenful of encoding noise.
|
|
10
|
+
*
|
|
11
|
+
* That invariant was documented only in a comment inside the generator, which
|
|
12
|
+
* meant nothing enforced it. The v2.4.0 release (18ec920) checked the manifest
|
|
13
|
+
* back in with literal em dashes — semantically identical, so
|
|
14
|
+
* manifest-truth.test.ts (which compares *parsed* descriptions) stayed green
|
|
15
|
+
* and CI never noticed. The cost showed up in the workflow instead:
|
|
16
|
+
* `gen-manifest-tools.mjs --check` failed on a clean checkout of main claiming
|
|
17
|
+
* "manifest.json disagrees with the server" when the copy was in fact correct,
|
|
18
|
+
* and `npm run gen:manifest` rewrote ten untouched lines every run.
|
|
19
|
+
*
|
|
20
|
+
* So the encoding lives here, in one typed place, exercised by
|
|
21
|
+
* manifest-truth.test.ts against the file that actually ships.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Escape every non-ASCII character in an already-serialized JSON string.
|
|
25
|
+
*
|
|
26
|
+
* JSON's `\u` escape is exactly four hex digits, so anything outside the BMP
|
|
27
|
+
* has to be emitted as the surrogate pair JSON already uses internally. The
|
|
28
|
+
* first version of this formatted the whole code point with a single escape,
|
|
29
|
+
* which turned a four-digit `padStart` into a five-digit sequence: an emoji
|
|
30
|
+
* (`U+1F600`) came out as `ὠ0`, which re-parses as `ὠ` followed by a
|
|
31
|
+
* literal `0` — `ὠ0`. Still valid JSON, so nothing threw; the description was
|
|
32
|
+
* just silently corrupted. No manifest string has ever contained an astral
|
|
33
|
+
* character, so this was latent rather than shipped, but the failure mode is
|
|
34
|
+
* quiet enough to be worth closing.
|
|
35
|
+
*/
|
|
36
|
+
export declare function escapeNonAscii(json: string): string;
|
|
37
|
+
/** A tool as both the manifest and a `tools/list` response describe it. */
|
|
38
|
+
export interface ManifestTool {
|
|
39
|
+
name: string;
|
|
40
|
+
description: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The exact bytes `manifest.json` should contain, given the manifest as parsed
|
|
44
|
+
* and the tools the server actually serves.
|
|
45
|
+
*
|
|
46
|
+
* Spreading rather than mutating keeps the caller's object intact; `tools`
|
|
47
|
+
* already exists on the manifest, so it keeps its original key position and
|
|
48
|
+
* the rewrite stays a content diff instead of a reordering.
|
|
49
|
+
*
|
|
50
|
+
* Emits LF endings. The repo is developed on Windows with `core.autocrlf=true`,
|
|
51
|
+
* so a working copy is checked out with CRLF while the committed blob is LF —
|
|
52
|
+
* comparisons against this output must normalize line endings rather than
|
|
53
|
+
* assume either one.
|
|
54
|
+
*/
|
|
55
|
+
export declare function renderManifest(manifest: Record<string, unknown>, tools: ManifestTool[]): string;
|
|
56
|
+
//# sourceMappingURL=manifest-format.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manifest-format.d.ts","sourceRoot":"","sources":["../../src/manifest-format.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAcnD;AAED,2EAA2E;AAC3E,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,KAAK,EAAE,YAAY,EAAE,GACpB,MAAM,CAER"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How `manifest.json` is written to disk — the encoding, not the content.
|
|
3
|
+
*
|
|
4
|
+
* `manifest.json` is generated from the live server by
|
|
5
|
+
* `scripts/gen-manifest-tools.mjs`, and it is checked in with every non-ASCII
|
|
6
|
+
* character as a `\uXXXX` escape. Both forms are valid JSON and decode
|
|
7
|
+
* identically, so the choice is purely about diffs: the descriptions are full
|
|
8
|
+
* of em dashes, and letting them land as literals buries the first real copy
|
|
9
|
+
* change under a screenful of encoding noise.
|
|
10
|
+
*
|
|
11
|
+
* That invariant was documented only in a comment inside the generator, which
|
|
12
|
+
* meant nothing enforced it. The v2.4.0 release (18ec920) checked the manifest
|
|
13
|
+
* back in with literal em dashes — semantically identical, so
|
|
14
|
+
* manifest-truth.test.ts (which compares *parsed* descriptions) stayed green
|
|
15
|
+
* and CI never noticed. The cost showed up in the workflow instead:
|
|
16
|
+
* `gen-manifest-tools.mjs --check` failed on a clean checkout of main claiming
|
|
17
|
+
* "manifest.json disagrees with the server" when the copy was in fact correct,
|
|
18
|
+
* and `npm run gen:manifest` rewrote ten untouched lines every run.
|
|
19
|
+
*
|
|
20
|
+
* So the encoding lives here, in one typed place, exercised by
|
|
21
|
+
* manifest-truth.test.ts against the file that actually ships.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Escape every non-ASCII character in an already-serialized JSON string.
|
|
25
|
+
*
|
|
26
|
+
* JSON's `\u` escape is exactly four hex digits, so anything outside the BMP
|
|
27
|
+
* has to be emitted as the surrogate pair JSON already uses internally. The
|
|
28
|
+
* first version of this formatted the whole code point with a single escape,
|
|
29
|
+
* which turned a four-digit `padStart` into a five-digit sequence: an emoji
|
|
30
|
+
* (`U+1F600`) came out as `ὠ0`, which re-parses as `ὠ` followed by a
|
|
31
|
+
* literal `0` — `ὠ0`. Still valid JSON, so nothing threw; the description was
|
|
32
|
+
* just silently corrupted. No manifest string has ever contained an astral
|
|
33
|
+
* character, so this was latent rather than shipped, but the failure mode is
|
|
34
|
+
* quiet enough to be worth closing.
|
|
35
|
+
*/
|
|
36
|
+
export function escapeNonAscii(json) {
|
|
37
|
+
let out = "";
|
|
38
|
+
// Iterating a string yields whole code points, so `ch` is the astral
|
|
39
|
+
// character itself (length 2) rather than a lone surrogate.
|
|
40
|
+
for (const ch of json) {
|
|
41
|
+
if (ch.codePointAt(0) <= 127) {
|
|
42
|
+
out += ch;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
for (let i = 0; i < ch.length; i++) {
|
|
46
|
+
out += "\\u" + ch.charCodeAt(i).toString(16).padStart(4, "0");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The exact bytes `manifest.json` should contain, given the manifest as parsed
|
|
53
|
+
* and the tools the server actually serves.
|
|
54
|
+
*
|
|
55
|
+
* Spreading rather than mutating keeps the caller's object intact; `tools`
|
|
56
|
+
* already exists on the manifest, so it keeps its original key position and
|
|
57
|
+
* the rewrite stays a content diff instead of a reordering.
|
|
58
|
+
*
|
|
59
|
+
* Emits LF endings. The repo is developed on Windows with `core.autocrlf=true`,
|
|
60
|
+
* so a working copy is checked out with CRLF while the committed blob is LF —
|
|
61
|
+
* comparisons against this output must normalize line endings rather than
|
|
62
|
+
* assume either one.
|
|
63
|
+
*/
|
|
64
|
+
export function renderManifest(manifest, tools) {
|
|
65
|
+
return escapeNonAscii(JSON.stringify({ ...manifest, tools }, null, 2)) + "\n";
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=manifest-format.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manifest-format.js","sourceRoot":"","sources":["../../src/manifest-format.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,cAAc,CAAC,IAAY;IACzC,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,qEAAqE;IACrE,4DAA4D;IAC5D,KAAK,MAAM,EAAE,IAAI,IAAI,EAAE,CAAC;QACtB,IAAI,EAAE,CAAC,WAAW,CAAC,CAAC,CAAE,IAAI,GAAG,EAAE,CAAC;YAC9B,GAAG,IAAI,EAAE,CAAC;YACV,SAAS;QACX,CAAC;QACD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACnC,GAAG,IAAI,KAAK,GAAG,EAAE,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;QAChE,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAQD;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,cAAc,CAC5B,QAAiC,EACjC,KAAqB;IAErB,OAAO,cAAc,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,QAAQ,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;AAChF,CAAC"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Application, ApplicationStatus } from "./schemas/career-schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* The one place a pipeline turns into numbers.
|
|
4
|
+
*
|
|
5
|
+
* `pipeline_view action=stats` and the dashboard both answer "what is my
|
|
6
|
+
* response rate?", and they answered it differently: the tool computed
|
|
7
|
+
* (total − applied) / total, which counts a role you have only *discovered* —
|
|
8
|
+
* and never sent anything to — as an employer response, and divides by a
|
|
9
|
+
* denominator that includes it. On the bundled sample that reported 75% while
|
|
10
|
+
* the dashboard, doing responded / sent, reported 71%. Two surfaces of one
|
|
11
|
+
* product disagreeing about the user's own number is worse than either being
|
|
12
|
+
* wrong: there is no way for them to tell which to believe.
|
|
13
|
+
*
|
|
14
|
+
* So the arithmetic lives here and both call it. Adding a stage to the funnel
|
|
15
|
+
* is now one edit rather than a hunt for every place a status list was inlined.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Stages a search is still live in — nothing has closed the door yet.
|
|
19
|
+
*
|
|
20
|
+
* `discovered` belongs here: a role you have found and not yet applied to is
|
|
21
|
+
* live work, and the board shows it as a column.
|
|
22
|
+
*/
|
|
23
|
+
export declare const ACTIVE_STATUSES: readonly ApplicationStatus[];
|
|
24
|
+
/**
|
|
25
|
+
* Did this application actually get sent?
|
|
26
|
+
*
|
|
27
|
+
* Everything past `discovered` did. This is the denominator for both rates
|
|
28
|
+
* below, because a role you never applied to cannot answer you and cannot
|
|
29
|
+
* ghost you — including it only dilutes the number you were asking about.
|
|
30
|
+
*/
|
|
31
|
+
export declare function wasSent(app: Application): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Did the employer come back?
|
|
34
|
+
*
|
|
35
|
+
* Anything past `applied` means someone on the other side acted, except
|
|
36
|
+
* `ghosted`, which is the recorded absence of exactly that.
|
|
37
|
+
*
|
|
38
|
+
* `withdrawn` counts as a response, which is right in the ordinary case — you
|
|
39
|
+
* withdraw from a process you are in — and generous in the rarer one where
|
|
40
|
+
* someone withdraws an application nobody ever answered. The stored data does
|
|
41
|
+
* not distinguish those, and the alternative (silently dropping withdrawals
|
|
42
|
+
* from the numerator) understates real conversations.
|
|
43
|
+
*/
|
|
44
|
+
export declare function gotResponse(app: Application): boolean;
|
|
45
|
+
export interface PipelineStats {
|
|
46
|
+
total: number;
|
|
47
|
+
/** Applications actually submitted — the denominator for both rates. */
|
|
48
|
+
sent: number;
|
|
49
|
+
active: number;
|
|
50
|
+
inConversation: number;
|
|
51
|
+
offers: number;
|
|
52
|
+
ghosted: number;
|
|
53
|
+
/** Percent of sent applications the employer answered, 0 when none sent. */
|
|
54
|
+
responseRate: number;
|
|
55
|
+
/** Percent of sent applications that went silent, 0 when none sent. */
|
|
56
|
+
ghostRate: number;
|
|
57
|
+
}
|
|
58
|
+
export declare function computeStats(apps: Application[]): PipelineStats;
|
|
59
|
+
//# sourceMappingURL=pipeline-stats.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pipeline-stats.d.ts","sourceRoot":"","sources":["../../src/pipeline-stats.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAEjF;;;;;;;;;;;;;;GAcG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,eAAe,EAAE,SAAS,iBAAiB,EAEvD,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,CAEjD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,CAErD;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,YAAY,EAAE,MAAM,CAAC;IACrB,uEAAuE;IACvE,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,WAAW,EAAE,GAAG,aAAa,CAgB/D"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a pipeline turns into numbers.
|
|
3
|
+
*
|
|
4
|
+
* `pipeline_view action=stats` and the dashboard both answer "what is my
|
|
5
|
+
* response rate?", and they answered it differently: the tool computed
|
|
6
|
+
* (total − applied) / total, which counts a role you have only *discovered* —
|
|
7
|
+
* and never sent anything to — as an employer response, and divides by a
|
|
8
|
+
* denominator that includes it. On the bundled sample that reported 75% while
|
|
9
|
+
* the dashboard, doing responded / sent, reported 71%. Two surfaces of one
|
|
10
|
+
* product disagreeing about the user's own number is worse than either being
|
|
11
|
+
* wrong: there is no way for them to tell which to believe.
|
|
12
|
+
*
|
|
13
|
+
* So the arithmetic lives here and both call it. Adding a stage to the funnel
|
|
14
|
+
* is now one edit rather than a hunt for every place a status list was inlined.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Stages a search is still live in — nothing has closed the door yet.
|
|
18
|
+
*
|
|
19
|
+
* `discovered` belongs here: a role you have found and not yet applied to is
|
|
20
|
+
* live work, and the board shows it as a column.
|
|
21
|
+
*/
|
|
22
|
+
export const ACTIVE_STATUSES = [
|
|
23
|
+
"discovered", "applied", "screening", "interviewing", "offer", "negotiating",
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Did this application actually get sent?
|
|
27
|
+
*
|
|
28
|
+
* Everything past `discovered` did. This is the denominator for both rates
|
|
29
|
+
* below, because a role you never applied to cannot answer you and cannot
|
|
30
|
+
* ghost you — including it only dilutes the number you were asking about.
|
|
31
|
+
*/
|
|
32
|
+
export function wasSent(app) {
|
|
33
|
+
return app.status !== "discovered";
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Did the employer come back?
|
|
37
|
+
*
|
|
38
|
+
* Anything past `applied` means someone on the other side acted, except
|
|
39
|
+
* `ghosted`, which is the recorded absence of exactly that.
|
|
40
|
+
*
|
|
41
|
+
* `withdrawn` counts as a response, which is right in the ordinary case — you
|
|
42
|
+
* withdraw from a process you are in — and generous in the rarer one where
|
|
43
|
+
* someone withdraws an application nobody ever answered. The stored data does
|
|
44
|
+
* not distinguish those, and the alternative (silently dropping withdrawals
|
|
45
|
+
* from the numerator) understates real conversations.
|
|
46
|
+
*/
|
|
47
|
+
export function gotResponse(app) {
|
|
48
|
+
return !["discovered", "applied", "ghosted"].includes(app.status);
|
|
49
|
+
}
|
|
50
|
+
export function computeStats(apps) {
|
|
51
|
+
const sent = apps.filter(wasSent).length;
|
|
52
|
+
const responded = apps.filter(gotResponse).length;
|
|
53
|
+
const ghosted = apps.filter((a) => a.status === "ghosted").length;
|
|
54
|
+
const pct = (n) => (sent ? Math.round((n / sent) * 100) : 0);
|
|
55
|
+
return {
|
|
56
|
+
total: apps.length,
|
|
57
|
+
sent,
|
|
58
|
+
active: apps.filter((a) => ACTIVE_STATUSES.includes(a.status)).length,
|
|
59
|
+
inConversation: apps.filter((a) => ["screening", "interviewing"].includes(a.status)).length,
|
|
60
|
+
offers: apps.filter((a) => ["offer", "negotiating", "accepted"].includes(a.status)).length,
|
|
61
|
+
ghosted,
|
|
62
|
+
responseRate: pct(responded),
|
|
63
|
+
ghostRate: pct(ghosted),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=pipeline-stats.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pipeline-stats.js","sourceRoot":"","sources":["../../src/pipeline-stats.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;GAcG;AAEH;;;;;GAKG;AACH,MAAM,CAAC,MAAM,eAAe,GAAiC;IAC3D,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,cAAc,EAAE,OAAO,EAAE,aAAa;CAC7E,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,UAAU,OAAO,CAAC,GAAgB;IACtC,OAAO,GAAG,CAAC,MAAM,KAAK,YAAY,CAAC;AACrC,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,WAAW,CAAC,GAAgB;IAC1C,OAAO,CAAC,CAAC,YAAY,EAAE,SAAS,EAAE,SAAS,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;AACpE,CAAC;AAgBD,MAAM,UAAU,YAAY,CAAC,IAAmB;IAC9C,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC;IACzC,MAAM,SAAS,GAAG,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC;IAClD,MAAM,OAAO,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,MAAM,CAAC;IAClE,MAAM,GAAG,GAAG,CAAC,CAAS,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAErE,OAAO;QACL,KAAK,EAAE,IAAI,CAAC,MAAM;QAClB,IAAI;QACJ,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM;QACrE,cAAc,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,WAAW,EAAE,cAAc,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM;QAC3F,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,aAAa,EAAE,UAAU,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM;QAC1F,OAAO;QACP,YAAY,EAAE,GAAG,CAAC,SAAS,CAAC;QAC5B,SAAS,EAAE,GAAG,CAAC,OAAO,CAAC;KACxB,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bundled Alex Rivera sample, kept current.
|
|
3
|
+
*
|
|
4
|
+
* `data/example/` is the one populated Career KB anybody can look at without
|
|
5
|
+
* typing their own history in: the README points at it, the screenshots are
|
|
6
|
+
* taken from it, and `CAREER_DATA_PATH=data/example` is the documented way to
|
|
7
|
+
* see the dashboard with something in it. Every date in it was written by hand
|
|
8
|
+
* to represent mid-June 2026 — a panel "coming up" on the 17th, a follow-up
|
|
9
|
+
* four days past due. Nothing ages those, so the demo curdles: within weeks the
|
|
10
|
+
* interviews have already happened, the follow-ups are months overdue, and the
|
|
11
|
+
* first impression of a job-search tool is a search that was abandoned.
|
|
12
|
+
*
|
|
13
|
+
* Rather than re-date the YAML on a schedule (which only moves the expiry), the
|
|
14
|
+
* dates are shifted at read time by the distance between {@link sampleAnchor}
|
|
15
|
+
* — the sample's own notion of "today" — and the real today. The relative shape
|
|
16
|
+
* the sample was authored with is exactly preserved: the panel is still three
|
|
17
|
+
* days out, the rejection still landed a week ago. The files on disk are never
|
|
18
|
+
* touched, and only the copy inside this package is affected: a user's own data
|
|
19
|
+
* goes through untouched, whatever dates it has.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* The anchor for a fixture this code cannot read.
|
|
23
|
+
*
|
|
24
|
+
* Only reachable when the sample directory is missing or unparseable, in which
|
|
25
|
+
* case there is nothing to shift and the value never gets used. It exists so
|
|
26
|
+
* the shift is a number rather than a special case.
|
|
27
|
+
*/
|
|
28
|
+
export declare const SAMPLE_ANCHOR_FALLBACK = "2026-06-16";
|
|
29
|
+
/**
|
|
30
|
+
* Return a copy of `value` with every date shifted forward to sit around
|
|
31
|
+
* `today`. Pure: the input is never mutated, so a caller can hand it a parsed
|
|
32
|
+
* document and keep the original.
|
|
33
|
+
*
|
|
34
|
+
* `today` and `anchor` are injectable so tests are not a function of the day
|
|
35
|
+
* they run on, nor of the fixture's current contents.
|
|
36
|
+
*/
|
|
37
|
+
export declare function freshenSampleDates<T>(value: T, today?: Date, anchor?: string): T;
|
|
38
|
+
export declare function bundledSampleDir(): string | null;
|
|
39
|
+
/** Is this data directory the demo that ships with the package? */
|
|
40
|
+
export declare function isBundledSampleDir(dir: string): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* The sample's own "today": the newest moment anything in it was written.
|
|
43
|
+
*
|
|
44
|
+
* Read from the fixture rather than declared, so re-dating the YAML moves the
|
|
45
|
+
* anchor with it and the two cannot drift apart again. Cached — the fixture is
|
|
46
|
+
* immutable for the life of the process, and this runs on every sample read.
|
|
47
|
+
*/
|
|
48
|
+
export declare function sampleAnchor(): string;
|
|
49
|
+
//# sourceMappingURL=sample-data.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sample-data.d.ts","sourceRoot":"","sources":["../../src/sample-data.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,eAAe,CAAC;AAwDnD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,EAClC,KAAK,EAAE,CAAC,EACR,KAAK,GAAE,IAAiB,EACxB,MAAM,GAAE,MAAuB,GAC9B,CAAC,CAcH;AAmDD,wBAAgB,gBAAgB,IAAI,MAAM,GAAG,IAAI,CAGhD;AAED,mEAAmE;AACnE,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAGvD;AA6DD;;;;;;GAMG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAGrC"}
|