@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
package/core/text.js ADDED
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Text helpers shared by provenance and the validators. Pure.
5
+ */
6
+
7
+ const { words, metricKey } = require("./scoring");
8
+
9
+ const NUMBER_WORDS = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12, dozen: 12 };
10
+ const COUNT_NOUNS = "person|people|members?|staff|clients?|employees?|products?|repositories|repository|aircraft|countries|country|teams?|managers?|stores?|locations?|sites?|vendors?|carriers?|reports?|cases|returns|orders?|records?|tests?|lines";
11
+
12
+ function norm(s) {
13
+ return String(s || "").toLowerCase().replace(/[^a-z0-9%$+.]+/g, " ").replace(/\s+/g, " ").trim();
14
+ }
15
+
16
+ function sentences(text) {
17
+ return String(text || "").split(/\r?\n|(?<=[.;:!?])\s+(?=[A-Z"(\[])/).map((s) => s.trim()).filter((s) => s.length > 0);
18
+ }
19
+
20
+ /** Phone numbers, emails, urls, zip codes, and calendar years are identifiers, not metrics. */
21
+ function stripIdentifiers(text) {
22
+ return String(text || "")
23
+ .replace(/\b\d{3}[-. ]\d{3}[-. ]\d{4}\b/g, " ")
24
+ .replace(/[\w.+-]+@[\w-]+\.[\w.]+/g, " ")
25
+ .replace(/\b(?:https?:\/\/|www\.)\S+|\b[\w-]+\.(?:com|net|org|io|dev)\b(?:\/\S*)?/gi, " ")
26
+ .replace(/\b(?:FY|CY|Q[1-4]\s?)?(?:19|20)\d{2}\b/gi, " ")
27
+ .replace(/(?<![\d,$.])\b\d{5}(?:-\d{4})?\b(?!,\d|[\d%]|\s*(?:%|percent|K\b|M\b))/g, " "); // bare five digit numbers are zip codes
28
+ }
29
+
30
+ /**
31
+ * Every figure in a prose field: money, percents, counts with a noun, N+ forms,
32
+ * durations, and number words with a noun. Returns [{ text, key, kind }].
33
+ */
34
+ function extractFigures(text) {
35
+ const src = stripIdentifiers(text);
36
+ const out = [];
37
+ const seen = new Set();
38
+ const push = (t, kind) => {
39
+ const clean = t.replace(/\s+/g, " ").trim();
40
+ const k = clean + "|" + kind;
41
+ if (seen.has(k)) return;
42
+ seen.add(k);
43
+ out.push({ text: clean, key: figureKey(clean), kind });
44
+ };
45
+ const re = new RegExp(
46
+ "\\$?\\s?\\d[\\d,]*(?:\\.\\d+)?\\s?[MK]?%?\\s*(?:to|-)\\s*\\$?\\d[\\d,]*(?:\\.\\d+)?\\s?[MK]?%?(?=\\W|$)" +
47
+ "|\\$\\s?\\d[\\d,]*(?:\\.\\d+)?\\s?[MK]?\\+?(?:\\s*(?:/|per)\\s*(?:month|year|yr|mo))?" +
48
+ "|\\d[\\d,]*(?:\\.\\d+)?\\s?(?:%|percent)\\+?" +
49
+ "|\\d[\\d,]*(?:\\.\\d+)?\\s+plus\\b(?!\\s*(?:years?|months?|weeks?|days?|yrs?)\\b)" +
50
+ "|\\b\\d+\\+(?!\\s*(?:years?|months?|weeks?|days?|yrs?)\\b)\\s*(?:" + COUNT_NOUNS + ")?" +
51
+ "|\\b\\d[\\d,]{2,}(?:\\.\\d+)?\\+?\\b" +
52
+ "|\\b(?:team of|staff of|group of)\\s+\\d{1,3}\\b" +
53
+ "|\\b\\d{1,3}\\s*(?:" + COUNT_NOUNS + ")\\b" +
54
+ "|\\b(?:" + Object.keys(NUMBER_WORDS).join("|") + ")\\s+(?:live\\s+)?(?:" + COUNT_NOUNS + ")\\b" +
55
+ "|\\b\\d[\\d,]*(?:\\.\\d+)?\\s*(?:\\+|plus\\s*)?\\s*(?:years?|months?|weeks?|days?|yrs?)\\b",
56
+ "gi");
57
+ let m;
58
+ while ((m = re.exec(src))) {
59
+ const t = m[0];
60
+ const kind = /\b(?:years?|months?|weeks?|days?|yrs?)\b/i.test(t) && !/\d\s*%/.test(t) ? "duration" : "metric";
61
+ push(t, kind);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** "$430,000" and "$430K" share a key; "five products" keys as "5"; ranges key on the first figure. */
67
+ function figureKey(text) {
68
+ const t = String(text).toLowerCase().replace(/\s*percent\b/, "%").replace(/\s+plus\b/, "+");
69
+ const wordMatch = t.match(/^(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|dozen)\b/);
70
+ if (wordMatch) return String(NUMBER_WORDS[wordMatch[1]]);
71
+ const range = t.match(/^\$?\s?(\d[\d,]*(?:\.\d+)?\s?[mk]?)\s?%?\s*(?:to|-)\s*/);
72
+ if (range) return metricKey((t.startsWith("$") ? "$" : "") + range[1] + (/%/.test(t) ? "%" : ""));
73
+ const teamOf = t.match(/\b(?:team of|staff of|group of)\s+(\d+)/);
74
+ if (teamOf) return teamOf[1];
75
+ // key on the first number token only, so a trailing noun never reads as a K/M scale
76
+ const first = t.match(/\$?\s?\d[\d,]*(?:\.\d+)?\s?(?:[mk]\b)?\s?%?/);
77
+ return metricKey(first ? first[0] : t);
78
+ }
79
+
80
+ function stems(text) {
81
+ return new Set(words(text));
82
+ }
83
+
84
+ function overlap(a, b) {
85
+ let n = 0;
86
+ for (const x of a) if (b.has(x)) n++;
87
+ return n;
88
+ }
89
+
90
+ function capitalizedRuns(sentence) {
91
+ const out = [];
92
+ const re = /\b([A-Z][a-zA-Z&.']+(?:\s+[A-Z][a-zA-Z&.']+)+)\b/g;
93
+ let m;
94
+ while ((m = re.exec(sentence))) out.push(m[1]);
95
+ return out;
96
+ }
97
+
98
+ module.exports = { norm, sentences, stripIdentifiers, extractFigures, figureKey, stems, overlap, capitalizedRuns, NUMBER_WORDS };
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Maps one row of the xlsx tracker to store shapes. Pure. The tracker's
5
+ * status column is authoritative; the fill color is a cross-check that
6
+ * produces a warning when they disagree. Interview rounds, people, and
7
+ * communications the tracker only holds as prose come from a rounds
8
+ * manifest (store/import_tracker.js), never from parsing notes.
9
+ */
10
+
11
+ const HEADERS = {
12
+ company: /^company$/i, title: /^role\s*\/\s*title$|^title$|^role$/i, location: /^location$/i,
13
+ applied: /^date applied$/i, source: /^source$/i, contact: /^contact name$/i, email: /^contact email$/i,
14
+ status: /^status$/i, follow_up: /^follow-?up date$/i, interview: /^interview date$/i, offer: /^offer/i,
15
+ notes: /^notes$/i, resume: /^tailored resume$/i, stage: /^stage$/i, fit: /^fit score$/i,
16
+ };
17
+
18
+ const STATUS = { applied: "applied", "to apply": "to_apply", maybe: "to_apply", pass: "pass", rejected: "rejected",
19
+ closed: "closed", offer: "offer", withdrawn: "closed" };
20
+
21
+ const FILL_STATUS = { BDD7EE: "applied", C6EFCE: "to_apply", FFC7CE: "closed_or_pass" };
22
+
23
+ /** Column letter -> field name from the header row. */
24
+ function mapHeaders(headerCells) {
25
+ const map = {};
26
+ for (const [col, text] of Object.entries(headerCells)) {
27
+ for (const [field, re] of Object.entries(HEADERS)) if (re.test(String(text).trim())) map[field] = col;
28
+ }
29
+ return map;
30
+ }
31
+
32
+ function excelSerialToIso(n) {
33
+ const ms = Math.round((Number(n) - 25569) * 86400 * 1000);
34
+ return new Date(ms).toISOString().slice(0, 10);
35
+ }
36
+
37
+ function toIsoDate(v) {
38
+ if (v == null || v === "") return null;
39
+ if (v instanceof Date) return v.toISOString().slice(0, 10);
40
+ if (typeof v === "number") return excelSerialToIso(v);
41
+ const s = String(v).trim();
42
+ if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10);
43
+ if (/^\d{5}$/.test(s)) return excelSerialToIso(Number(s));
44
+ const m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/);
45
+ if (m) return `${m[3]}-${m[1].padStart(2, "0")}-${m[2].padStart(2, "0")}`;
46
+ return null;
47
+ }
48
+
49
+ function fillColor(argb) {
50
+ if (!argb) return null;
51
+ return String(argb).toUpperCase().slice(-6);
52
+ }
53
+
54
+ /** @param {{cells: Record<string,string>, fill?: string}} row @param {Record<string,string>} cols */
55
+ function mapRow(row, cols) {
56
+ const c = (f) => (cols[f] ? row.cells[cols[f]] : undefined);
57
+ const company = String(c("company") || "").trim();
58
+ const title = String(c("title") || "").trim();
59
+ if (!company || !title) return null;
60
+ const rawStatus = String(c("status") || "").trim().toLowerCase();
61
+ const status = STATUS[rawStatus] || "to_apply";
62
+ const fill = FILL_STATUS[fillColor(row.fill)] || null;
63
+ const warnings = [];
64
+ if (fill && fill !== status && !(fill === "closed_or_pass" && ["pass", "rejected", "closed"].includes(status))) {
65
+ warnings.push(`status "${rawStatus}" disagrees with fill ${row.fill}`);
66
+ }
67
+ const fitRaw = c("fit");
68
+ const fit = fitRaw == null || fitRaw === "" ? null : Number(fitRaw);
69
+ return {
70
+ company, title,
71
+ location: c("location") || null,
72
+ applied_at: toIsoDate(c("applied")),
73
+ source_url: /^https?:/i.test(String(c("source") || "")) ? String(c("source")).split(/\s|\|/)[0] : null,
74
+ source_text: c("source") || null,
75
+ contact: c("contact") || null,
76
+ email: c("email") || null,
77
+ status,
78
+ follow_up: c("follow_up") || null,
79
+ interview_date: toIsoDate(c("interview")),
80
+ offer: c("offer") || null,
81
+ notes: c("notes") || "",
82
+ resume_file: c("resume") || null,
83
+ stage: c("stage") || "",
84
+ fit: Number.isFinite(fit) ? Math.round(fit * 10) / 10 : null,
85
+ warnings,
86
+ };
87
+ }
88
+
89
+ /** Natural key for idempotent import: company, title, applied date. */
90
+ function naturalKey(r) {
91
+ return [r.company.toLowerCase(), r.title.toLowerCase(), r.applied_at || ""].join("|");
92
+ }
93
+
94
+ module.exports = { mapHeaders, mapRow, naturalKey, toIsoDate, excelSerialToIso, STATUS, FILL_STATUS };
package/core/ulid.js ADDED
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * ULID: 48 bits of millisecond time plus 80 random bits, Crockford base32,
5
+ * 26 characters, lexically sortable by creation time. Time is a parameter so
6
+ * core stays clock-free; callers in store pass Date.now().
7
+ */
8
+
9
+ const crypto = require("crypto");
10
+ const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
11
+
12
+ function encodeTime(ms) {
13
+ let out = "";
14
+ let t = ms;
15
+ for (let i = 0; i < 10; i++) {
16
+ out = ALPHABET[t % 32] + out;
17
+ t = Math.floor(t / 32);
18
+ }
19
+ return out;
20
+ }
21
+
22
+ function encodeRandom() {
23
+ const bytes = crypto.randomBytes(16);
24
+ let out = "";
25
+ for (let i = 0; i < 16; i++) out += ALPHABET[bytes[i] & 31];
26
+ return out;
27
+ }
28
+
29
+ function ulid(nowMs) {
30
+ if (!Number.isInteger(nowMs) || nowMs < 0) throw new Error("ulid needs a millisecond timestamp");
31
+ return encodeTime(nowMs) + encodeRandom();
32
+ }
33
+
34
+ function isUlid(s) {
35
+ return typeof s === "string" && /^[0-9A-HJKMNP-TV-Z]{26}$/.test(s);
36
+ }
37
+
38
+ module.exports = { ulid, isUlid };
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Ledger rule 2, the F2 gate, with the semantics the shipped scoring.js
5
+ * enforces: every figure claimed in Career Highlights must appear in a body
6
+ * bullet by metric key, or the body must carry a different figure from the
7
+ * same record ("inside 60 days" in a highlight and "within the first two
8
+ * months" in the body are one claim in two units; F2 catches missing
9
+ * evidence, not unit choice). Resume kind only. In ingest mode highlights and
10
+ * bullets come from ctx.resume; in lint mode from the text by section.
11
+ */
12
+
13
+ const { extractFigures } = require("../text");
14
+
15
+ function sections(text) {
16
+ const t = String(text);
17
+ const hi = t.search(/career\s+highlights/i);
18
+ const rest = hi === -1 ? -1 : t.slice(hi + 17).search(/\b(experience|logistics\s*&?\s*operations\s+experience|process\s+improvement)\b/i);
19
+ if (hi === -1 || rest === -1) return null;
20
+ const body = hi + 17 + rest;
21
+ return { highlights: [t.slice(hi, body)], bullets: [t.slice(body)] };
22
+ }
23
+
24
+ function recordKeys(r) {
25
+ const keys = new Set();
26
+ for (const m of r.metrics_json || []) for (const k of m.keys || [m.key]) keys.add(k);
27
+ for (const f of extractFigures((r.summary || "") + " " + (r.wordings_json || []).map((w) => w.text).join(" "))) keys.add(f.key);
28
+ return keys;
29
+ }
30
+
31
+ function run(fields, ctx) {
32
+ if (ctx.kind !== "resume") return [];
33
+ const parts = ctx.resume || sections(fields.map((f) => f.value).join("\n"));
34
+ if (!parts) return [];
35
+ const bodyKeys = new Set(extractFigures(parts.bullets.join("\n")).map((f) => f.key));
36
+ const records = [...(ctx.slice || new Map()).values()].map((r) => recordKeys(r));
37
+ const sameEvidence = (key) => records.some((keys) => keys.has(key) && [...keys].some((k) => k !== key && bodyKeys.has(k)));
38
+ const out = [];
39
+ parts.highlights.forEach((h, i) => {
40
+ for (const fig of extractFigures(h)) {
41
+ if (fig.kind === "duration" && !bodyKeys.has(fig.key) && sameEvidence(fig.key)) continue;
42
+ if (bodyKeys.has(fig.key) || sameEvidence(fig.key)) continue;
43
+ out.push({ path: `careerHighlights[${i}]`, span: fig.text, hint: "highlight figure has no supporting body bullet; restore the bullet or drop the claim" });
44
+ }
45
+ });
46
+ return out;
47
+ }
48
+
49
+ module.exports = { name: "highlight_body_match", kind: "integrity", run, sections };
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The failure ledger as functions. runAll takes the schema walk of a
5
+ * content object (or a single prose field in lint mode) and a context, and
6
+ * returns blocks and warnings. Severity of style rules is per kind from the
7
+ * house rules; integrity rules always block. Pure.
8
+ *
9
+ * ctx: { kind, mode: "ingest" | "lint", slice, records, application, universe, house, resume?: { highlights[], bullets[] } }
10
+ */
11
+
12
+ const { severity } = require("../house_rules");
13
+
14
+ const RULES = [
15
+ require("./no_dashes"),
16
+ require("./no_derived_tenure"),
17
+ require("./metric_pairing"),
18
+ require("./verb_ladder"),
19
+ require("./highlight_body_match"),
20
+ require("./style_config"),
21
+ require("./no_confabulated_history"),
22
+ ];
23
+
24
+ function runAll(fields, ctx) {
25
+ const blocks = [];
26
+ const warnings = [];
27
+ for (const rule of RULES) {
28
+ const findings = rule.run(fields, ctx) || [];
29
+ for (const f of findings) {
30
+ const sev = f.severity || severity(ctx.house, rule.name, ctx.kind);
31
+ if (sev === "off") continue;
32
+ (sev === "block" ? blocks : warnings).push({ rule: rule.name, ...f, severity: undefined });
33
+ }
34
+ }
35
+ return { blocks, warnings };
36
+ }
37
+
38
+ /** Lint mode: one prose field from plain text, no claims, no provenance. */
39
+ function lintFields(text) {
40
+ return [{ path: "$.text", value: String(text || ""), class: "prose", claims: null, claimed: false }];
41
+ }
42
+
43
+ module.exports = { runAll, lintFields, RULES };
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Ledger rule 4: every figure in prose must be a confirmed metric on a
5
+ * record in scope, paired with its recorded outcome. In ingest mode the
6
+ * scope is the field's cited records and a metric claim with that value
7
+ * must exist; in lint mode the scope is the whole slice. Durations are left
8
+ * to no_derived_tenure. Identifier fields are never scanned.
9
+ */
10
+
11
+ const { extractFigures, norm, stems, overlap } = require("../text");
12
+ const { citedRecords } = require("../provenance");
13
+
14
+ function metricsOf(record, ctx) {
15
+ return (record.metrics_json || []).filter((m) => m.review_state === "confirmed" || (ctx && ctx.include_review));
16
+ }
17
+
18
+ function keysOf(m) {
19
+ const { figureKey } = require("../text");
20
+ return new Set([m.key, figureKey(m.value), ...(m.keys || []), ...(m.aliases || []).map(figureKey)]);
21
+ }
22
+
23
+ function run(fields, ctx) {
24
+ const out = [];
25
+ for (const f of fields) {
26
+ if (f.class !== "prose") continue;
27
+ const figures = extractFigures(f.value).filter((x) => x.kind === "metric");
28
+ if (!figures.length) continue;
29
+ const scope = ctx.mode === "lint" || !f.claims ? [...ctx.slice.values()] : [...citedRecords(f, ctx.slice).values()];
30
+ const sentenceStems = stems(f.value);
31
+ for (const fig of figures) {
32
+ const hits = [];
33
+ for (const r of scope) for (const m of metricsOf(r, ctx)) if (keysOf(m).has(fig.key)) hits.push({ r, m });
34
+ if (!hits.length) {
35
+ out.push({ path: f.path, span: fig.text, hint: ctx.mode === "lint"
36
+ ? "figure is not a confirmed metric on any record"
37
+ : "figure is not a confirmed metric on a cited record" });
38
+ continue;
39
+ }
40
+ if (ctx.mode !== "lint" && f.claims) {
41
+ const claimed = f.claims.some((c) => c.kind === "metric" && hits.some((h) => keysOf({ key: h.m.key, value: c.value, aliases: [] }).has(fig.key) && h.r.id === c.source_id));
42
+ if (!claimed) { out.push({ path: f.path, span: fig.text, hint: "figure has no metric claim naming its record and outcome" }); continue; }
43
+ }
44
+ // outcome pairing: the sentence must share a stem with the recorded outcome or title of some hit
45
+ const paired = hits.some((h) => overlap(sentenceStems, stems((h.m.outcome || "") + " " + h.r.title)) >= 1);
46
+ if (!paired) out.push({ path: f.path, span: fig.text, hint: `figure is recorded against "${hits[0].m.outcome}"; this sentence does not mention that outcome` });
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+
52
+ module.exports = { name: "metric_pairing", kind: "integrity", run, _norm: norm };
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Ledger rules 3, 11, 12: the record is the memory. Applies to prep,
5
+ * onepager, deep_answers, and email. Two universes come from ctx: this
6
+ * application's (rounds, formats, people, documents, company) and the
7
+ * candidate's full one (every company across applications, aliases).
8
+ *
9
+ * - a sentence with a history cue needs a history claim (ingest) or a
10
+ * resolvable round or format word (lint), else unsupported_history_claim
11
+ * - a company from another application blocks as cross_application_reference
12
+ * - a capitalized run in a history sentence that is in neither universe
13
+ * blocks as unknown_entity
14
+ * - completed homework needs a practice or cheatsheet document of this
15
+ * application, else unverified_homework
16
+ */
17
+
18
+ const { sentences, capitalizedRuns, norm } = require("../text");
19
+
20
+ const KINDS = new Set(["prep", "onepager", "deep_answers", "email"]);
21
+ const HISTORY_CUE = /\b(?:as (?:we|you) discussed|last round|previous round|prior round|in (?:the )?(?:r\d|round \d)|the (?:recruiter )?screen|what landed|you (?:told|mentioned|said)|(?:he|she|they) (?:said|asked|engaged|liked|probed)|when we (?:spoke|talked))\b/i;
22
+ const HOMEWORK_CUE = /\b(?:you (?:are|were) drilled|you drilled|we (?:drilled|built|prepared|practiced)|you (?:debugged|practiced|rehearsed)|the practice workbook|this weekend you)\b/i;
23
+
24
+ function wordIn(text, phrase) {
25
+ return new RegExp(`(?<![A-Za-z])${phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z])`, "i").test(text);
26
+ }
27
+
28
+ function run(fields, ctx) {
29
+ if (!KINDS.has(ctx.kind)) return [];
30
+ const u = ctx.universe || {};
31
+ const thisApp = ctx.application || {};
32
+ const otherCompanies = (u.other_companies || []).flatMap((c) => [c, ...(u.aliases && u.aliases[c] || []), ...(c.split(/\s+/)[0].length >= 4 ? [c.split(/\s+/)[0]] : [])]);
33
+ const known = new Set([...(u.known_entities || []), ...(thisApp.people || []), ...(thisApp.company ? [thisApp.company] : [])].map((s) => norm(s)));
34
+ const roundWords = new Set([...(thisApp.rounds || []).map((r) => r.toLowerCase()), ...(thisApp.formats || []).map((f) => f.toLowerCase())]);
35
+ const out = [];
36
+ for (const f of fields) {
37
+ if (f.class !== "prose") continue;
38
+ for (const s of sentences(f.value)) {
39
+ for (const c of otherCompanies) {
40
+ if (wordIn(s, c)) out.push({ path: f.path, span: s.slice(0, 80), hint: `cross_application_reference: "${c}" belongs to another application` });
41
+ }
42
+ const hasCue = HISTORY_CUE.test(s);
43
+ if (hasCue) {
44
+ const ok = ctx.mode === "lint" || !f.claims
45
+ ? [...roundWords].some((w) => new RegExp(`\\b${w}\\b`, "i").test(s)) || /\b(?:screen|recruiter)\b/i.test(s) && (thisApp.formats || []).includes("screen")
46
+ : (f.claims || []).some((c) => c.kind === "history" || c.kind === "quote");
47
+ if (!ok) out.push({ path: f.path, span: s.slice(0, 80), hint: "unsupported_history_claim: no history claim or round of this application" });
48
+ for (const run of capitalizedRuns(s)) {
49
+ const n = norm(run);
50
+ if (known.has(n) || [...known].some((k) => k.includes(n) || n.includes(k))) continue;
51
+ if (/^(?:the|this|that|monday|tuesday|wednesday|thursday|friday|excel|teams|zoom|google|microsoft)\b/i.test(run)) continue;
52
+ out.push({ path: f.path, span: run, hint: "unknown_entity in a history sentence" });
53
+ }
54
+ }
55
+ if (HOMEWORK_CUE.test(s) && !(thisApp.document_kinds || []).some((k) => ["practice", "cheatsheet"].includes(k))) {
56
+ out.push({ path: f.path, span: s.slice(0, 80), hint: "unverified_homework: no practice or cheatsheet document exists for this application" });
57
+ }
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ module.exports = { name: "no_confabulated_history", kind: "integrity", run };
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+
3
+ /** Ledger rule 6: no dashes in outbound prose, including the email subject line. */
4
+
5
+ const DASHES = /[‒-―−]/g;
6
+ const SPACED_HYPHEN = /\s-\s|\s-(?=\S)|(?<=\S)-\s/g;
7
+ const COMPOUND = /(?<=[A-Za-z])-(?=[A-Za-z])/g;
8
+ const PHONE = /\b\d{3}-\d{3}-\d{4}\b/g;
9
+
10
+ function run(fields, ctx) {
11
+ const allow = new Set((ctx.house && ctx.house.hyphen_allow || []).map((s) => s.toLowerCase()));
12
+ const out = [];
13
+ for (const f of fields) {
14
+ if (f.class !== "prose") continue;
15
+ const text = f.value.replace(PHONE, (m) => " ".repeat(m.length));
16
+ for (const m of text.matchAll(DASHES)) out.push({ path: f.path, span: snippet(text, m.index), hint: "use a comma, colon, or a new sentence" });
17
+ for (const m of text.matchAll(SPACED_HYPHEN)) out.push({ path: f.path, span: snippet(text, m.index), hint: "hyphen used as punctuation" });
18
+ for (const m of text.matchAll(COMPOUND)) {
19
+ const word = text.slice(text.lastIndexOf(" ", m.index) + 1, (text.indexOf(" ", m.index) + 1 || text.length + 1) - 1);
20
+ if (allow.has(word.toLowerCase())) continue;
21
+ out.push({ path: f.path, span: word, hint: "write the compound open or add it to hyphen_allow" });
22
+ }
23
+ }
24
+ return out;
25
+ }
26
+
27
+ function snippet(text, i) {
28
+ return text.slice(Math.max(0, i - 12), i + 12).trim();
29
+ }
30
+
31
+ module.exports = { name: "no_dashes", kind: "style", run };
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Ledger rules 1 and 9: no derived tenure arithmetic. A tenure phrase passes
5
+ * only as a confirmed tenure claim on a record in scope; a duration passes
6
+ * when it is a confirmed duration claim or metric on a record in scope.
7
+ * quoted_source fields are exempt (a requirement echo is not a claim).
8
+ */
9
+
10
+ const { norm } = require("../text");
11
+ const { citedRecords } = require("../provenance");
12
+
13
+ const NUM = "(?:\\d+(?:\\+|\\s*plus)?|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|fifteen|twenty)";
14
+ const TENURE = new RegExp(`\\b(?:(?:over|nearly|almost|more than|about|roughly)\\s+)?(?:a\\s+decade|two\\s+decades|${NUM}\\s*\\+?\\s*(?:years?|yrs?)(?:\\s+of)?(?:\\s+(?:experience|tenure|sales|selling|career|leading|in\\b))?)`, "gi");
15
+ const DURATION = new RegExp(`\\b(?:(?:over|in|within|nearly|almost|about|roughly|under|the\\s+first)\\s+)?(?:the\\s+first\\s+)?${NUM}\\s*(?:years?|months?|weeks?|days?)\\b`, "gi");
16
+
17
+ function scopeRecords(f, ctx) {
18
+ if (ctx.mode === "lint" || !f.claims) return [...ctx.slice.values()];
19
+ return [...citedRecords(f, ctx.slice).values()];
20
+ }
21
+
22
+ const WORDS = { one: "1", two: "2", three: "3", four: "4", five: "5", six: "6", seven: "7", eight: "8", nine: "9", ten: "10", eleven: "11", twelve: "12" };
23
+ const digitsForWords = (s) => s.replace(/\b(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b/g, (m) => WORDS[m]);
24
+
25
+ function confirmedPhrase(records, phrase) {
26
+ const p = digitsForWords(norm(phrase)).replace(/^(over|in|within|nearly|almost|about|roughly|under|the first)\s+/, "").replace(/\s*plus\b/, "+").replace(/\s*\+\s*/, "+ ");
27
+ // a confirmed claim may be the fuller wording the candidate chose ("7+ years in fulfillment"); the regex match is its head
28
+ const head = (t) => digitsForWords(norm(t)).replace(/^(over|in|within|nearly|almost|about|roughly|under|the first)\s+/, "").replace(/\s*plus\b/, "+").replace(/\s*\+\s*/, "+ ");
29
+ return records.some((r) =>
30
+ (r.verbatim_claims_json || []).some((c) => c.review_state === "confirmed" && (head(c.text) === p || head(c.text).startsWith(p + " ") || head(c.text).startsWith(p)))
31
+ || (r.metrics_json || []).some((m) => m.review_state === "confirmed" && digitsForWords(norm(m.value)) === p));
32
+ }
33
+
34
+ function run(fields, ctx) {
35
+ const out = [];
36
+ for (const f of fields) {
37
+ if (f.class !== "prose") continue;
38
+ const records = scopeRecords(f, ctx);
39
+ const seen = new Set();
40
+ for (const re of [TENURE, DURATION]) {
41
+ re.lastIndex = 0;
42
+ let m;
43
+ while ((m = re.exec(f.value))) {
44
+ const phrase = m[0].replace(/\s+/g, " ").trim();
45
+ if (seen.has(phrase.toLowerCase())) continue;
46
+ seen.add(phrase.toLowerCase());
47
+ if (/\bsince\s+(?:19|20)\d{2}/i.test(f.value.slice(Math.max(0, m.index - 20), m.index + phrase.length + 20)) && re === DURATION) continue;
48
+ if (confirmedPhrase(records, phrase)) continue;
49
+ const claimed = (f.claims || []).some((c) => c.kind === "tenure" && (norm(c.value) === norm(phrase) || norm(c.value).startsWith(norm(phrase))) && norm(f.value).includes(norm(c.value)));
50
+ if (claimed) continue;
51
+ out.push({ path: f.path, span: phrase, hint: re === TENURE
52
+ ? "tenure must be a confirmed verbatim claim; anchor to a recorded figure or let the dates speak"
53
+ : "a duration must be a confirmed duration claim or metric on a cited record" });
54
+ }
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+
60
+ module.exports = { name: "no_derived_tenure", kind: "integrity", run };
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+
3
+ /** House vocabulary (ecommerce, N+, %, P&L), per candidate, prose fields only. */
4
+
5
+ function run(fields, ctx) {
6
+ const rules = (ctx.house && ctx.house.vocabulary) || [];
7
+ const out = [];
8
+ for (const f of fields) {
9
+ if (f.class !== "prose") continue;
10
+ for (const r of rules) {
11
+ const re = new RegExp(r.pattern, (r.flags || "") + "g");
12
+ for (const m of f.value.matchAll(re)) out.push({ path: f.path, span: m[0], hint: r.message });
13
+ }
14
+ }
15
+ return out;
16
+ }
17
+
18
+ module.exports = { name: "style_config", kind: "style", run };
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Ledger rule 5: ownership verbs never promote. Ownership claims are checked
5
+ * against the cited record's confirmed verbs; a record with only review
6
+ * state verbs supports tier 1 at most. Prose defense: a tier-3 lead verb in
7
+ * a sentence whose cited records carry no confirmed tier-3 verb blocks. Weak
8
+ * and unlisted verbs are ignored. A subject with no overlap against the
9
+ * record is a warning, never a block.
10
+ */
11
+
12
+ const { sentences, stems, overlap, norm } = require("../text");
13
+ const { tier, leadVerb } = require("../verbs");
14
+ const { citedRecords } = require("../provenance");
15
+
16
+ function maxTier(record) {
17
+ const confirmed = (record.verbs_json || []).filter((v) => v.review_state === "confirmed");
18
+ return confirmed.length ? Math.max(...confirmed.map((v) => v.tier || tier(v.verb))) : 1;
19
+ }
20
+
21
+ function run(fields, ctx) {
22
+ const out = [];
23
+ for (const f of fields) {
24
+ if (f.class !== "prose") continue;
25
+ const scope = ctx.mode === "lint" || !f.claims ? null : citedRecords(f, ctx.slice);
26
+ for (const c of f.claims || []) {
27
+ if (c.kind !== "ownership") continue;
28
+ const r = ctx.slice.get(c.source_id);
29
+ if (!r) continue; // provenance already blocked it
30
+ const t = tier(c.verb);
31
+ if (t > maxTier(r)) out.push({ path: f.path, span: c.verb, hint: `"${c.verb}" is tier ${t}; ${r.id} supports tier ${maxTier(r)} on confirmed verbs` });
32
+ if (c.subject && overlap(stems(c.subject), stems(r.title + " " + (r.summary || ""))) === 0) {
33
+ out.push({ path: f.path, span: c.subject, hint: `subject has no overlap with ${r.id}; confirm it is this record`, severity: "warn" });
34
+ }
35
+ }
36
+ for (const s of sentences(f.value)) {
37
+ const v = leadVerb(s);
38
+ if (!v || tier(v) < 3) continue;
39
+ if (ctx.mode === "lint" || !f.claims) {
40
+ const ok = [...ctx.slice.values()].some((r) => maxTier(r) >= 3 && (r.verbs_json || []).some((x) => x.verb === v && x.review_state === "confirmed")
41
+ && (overlap(stems(s), stems(r.title)) >= 2 || (r.metrics_json || []).some((m) => m.review_state === "confirmed" && norm(s).replace(/\s+/g, "").includes(norm(m.value).replace(/\s+/g, "")))));
42
+ if (!ok) out.push({ path: f.path, span: s.slice(0, 80), hint: `"${v}" is a tier 3 ownership verb; no record in scope confirms it for this sentence` });
43
+ } else if (![...scope.values()].some((r) => maxTier(r) >= 3)) {
44
+ out.push({ path: f.path, span: s.slice(0, 80), hint: `"${v}" is a tier 3 ownership verb; no cited record carries a confirmed tier 3 verb` });
45
+ }
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ module.exports = { name: "verb_ladder", kind: "integrity", run };
package/core/verbs.js ADDED
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The ownership verb ladder (ledger rule 5). Tier 3 claims ownership, tier 2
5
+ * claims management, tier 1 claims participation. Verbs not on the ladder
6
+ * (reduced, recovered, cut, improved) describe outcomes, not ownership, and
7
+ * validator 4 ignores them: inflation is the risk, deflation is not.
8
+ */
9
+
10
+ const LADDER = {
11
+ led: 3, built: 3, created: 3, owned: 3, founded: 3, launched: 3, ran: 3, directed: 3,
12
+ managed: 2, drove: 2, delivered: 2, designed: 2, implemented: 2, grew: 2, established: 2, developed: 2, scaled: 2,
13
+ contributed: 1, supported: 1, assisted: 1, participated: 1, helped: 1,
14
+ };
15
+
16
+ const SYNONYM = { lead: "led", leading: "led", build: "built", building: "built", create: "created", own: "owned",
17
+ launch: "launched", run: "ran", manage: "managed", drive: "drove",
18
+ deliver: "delivered", implement: "implemented", grow: "grew", establish: "established", develop: "developed",
19
+ scale: "scaled", contribute: "contributed", support: "supported", assist: "assisted", participate: "participated",
20
+ help: "helped" };
21
+
22
+ function canonical(word) {
23
+ const w = String(word || "").toLowerCase();
24
+ return LADDER[w] ? w : SYNONYM[w] || null;
25
+ }
26
+
27
+ function tier(word) {
28
+ const c = canonical(word);
29
+ return c ? LADDER[c] : 0;
30
+ }
31
+
32
+ /**
33
+ * The ownership verb a wording claims: its first ladder verb in past tense
34
+ * form, if any. Base forms are not accepted here: "direct team of 6" and
35
+ * "found by auditing" are not ownership claims.
36
+ */
37
+ function leadVerb(text) {
38
+ const tokens = String(text || "").toLowerCase().match(/[a-z]+/g) || [];
39
+ for (const t of tokens.slice(0, 3)) if (LADDER[t]) return t;
40
+ return null;
41
+ }
42
+
43
+ module.exports = { LADDER, canonical, tier, leadVerb };