@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
+ # Requirement scoring rubric (Update Spec Part K)
2
+
3
+ You have called a scoring tool. The server has already done the deterministic
4
+ half: it scanned the posting, ranked the knowledge base against it, and ran the
5
+ mechanical gates. What remains is judgement, and it is yours. Follow this
6
+ rubric exactly.
7
+
8
+ **This output is a diagnostic, not a deliverable.** See "Output handling".
9
+
10
+ ---
11
+
12
+ ## Pass 1 — Requirement extraction and weighting
13
+
14
+ Produce 5 to 8 categories. More than 8 means you are splitting too finely.
15
+
16
+ | Field | Rule |
17
+ | --- | --- |
18
+ | `category` | A short label in the POSTING's own language, e.g. "3PL and Outsourced Logistics Vendor Management". Never map onto a fixed taxonomy. Categories differ per posting. |
19
+ | `weight` | 1 to 10, inferred from the employer's emphasis: minimum versus preferred qualifications, repetition, position in the responsibilities list, and whether the opening paragraph names it. |
20
+ | `jd_evidence` | The verbatim posting lines that produced the category. **Required.** A category with no quoted source text is invalid; delete it. |
21
+ | `is_hard_gate` | True when phrased as required, must have, a named credential, or a years of experience minimum. The tool returns candidate hard gate lines; confirm each. |
22
+
23
+ **The single most important constraint in Part K: weight from the employer's
24
+ emphasis, never from what the candidate happens to be good at.** A scorer that weights
25
+ toward the candidate's strengths is worthless. If you notice yourself raising a
26
+ weight because the evidence is strong, you have already failed.
27
+
28
+ The tool reports whether the G1 AI triggers fired. If they did, AI capability is
29
+ a real category and the entry placement rule changes; do not treat it as
30
+ incidental.
31
+
32
+ ## Pass 1b — Evidence ranking
33
+
34
+ The tool returns a ranked shortlist per category with a provisional
35
+ `evidence_strength`. Read the accomplishments before accepting it. Downgrade
36
+ anything whose match is vocabulary rather than substance. Mark a category
37
+ `Absent` when nothing in the knowledge base genuinely speaks to it, and say so
38
+ plainly rather than stretching an adjacent bullet to cover it.
39
+
40
+ This ranking feeds Career Highlights selection. It converts highlight choice
41
+ from recall into evidence ranking, which is what stops a leadership requirement
42
+ being answered with no leadership bullet on the page.
43
+
44
+ ## Pass 2 — Score the finished resume
45
+
46
+ | Field | Rule |
47
+ | --- | --- |
48
+ | `score` | 1 to 10 for how well THE RESUME evidences the category. Not how well the candidate does. |
49
+ | `rationale` | Two or three sentences citing specific resume content. Quote it. |
50
+ | `missing_evidence` | Knowledge base material that would have scored higher and was not selected. The tool computes a candidate list. |
51
+
52
+ ### The pre-send gate is enforced, not yours to apply
53
+
54
+ Pass your scores to `score_resume` as `scores`, an array of
55
+ `{category, weight, score}`. The server computes the weighted average to one
56
+ decimal and returns a `PRE-SEND GATE` section carrying `gate`, `overall`, and
57
+ `blocking`. You do not compute the overall and you do not decide the verdict.
58
+
59
+ `gate` is **BLOCKED** when either of these is true:
60
+
61
+ - any category with `weight >= 8` scores below 7, or
62
+ - any mechanical finding has severity `blocker` — decided with or without
63
+ `scores`, because a Career Highlight claiming a metric no body bullet supports
64
+ is not sendable at any score.
65
+
66
+ BLOCKED means the document is not sendable and must be regenerated. It is a
67
+ verdict on the resume, not advice about it, and there is no threshold of overall
68
+ score that overrides it. When the gate blocks, the tool says so in the first
69
+ line of its output, ahead of this rubric; act on that line before reading
70
+ anything else. Fix every blocking item, regenerate, and score again.
71
+
72
+ Omitting `scores` does not make a weak resume sendable. It only means the
73
+ weighted half of the gate was never checked, so no PASS is reported.
74
+
75
+ ## Gap classification
76
+
77
+ | Class | Meaning | Action |
78
+ | --- | --- | --- |
79
+ | Fixable by wording | The evidence exists; the resume did not surface it, or used different vocabulary | Regenerate with corrected wording |
80
+ | Fixable by KB | The candidate likely has this but it is not in the knowledge base | Ask the candidate to confirm, then add it |
81
+ | Genuine gap | He does not have it | Feed to the cover letter's gap naming paragraph. Never hide it |
82
+
83
+ Worked examples from the 2026-08-11 Meta scoring: "formal OKR framework
84
+ governance is light versus KPI driven cost management" is fixable by wording,
85
+ because the record says KPI throughout and the posting says OKR. "No explicit
86
+ Sarbanes Oxley experience" is a genuine gap with internal controls work as a
87
+ legitimate transferable proxy.
88
+
89
+ ## Guarding against self-grading
90
+
91
+ If this generator scores its own output against its own rubric, it will flatter
92
+ itself. Four mitigations, all required:
93
+
94
+ 1. The rubric comes from the posting verbatim. Every category carries
95
+ `jd_evidence`.
96
+ 2. Score the document, not the person. The question is "does this resume prove
97
+ it", never "can the candidate do it".
98
+ 3. **Absence is scoreable.** If the resume does not contain the evidence, the
99
+ score is low even when the knowledge base does contain it. That divergence
100
+ is exactly what `missing_evidence` exists to surface. Resist the pull to
101
+ score what you know rather than what is on the page.
102
+ 4. Calibrate against an independent tool periodically. CandidateMatch scored the
103
+ round 5 Meta resume 8.8 out of 10 on 2026-08-11. That is the benchmark. If
104
+ this scorer rates the same resume and posting meaningfully higher, the rubric
105
+ has drifted and the scores are worth nothing until it is re-anchored.
106
+
107
+ ## Output handling
108
+
109
+ Render to the working session only. Do not write a shareable file, and never
110
+ auto-generate a candidate facing "match report".
111
+
112
+ A candidate authored match report is self assessment, and its gap section is a
113
+ pre-drafted rejection rationale sitting in a forwardable file with the candidate's name
114
+ on it. The same document sent by a recruiter is a third party assessment and
115
+ carries real weight. Identical artifact, opposite effect, depending on sender.
116
+
117
+ The one sanctioned export is arming a recruiter already advocating for him, and
118
+ it requires him to say so per instance. When `export_for_recruiter` is set, the
119
+ tool omits the gap list; present what remains as a requirement to evidence map,
120
+ not as an automated score.
@@ -0,0 +1,342 @@
1
+ -- GAF-J schema, version 1. Plan section K.
2
+ -- Enums are CHECK constraints. Rules that must survive a UI bug are triggers.
3
+ -- IDs are ULIDs. Timestamps are ISO 8601 UTC strings stamped by the server.
4
+ -- PRAGMAs are set by store/db.js in the documented order, not here.
5
+
6
+ CREATE TABLE candidate (
7
+ id TEXT PRIMARY KEY,
8
+ name TEXT NOT NULL,
9
+ contact_json TEXT NOT NULL DEFAULT '{}',
10
+ style_config_json TEXT NOT NULL DEFAULT '{}',
11
+ company_aliases_json TEXT NOT NULL DEFAULT '{}',
12
+ kb_revision INTEGER NOT NULL DEFAULT 0,
13
+ created_at TEXT NOT NULL
14
+ );
15
+
16
+ CREATE TABLE accomplishment (
17
+ id TEXT PRIMARY KEY,
18
+ candidate_id TEXT NOT NULL REFERENCES candidate(id),
19
+ title TEXT NOT NULL,
20
+ company TEXT NOT NULL,
21
+ role TEXT NOT NULL,
22
+ date_start TEXT,
23
+ date_end TEXT,
24
+ summary TEXT,
25
+ metrics_json TEXT NOT NULL DEFAULT '[]',
26
+ verbatim_claims_json TEXT NOT NULL DEFAULT '[]',
27
+ verbs_json TEXT NOT NULL DEFAULT '[]',
28
+ subjects_json TEXT NOT NULL DEFAULT '[]',
29
+ wordings_json TEXT NOT NULL DEFAULT '[]',
30
+ tags_json TEXT NOT NULL DEFAULT '[]',
31
+ strength_score INTEGER,
32
+ source TEXT NOT NULL CHECK (source IN ('import', 'confirmed_pending')),
33
+ confirmed_at TEXT NOT NULL
34
+ );
35
+ CREATE INDEX accomplishment_candidate ON accomplishment(candidate_id);
36
+
37
+ CREATE TABLE onboarding_batch (
38
+ id TEXT PRIMARY KEY,
39
+ candidate_id TEXT NOT NULL REFERENCES candidate(id),
40
+ status TEXT NOT NULL CHECK (status IN ('open', 'review', 'completed', 'abandoned')),
41
+ created_at TEXT NOT NULL,
42
+ completed_at TEXT
43
+ );
44
+
45
+ CREATE TABLE source_document (
46
+ id TEXT PRIMARY KEY,
47
+ candidate_id TEXT NOT NULL REFERENCES candidate(id),
48
+ batch_id TEXT NOT NULL REFERENCES onboarding_batch(id),
49
+ filename TEXT NOT NULL,
50
+ kind TEXT NOT NULL CHECK (kind IN ('docx', 'pdf', 'md', 'txt')),
51
+ file_hash TEXT NOT NULL,
52
+ text_hash TEXT NOT NULL,
53
+ extractor TEXT NOT NULL,
54
+ extractor_version TEXT NOT NULL,
55
+ text TEXT NOT NULL CHECK (length(text) <= 1048576),
56
+ uploaded_at TEXT NOT NULL,
57
+ UNIQUE (candidate_id, file_hash)
58
+ );
59
+ CREATE TRIGGER batch_source_cap BEFORE INSERT ON source_document
60
+ WHEN (SELECT count(*) FROM source_document WHERE batch_id = NEW.batch_id) >= 20
61
+ BEGIN SELECT RAISE(ABORT, 'batch holds 20 sources; open another'); END;
62
+
63
+ CREATE TABLE posting (
64
+ id TEXT PRIMARY KEY,
65
+ candidate_id TEXT NOT NULL REFERENCES candidate(id),
66
+ company TEXT NOT NULL,
67
+ title TEXT NOT NULL,
68
+ raw_text TEXT NOT NULL CHECK (length(raw_text) BETWEEN 1 AND 262144),
69
+ content_hash TEXT NOT NULL,
70
+ status TEXT NOT NULL CHECK (status IN ('proposed', 'accepted', 'archived')),
71
+ source_url TEXT,
72
+ comp_text TEXT,
73
+ location TEXT,
74
+ captured_at TEXT NOT NULL,
75
+ taken_down_at TEXT,
76
+ UNIQUE (candidate_id, company, title, content_hash)
77
+ );
78
+ CREATE INDEX posting_candidate ON posting(candidate_id, captured_at DESC);
79
+ CREATE TRIGGER posting_raw_immutable BEFORE UPDATE OF raw_text, content_hash ON posting
80
+ BEGIN SELECT RAISE(ABORT, 'posting text is the permanent record'); END;
81
+
82
+ CREATE TABLE kb_gap (
83
+ id TEXT PRIMARY KEY,
84
+ candidate_id TEXT NOT NULL REFERENCES candidate(id),
85
+ posting_id TEXT NOT NULL REFERENCES posting(id),
86
+ requirement_text TEXT NOT NULL,
87
+ question TEXT NOT NULL,
88
+ created_at TEXT NOT NULL,
89
+ resolved_at TEXT
90
+ );
91
+
92
+ CREATE TABLE pending_accomplishment (
93
+ id TEXT PRIMARY KEY,
94
+ candidate_id TEXT NOT NULL REFERENCES candidate(id),
95
+ draft_json TEXT NOT NULL,
96
+ source TEXT NOT NULL CHECK (source IN ('gap_answer', 'manual', 'proposal', 'onboarding')),
97
+ batch_id TEXT REFERENCES onboarding_batch(id),
98
+ source_document_id TEXT REFERENCES source_document(id),
99
+ ai_attempt_id TEXT REFERENCES ai_attempt(id),
100
+ dedup_group_id TEXT,
101
+ gap_id TEXT REFERENCES kb_gap(id),
102
+ proposed_at TEXT NOT NULL,
103
+ CHECK (source <> 'onboarding' OR (batch_id IS NOT NULL AND source_document_id IS NOT NULL))
104
+ );
105
+ CREATE TRIGGER pending_cap_campaign BEFORE INSERT ON pending_accomplishment
106
+ WHEN NEW.source <> 'onboarding'
107
+ AND (SELECT count(*) FROM pending_accomplishment
108
+ WHERE candidate_id = NEW.candidate_id AND source <> 'onboarding') >= 20
109
+ BEGIN SELECT RAISE(ABORT, 'pending queue is full; confirm or dismiss first'); END;
110
+ CREATE TRIGGER pending_cap_source BEFORE INSERT ON pending_accomplishment
111
+ WHEN NEW.source = 'onboarding'
112
+ AND (SELECT count(*) FROM pending_accomplishment WHERE source_document_id = NEW.source_document_id) >= 40
113
+ BEGIN SELECT RAISE(ABORT, '40 proposals per source; review before proposing more'); END;
114
+ CREATE TRIGGER pending_cap_batch BEFORE INSERT ON pending_accomplishment
115
+ WHEN NEW.source = 'onboarding'
116
+ AND (SELECT count(*) FROM pending_accomplishment WHERE batch_id = NEW.batch_id) >= 250
117
+ BEGIN SELECT RAISE(ABORT, '250 proposals per batch'); END;
118
+
119
+ CREATE TABLE scoring (
120
+ id TEXT PRIMARY KEY,
121
+ posting_id TEXT NOT NULL REFERENCES posting(id),
122
+ categories_json TEXT NOT NULL,
123
+ status TEXT NOT NULL CHECK (status IN ('proposed', 'frozen')),
124
+ fit_score REAL CHECK (fit_score IS NULL OR fit_score BETWEEN 0 AND 10),
125
+ verdict TEXT CHECK (verdict IS NULL OR verdict IN ('pursue', 'pass')),
126
+ verdict_reason TEXT,
127
+ engine_version TEXT NOT NULL,
128
+ rubric_version TEXT NOT NULL,
129
+ kb_revision INTEGER NOT NULL,
130
+ scoring_evidence_hash TEXT,
131
+ supersedes_id TEXT REFERENCES scoring(id),
132
+ scored_at TEXT NOT NULL,
133
+ CHECK ((status = 'proposed' AND fit_score IS NULL AND verdict IS NULL)
134
+ OR (status = 'frozen' AND fit_score IS NOT NULL AND verdict IS NOT NULL AND scoring_evidence_hash IS NOT NULL))
135
+ );
136
+
137
+ CREATE TABLE application (
138
+ id TEXT PRIMARY KEY,
139
+ posting_id TEXT NOT NULL UNIQUE REFERENCES posting(id),
140
+ status TEXT NOT NULL CHECK (status IN ('to_apply', 'applied', 'pass', 'rejected', 'closed', 'offer')),
141
+ applied_at TEXT,
142
+ scoring_id TEXT REFERENCES scoring(id),
143
+ decision_expected_at TEXT,
144
+ resume_doc_id TEXT REFERENCES document(id),
145
+ cover_doc_id TEXT REFERENCES document(id),
146
+ comp_floor_ok INTEGER NOT NULL DEFAULT 0,
147
+ notes TEXT NOT NULL DEFAULT ''
148
+ );
149
+ -- An application may only reference an accepted posting, a frozen score for that posting,
150
+ -- and passed documents of the right kind for that posting. Checked on insert and update.
151
+ CREATE TRIGGER application_posting_insert BEFORE INSERT ON application
152
+ WHEN (SELECT status FROM posting WHERE id = NEW.posting_id) IS NOT 'accepted'
153
+ BEGIN SELECT RAISE(ABORT, 'applications require an accepted posting'); END;
154
+ CREATE TRIGGER application_posting_update BEFORE UPDATE OF posting_id ON application
155
+ WHEN (SELECT status FROM posting WHERE id = NEW.posting_id) IS NOT 'accepted'
156
+ BEGIN SELECT RAISE(ABORT, 'applications require an accepted posting'); END;
157
+ CREATE TRIGGER application_scoring_insert BEFORE INSERT ON application
158
+ WHEN NEW.scoring_id IS NOT NULL AND NOT EXISTS (
159
+ SELECT 1 FROM scoring s WHERE s.id = NEW.scoring_id AND s.status = 'frozen' AND s.posting_id = NEW.posting_id)
160
+ BEGIN SELECT RAISE(ABORT, 'scoring_id must be a frozen score for this posting'); END;
161
+ CREATE TRIGGER application_scoring_update BEFORE UPDATE OF scoring_id, posting_id ON application
162
+ WHEN NEW.scoring_id IS NOT NULL AND NOT EXISTS (
163
+ SELECT 1 FROM scoring s WHERE s.id = NEW.scoring_id AND s.status = 'frozen' AND s.posting_id = NEW.posting_id)
164
+ BEGIN SELECT RAISE(ABORT, 'scoring_id must be a frozen score for this posting'); END;
165
+ CREATE TRIGGER application_resume_insert BEFORE INSERT ON application
166
+ WHEN NEW.resume_doc_id IS NOT NULL AND NOT EXISTS (
167
+ SELECT 1 FROM document d WHERE d.id = NEW.resume_doc_id AND d.kind = 'resume' AND d.status = 'passed' AND d.posting_id = NEW.posting_id)
168
+ BEGIN SELECT RAISE(ABORT, 'resume_doc_id must be a passed resume for this posting'); END;
169
+ CREATE TRIGGER application_resume_update BEFORE UPDATE OF resume_doc_id, posting_id ON application
170
+ WHEN NEW.resume_doc_id IS NOT NULL AND NOT EXISTS (
171
+ SELECT 1 FROM document d WHERE d.id = NEW.resume_doc_id AND d.kind = 'resume' AND d.status = 'passed' AND d.posting_id = NEW.posting_id)
172
+ BEGIN SELECT RAISE(ABORT, 'resume_doc_id must be a passed resume for this posting'); END;
173
+ CREATE TRIGGER application_cover_insert BEFORE INSERT ON application
174
+ WHEN NEW.cover_doc_id IS NOT NULL AND NOT EXISTS (
175
+ SELECT 1 FROM document d WHERE d.id = NEW.cover_doc_id AND d.kind = 'cover' AND d.status = 'passed' AND d.posting_id = NEW.posting_id)
176
+ BEGIN SELECT RAISE(ABORT, 'cover_doc_id must be a passed cover letter for this posting'); END;
177
+ CREATE TRIGGER application_cover_update BEFORE UPDATE OF cover_doc_id, posting_id ON application
178
+ WHEN NEW.cover_doc_id IS NOT NULL AND NOT EXISTS (
179
+ SELECT 1 FROM document d WHERE d.id = NEW.cover_doc_id AND d.kind = 'cover' AND d.status = 'passed' AND d.posting_id = NEW.posting_id)
180
+ BEGIN SELECT RAISE(ABORT, 'cover_doc_id must be a passed cover letter for this posting'); END;
181
+
182
+ CREATE TABLE person (
183
+ id TEXT PRIMARY KEY,
184
+ candidate_id TEXT NOT NULL REFERENCES candidate(id),
185
+ name TEXT NOT NULL,
186
+ title TEXT,
187
+ org TEXT,
188
+ intel_text TEXT,
189
+ links_json TEXT NOT NULL DEFAULT '[]',
190
+ first_seen_at TEXT NOT NULL,
191
+ merged_into_id TEXT REFERENCES person(id)
192
+ );
193
+ CREATE TABLE application_person (
194
+ application_id TEXT NOT NULL REFERENCES application(id),
195
+ person_id TEXT NOT NULL REFERENCES person(id),
196
+ role TEXT NOT NULL CHECK (role IN ('interviewer', 'recruiter', 'referral', 'other')),
197
+ PRIMARY KEY (application_id, person_id, role)
198
+ );
199
+
200
+ CREATE TABLE interview (
201
+ id TEXT PRIMARY KEY,
202
+ application_id TEXT NOT NULL REFERENCES application(id),
203
+ round_label TEXT NOT NULL,
204
+ scheduled_at TEXT NOT NULL,
205
+ tz TEXT NOT NULL,
206
+ format TEXT NOT NULL CHECK (format IN ('screen', 'hm', 'panel', 'technical', 'onsite')),
207
+ status TEXT NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'completed', 'cancelled', 'superseded')),
208
+ completed_at TEXT,
209
+ invite_received_at TEXT,
210
+ supersedes_id TEXT REFERENCES interview(id),
211
+ pinned_ids_json TEXT NOT NULL DEFAULT '[]',
212
+ outcome TEXT NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending', 'advanced', 'rejected', 'ghosted')),
213
+ debrief_text TEXT CHECK (debrief_text IS NULL OR length(debrief_text) <= 65536),
214
+ debrief_captured_at TEXT
215
+ );
216
+ CREATE INDEX interview_app ON interview(application_id, scheduled_at);
217
+ CREATE UNIQUE INDEX interview_one_scheduled ON interview(application_id, round_label) WHERE status = 'scheduled';
218
+ CREATE TABLE interview_person (
219
+ interview_id TEXT NOT NULL REFERENCES interview(id),
220
+ person_id TEXT NOT NULL REFERENCES person(id),
221
+ PRIMARY KEY (interview_id, person_id)
222
+ );
223
+
224
+ CREATE TABLE document (
225
+ id TEXT PRIMARY KEY,
226
+ candidate_id TEXT NOT NULL REFERENCES candidate(id),
227
+ kind TEXT NOT NULL CHECK (kind IN ('resume', 'cover', 'prep', 'onepager', 'deep_answers', 'practice', 'cheatsheet', 'email')),
228
+ posting_id TEXT REFERENCES posting(id),
229
+ application_id TEXT REFERENCES application(id),
230
+ interview_id TEXT REFERENCES interview(id),
231
+ content_json TEXT NOT NULL,
232
+ provenance_json TEXT NOT NULL,
233
+ gate_result_json TEXT NOT NULL,
234
+ status TEXT NOT NULL CHECK (status IN ('passed', 'blocked')),
235
+ warnings_json TEXT NOT NULL DEFAULT '[]',
236
+ route TEXT NOT NULL CHECK (route IN ('mcp', 'paste', 'byo_key', 'credits', 'manual')),
237
+ rules_version TEXT NOT NULL,
238
+ schema_version TEXT NOT NULL,
239
+ ai_attempt_id TEXT REFERENCES ai_attempt(id),
240
+ rendered_rel_path TEXT,
241
+ created_at TEXT NOT NULL,
242
+ CHECK ((kind IN ('resume', 'cover') AND posting_id IS NOT NULL AND application_id IS NULL AND interview_id IS NULL)
243
+ OR (kind IN ('prep', 'onepager', 'deep_answers', 'practice', 'cheatsheet') AND interview_id IS NOT NULL AND posting_id IS NULL AND application_id IS NULL)
244
+ OR (kind = 'email' AND application_id IS NOT NULL AND posting_id IS NULL))
245
+ );
246
+ CREATE INDEX document_interview ON document(interview_id, kind, created_at DESC);
247
+ CREATE INDEX document_posting ON document(posting_id, kind, created_at DESC);
248
+ CREATE TRIGGER document_email_interview BEFORE INSERT ON document
249
+ WHEN NEW.kind = 'email' AND NEW.interview_id IS NOT NULL
250
+ AND (SELECT application_id FROM interview WHERE id = NEW.interview_id) IS NOT NEW.application_id
251
+ BEGIN SELECT RAISE(ABORT, 'interview belongs to another application'); END;
252
+ CREATE TRIGGER document_immutable BEFORE UPDATE OF
253
+ candidate_id, kind, posting_id, application_id, interview_id, content_json, provenance_json,
254
+ gate_result_json, status, warnings_json, route, rules_version, schema_version, ai_attempt_id, created_at
255
+ ON document
256
+ BEGIN SELECT RAISE(ABORT, 'documents are versions; only rendered_rel_path may change'); END;
257
+
258
+ CREATE TABLE communication (
259
+ id TEXT PRIMARY KEY,
260
+ application_id TEXT NOT NULL REFERENCES application(id),
261
+ interview_id TEXT REFERENCES interview(id),
262
+ kind TEXT NOT NULL CHECK (kind IN ('thankyou', 'nudge', 'negotiation', 'other')),
263
+ document_id TEXT REFERENCES document(id),
264
+ to_addr TEXT,
265
+ sent_at TEXT NOT NULL,
266
+ sent_body_text TEXT
267
+ );
268
+ CREATE TRIGGER communication_interview_insert BEFORE INSERT ON communication
269
+ WHEN NEW.interview_id IS NOT NULL
270
+ AND (SELECT application_id FROM interview WHERE id = NEW.interview_id) IS NOT NEW.application_id
271
+ BEGIN SELECT RAISE(ABORT, 'interview belongs to another application'); END;
272
+ CREATE TRIGGER communication_interview_update BEFORE UPDATE OF interview_id, application_id ON communication
273
+ WHEN NEW.interview_id IS NOT NULL
274
+ AND (SELECT application_id FROM interview WHERE id = NEW.interview_id) IS NOT NEW.application_id
275
+ BEGIN SELECT RAISE(ABORT, 'interview belongs to another application'); END;
276
+ CREATE TRIGGER communication_doc_insert BEFORE INSERT ON communication
277
+ WHEN NEW.document_id IS NOT NULL AND NOT EXISTS (
278
+ SELECT 1 FROM document d WHERE d.id = NEW.document_id AND d.kind = 'email' AND d.status = 'passed'
279
+ AND d.application_id = NEW.application_id
280
+ AND (d.interview_id IS NULL OR d.interview_id IS NEW.interview_id))
281
+ BEGIN SELECT RAISE(ABORT, 'draft must be a passed email for this application and interview'); END;
282
+ CREATE TRIGGER communication_doc_update BEFORE UPDATE OF document_id, application_id, interview_id ON communication
283
+ WHEN NEW.document_id IS NOT NULL AND NOT EXISTS (
284
+ SELECT 1 FROM document d WHERE d.id = NEW.document_id AND d.kind = 'email' AND d.status = 'passed'
285
+ AND d.application_id = NEW.application_id
286
+ AND (d.interview_id IS NULL OR d.interview_id IS NEW.interview_id))
287
+ BEGIN SELECT RAISE(ABORT, 'draft must be a passed email for this application and interview'); END;
288
+
289
+ CREATE TABLE event_log (
290
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
291
+ candidate_id TEXT NOT NULL REFERENCES candidate(id),
292
+ entity TEXT NOT NULL,
293
+ entity_id TEXT NOT NULL,
294
+ event TEXT NOT NULL,
295
+ actor_type TEXT NOT NULL CHECK (actor_type IN ('ui', 'cli', 'mcp', 'bridge', 'import', 'system')),
296
+ actor_ref TEXT,
297
+ payload_json TEXT NOT NULL DEFAULT '{}',
298
+ prior_json TEXT,
299
+ at TEXT NOT NULL
300
+ );
301
+ CREATE TRIGGER event_log_no_update BEFORE UPDATE ON event_log BEGIN SELECT RAISE(ABORT, 'append only'); END;
302
+ CREATE TRIGGER event_log_no_delete BEFORE DELETE ON event_log BEGIN SELECT RAISE(ABORT, 'append only'); END;
303
+
304
+ CREATE TABLE ai_run (
305
+ id TEXT PRIMARY KEY,
306
+ candidate_id TEXT NOT NULL REFERENCES candidate(id),
307
+ route TEXT NOT NULL CHECK (route IN ('mcp', 'paste', 'byo_key', 'credits', 'manual')),
308
+ operation TEXT NOT NULL CHECK (operation IN ('resume', 'cover', 'prep', 'onepager', 'deep_answers', 'followup', 'practice', 'categories', 'extract', 'style', 'dedup')),
309
+ interview_id TEXT REFERENCES interview(id),
310
+ posting_id TEXT REFERENCES posting(id),
311
+ source_document_id TEXT REFERENCES source_document(id),
312
+ onboarding_batch_id TEXT REFERENCES onboarding_batch(id),
313
+ rules_version TEXT NOT NULL,
314
+ schema_version TEXT NOT NULL,
315
+ packet_version TEXT NOT NULL,
316
+ kb_revision INTEGER NOT NULL,
317
+ packet_hash TEXT NOT NULL,
318
+ kb_ids_json TEXT NOT NULL,
319
+ started_at TEXT NOT NULL,
320
+ finished_at TEXT,
321
+ final_result TEXT NOT NULL DEFAULT 'pending' CHECK (final_result IN ('pending', 'passed', 'blocked', 'error', 'abandoned')),
322
+ CHECK ((operation IN ('resume', 'cover', 'categories') AND posting_id IS NOT NULL)
323
+ OR (operation IN ('prep', 'onepager', 'deep_answers', 'followup', 'practice') AND interview_id IS NOT NULL)
324
+ OR (operation IN ('extract', 'style') AND source_document_id IS NOT NULL)
325
+ OR (operation = 'dedup' AND onboarding_batch_id IS NOT NULL))
326
+ );
327
+ CREATE TABLE ai_attempt (
328
+ id TEXT PRIMARY KEY,
329
+ ai_run_id TEXT NOT NULL REFERENCES ai_run(id),
330
+ attempt_no INTEGER NOT NULL CHECK (attempt_no BETWEEN 1 AND 3),
331
+ provider TEXT,
332
+ model TEXT,
333
+ tokens_in INTEGER,
334
+ tokens_out INTEGER,
335
+ latency_ms INTEGER,
336
+ output_hash TEXT,
337
+ result TEXT NOT NULL CHECK (result IN ('passed', 'blocked', 'error')),
338
+ error_code TEXT,
339
+ started_at TEXT NOT NULL,
340
+ finished_at TEXT,
341
+ UNIQUE (ai_run_id, attempt_no)
342
+ );
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ const { str, arr, obj } = require("./lang");
3
+
4
+ /**
5
+ * Proposed scoring categories, Part K. Every jd_evidence line must be a
6
+ * verbatim substring of the posting text or the proposal is rejected.
7
+ * Weights come from the employer's emphasis, never the candidate's strengths.
8
+ */
9
+ module.exports = obj({
10
+ categories: arr(obj({
11
+ category: str("prose", { max: 80 }),
12
+ weight: { t: "number", min: 1, max: 10 },
13
+ jd_evidence: arr(str("quoted_source", { max: 400 }), 1, 6),
14
+ is_hard_gate: { t: "boolean" },
15
+ }), 5, 8),
16
+ }, ["categories"]);
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ const { str, claimed, arr, obj } = require("./lang");
3
+
4
+ /** Cover letter. A requirement the letter restates goes in quoted_source, never prose. */
5
+ module.exports = obj({
6
+ greeting: str("prose", { max: 80 }),
7
+ paragraphs: arr(claimed(900), 2, 5),
8
+ requirement_echoes: arr(str("quoted_source", { max: 300 }), 0, 6),
9
+ closing: str("prose", { max: 200 }),
10
+ }, ["greeting", "paragraphs", "closing"]);
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ const { str, claimed, arr, obj } = require("./lang");
3
+
4
+ /**
5
+ * Spoken-word scripts, Codification 3.3. Anything only the candidate lived
6
+ * is the literal token [YOURS]; a placeholder is never delivered and is
7
+ * excluded from provenance.
8
+ */
9
+ module.exports = obj({
10
+ answers: arr(obj({
11
+ question: str("prose", { max: 200 }),
12
+ seconds: { t: "number", min: 30, max: 150 },
13
+ script: claimed(1800),
14
+ skeleton_only: { t: "boolean" },
15
+ }, ["question", "script"]), 3, 12),
16
+ }, ["answers"]);
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ const { str, arr, obj } = require("./lang");
3
+
4
+ /**
5
+ * Discovery (plan section U, the shakedown): questions the AI asks about the
6
+ * confirmed records, each tied to one record id from the slice. The store
7
+ * rejects a question whose id is not in the packet. Nothing here is a claim;
8
+ * the answer is the candidate's, written on the knowledge base screen, and
9
+ * lands pending against that record.
10
+ */
11
+ module.exports = obj({
12
+ questions: arr(obj({
13
+ accomplishment_id: str("identifier", { max: 80 }),
14
+ kind: str("identifier", { enum: ["metric", "scope", "context", "story", "tenure", "skill"] }),
15
+ question: str("prose", { max: 400 }),
16
+ why: str("prose", { max: 240 }),
17
+ }, ["accomplishment_id", "kind", "question"]), 1, 12),
18
+ notes: str("prose", { max: 600 }),
19
+ }, ["questions"]);
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ const { str, arr, obj } = require("./lang");
3
+
4
+ /**
5
+ * Onboarding extraction (plan section U): accomplishments proposed from
6
+ * one source document. Every factual atom is an excerpt: the value plus
7
+ * the [start, end) span of the source text that contains it, checked by
8
+ * propose_from_source. Prose (summary) may paraphrase; atoms may not.
9
+ * No claims here: this never crosses the document ledger.
10
+ */
11
+ const ATOM = obj({ value: str("quoted_source", { max: 300 }), start: { t: "number", min: 0 }, end: { t: "number", min: 0 } });
12
+ const METRIC = obj({ value: str("quoted_source", { max: 80 }), start: { t: "number", min: 0 }, end: { t: "number", min: 0 }, outcome: str("prose", { max: 160 }) }, ["value", "start", "end"]);
13
+
14
+ module.exports = obj({
15
+ accomplishments: arr(obj({
16
+ title: str("prose", { max: 200 }),
17
+ company: ATOM,
18
+ role: ATOM,
19
+ dates: ATOM,
20
+ summary: str("prose", { max: 1200 }),
21
+ metrics: arr(METRIC, 0, 12),
22
+ verbs: arr(ATOM, 0, 8),
23
+ wordings: arr(ATOM, 1, 6),
24
+ scope: ATOM,
25
+ tags: arr(str("identifier", { max: 40 }), 0, 8),
26
+ }, ["title", "company", "role", "wordings"]), 1, 40),
27
+ style_observed: obj({
28
+ dashes: str("identifier", { enum: ["none", "hyphens", "em_dashes", "mixed"] }),
29
+ ecommerce_spelling: str("identifier", { max: 20 }),
30
+ percent_style: str("identifier", { enum: ["symbol", "word", "mixed"] }),
31
+ }, []),
32
+ }, ["accomplishments"]);
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ const { str, claimed, obj } = require("./lang");
3
+
4
+ /** Thank-you, nudge, negotiation. Subject and body are outbound prose; dashes block by default. */
5
+ module.exports = obj({
6
+ kind: str("identifier", { enum: ["thankyou", "nudge", "negotiation", "other"] }),
7
+ subject: str("prose", { max: 120 }),
8
+ body: claimed(2200),
9
+ open_loop_closed: str("prose", { max: 200 }),
10
+ }, ["kind", "subject", "body"]);
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * A small schema language for model output. Every string field carries a
5
+ * class (prose, identifier, quoted_source, url, email); a claim-bearing
6
+ * field is an object { text, claims[] }. The same schema validates model
7
+ * output, drives the walk the validators consume, and renders to JSON
8
+ * Schema for the packet. No date field exists in any output schema, and no
9
+ * field can carry the comp floor; a test walks every schema for both.
10
+ */
11
+
12
+ const lang = require("./lang");
13
+
14
+ const SCHEMAS = {
15
+ resume: require("./resume"),
16
+ cover: require("./cover"),
17
+ prep: require("./prep"),
18
+ onepager: require("./onepager"),
19
+ deep_answers: require("./deep_answers"),
20
+ followup: require("./followup"),
21
+ practice: require("./practice"),
22
+ categories: require("./categories"),
23
+ extract: require("./extract"),
24
+ discover: require("./discover"),
25
+ };
26
+
27
+ const SCHEMA_VERSION = "1";
28
+
29
+ /** Document kind for a schema key. followup lands as an email document; categories is a proposal. */
30
+ const KIND_OF = { resume: "resume", cover: "cover", prep: "prep", onepager: "onepager", deep_answers: "deep_answers", followup: "email", practice: "practice" };
31
+
32
+ module.exports = { ...lang, SCHEMAS, SCHEMA_VERSION, KIND_OF };