@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,140 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * ingest_document: the only path onto the document table. Resolves the run,
5
+ * counts the attempt, extracts JSON, validates the schema, runs provenance
6
+ * and the validators, and writes the document, the attempt, and the events
7
+ * in one transaction. The fourth attempt writes nothing and returns stop.
8
+ */
9
+
10
+ const crypto = require("crypto");
11
+ const { ulid } = require("../core/ulid");
12
+ const { extractJson } = require("../core/extract");
13
+ const { SCHEMAS, SCHEMA_VERSION, KIND_OF, validate, walk } = require("../core/schemas");
14
+ const { checkClaims } = require("../core/provenance");
15
+ const { runAll } = require("../core/validators");
16
+ const { rulesVersion } = require("../core/rules");
17
+ const { buildContext } = require("./context");
18
+ const { withTx } = require("./tx");
19
+ const { appendEvent } = require("./events");
20
+
21
+ const MAX_ATTEMPTS = 3;
22
+
23
+ function sha(s) {
24
+ return crypto.createHash("sha256").update(s).digest("hex");
25
+ }
26
+
27
+ function targetFor(db, op, { interview_id, posting_id }) {
28
+ if (["resume", "cover", "categories"].includes(op)) {
29
+ if (!posting_id) throw new Error(`${op} needs a posting_id`);
30
+ const p = db.prepare("SELECT id, candidate_id, status FROM posting WHERE id = ?").get(posting_id);
31
+ if (!p) throw new Error("unknown posting");
32
+ if (p.status !== "accepted") throw new Error("posting is not accepted");
33
+ return { candidate_id: p.candidate_id, posting_id, interview_id: null, application_id: null };
34
+ }
35
+ if (!interview_id) throw new Error(`${op} needs an interview_id`);
36
+ const iv = db.prepare(`SELECT i.id, i.application_id, p.candidate_id FROM interview i JOIN application a ON a.id = i.application_id
37
+ JOIN posting p ON p.id = a.posting_id WHERE i.id = ?`).get(interview_id);
38
+ if (!iv) throw new Error("unknown interview");
39
+ return { candidate_id: iv.candidate_id, posting_id: null, interview_id, application_id: iv.application_id };
40
+ }
41
+
42
+ /** Find or create the run this ingest belongs to. Never guesses from "latest pending". */
43
+ function resolveRun(db, args, now) {
44
+ if (args.run_id) {
45
+ const run = db.prepare("SELECT * FROM ai_run WHERE id = ?").get(args.run_id);
46
+ if (!run) throw new Error("unknown run_id");
47
+ return run;
48
+ }
49
+ const t = targetFor(db, args.op, args);
50
+ const id = ulid(Date.parse(now));
51
+ db.prepare(`INSERT INTO ai_run (id, candidate_id, route, operation, interview_id, posting_id, rules_version, schema_version, packet_version, kb_revision, packet_hash, kb_ids_json, started_at)
52
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'none', ?, 'manual', '[]', ?)`)
53
+ .run(id, t.candidate_id, args.route || "manual", args.op, t.interview_id, t.posting_id, rulesVersion(), SCHEMA_VERSION,
54
+ db.prepare("SELECT kb_revision FROM candidate WHERE id = ?").get(t.candidate_id).kb_revision, now);
55
+ return db.prepare("SELECT * FROM ai_run WHERE id = ?").get(id);
56
+ }
57
+
58
+ function resumeParts(content) {
59
+ return {
60
+ highlights: (content.careerHighlights || []).map((h) => `${h.leadIn} ${h.proof && h.proof.text || ""}`),
61
+ bullets: (content.experience || []).flatMap((e) => (e.bullets || []).map((b) => b.text && b.text.text || "")),
62
+ };
63
+ }
64
+
65
+ /**
66
+ * @param {object} args { run_id? | op + interview_id|posting_id, content (text or object), route, actor:{type,ref}, now, provider?, model?, tokens_in?, tokens_out?, latency_ms? }
67
+ */
68
+ function ingestDocument(db, args) {
69
+ const now = args.now;
70
+ const actor = args.actor || { type: "system", ref: "ingest" };
71
+ return withTx(db, (d) => {
72
+ const run = resolveRun(d, args, now);
73
+ const op = run.operation;
74
+ const kind = KIND_OF[op];
75
+ if (!kind) throw new Error(`operation ${op} does not produce a document`);
76
+ const attempts = d.prepare("SELECT count(*) c FROM ai_attempt WHERE ai_run_id = ?").get(run.id).c;
77
+ if (attempts >= MAX_ATTEMPTS) {
78
+ return { run_id: run.id, status: "blocked", attempt: attempts, stop: true, blocks: [{ rule: "attempts", path: "$", span: "", hint: "three attempts used; hand this to the user" }], warnings: [] };
79
+ }
80
+ const attemptNo = attempts + 1;
81
+ const startedAt = now;
82
+ const blocks = [];
83
+ const warnings = [];
84
+ let content = null;
85
+ const ex = extractJson(args.content);
86
+ if (ex.error) blocks.push({ rule: "schema", path: "$", span: "", hint: ex.error });
87
+ else {
88
+ content = ex.value;
89
+ for (const e of validate(content, SCHEMAS[op])) blocks.push({ rule: "schema", path: e.path, span: "", hint: e.message });
90
+ }
91
+ let provenance = {};
92
+ if (!blocks.length) {
93
+ const ctx = buildContext(d, { candidate_id: run.candidate_id, kind, mode: "ingest", application_id: run.interview_id
94
+ ? d.prepare("SELECT application_id FROM interview WHERE id = ?").get(run.interview_id).application_id : null,
95
+ kb_ids: JSON.parse(run.kb_ids_json || "[]") });
96
+ if (kind === "resume") ctx.resume = resumeParts(content);
97
+ const fields = walk(content, SCHEMAS[op]);
98
+ const prov = checkClaims(fields, ctx);
99
+ blocks.push(...prov.blocks);
100
+ warnings.push(...prov.warnings);
101
+ const v = runAll(fields, ctx);
102
+ blocks.push(...v.blocks);
103
+ warnings.push(...v.warnings);
104
+ for (const f of fields) if (f.claimed && f.claims.length) provenance[f.path] = f.claims.filter((c) => c.source_id).map((c) => c.source_id);
105
+ }
106
+ const status = blocks.length ? "blocked" : "passed";
107
+ const attemptId = ulid(Date.parse(now) + attemptNo);
108
+ const outputHash = content ? sha(JSON.stringify(content)) : null;
109
+ 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)
110
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
111
+ .run(attemptId, run.id, attemptNo, args.provider || null, args.model || null, args.tokens_in ?? null, args.tokens_out ?? null, args.latency_ms ?? null,
112
+ outputHash, status, blocks.length ? blocks[0].rule : null, startedAt, now);
113
+ let document_id = null;
114
+ if (content) {
115
+ document_id = ulid(Date.parse(now) + 10 + attemptNo);
116
+ const applicationId = kind === "email" ? d.prepare("SELECT application_id FROM interview WHERE id = ?").get(run.interview_id).application_id : null;
117
+ d.prepare(`INSERT INTO document (id, candidate_id, kind, posting_id, application_id, interview_id, content_json, provenance_json, gate_result_json, status, warnings_json, route, rules_version, schema_version, ai_attempt_id, created_at)
118
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
119
+ .run(document_id, run.candidate_id, kind, run.posting_id, applicationId, kind === "email" ? run.interview_id : (run.interview_id || null),
120
+ JSON.stringify(content), JSON.stringify(provenance), JSON.stringify({ status: status === "passed" ? "PASS" : "BLOCKED", blocks }), status,
121
+ JSON.stringify(warnings), args.route || run.route || "manual", run.rules_version, run.schema_version, attemptId, now);
122
+ }
123
+ const final = status === "passed" ? "passed" : attemptNo >= MAX_ATTEMPTS ? "blocked" : "pending";
124
+ 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);
125
+ appendEvent(d, { candidate_id: run.candidate_id, entity: "document", entity_id: document_id || run.id, event: status === "passed" ? "ingested" : "blocked",
126
+ actor_type: actor.type, actor_ref: actor.ref, payload: { run_id: run.id, attempt: attemptNo, kind, blocks: blocks.length, warnings: warnings.length }, at: now });
127
+ return { run_id: run.id, document_id, status, attempt: attemptNo, stop: attemptNo >= MAX_ATTEMPTS && status !== "passed", blocks, warnings };
128
+ });
129
+ }
130
+
131
+ /** Lint mode: plain text through the validators with no provenance, for calibration and for pasted drafts. */
132
+ function lintText(db, { candidate_id, kind, text, application_id, include_review = false }) {
133
+ const { lintFields } = require("../core/validators");
134
+ const ctx = buildContext(db, { candidate_id, kind, mode: "lint", application_id });
135
+ ctx.include_review = include_review;
136
+ const fields = lintFields(text);
137
+ return runAll(fields, ctx);
138
+ }
139
+
140
+ module.exports = { ingestDocument, lintText, MAX_ATTEMPTS, targetFor };
package/store/kb.js ADDED
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Knowledge base writes the human owns (screen 5): answer a gap, confirm or
5
+ * dismiss a pending accomplishment, and the summaries the screen reads.
6
+ * Confirming a pending row is the only path that creates an accomplishment
7
+ * after import; every dimension on it lands confirmed because the human
8
+ * just reviewed each one, and kb_revision bumps.
9
+ */
10
+
11
+ const { ulid } = require("../core/ulid");
12
+ const { normalizeMetrics, extractClaims, extractVerbs } = require("../core/kb_import");
13
+ const { tier } = require("../core/verbs");
14
+ const { withTx } = require("./tx");
15
+ const { appendEvent } = require("./events");
16
+ const { TransitionError, NotFound } = require("./transitions");
17
+
18
+ const slug = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "").slice(0, 60) || "record";
19
+
20
+ function summary(db, candidate_id) {
21
+ return {
22
+ confirmed: db.prepare("SELECT count(*) c FROM accomplishment WHERE candidate_id = ?").get(candidate_id).c,
23
+ gaps: db.prepare("SELECT count(*) c FROM kb_gap WHERE candidate_id = ? AND resolved_at IS NULL").get(candidate_id).c,
24
+ pending: db.prepare("SELECT count(*) c FROM pending_accomplishment WHERE candidate_id = ?").get(candidate_id).c,
25
+ kb_revision: db.prepare("SELECT kb_revision FROM candidate WHERE id = ?").get(candidate_id).kb_revision,
26
+ };
27
+ }
28
+
29
+ function listGaps(db, candidate_id, { open = true } = {}) {
30
+ return db.prepare(`SELECT g.id AS gap_id, g.posting_id, g.requirement_text, g.question, g.created_at, g.resolved_at, p.company, p.title
31
+ FROM kb_gap g JOIN posting p ON p.id = g.posting_id WHERE g.candidate_id = ? ${open ? "AND g.resolved_at IS NULL" : ""} ORDER BY g.created_at DESC`).all(candidate_id);
32
+ }
33
+
34
+ function listPending(db, candidate_id) {
35
+ return db.prepare("SELECT id AS pending_id, draft_json, source, gap_id, dedup_group_id, proposed_at FROM pending_accomplishment WHERE candidate_id = ? ORDER BY proposed_at DESC").all(candidate_id)
36
+ .map((r) => ({ ...r, draft: JSON.parse(r.draft_json), draft_json: undefined }));
37
+ }
38
+
39
+ function cleanDraft(draft) {
40
+ if (!draft || typeof draft !== "object") throw new TransitionError("draft is required");
41
+ const need = (k, max) => { const v = String(draft[k] || "").trim(); if (!v) throw new TransitionError(`draft.${k} is required`); return v.slice(0, max); };
42
+ return {
43
+ ...(draft.replaces ? { replaces: String(draft.replaces).slice(0, 80) } : {}),
44
+ ...(draft.tenure ? { tenure: String(draft.tenure).trim().slice(0, 80) } : {}),
45
+ title: need("title", 200), company: need("company", 200), role: need("role", 200),
46
+ dates: String(draft.dates || "").slice(0, 60), summary: String(draft.summary || "").slice(0, 1200),
47
+ metrics: (Array.isArray(draft.metrics) ? draft.metrics : []).map((m) => String(m).trim()).filter(Boolean).slice(0, 12),
48
+ verbs: (Array.isArray(draft.verbs) ? draft.verbs : []).map((v) => String(v).trim().toLowerCase()).filter(Boolean).slice(0, 8),
49
+ wordings: (Array.isArray(draft.wordings) ? draft.wordings : []).map((w) => String(w).trim()).filter(Boolean).slice(0, 6),
50
+ tags: (Array.isArray(draft.tags) ? draft.tags : []).map((t) => String(t).trim()).filter(Boolean).slice(0, 10),
51
+ };
52
+ }
53
+
54
+ /** The candidate answers a gap in their own words: a pending row linked to the gap, the gap resolved. */
55
+ function answerGap(db, { candidate_id, gap_id, draft, now, actor }) {
56
+ const clean = cleanDraft(draft);
57
+ return withTx(db, (d) => {
58
+ const g = d.prepare("SELECT * FROM kb_gap WHERE id = ? AND candidate_id = ?").get(gap_id, candidate_id);
59
+ if (!g) throw new NotFound("gap");
60
+ if (g.resolved_at) throw new TransitionError("this gap is already answered");
61
+ const id = ulid(Date.parse(now));
62
+ d.prepare("INSERT INTO pending_accomplishment (id, candidate_id, draft_json, source, gap_id, proposed_at) VALUES (?, ?, ?, 'gap_answer', ?, ?)").run(id, candidate_id, JSON.stringify(clean), g.id, now);
63
+ d.prepare("UPDATE kb_gap SET resolved_at = ? WHERE id = ?").run(now, g.id);
64
+ appendEvent(d, { candidate_id, entity: "kb_gap", entity_id: g.id, event: "answered", actor_type: actor.type, actor_ref: actor.ref, payload: { pending_id: id }, at: now });
65
+ return { pending_id: id, gap_id: g.id };
66
+ });
67
+ }
68
+
69
+ /** A record the candidate writes by hand lands pending too; nothing skips the confirm step. */
70
+ function proposeManual(db, { candidate_id, draft, now, actor }) {
71
+ const clean = cleanDraft(draft);
72
+ return withTx(db, (d) => {
73
+ const id = ulid(Date.parse(now));
74
+ d.prepare("INSERT INTO pending_accomplishment (id, candidate_id, draft_json, source, proposed_at) VALUES (?, ?, ?, 'manual', ?)").run(id, candidate_id, JSON.stringify(clean), now);
75
+ appendEvent(d, { candidate_id, entity: "pending_accomplishment", entity_id: id, event: "proposed", actor_type: actor.type, actor_ref: actor.ref, payload: { title: clean.title }, at: now });
76
+ return { pending_id: id };
77
+ });
78
+ }
79
+
80
+ function dismissPending(db, { candidate_id, pending_id, now, actor }) {
81
+ return withTx(db, (d) => {
82
+ const p = d.prepare("SELECT id FROM pending_accomplishment WHERE id = ? AND candidate_id = ?").get(pending_id, candidate_id);
83
+ if (!p) throw new NotFound("pending row");
84
+ d.prepare("DELETE FROM pending_accomplishment WHERE id = ?").run(p.id);
85
+ appendEvent(d, { candidate_id, entity: "pending_accomplishment", entity_id: p.id, event: "dismissed", actor_type: actor.type, actor_ref: actor.ref, at: now });
86
+ return { pending_id: p.id, dismissed: true };
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Confirm: the reviewed draft (edits applied) becomes an accomplishment with
92
+ * every dimension confirmed, the pending row is deleted, kb_revision bumps.
93
+ * A draft that answers a hole carries replaces: the existing record gains
94
+ * the new metrics, verbs, and wordings as confirmed dimensions instead of a
95
+ * second record with the same title appearing beside it.
96
+ */
97
+ function confirmPending(db, { candidate_id, pending_id, edits, now, actor }) {
98
+ return withTx(db, (d) => {
99
+ const p = d.prepare("SELECT * FROM pending_accomplishment WHERE id = ? AND candidate_id = ?").get(pending_id, candidate_id);
100
+ if (!p) throw new NotFound("pending row");
101
+ const draft = cleanDraft({ ...JSON.parse(p.draft_json), ...(edits || {}) });
102
+ if (draft.replaces) return extendRecord(d, { candidate_id, pending: p, draft, now, actor });
103
+ const base = `acc-${slug(draft.company).slice(0, 12)}-${slug(draft.title).slice(0, 24)}`;
104
+ let id = base;
105
+ for (let n = 2; d.prepare("SELECT 1 FROM accomplishment WHERE id = ?").get(id); n++) id = `${base}-${n}`;
106
+ const wordings = draft.wordings.map((text, i) => ({ id: `${id}-w${i + 1}`, text, recommended: i === 0, score: 0 }));
107
+ const outcome = { key: slug(draft.title), text: draft.title };
108
+ const metrics = normalizeMetrics(draft.metrics.map((value) => ({ value })), outcome).map((m) => ({ ...m, review_state: "confirmed" }));
109
+ const claims = extractClaims(wordings).map((c) => ({ ...c, review_state: "confirmed" }));
110
+ if (draft.tenure) claims.push({ text: draft.tenure, type: "tenure", source_variation_ids: [], review_state: "confirmed" });
111
+ const verbSet = new Map(extractVerbs(wordings).map((v) => [v.verb, { ...v, review_state: "confirmed" }]));
112
+ for (const v of draft.verbs) if (!verbSet.has(v)) verbSet.set(v, { verb: v, tier: tier(v), source_variation_ids: [], review_state: "confirmed" });
113
+ const [ds, de] = draft.dates.split(/\s+(?:to|-|–)\s+/).map((x) => x && x.trim());
114
+ d.prepare(`INSERT INTO accomplishment (id, candidate_id, title, company, role, date_start, date_end, summary, metrics_json, verbatim_claims_json, verbs_json, subjects_json, wordings_json, tags_json, strength_score, source, confirmed_at)
115
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', ?, ?, NULL, 'confirmed_pending', ?)`)
116
+ .run(id, candidate_id, draft.title, draft.company, draft.role, ds || null, de || null, draft.summary || null, JSON.stringify(metrics), JSON.stringify(claims), JSON.stringify([...verbSet.values()]), JSON.stringify(wordings), JSON.stringify(draft.tags), now);
117
+ d.prepare("DELETE FROM pending_accomplishment WHERE id = ?").run(p.id);
118
+ d.prepare("UPDATE candidate SET kb_revision = kb_revision + 1 WHERE id = ?").run(candidate_id);
119
+ appendEvent(d, { candidate_id, entity: "accomplishment", entity_id: id, event: "confirmed", actor_type: actor.type, actor_ref: actor.ref, payload: { pending_id: p.id, source: p.source, metrics: metrics.length, verbs: verbSet.size }, at: now });
120
+ return { accomplishment_id: id, metrics: metrics.length, verbs: verbSet.size };
121
+ });
122
+ }
123
+
124
+ /** The replaces path of confirmPending: merge the answered dimensions onto the existing record. */
125
+ function extendRecord(d, { candidate_id, pending, draft, now, actor }) {
126
+ const a = d.prepare("SELECT * FROM accomplishment WHERE id = ? AND candidate_id = ?").get(draft.replaces, candidate_id);
127
+ if (!a) throw new NotFound("the record this answer extends");
128
+ const metrics = JSON.parse(a.metrics_json || "[]");
129
+ const outcome = { key: slug(a.title), text: a.title };
130
+ const have = new Set(metrics.map((m) => String(m.value)));
131
+ const added = normalizeMetrics(draft.metrics.filter((v) => !have.has(v)).map((value) => ({ value })), outcome).map((m) => ({ ...m, review_state: "confirmed" }));
132
+ const wordings = JSON.parse(a.wordings_json || "[]");
133
+ const haveW = new Set(wordings.map((w) => w.text));
134
+ const newW = draft.wordings.filter((t) => !haveW.has(t)).map((text, i) => ({ id: `${a.id}-w${wordings.length + i + 1}`, text, recommended: false, score: 0 }));
135
+ const verbs = JSON.parse(a.verbs_json || "[]");
136
+ const haveV = new Set(verbs.map((v) => v.verb));
137
+ const newV = [...extractVerbs(newW).map((v) => ({ ...v, review_state: "confirmed" })), ...draft.verbs.map((verb) => ({ verb, tier: tier(verb), source_variation_ids: [], review_state: "confirmed" }))].filter((v) => !haveV.has(v.verb) && haveV.add(v.verb));
138
+ const summary = a.summary && a.summary.trim() ? a.summary : (draft.summary || null);
139
+ const claims = JSON.parse(a.verbatim_claims_json || "[]");
140
+ const normT = (t) => String(t).toLowerCase().replace(/\s+/g, " ").trim();
141
+ const newC = extractClaims(newW).map((c) => ({ ...c, review_state: "confirmed" }));
142
+ if (draft.tenure) newC.push({ text: draft.tenure, type: "tenure", source_variation_ids: [], review_state: "confirmed" });
143
+ const haveC = new Set(claims.map((c) => c.type + ":" + normT(c.text)));
144
+ const addedC = newC.filter((c) => !haveC.has(c.type + ":" + normT(c.text)) && haveC.add(c.type + ":" + normT(c.text)));
145
+ d.prepare("UPDATE accomplishment SET metrics_json = ?, wordings_json = ?, verbs_json = ?, verbatim_claims_json = ?, summary = ? WHERE id = ?")
146
+ .run(JSON.stringify([...metrics, ...added]), JSON.stringify([...wordings, ...newW]), JSON.stringify([...verbs, ...newV]), JSON.stringify([...claims, ...addedC]), summary, a.id);
147
+ d.prepare("DELETE FROM pending_accomplishment WHERE id = ?").run(pending.id);
148
+ d.prepare("UPDATE candidate SET kb_revision = kb_revision + 1 WHERE id = ?").run(candidate_id);
149
+ appendEvent(d, { candidate_id, entity: "accomplishment", entity_id: a.id, event: "extended", actor_type: actor.type, actor_ref: actor.ref, payload: { pending_id: pending.id, source: pending.source, metrics: added.length, verbs: newV.length, wordings: newW.length, claims: addedC.length }, at: now });
150
+ return { accomplishment_id: a.id, extended: true, metrics: added.length, verbs: newV.length, wordings: newW.length, claims: addedC.length };
151
+ }
152
+
153
+ // ----- holes (plan section U): the questions the records themselves raise -----
154
+
155
+ const SCOPE = /\b(?:team of \d+|\d[\d,.]*\s*(?:\+|plus)?\s*(?:person|people|members?|teams?|direct reports?|reports|staff|employees|associates|drivers|sites?|locations?|warehouses?|stores?|facilities|countries|regions?|markets?|fte)s?\b|p&l|budget)/i;
156
+
157
+ /**
158
+ * A hole is a record with no confirmed figure, or a record whose confirmed
159
+ * ownership verb is on the top tier with no scope figure anywhere on it.
160
+ * Each carries the question to ask and a prefilled draft that, confirmed,
161
+ * extends that record rather than adding a twin. Computed on read; nothing
162
+ * is stored until the candidate answers.
163
+ */
164
+ function listHoles(db, candidate_id, { limit = 20, now = new Date().toISOString() } = {}) {
165
+ const rows = db.prepare("SELECT id, title, company, role, date_start, date_end, summary, metrics_json, verbs_json, wordings_json, verbatim_claims_json FROM accomplishment WHERE candidate_id = ? ORDER BY confirmed_at DESC, id").all(candidate_id);
166
+ const holes = [];
167
+ // tenure: the dates add up to something the resume is not allowed to say until the candidate claims it in words
168
+ const year = (s) => { const m = String(s || "").match(/^(\d{4})/); return m ? Number(m[1]) : null; };
169
+ const starts = rows.map((a) => year(a.date_start)).filter(Boolean);
170
+ const ends = rows.map((a) => (a.date_end && !/present|current|now/i.test(a.date_end) ? year(a.date_end) : year(now))).filter(Boolean);
171
+ const hasTenure = rows.some((a) => JSON.parse(a.verbatim_claims_json || "[]").some((c) => c.type === "tenure" && c.review_state === "confirmed"));
172
+ if (starts.length && !hasTenure) {
173
+ const span = Math.max(...ends) - Math.min(...starts);
174
+ if (span >= 2) {
175
+ const anchor = rows.find((a) => year(a.date_start) === Math.min(...starts)) || rows[0];
176
+ holes.push({ accomplishment_id: anchor.id, title: anchor.title, company: anchor.company, role: anchor.role, dates: [anchor.date_start, anchor.date_end].filter(Boolean).join(" to "), hole_id: `${anchor.id}:tenure`, kind: "tenure", years: span,
177
+ question: `Your dates add up to about ${span} years. The resume is not allowed to say "${span}+ years" until you claim it in your own words. If you want it, put the exact phrase you would put on a resume in the tenure box, for example "${span}+ years in fulfillment". Leave it blank and the dates speak for themselves.`,
178
+ draft: { replaces: anchor.id, title: anchor.title, company: anchor.company, role: anchor.role, dates: [anchor.date_start, anchor.date_end].filter(Boolean).join(" to "), summary: "", metrics: [], verbs: [], wordings: [], tags: [], tenure: `${span}+ years` } });
179
+ }
180
+ }
181
+ for (const a of rows) {
182
+ const allMetrics = JSON.parse(a.metrics_json || "[]");
183
+ const metrics = allMetrics.filter((m) => m.review_state === "confirmed");
184
+ const waiting = allMetrics.filter((m) => m.review_state !== "confirmed").map((m) => m.value);
185
+ const verbs = JSON.parse(a.verbs_json || "[]").filter((v) => v.review_state === "confirmed");
186
+ const wordings = JSON.parse(a.wordings_json || "[]").map((w) => w.text);
187
+ const dates = [a.date_start, a.date_end].filter(Boolean).join(" to ");
188
+ const draft = { replaces: a.id, title: a.title, company: a.company, role: a.role, dates, summary: "", metrics: [], verbs: [], wordings: [], tags: [] };
189
+ const base = { accomplishment_id: a.id, title: a.title, company: a.company, role: a.role, dates };
190
+ if (!metrics.length) holes.push({ ...base, hole_id: `${a.id}:no_metric`, kind: "no_metric", waiting,
191
+ question: waiting.length
192
+ ? `No confirmed figure yet. ${waiting.join(", ")} came in with the import and is waiting under Review below: confirm it there if you can stand behind it, or put the number you can in the metrics box.`
193
+ : `What number moved? A percent, a dollar figure, a count, or time saved. Put it in the metrics box; it lands on this record as a confirmed figure.`, draft });
194
+ const lead = verbs.find((v) => (v.tier || tier(v.verb)) >= 3);
195
+ if (lead && !SCOPE.test([a.summary || "", ...wordings, ...metrics.map((m) => String(m.value))].join(" "))) holes.push({ ...base, hole_id: `${a.id}:no_scope`, kind: "no_scope", verb: lead.verb,
196
+ question: `"${lead.verb}" is an ownership claim with no scope on the record. How big was it: people, sites, dollars? Put the figure in the metrics box (for example "team of 6" or "$2.1M budget").`, draft });
197
+ if (holes.length >= limit) break;
198
+ }
199
+ return holes.slice(0, limit);
200
+ }
201
+
202
+ module.exports = { summary, listGaps, listPending, answerGap, proposeManual, dismissPending, confirmPending, cleanDraft, listHoles };
package/store/log.js ADDED
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Operational log (plan section O): HOME/logs/gafj.jsonl, one JSON object
5
+ * per line, and only ever these keys:
6
+ *
7
+ * { ts, level, event, entity, entity_id, actor, ms, status }
8
+ *
9
+ * The shape is the guarantee. There is no free-text field, so a request
10
+ * body, a provider key, an error message carrying either, or the comp
11
+ * floor cannot reach the file: every value is coerced to a short token
12
+ * (200 characters) or a number, and unknown keys are dropped. The file
13
+ * rotates past 10 MB, keeping three older files. Writes are synchronous
14
+ * appends and never throw: a log that cannot be written is a log that is
15
+ * skipped, never a request that fails.
16
+ */
17
+
18
+ const fs = require("fs");
19
+ const path = require("path");
20
+
21
+ const KEYS = ["entity", "entity_id", "actor", "ms", "status"];
22
+ const LEVELS = new Set(["info", "warn", "error"]);
23
+ const MAX_BYTES = 10 * 1024 * 1024;
24
+ const KEEP = 3;
25
+ const FILE = "gafj.jsonl";
26
+
27
+ function token(v) {
28
+ if (v === undefined || v === null) return undefined;
29
+ if (typeof v === "number") return Number.isFinite(v) ? v : undefined;
30
+ if (typeof v === "boolean") return v;
31
+ return String(v).replace(/[\r\n\t]/g, " ").slice(0, 200);
32
+ }
33
+
34
+ class Log {
35
+ constructor({ dir, maxBytes = MAX_BYTES, now = () => new Date().toISOString(), stderr = false } = {}) {
36
+ this.dir = dir;
37
+ this.file = dir ? path.join(dir, FILE) : null;
38
+ this.maxBytes = maxBytes;
39
+ this.now = now;
40
+ this.stderr = stderr;
41
+ this.size = null;
42
+ }
43
+
44
+ /** Build the line with the whitelisted keys only. Exposed so a test can check the shape without a file. */
45
+ line(level, event, fields = {}) {
46
+ const out = { ts: this.now(), level: LEVELS.has(level) ? level : "info", event: token(event) || "event" };
47
+ for (const k of KEYS) { const v = token(fields[k]); if (v !== undefined) out[k] = v; }
48
+ return out;
49
+ }
50
+
51
+ write(level, event, fields) {
52
+ const text = JSON.stringify(this.line(level, event, fields)) + "\n";
53
+ if (this.stderr) process.stderr.write(text);
54
+ if (!this.file) return;
55
+ try {
56
+ if (this.size === null) this.size = fs.existsSync(this.file) ? fs.statSync(this.file).size : 0;
57
+ if (this.size + text.length > this.maxBytes) this.rotate();
58
+ fs.mkdirSync(this.dir, { recursive: true });
59
+ fs.appendFileSync(this.file, text);
60
+ this.size += text.length;
61
+ } catch (_) { /* a log that cannot be written is skipped */ }
62
+ }
63
+
64
+ info(event, fields) { this.write("info", event, fields); }
65
+ warn(event, fields) { this.write("warn", event, fields); }
66
+ error(event, fields) { this.write("error", event, fields); }
67
+
68
+ /** Rotate when the live file is past the cap (also called once at serve start). Keeps KEEP older files. */
69
+ rotate(force = false) {
70
+ if (!this.file) return false;
71
+ try {
72
+ if (!fs.existsSync(this.file)) { this.size = 0; return false; }
73
+ const size = fs.statSync(this.file).size;
74
+ if (!force && size < this.maxBytes) { this.size = size; return false; }
75
+ const stamp = this.now().replace(/[:.]/g, "-");
76
+ fs.renameSync(this.file, path.join(this.dir, `gafj.${stamp}.jsonl`));
77
+ this.size = 0;
78
+ const old = fs.readdirSync(this.dir).filter((f) => /^gafj\..+\.jsonl$/.test(f)).sort();
79
+ for (const f of old.slice(0, Math.max(0, old.length - KEEP))) fs.unlinkSync(path.join(this.dir, f));
80
+ return true;
81
+ } catch (_) { return false; }
82
+ }
83
+ }
84
+
85
+ /** A log that writes nowhere, for tests and for commands that do not serve. */
86
+ const silent = () => new Log({});
87
+
88
+ module.exports = { Log, silent, KEYS, MAX_BYTES, KEEP, FILE };
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Schema versioning by PRAGMA user_version. Migrations run inside one
5
+ * BEGIN IMMEDIATE transaction, which is also the migration lock: a second
6
+ * process opening the same file blocks on busy_timeout until the first
7
+ * commits, then re-reads user_version and finds nothing to do.
8
+ * There is no migrate-down. Restore from a backup instead.
9
+ */
10
+
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+
14
+ const MIGRATIONS = [
15
+ { version: 1, file: path.join(__dirname, "..", "core", "schema.sql") },
16
+ { version: 2, file: path.join(__dirname, "..", "core", "migrations", "002-profile.sql") },
17
+ { version: 3, file: path.join(__dirname, "..", "core", "migrations", "003-discover.sql") },
18
+ ];
19
+
20
+ const CURRENT_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
21
+
22
+ function userVersion(db) {
23
+ return Number(db.prepare("PRAGMA user_version").get().user_version);
24
+ }
25
+
26
+ /**
27
+ * The documented SQLite recipe for a migration that rebuilds a table: foreign keys off for the
28
+ * transaction (the pragma is a no-op inside one, so it is set before BEGIN), foreign_key_check
29
+ * before COMMIT so a rebuild that orphaned a row fails the whole migration, keys back on after.
30
+ */
31
+ function migrate(db) {
32
+ if (userVersion(db) >= CURRENT_VERSION) return userVersion(db);
33
+ db.exec("PRAGMA foreign_keys = OFF");
34
+ db.exec("BEGIN IMMEDIATE");
35
+ try {
36
+ let v = userVersion(db); // re-read under the lock
37
+ for (const m of MIGRATIONS) {
38
+ if (m.version <= v) continue;
39
+ db.exec(fs.readFileSync(m.file, "utf8"));
40
+ db.exec(`PRAGMA user_version = ${m.version}`);
41
+ v = m.version;
42
+ }
43
+ const orphans = db.prepare("PRAGMA foreign_key_check").all();
44
+ if (orphans.length) throw new Error(`migration left ${orphans.length} dangling foreign key rows (${orphans[0].table})`);
45
+ db.exec("COMMIT");
46
+ return v;
47
+ } catch (e) {
48
+ db.exec("ROLLBACK");
49
+ throw e;
50
+ } finally {
51
+ db.exec("PRAGMA foreign_keys = ON");
52
+ }
53
+ }
54
+
55
+ module.exports = { migrate, userVersion, CURRENT_VERSION, MIGRATIONS };