@gafj/gafj 0.1.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.
Files changed (121) hide show
  1. package/README.md +99 -0
  2. package/bin/cli.js +628 -0
  3. package/bridge/byo_key.js +50 -0
  4. package/bridge/credits.js +72 -0
  5. package/bridge/paste.js +44 -0
  6. package/bridge/providers/anthropic.js +16 -0
  7. package/bridge/providers/gemini.js +15 -0
  8. package/bridge/providers/index.js +39 -0
  9. package/bridge/providers/openai_compatible.js +17 -0
  10. package/core/extract.js +55 -0
  11. package/core/generators/excerpt.js +64 -0
  12. package/core/generators/packet.js +197 -0
  13. package/core/generators/serialize.js +56 -0
  14. package/core/generators/slice.js +61 -0
  15. package/core/house_rules.js +49 -0
  16. package/core/kb_import.js +257 -0
  17. package/core/migrations/002-profile.sql +3 -0
  18. package/core/migrations/003-discover.sql +49 -0
  19. package/core/parsers.js +79 -0
  20. package/core/practice.js +75 -0
  21. package/core/provenance.js +90 -0
  22. package/core/qr.js +185 -0
  23. package/core/rules/categories.md +5 -0
  24. package/core/rules/deep_answers.md +7 -0
  25. package/core/rules/discover.md +30 -0
  26. package/core/rules/extract.md +31 -0
  27. package/core/rules/followup.md +9 -0
  28. package/core/rules/index.js +32 -0
  29. package/core/rules/onepager.md +15 -0
  30. package/core/rules/practice.md +10 -0
  31. package/core/rules/prep.md +47 -0
  32. package/core/rules/resume.md +49 -0
  33. package/core/rules/rubric.md +120 -0
  34. package/core/schema.sql +342 -0
  35. package/core/schemas/categories.js +16 -0
  36. package/core/schemas/cover.js +10 -0
  37. package/core/schemas/deep_answers.js +16 -0
  38. package/core/schemas/discover.js +19 -0
  39. package/core/schemas/extract.js +32 -0
  40. package/core/schemas/followup.js +10 -0
  41. package/core/schemas/index.js +32 -0
  42. package/core/schemas/lang.js +111 -0
  43. package/core/schemas/onepager.js +26 -0
  44. package/core/schemas/practice.js +18 -0
  45. package/core/schemas/prep.js +15 -0
  46. package/core/schemas/resume.js +25 -0
  47. package/core/scoring.js +586 -0
  48. package/core/text.js +98 -0
  49. package/core/tracker_map.js +94 -0
  50. package/core/ulid.js +38 -0
  51. package/core/validators/highlight_body_match.js +49 -0
  52. package/core/validators/index.js +43 -0
  53. package/core/validators/metric_pairing.js +52 -0
  54. package/core/validators/no_confabulated_history.js +63 -0
  55. package/core/validators/no_dashes.js +31 -0
  56. package/core/validators/no_derived_tenure.js +60 -0
  57. package/core/validators/style_config.js +18 -0
  58. package/core/validators/verb_ladder.js +51 -0
  59. package/core/verbs.js +43 -0
  60. package/http/api.js +260 -0
  61. package/http/guard.js +106 -0
  62. package/http/headers.js +17 -0
  63. package/http/hosted.js +273 -0
  64. package/http/identity.js +70 -0
  65. package/http/server.js +136 -0
  66. package/http/sse.js +41 -0
  67. package/mcp/server.js +25 -0
  68. package/mcp/tools.js +237 -0
  69. package/package.json +59 -0
  70. package/store/backup.js +63 -0
  71. package/store/backup_dir.js +60 -0
  72. package/store/config.js +32 -0
  73. package/store/context.js +74 -0
  74. package/store/db.js +37 -0
  75. package/store/discover.js +120 -0
  76. package/store/events.js +28 -0
  77. package/store/export_xlsx.js +62 -0
  78. package/store/home.js +36 -0
  79. package/store/import_kb.js +88 -0
  80. package/store/import_tracker.js +195 -0
  81. package/store/ingest.js +140 -0
  82. package/store/kb.js +202 -0
  83. package/store/log.js +88 -0
  84. package/store/migrate.js +55 -0
  85. package/store/onboarding.js +267 -0
  86. package/store/packets.js +110 -0
  87. package/store/practice.js +52 -0
  88. package/store/proposals.js +119 -0
  89. package/store/reads.js +110 -0
  90. package/store/render.js +111 -0
  91. package/store/reply.js +19 -0
  92. package/store/review.js +64 -0
  93. package/store/scoring.js +134 -0
  94. package/store/settings.js +142 -0
  95. package/store/snapshot.js +88 -0
  96. package/store/transitions.js +391 -0
  97. package/store/tx.js +71 -0
  98. package/store/tz.js +24 -0
  99. package/ui/app.css +137 -0
  100. package/ui/app.js +75 -0
  101. package/ui/fonts/plexmono-400.woff2 +0 -0
  102. package/ui/fonts/plexmono-500.woff2 +0 -0
  103. package/ui/fonts/plexmono-600.woff2 +0 -0
  104. package/ui/fonts/spacegrotesk-var.woff2 +0 -0
  105. package/ui/hosted/signin.css +8 -0
  106. package/ui/hosted/signin.html +30 -0
  107. package/ui/hosted/signin.js +28 -0
  108. package/ui/icon-192.png +0 -0
  109. package/ui/icon-512.png +0 -0
  110. package/ui/index.html +20 -0
  111. package/ui/lib.js +92 -0
  112. package/ui/manifest.webmanifest +18 -0
  113. package/ui/screens/application.js +73 -0
  114. package/ui/screens/dashboard.js +37 -0
  115. package/ui/screens/document.js +64 -0
  116. package/ui/screens/interview.js +85 -0
  117. package/ui/screens/kb.js +142 -0
  118. package/ui/screens/packet.js +122 -0
  119. package/ui/screens/postings.js +92 -0
  120. package/ui/screens/settings.js +115 -0
  121. package/ui/vendor/preact.mjs +1 -0
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Discovery (plan section U, the shakedown): a candidate-level run whose
5
+ * reply is questions, not a document. saveDiscovery counts the attempt,
6
+ * validates the schema, refuses any question whose record id is outside
7
+ * the packet's slice, and stores the rest as open questions. The candidate
8
+ * answers on the knowledge base screen: the answer is a pending row with
9
+ * replaces, so confirming it extends the record. Three attempts per run;
10
+ * a new run is refused while open questions remain, so questions never pile.
11
+ */
12
+
13
+ const { ulid } = require("../core/ulid");
14
+ const { extractJson } = require("../core/extract");
15
+ const { SCHEMAS, validate } = require("../core/schemas");
16
+ const { withTx } = require("./tx");
17
+ const { appendEvent } = require("./events");
18
+ const { TransitionError, NotFound } = require("./transitions");
19
+ const kb = require("./kb");
20
+
21
+ const MAX_ATTEMPTS = 3;
22
+ const MAX_OPEN = 12;
23
+
24
+ function status(q) {
25
+ return q.answered_at ? "answered" : q.dismissed_at ? "dismissed" : "open";
26
+ }
27
+
28
+ function listQuestions(db, candidate_id, { all = false } = {}) {
29
+ return db.prepare(`SELECT q.id AS question_id, q.ai_run_id, q.accomplishment_id, q.kind, q.question, q.why, q.asked_at, q.answered_at, q.dismissed_at, q.pending_id,
30
+ a.title, a.company, a.role, a.date_start, a.date_end FROM kb_question q JOIN accomplishment a ON a.id = q.accomplishment_id
31
+ WHERE q.candidate_id = ? ${all ? "" : "AND q.answered_at IS NULL AND q.dismissed_at IS NULL"} ORDER BY q.asked_at DESC, q.id`).all(candidate_id)
32
+ .map((q) => ({ ...q, status: status(q), dates: [q.date_start, q.date_end].filter(Boolean).join(" to "), date_start: undefined, date_end: undefined,
33
+ draft: { replaces: q.accomplishment_id, title: q.title, company: q.company, role: q.role, dates: [q.date_start, q.date_end].filter(Boolean).join(" to "), summary: "", metrics: [], verbs: [], wordings: [], tags: [] } }));
34
+ }
35
+
36
+ function openCount(db, candidate_id) {
37
+ return db.prepare("SELECT count(*) c FROM kb_question WHERE candidate_id = ? AND answered_at IS NULL AND dismissed_at IS NULL").get(candidate_id).c;
38
+ }
39
+
40
+ /** Refuse a new round while the last one is unanswered. Called by openRun's gather through the api. */
41
+ function assertCanOpen(db, candidate_id) {
42
+ const open = openCount(db, candidate_id);
43
+ if (open) throw new TransitionError(`${open} discovery question${open === 1 ? " is" : "s are"} still open; answer or dismiss them before asking for more`);
44
+ }
45
+
46
+ /** The discovery door: questions in, open questions out. Never touches the record. */
47
+ function saveDiscovery(db, { run_id, content, route, actor, now, provider, model, tokens_in, tokens_out, latency_ms }) {
48
+ return withTx(db, (d) => {
49
+ const run = d.prepare("SELECT * FROM ai_run WHERE id = ?").get(run_id);
50
+ if (!run) throw new NotFound("run");
51
+ if (run.operation !== "discover") throw new TransitionError("this run is not a discovery; use the document door");
52
+ const attempts = d.prepare("SELECT count(*) c FROM ai_attempt WHERE ai_run_id = ?").get(run.id).c;
53
+ if (attempts >= MAX_ATTEMPTS) return { run_id: run.id, status: "blocked", attempt: attempts, stop: true, errors: ["three attempts used; open a fresh packet"], question_ids: [] };
54
+ const attemptNo = attempts + 1;
55
+ const ex = extractJson(content);
56
+ const errors = [];
57
+ let parsed = null;
58
+ if (ex.error) errors.push("schema: " + ex.error);
59
+ else {
60
+ parsed = ex.value;
61
+ for (const e of validate(parsed, SCHEMAS.discover)) errors.push(`${e.path}: ${e.message}`);
62
+ }
63
+ const allowed = new Set(JSON.parse(run.kb_ids_json || "[]"));
64
+ const seen = new Set();
65
+ const accepted = [];
66
+ if (parsed && !errors.length) {
67
+ parsed.questions.forEach((q, i) => {
68
+ if (!allowed.has(q.accomplishment_id)) { errors.push(`questions[${i}]: record ${q.accomplishment_id} is not in this packet's slice`); return; }
69
+ const key = q.accomplishment_id + "|" + q.question.toLowerCase().replace(/\s+/g, " ").trim();
70
+ if (seen.has(key)) return;
71
+ seen.add(key);
72
+ accepted.push(q);
73
+ });
74
+ }
75
+ const attemptId = ulid(Date.parse(now) + 100 + attemptNo);
76
+ const ok = !errors.length && accepted.length > 0;
77
+ d.prepare(`INSERT INTO ai_attempt (id, ai_run_id, attempt_no, provider, model, tokens_in, tokens_out, latency_ms, output_hash, result, error_code, started_at, finished_at)
78
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(attemptId, run.id, attemptNo, provider || null, model || null, tokens_in ?? null, tokens_out ?? null, latency_ms ?? null, null, ok ? "passed" : "blocked", ok ? null : (ex.error ? "schema" : "slice"), now, now);
79
+ const ids = [];
80
+ if (ok) {
81
+ const room = Math.max(0, MAX_OPEN - openCount(d, run.candidate_id));
82
+ for (const q of accepted.slice(0, room)) {
83
+ const id = ulid(Date.parse(now) + ids.length);
84
+ d.prepare("INSERT INTO kb_question (id, candidate_id, ai_run_id, accomplishment_id, kind, question, why, asked_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
85
+ .run(id, run.candidate_id, run.id, q.accomplishment_id, q.kind, q.question.trim(), (q.why || "").trim() || null, now);
86
+ ids.push(id);
87
+ }
88
+ }
89
+ const final = ok ? "passed" : attemptNo >= MAX_ATTEMPTS ? "blocked" : "pending";
90
+ d.prepare("UPDATE ai_run SET final_result = ?, finished_at = CASE WHEN ? = 'pending' THEN finished_at ELSE ? END WHERE id = ?").run(final, final, now, run.id);
91
+ appendEvent(d, { candidate_id: run.candidate_id, entity: "ai_run", entity_id: run.id, event: ok ? "discovery_saved" : "discovery_blocked", actor_type: actor.type, actor_ref: actor.ref, payload: { attempt: attemptNo, questions: ids.length, errors: errors.length, route }, at: now });
92
+ return { run_id: run.id, status: ok ? "passed" : "blocked", attempt: attemptNo, stop: attemptNo >= MAX_ATTEMPTS && !ok, errors, question_ids: ids, notes: parsed && ok ? parsed.notes || null : null };
93
+ });
94
+ }
95
+
96
+ /** The candidate answers in their own words: a pending row that extends the record; the question closes when it lands. */
97
+ function answerQuestion(db, { candidate_id, question_id, draft, now, actor }) {
98
+ return withTx(db, (d) => {
99
+ const q = d.prepare("SELECT * FROM kb_question WHERE id = ? AND candidate_id = ?").get(question_id, candidate_id);
100
+ if (!q) throw new NotFound("question");
101
+ if (q.answered_at || q.dismissed_at) throw new TransitionError("this question is closed");
102
+ const r = kb.proposeManual(d, { candidate_id, draft: { ...(draft || {}), replaces: q.accomplishment_id }, now, actor });
103
+ d.prepare("UPDATE kb_question SET answered_at = ?, pending_id = ? WHERE id = ?").run(now, r.pending_id, q.id);
104
+ appendEvent(d, { candidate_id, entity: "kb_question", entity_id: q.id, event: "answered", actor_type: actor.type, actor_ref: actor.ref, payload: { pending_id: r.pending_id, kind: q.kind }, at: now });
105
+ return { question_id: q.id, pending_id: r.pending_id };
106
+ });
107
+ }
108
+
109
+ function dismissQuestion(db, { candidate_id, question_id, now, actor }) {
110
+ return withTx(db, (d) => {
111
+ const q = d.prepare("SELECT * FROM kb_question WHERE id = ? AND candidate_id = ?").get(question_id, candidate_id);
112
+ if (!q) throw new NotFound("question");
113
+ if (q.answered_at || q.dismissed_at) throw new TransitionError("this question is closed");
114
+ d.prepare("UPDATE kb_question SET dismissed_at = ? WHERE id = ?").run(now, q.id);
115
+ appendEvent(d, { candidate_id, entity: "kb_question", entity_id: q.id, event: "dismissed", actor_type: actor.type, actor_ref: actor.ref, at: now });
116
+ return { question_id: q.id, dismissed: true };
117
+ });
118
+ }
119
+
120
+ module.exports = { saveDiscovery, listQuestions, answerQuestion, dismissQuestion, openCount, assertCanOpen, MAX_ATTEMPTS, MAX_OPEN };
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Append-only event log. Every important mutation calls this inside the
5
+ * same transaction as the mutation. actor_type says who did it; prior holds
6
+ * the values an edit replaced so corrections are recoverable.
7
+ */
8
+
9
+ const ACTORS = new Set(["ui", "cli", "mcp", "bridge", "import", "system"]);
10
+
11
+ function appendEvent(db, e) {
12
+ if (!ACTORS.has(e.actor_type)) throw new Error(`bad actor_type ${e.actor_type}`);
13
+ const stmt = db.prepare(
14
+ `INSERT INTO event_log (candidate_id, entity, entity_id, event, actor_type, actor_ref, payload_json, prior_json, at)
15
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
16
+ );
17
+ const r = stmt.run(
18
+ e.candidate_id, e.entity, e.entity_id, e.event, e.actor_type, e.actor_ref ?? null,
19
+ JSON.stringify(e.payload ?? {}), e.prior == null ? null : JSON.stringify(e.prior), e.at
20
+ );
21
+ return Number(r.lastInsertRowid);
22
+ }
23
+
24
+ function eventsSince(db, cursor, limit = 200) {
25
+ return db.prepare("SELECT * FROM event_log WHERE id > ? ORDER BY id LIMIT ?").all(cursor, limit);
26
+ }
27
+
28
+ module.exports = { appendEvent, eventsSince, ACTORS };
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The xlsx tracker as an export view (decision 3): same fifteen columns,
5
+ * same status fills, a dated file in the export folder, never the live
6
+ * tracker path. The five equivalence criteria: same rows, same columns and
7
+ * order, same displayed values, status fills preserved, opens cleanly.
8
+ */
9
+
10
+ const path = require("path");
11
+
12
+ const COLUMNS = ["Company", "Role / Title", "Location", "Date Applied", "Source", "Contact Name", "Contact Email", "Status",
13
+ "Follow-Up Date", "Interview Date", "Offer ($)", "Notes", "Tailored Resume", "Stage", "Fit Score"];
14
+ const FILL = { applied: "FFBDD7EE", to_apply: "FFC6EFCE", pass: "FFFFC7CE", rejected: "FFFFC7CE", closed: "FFFFC7CE", offer: "FFBDD7EE" };
15
+ const STATUS_LABEL = { applied: "Applied", to_apply: "To Apply", pass: "Pass", rejected: "Rejected", closed: "Closed", offer: "Offer" };
16
+
17
+ function rowsFor(db, candidate_id) {
18
+ const apps = db.prepare(`
19
+ SELECT a.id, a.status, a.applied_at, a.notes, p.company, p.title, p.location, p.source_url, s.fit_score
20
+ FROM application a JOIN posting p ON p.id = a.posting_id LEFT JOIN scoring s ON s.id = a.scoring_id
21
+ WHERE p.candidate_id = ? ORDER BY COALESCE(a.applied_at, '') , p.company`).all(candidate_id);
22
+ const nextInterview = db.prepare(`SELECT scheduled_at FROM interview WHERE application_id = ? AND status IN ('scheduled','completed') ORDER BY scheduled_at DESC LIMIT 1`);
23
+ const contact = db.prepare(`SELECT pe.name, pe.title, pe.links_json FROM application_person ap JOIN person pe ON pe.id = ap.person_id
24
+ WHERE ap.application_id = ? ORDER BY CASE ap.role WHEN 'interviewer' THEN 0 WHEN 'recruiter' THEN 1 ELSE 2 END LIMIT 1`);
25
+ return apps.map((a) => {
26
+ const iv = nextInterview.get(a.id);
27
+ const c = contact.get(a.id);
28
+ const email = c ? (JSON.parse(c.links_json).find((l) => l.kind === "email") || {}).value : null;
29
+ const notes = a.notes || "";
30
+ const stage = (notes.match(/^Stage: (.*)$/m) || [])[1] || "";
31
+ const resume = (notes.match(/^Resume file: (.*)$/m) || [])[1] || "";
32
+ const plainNotes = notes.split("\n").filter((l) => !/^(Stage|Resume file): /.test(l)).join("\n");
33
+ return {
34
+ status: a.status,
35
+ values: [a.company, a.title, a.location || "", a.applied_at ? a.applied_at.slice(0, 10) : "", a.source_url || "",
36
+ c ? [c.name, c.title].filter(Boolean).join(", ") : "", email || "", STATUS_LABEL[a.status] || a.status, "",
37
+ iv ? iv.scheduled_at.slice(0, 10) : "", "", plainNotes, resume, stage, a.fit_score == null ? "" : a.fit_score],
38
+ };
39
+ });
40
+ }
41
+
42
+ async function exportTracker(db, { candidate_id, outDir, now }) {
43
+ const ExcelJS = require("exceljs");
44
+ const wb = new ExcelJS.Workbook();
45
+ const ws = wb.addWorksheet("Tracker");
46
+ ws.addRow(["Job Application Tracker"]);
47
+ ws.addRow([]);
48
+ const header = ws.addRow(COLUMNS);
49
+ header.font = { bold: true };
50
+ for (const r of rowsFor(db, candidate_id)) {
51
+ const row = ws.addRow(r.values);
52
+ const fill = FILL[r.status];
53
+ if (fill) row.eachCell({ includeEmpty: true }, (cell) => { cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: fill } }; });
54
+ }
55
+ ws.columns.forEach((col, i) => { col.width = i === 11 ? 60 : 22; });
56
+ const stamp = String(now).replace(/[-:]/g, "").slice(0, 13).replace("T", "-");
57
+ const file = path.join(outDir, `tracker-${stamp}.xlsx`);
58
+ await wb.xlsx.writeFile(file);
59
+ return { file, rows: ws.rowCount - 3 };
60
+ }
61
+
62
+ module.exports = { exportTracker, rowsFor, COLUMNS, FILL };
package/store/home.js ADDED
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Where the store lives. GAFJ_HOME overrides; otherwise the OS app-data
5
+ * folder. Never Documents: sync clients corrupt a live SQLite file, so a
6
+ * home under a known sync folder produces a warning the CLI prints.
7
+ */
8
+
9
+ const os = require("os");
10
+ const path = require("path");
11
+ const fs = require("fs");
12
+
13
+ const SYNC_FOLDER_MARKERS = ["OneDrive", "Dropbox", "Google Drive", "GoogleDrive", "iCloud", "Box"];
14
+
15
+ function defaultHome(env, platform) {
16
+ if (platform === "win32") {
17
+ return path.join(env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "GAFJ");
18
+ }
19
+ if (platform === "darwin") {
20
+ return path.join(os.homedir(), "Library", "Application Support", "GAFJ");
21
+ }
22
+ return path.join(env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"), "gafj");
23
+ }
24
+
25
+ function syncWarning(dir) {
26
+ const hit = SYNC_FOLDER_MARKERS.find((m) => dir.split(/[\\/]/).some((seg) => seg.toLowerCase() === m.toLowerCase()));
27
+ return hit ? `${dir} is under ${hit}; a synced folder can corrupt a live SQLite file. Set GAFJ_HOME elsewhere.` : null;
28
+ }
29
+
30
+ function resolveHome(env = process.env, platform = process.platform, { create = true } = {}) {
31
+ const dir = env.GAFJ_HOME ? path.resolve(env.GAFJ_HOME) : defaultHome(env, platform);
32
+ if (create) fs.mkdirSync(dir, { recursive: true });
33
+ return { dir, warning: syncWarning(dir) };
34
+ }
35
+
36
+ module.exports = { resolveHome, defaultHome, syncWarning };
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Loads accomplishment rows into the store. Idempotent by id: a re-import
5
+ * refreshes wordings and metrics but never demotes a dimension the candidate
6
+ * has already confirmed. Every record logs one import event.
7
+ */
8
+
9
+ const { buildAccomplishmentRows, reviewList } = require("../core/kb_import");
10
+ const { withTx } = require("./tx");
11
+ const { appendEvent } = require("./events");
12
+
13
+ const DIMS = ["metrics_json", "verbs_json", "verbatim_claims_json"];
14
+ const keyOf = { metrics_json: (m) => m.key + "|" + m.outcome_key, verbs_json: (v) => v.verb, verbatim_claims_json: (c) => c.type + "|" + c.text.toLowerCase() };
15
+
16
+ function mergeConfirmed(existing, incoming) {
17
+ for (const dim of DIMS) {
18
+ const prior = new Map((JSON.parse(existing[dim] || "[]")).map((x) => [keyOf[dim](x), x]));
19
+ for (const item of incoming[dim]) {
20
+ const p = prior.get(keyOf[dim](item));
21
+ if (p && p.review_state === "confirmed") item.review_state = "confirmed";
22
+ }
23
+ }
24
+ return incoming;
25
+ }
26
+
27
+ function ensureCandidate(db, candidate, now) {
28
+ const row = db.prepare("SELECT id FROM candidate WHERE id = ?").get(candidate.id);
29
+ if (!row) {
30
+ db.prepare("INSERT INTO candidate (id, name, contact_json, created_at) VALUES (?, ?, ?, ?)")
31
+ .run(candidate.id, candidate.name, JSON.stringify(candidate.contact || {}), now);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * @param {object} args { candidate: {id, name}, accomplishments, variations, fixtureTexts, now, actor: {type, ref} }
37
+ */
38
+ function importKb(db, args) {
39
+ const { candidate, accomplishments, variations, fixtureTexts = [], now } = args;
40
+ const actor = args.actor || { type: "import", ref: "kb" };
41
+ const rows = buildAccomplishmentRows(accomplishments, variations, fixtureTexts);
42
+ const result = { inserted: 0, updated: 0, review: [] };
43
+ withTx(db, (d) => {
44
+ ensureCandidate(d, candidate, now);
45
+ const get = d.prepare("SELECT * FROM accomplishment WHERE id = ?");
46
+ const ins = d.prepare(`INSERT INTO accomplishment (id, candidate_id, title, company, role, date_start, date_end, summary,
47
+ metrics_json, verbatim_claims_json, verbs_json, subjects_json, wordings_json, tags_json, strength_score, source, confirmed_at)
48
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'import', ?)`);
49
+ const upd = d.prepare(`UPDATE accomplishment SET title = ?, company = ?, role = ?, date_start = ?, date_end = ?, summary = ?,
50
+ metrics_json = ?, verbatim_claims_json = ?, verbs_json = ?, wordings_json = ?, tags_json = ?, strength_score = ? WHERE id = ?`);
51
+ for (const r of rows) {
52
+ const existing = get.get(r.id);
53
+ const row = existing ? mergeConfirmed(existing, r) : r;
54
+ const j = (x) => JSON.stringify(x);
55
+ if (existing) {
56
+ upd.run(row.title, row.company, row.role, row.date_start, row.date_end, row.summary,
57
+ j(row.metrics_json), j(row.verbatim_claims_json), j(row.verbs_json), j(row.wordings_json), j(row.tags_json), row.strength_score, row.id);
58
+ result.updated++;
59
+ } else {
60
+ ins.run(row.id, candidate.id, row.title, row.company, row.role, row.date_start, row.date_end, row.summary,
61
+ j(row.metrics_json), j(row.verbatim_claims_json), j(row.verbs_json), j(row.subjects_json), j(row.wordings_json),
62
+ j(row.tags_json), row.strength_score, now);
63
+ result.inserted++;
64
+ }
65
+ appendEvent(d, { candidate_id: candidate.id, entity: "accomplishment", entity_id: row.id, event: existing ? "reimported" : "imported",
66
+ actor_type: actor.type, actor_ref: actor.ref, payload: { metrics: row.metrics_json.length, verbs: row.verbs_json.length }, at: now });
67
+ }
68
+ result.review = reviewList(rows.map((r) => {
69
+ const stored = get.get(r.id);
70
+ return { ...r, metrics_json: JSON.parse(stored.metrics_json), verbs_json: JSON.parse(stored.verbs_json), verbatim_claims_json: JSON.parse(stored.verbatim_claims_json) };
71
+ }));
72
+ });
73
+ return result;
74
+ }
75
+
76
+ /** Store the candidate profile (employment skeleton, header, framing notes). Candidate data lives here, never in core/rules. */
77
+ function setProfile(db, { candidate_id, profile, now, actor }) {
78
+ return withTx(db, (d) => {
79
+ const prior = d.prepare("SELECT profile_json FROM candidate WHERE id = ?").get(candidate_id);
80
+ if (!prior) throw new Error("unknown candidate");
81
+ d.prepare("UPDATE candidate SET profile_json = ? WHERE id = ?").run(JSON.stringify(profile), candidate_id);
82
+ appendEvent(d, { candidate_id, entity: "candidate", entity_id: candidate_id, event: "profile_set", actor_type: actor.type, actor_ref: actor.ref,
83
+ payload: { keys: Object.keys(profile) }, prior: { profile_json: prior.profile_json }, at: now });
84
+ return { ok: true };
85
+ });
86
+ }
87
+
88
+ module.exports = { importKb, setProfile };
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Reads the xlsx tracker and a rounds manifest into postings, applications,
5
+ * frozen scores, interviews, people, and communications. Idempotent: rows
6
+ * are keyed by (company, title, applied date); interviews by round label;
7
+ * people by name; communications by kind and sent_at.
8
+ *
9
+ * The rounds manifest holds what the tracker keeps only as prose:
10
+ * { "rounds": [ { "company", "title", "round_label", "scheduled_at", "tz", "format", "status",
11
+ * "completed_at", "outcome", "people": [ { "name", "title", "org", "email" } ] } ],
12
+ * "people": [ { "company", "title", "name", "title_text", "org", "role", "email", "intel" } ],
13
+ * "communications": [ { "company", "title", "kind", "sent_at", "to", "round_label", "note" } ],
14
+ * "overrides": [ { "company", "title", "status", "decision_expected_at", "taken_down_at", "posting_text_file" } ] }
15
+ */
16
+
17
+ const fs = require("fs");
18
+ const crypto = require("crypto");
19
+ const { mapHeaders, mapRow, naturalKey } = require("../core/tracker_map");
20
+ const { ulid } = require("../core/ulid");
21
+ const { withTx } = require("./tx");
22
+ const { appendEvent } = require("./events");
23
+ const { assertIanaZone } = require("./tz");
24
+
25
+ const ENGINE_VERSION = "tracker-import-1";
26
+
27
+ async function readTracker(file) {
28
+ const ExcelJS = require("exceljs");
29
+ const wb = new ExcelJS.Workbook();
30
+ await wb.xlsx.readFile(file);
31
+ const ws = wb.worksheets[0];
32
+ let cols = null;
33
+ const rows = [];
34
+ ws.eachRow((row, n) => {
35
+ const cells = {};
36
+ let fill = null;
37
+ row.eachCell({ includeEmpty: false }, (cell) => {
38
+ const col = cell.address.replace(/\d+/g, "");
39
+ let v = cell.value;
40
+ if (v && typeof v === "object" && "richText" in v) v = v.richText.map((t) => t.text).join("");
41
+ if (v && typeof v === "object" && "text" in v) v = v.text;
42
+ if (v && typeof v === "object" && "result" in v) v = v.result;
43
+ cells[col] = v instanceof Date ? v.toISOString().slice(0, 10) : v;
44
+ if (!fill && cell.fill && cell.fill.fgColor && cell.fill.fgColor.argb && cell.fill.pattern === "solid") fill = cell.fill.fgColor.argb;
45
+ });
46
+ if (!cols) {
47
+ const m = mapHeaders(cells);
48
+ if (m.company && m.title && m.status) cols = m;
49
+ return;
50
+ }
51
+ rows.push({ n, cells, fill });
52
+ });
53
+ if (!cols) throw new Error("no header row with Company, Role / Title, and Status found");
54
+ return { cols, rows };
55
+ }
56
+
57
+ function sha(text) {
58
+ return crypto.createHash("sha256").update(text).digest("hex");
59
+ }
60
+
61
+ function stubPosting(r) {
62
+ return `Posting text not captured for ${r.company}, ${r.title}. Source: ${r.source_text || "tracker"}.`;
63
+ }
64
+
65
+ function findRow(rows, ref) {
66
+ const hit = rows.find((r) => r.company.toLowerCase() === ref.company.toLowerCase() && r.title.toLowerCase() === ref.title.toLowerCase());
67
+ if (!hit) throw new Error(`manifest refers to ${ref.company} / ${ref.title}, which is not in the tracker`);
68
+ return hit;
69
+ }
70
+
71
+ function upsertPerson(d, candidateId, p, now) {
72
+ const existing = d.prepare("SELECT id FROM person WHERE candidate_id = ? AND lower(name) = lower(?) AND merged_into_id IS NULL").get(candidateId, p.name);
73
+ const links = p.email ? [{ kind: "email", value: p.email }] : [];
74
+ if (existing) {
75
+ d.prepare("UPDATE person SET title = COALESCE(?, title), org = COALESCE(?, org), intel_text = COALESCE(?, intel_text) WHERE id = ?")
76
+ .run(p.title_text || p.title || null, p.org || null, p.intel || null, existing.id);
77
+ return existing.id;
78
+ }
79
+ const id = ulid(Date.parse(now));
80
+ d.prepare("INSERT INTO person (id, candidate_id, name, title, org, intel_text, links_json, first_seen_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
81
+ .run(id, candidateId, p.name, p.title_text || p.title || null, p.org || null, p.intel || null, JSON.stringify(links), now);
82
+ return id;
83
+ }
84
+
85
+ /**
86
+ * @param {object} args { candidate_id, tracker: {cols, rows}, manifest, postingTexts: {[file]: text}, now, actor }
87
+ */
88
+ function importTracker(db, args) {
89
+ const { candidate_id, tracker, manifest = {}, postingTexts = {}, now } = args;
90
+ const actor = args.actor || { type: "import", ref: "tracker" };
91
+ const mapped = tracker.rows.map((r) => ({ ...mapRow(r, tracker.cols), n: r.n })).filter(Boolean);
92
+ const result = { postings: 0, applications: 0, interviews: 0, people: 0, communications: 0, warnings: [], skipped: [] };
93
+ const overrides = new Map((manifest.overrides || []).map((o) => [o.company.toLowerCase() + "|" + o.title.toLowerCase(), o]));
94
+
95
+ withTx(db, (d) => {
96
+ const appByKey = new Map();
97
+ for (const r of mapped) {
98
+ for (const w of r.warnings) result.warnings.push(`row ${r.n}: ${w}`);
99
+ const ov = overrides.get(r.company.toLowerCase() + "|" + r.title.toLowerCase()) || {};
100
+ const rawText = ov.posting_text_file && postingTexts[ov.posting_text_file] ? postingTexts[ov.posting_text_file] : stubPosting(r);
101
+ const hash = sha(rawText);
102
+ let posting = d.prepare("SELECT id FROM posting WHERE candidate_id = ? AND company = ? AND title = ? AND content_hash = ?")
103
+ .get(candidate_id, r.company, r.title, hash);
104
+ if (!posting) {
105
+ const id = ulid(Date.parse(now));
106
+ d.prepare(`INSERT INTO posting (id, candidate_id, company, title, raw_text, content_hash, status, source_url, comp_text, location, captured_at, taken_down_at)
107
+ VALUES (?, ?, ?, ?, ?, ?, 'accepted', ?, ?, ?, ?, ?)`)
108
+ .run(id, candidate_id, r.company, r.title, rawText, hash, r.source_url, ov.comp_text || null, r.location, r.applied_at ? r.applied_at + "T00:00:00Z" : now, ov.taken_down_at || null);
109
+ posting = { id };
110
+ result.postings++;
111
+ appendEvent(d, { candidate_id, entity: "posting", entity_id: id, event: "imported", actor_type: actor.type, actor_ref: actor.ref, payload: { row: r.n }, at: now });
112
+ }
113
+ let scoring = d.prepare("SELECT id FROM scoring WHERE posting_id = ? AND status = 'frozen' ORDER BY scored_at DESC LIMIT 1").get(posting.id);
114
+ if (!scoring && r.fit != null) {
115
+ const id = ulid(Date.parse(now));
116
+ d.prepare(`INSERT INTO scoring (id, posting_id, categories_json, status, fit_score, verdict, verdict_reason, engine_version, rubric_version, kb_revision, scoring_evidence_hash, scored_at)
117
+ VALUES (?, ?, '[]', 'frozen', ?, ?, ?, ?, 'tracker', 0, ?, ?)`)
118
+ .run(id, posting.id, r.fit, ["pass", "rejected", "closed"].includes(r.status) && r.fit < 5 ? "pass" : "pursue",
119
+ r.stage || null, ENGINE_VERSION, sha("tracker:" + r.fit), now);
120
+ scoring = { id };
121
+ }
122
+ const status = ov.status || r.status;
123
+ let app = d.prepare("SELECT id, status FROM application WHERE posting_id = ?").get(posting.id);
124
+ if (!app) {
125
+ const id = ulid(Date.parse(now));
126
+ d.prepare(`INSERT INTO application (id, posting_id, status, applied_at, scoring_id, decision_expected_at, notes)
127
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
128
+ .run(id, posting.id, status, r.applied_at ? r.applied_at + "T00:00:00Z" : null, scoring ? scoring.id : null, ov.decision_expected_at || null,
129
+ [r.notes, r.stage && `Stage: ${r.stage}`, r.resume_file && `Resume file: ${r.resume_file}`].filter(Boolean).join("\n"));
130
+ app = { id, status };
131
+ result.applications++;
132
+ appendEvent(d, { candidate_id, entity: "application", entity_id: id, event: "imported", actor_type: actor.type, actor_ref: actor.ref, payload: { status, row: r.n }, at: now });
133
+ } else if (app.status !== status) {
134
+ d.prepare("UPDATE application SET status = ?, decision_expected_at = COALESCE(?, decision_expected_at) WHERE id = ?").run(status, ov.decision_expected_at || null, app.id);
135
+ appendEvent(d, { candidate_id, entity: "application", entity_id: app.id, event: "status", actor_type: actor.type, actor_ref: actor.ref,
136
+ payload: { status }, prior: { status: app.status }, at: now });
137
+ }
138
+ appByKey.set(naturalKey(r), { app: app.id, posting: posting.id, row: r });
139
+ if (r.contact && !manifest.people) result.warnings.push(`row ${r.n}: contact "${r.contact}" not imported; list it in the manifest people[]`);
140
+ }
141
+
142
+ const lookup = (ref) => {
143
+ const r = findRow(mapped, ref);
144
+ return appByKey.get(naturalKey(r));
145
+ };
146
+
147
+ for (const p of manifest.people || []) {
148
+ const { app } = lookup(p);
149
+ const before = d.prepare("SELECT count(*) c FROM person").get().c;
150
+ const pid = upsertPerson(d, candidate_id, p, now);
151
+ d.prepare("INSERT OR IGNORE INTO application_person (application_id, person_id, role) VALUES (?, ?, ?)").run(app, pid, p.role || "other");
152
+ if (d.prepare("SELECT count(*) c FROM person").get().c > before) result.people++;
153
+ }
154
+
155
+ for (const iv of manifest.rounds || []) {
156
+ const { app } = lookup(iv);
157
+ assertIanaZone(iv.tz);
158
+ let row = d.prepare("SELECT id FROM interview WHERE application_id = ? AND round_label = ? AND status IN ('scheduled','completed') ORDER BY scheduled_at DESC LIMIT 1")
159
+ .get(app, iv.round_label);
160
+ if (!row) {
161
+ const id = ulid(Date.parse(now));
162
+ d.prepare(`INSERT INTO interview (id, application_id, round_label, scheduled_at, tz, format, status, completed_at, invite_received_at, outcome)
163
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
164
+ .run(id, app, iv.round_label, iv.scheduled_at, iv.tz, iv.format, iv.status || "scheduled", iv.completed_at || null, iv.invite_received_at || null, iv.outcome || "pending");
165
+ row = { id };
166
+ result.interviews++;
167
+ appendEvent(d, { candidate_id, entity: "interview", entity_id: id, event: "imported", actor_type: actor.type, actor_ref: actor.ref, payload: { round: iv.round_label }, at: now });
168
+ } else {
169
+ d.prepare("UPDATE interview SET status = ?, completed_at = COALESCE(?, completed_at), outcome = ? WHERE id = ?")
170
+ .run(iv.status || "scheduled", iv.completed_at || null, iv.outcome || "pending", row.id);
171
+ }
172
+ for (const p of iv.people || []) {
173
+ const pid = upsertPerson(d, candidate_id, p, now);
174
+ d.prepare("INSERT OR IGNORE INTO interview_person (interview_id, person_id) VALUES (?, ?)").run(row.id, pid);
175
+ d.prepare("INSERT OR IGNORE INTO application_person (application_id, person_id, role) VALUES (?, ?, 'interviewer')").run(app, pid);
176
+ }
177
+ }
178
+
179
+ for (const c of manifest.communications || []) {
180
+ const { app } = lookup(c);
181
+ const iv = c.round_label
182
+ ? d.prepare("SELECT id FROM interview WHERE application_id = ? AND round_label = ? ORDER BY scheduled_at DESC LIMIT 1").get(app, c.round_label)
183
+ : null;
184
+ const exists = d.prepare("SELECT id FROM communication WHERE application_id = ? AND kind = ? AND sent_at = ?").get(app, c.kind, c.sent_at);
185
+ if (exists) continue;
186
+ const id = ulid(Date.parse(now));
187
+ d.prepare("INSERT INTO communication (id, application_id, interview_id, kind, to_addr, sent_at, sent_body_text) VALUES (?, ?, ?, ?, ?, ?, ?)")
188
+ .run(id, app, iv ? iv.id : null, c.kind, c.to || null, c.sent_at, c.note || null);
189
+ result.communications++;
190
+ }
191
+ });
192
+ return result;
193
+ }
194
+
195
+ module.exports = { readTracker, importTracker, ENGINE_VERSION };