job-application-agent 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +179 -0
- package/bin/job-application-agent.mjs +7 -0
- package/installer/src/cli.mjs +85 -0
- package/installer/src/installer.mjs +110 -0
- package/installer/src/runner.mjs +23 -0
- package/installer/src/scheduler.mjs +79 -0
- package/job-application-agent/SKILL.md +88 -0
- package/job-application-agent/agents/openai.yaml +4 -0
- package/job-application-agent/references/ANALYTICS.md +65 -0
- package/job-application-agent/references/APPLICATION_GUIDANCE.md +12 -0
- package/job-application-agent/references/SCHEMAS.md +156 -0
- package/job-application-agent/scripts/job-application.mjs +890 -0
- package/job-application-agent/scripts/telemetry-client.mjs +176 -0
- package/job-application-agent/scripts/telemetry-schema.mjs +161 -0
- package/job-application-agent/tests/job-application.test.mjs +432 -0
- package/job-application-agent/tests/privacy-audit.test.mjs +42 -0
- package/job-application-agent/tests/telemetry-client.test.mjs +132 -0
- package/job-application-agent/tests/telemetry-schema.test.mjs +98 -0
- package/package.json +46 -0
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { appendFile, chmod, mkdir, open, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { homedir, platform } from 'node:os';
|
|
7
|
+
import { basename, join, resolve } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { TelemetryClient } from './telemetry-client.mjs';
|
|
10
|
+
import { jobIdentity } from './telemetry-schema.mjs';
|
|
11
|
+
|
|
12
|
+
const KEYCHAIN_SERVICE = process.env.JOB_APPLICATION_AGENT_KEYCHAIN_SERVICE ?? 'com.openai.codex.job-application-agent';
|
|
13
|
+
const KEYCHAIN_ACCOUNT = 'profile';
|
|
14
|
+
const DEFAULT_STATE_DIR = join(homedir(), 'Library/Application Support/Codex/job-application-agent');
|
|
15
|
+
const SOURCES = new Set(['linkedin', 'greenhouse', 'lever', 'ashby', 'workable', 'comeet', 'workday', 'rippling', 'smartrecruiters', 'google-form', 'company', 'email', 'other']);
|
|
16
|
+
const ELIGIBILITY = new Set(['eligible', 'unclear', 'ineligible']);
|
|
17
|
+
const POSTING_STATUS = new Set(['active', 'closed', 'unclear']);
|
|
18
|
+
const WORK_MODES = new Set(['remote', 'hybrid', 'onsite', 'unspecified']);
|
|
19
|
+
const JOB_SENIORITIES = new Set(['junior', 'mid', 'senior', 'staff', 'principal', 'lead', 'manager', 'director', 'founding', 'unspecified']);
|
|
20
|
+
const ROLE_FAMILIES = new Set(['frontend', 'backend', 'full-stack', 'product-engineering', 'ai-ml', 'platform', 'infrastructure', 'mobile', 'engineering-management', 'architecture', 'security', 'data', 'other']);
|
|
21
|
+
const MUST_HAVE_STATUS = new Set(['met', 'partial', 'missing', 'unclear']);
|
|
22
|
+
const INTERVIEW_QUALITIES = new Set(['promising', 'viable', 'weak', 'dead']);
|
|
23
|
+
const FAILURE_POINTS = new Set(['role-scope', 'company-problem', 'constraints', 'interviewer', 'process', 'unknown']);
|
|
24
|
+
const APPROVALS = new Set(['APPROVE SUBMIT', 'STANDING AUTHORIZATION']);
|
|
25
|
+
const MODES = new Set(['review-each', 'routine-auto']);
|
|
26
|
+
const TELEMETRY_DURATIONS = new Set(['under-1s', '1-5s', '5-30s', '30-60s', '1-2m', '2-5m', '5-15m', '15m-plus']);
|
|
27
|
+
const REQUIRED_PROFILE = ['name', 'email', 'phone', 'location', 'workAuthorization', 'roleFamilies', 'seniority', 'targetLocations', 'workModes', 'submissionMode', 'yearsExperience', 'autoSubmitMinScore', 'manualReviewMinScore', 'minMustHaveCoverage'];
|
|
28
|
+
const STRING_PROFILE_FIELDS = new Set(['name', 'email', 'phone', 'location', 'workAuthorization', 'linkedin', 'github', 'portfolio', 'availability', 'currentCompensation', 'targetCompensation', 'submissionMode']);
|
|
29
|
+
const ARRAY_PROFILE_FIELDS = new Set(['roleFamilies', 'seniority', 'skills', 'targetLocations', 'excludedLocations', 'workModes', 'industries', 'excludedCompanies']);
|
|
30
|
+
const NUMBER_PROFILE_FIELDS = new Set(['yearsExperience', 'autoSubmitMinScore', 'manualReviewMinScore', 'minMustHaveCoverage']);
|
|
31
|
+
const OBJECT_PROFILE_FIELDS = new Set(['compensationFloor']);
|
|
32
|
+
const LEGACY_PROFILE_FIELDS = new Set(['salaryPreference']);
|
|
33
|
+
const DEFAULT_TARGETING = {
|
|
34
|
+
roleFamilies: ['product-engineering', 'full-stack', 'ai-ml'],
|
|
35
|
+
seniority: ['senior', 'staff'],
|
|
36
|
+
skills: ['TypeScript', 'Python', 'React', 'Node.js', 'PostgreSQL', 'MCP', 'AI agents'],
|
|
37
|
+
targetLocations: ['India', 'Remote', 'Worldwide'],
|
|
38
|
+
workModes: ['remote'],
|
|
39
|
+
industries: ['AI', 'developer tools'],
|
|
40
|
+
submissionMode: 'routine-auto',
|
|
41
|
+
yearsExperience: 10,
|
|
42
|
+
autoSubmitMinScore: 80,
|
|
43
|
+
manualReviewMinScore: 70,
|
|
44
|
+
minMustHaveCoverage: 70,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function stateDir() {
|
|
48
|
+
return process.env.JOB_APPLICATION_AGENT_STATE_DIR || DEFAULT_STATE_DIR;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function durationBucket(milliseconds) {
|
|
52
|
+
if (milliseconds < 1_000) return 'under-1s';
|
|
53
|
+
if (milliseconds < 5_000) return '1-5s';
|
|
54
|
+
if (milliseconds < 30_000) return '5-30s';
|
|
55
|
+
if (milliseconds < 60_000) return '30-60s';
|
|
56
|
+
if (milliseconds < 120_000) return '1-2m';
|
|
57
|
+
if (milliseconds < 300_000) return '2-5m';
|
|
58
|
+
if (milliseconds < 900_000) return '5-15m';
|
|
59
|
+
return '15m-plus';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function commandCategory([area, action]) {
|
|
63
|
+
if (area === 'profile' && action === 'set') return 'onboard';
|
|
64
|
+
if (area === 'profile' && action === 'migrate') return 'onboard';
|
|
65
|
+
if (area === 'profile') return 'profile';
|
|
66
|
+
if (area === 'resume') return 'resume';
|
|
67
|
+
if (area === 'score') return 'assess';
|
|
68
|
+
if (area === 'ledger' && action === 'add') return 'apply';
|
|
69
|
+
if (area === 'ledger' && action === 'outcome') return 'outcome';
|
|
70
|
+
if (area === 'ledger' && action === 'review') return 'review';
|
|
71
|
+
if (area === 'ledger' && action === 'review-ack') return 'review';
|
|
72
|
+
if (area === 'ledger') return 'apply';
|
|
73
|
+
if (area === 'telemetry') return 'telemetry';
|
|
74
|
+
return 'other';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function telemetryStage(command) {
|
|
78
|
+
return ({ search: 'discovery', assess: 'assessment', apply: 'submission', outcome: 'outcome', review: 'review', resume: 'resume', profile: 'contact', onboard: 'contact' })[command] ?? 'application';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function telemetryErrorCode(error) {
|
|
82
|
+
const message = String(error?.message ?? '');
|
|
83
|
+
if (/keychain|authentication|login/i.test(message)) return 'authentication_required';
|
|
84
|
+
if (/network|fetch|http/i.test(message)) return 'network_failure';
|
|
85
|
+
if (/invalid|must|required|expected|unsupported/i.test(message)) return 'invalid_input';
|
|
86
|
+
return 'internal_error';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sourceToAts(source) {
|
|
90
|
+
return SOURCES.has(source) ? source : 'other';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function assessmentTags(result) {
|
|
94
|
+
const matches = [];
|
|
95
|
+
const gaps = [];
|
|
96
|
+
for (const reason of result.reasons ?? []) {
|
|
97
|
+
if (/^Role family:/i.test(reason)) matches.push('role_family');
|
|
98
|
+
else if (/^Seniority:/i.test(reason)) matches.push('seniority');
|
|
99
|
+
else if (/^Skills:/i.test(reason)) matches.push('skills');
|
|
100
|
+
else if (/^Industry:/i.test(reason)) matches.push('industry');
|
|
101
|
+
else if (/Remote-compatible/i.test(reason)) matches.push('remote');
|
|
102
|
+
else if (/Target location:/i.test(reason)) matches.push('location');
|
|
103
|
+
}
|
|
104
|
+
for (const gap of result.gaps ?? []) {
|
|
105
|
+
if (/role family/i.test(gap)) gaps.push('role_family');
|
|
106
|
+
else if (/seniority/i.test(gap)) gaps.push('seniority');
|
|
107
|
+
else if (/skill/i.test(gap)) gaps.push('skills');
|
|
108
|
+
else if (/location|work mode/i.test(gap)) gaps.push('location');
|
|
109
|
+
else if (/eligibility|authorization/i.test(gap)) gaps.push('authorization_unclear');
|
|
110
|
+
else gaps.push('other');
|
|
111
|
+
}
|
|
112
|
+
return { matchTags: [...new Set(matches)], gapTags: [...new Set(gaps)] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function telemetryJobAssessed(job, result) {
|
|
116
|
+
if (!job.url) return null;
|
|
117
|
+
const identity = await jobIdentity(job.url);
|
|
118
|
+
return {
|
|
119
|
+
event: 'job_assessed',
|
|
120
|
+
properties: {
|
|
121
|
+
...identity,
|
|
122
|
+
company: job.company,
|
|
123
|
+
title: job.title,
|
|
124
|
+
ats: sourceToAts(String(job.source).toLowerCase()),
|
|
125
|
+
fitScore: result.score,
|
|
126
|
+
eligibility: String(job.eligibility).toLowerCase(),
|
|
127
|
+
decision: result.decision,
|
|
128
|
+
...assessmentTags(result),
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function telemetryApplicationSubmitted(entry, details = {}) {
|
|
134
|
+
const identity = await jobIdentity(entry.url);
|
|
135
|
+
const answers = Object.keys(entry.answers ?? {});
|
|
136
|
+
return {
|
|
137
|
+
event: 'application_submitted',
|
|
138
|
+
properties: {
|
|
139
|
+
...identity,
|
|
140
|
+
company: entry.company,
|
|
141
|
+
title: entry.role,
|
|
142
|
+
ats: sourceToAts(entry.source),
|
|
143
|
+
durationBucket: details.durationBucket ?? 'under-1s',
|
|
144
|
+
fieldsFilled: details.fieldsFilled ?? answers.length,
|
|
145
|
+
shortAnswerCount: details.shortAnswerCount ?? answers.filter((key) => !/resume|attachment/i.test(key)).length,
|
|
146
|
+
resumeUploaded: details.resumeUploaded ?? answers.some((key) => /resume|attachment/i.test(key)),
|
|
147
|
+
approvalMode: entry.approval === 'STANDING AUTHORIZATION' ? 'routine-auto' : 'review-each',
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function object(value, label) {
|
|
153
|
+
if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`${label} must be an object.`);
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function string(value, label, max = 5000) {
|
|
158
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max) throw new Error(`${label} must be a non-empty string no longer than ${max} characters.`);
|
|
159
|
+
return value.trim();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function stringArray(value, label, required = false) {
|
|
163
|
+
if (!Array.isArray(value) || (required && value.length === 0)) throw new Error(`${label} must be ${required ? 'a non-empty' : 'an'} array of strings.`);
|
|
164
|
+
return value.map((item, index) => string(item, `${label}[${index}]`, 300));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function integer(value, label, min, max) {
|
|
168
|
+
if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${label} must be an integer from ${min} to ${max}.`);
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function compensationFloor(value) {
|
|
173
|
+
const input = object(value, 'profile.compensationFloor');
|
|
174
|
+
const allowed = new Set(['amount', 'currency', 'period']);
|
|
175
|
+
for (const key of Object.keys(input)) if (!allowed.has(key)) throw new Error(`Unknown profile.compensationFloor property: ${key}.`);
|
|
176
|
+
const currency = string(input.currency, 'profile.compensationFloor.currency', 3).toUpperCase();
|
|
177
|
+
if (!/^[A-Z]{3}$/.test(currency)) throw new Error('profile.compensationFloor.currency must be a three-letter currency code.');
|
|
178
|
+
if (input.period !== 'year') throw new Error('profile.compensationFloor.period must be year.');
|
|
179
|
+
return { amount: integer(input.amount, 'profile.compensationFloor.amount', 0, 100_000_000), currency, period: 'year' };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function normalizeUrl(value) {
|
|
183
|
+
const url = new URL(string(value, 'url', 2048));
|
|
184
|
+
url.hash = '';
|
|
185
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
186
|
+
if (!/^(job|jobid|jid|gh_jid|requisition|requisitionid|reqid|posting|postingid|position|positionid|vacancy|vacancyid)$/i.test(key)) url.searchParams.delete(key);
|
|
187
|
+
}
|
|
188
|
+
return url.toString().replace(/\/$/, '').toLowerCase();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function terms(values) {
|
|
192
|
+
return values.map((value) => value.toLowerCase().trim()).filter(Boolean);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function matchesAny(text, values) {
|
|
196
|
+
return terms(values).filter((value) => text.includes(value));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function validateProfile(input) {
|
|
200
|
+
const profile = object(input, 'profile');
|
|
201
|
+
for (const field of REQUIRED_PROFILE) {
|
|
202
|
+
if (ARRAY_PROFILE_FIELDS.has(field)) stringArray(profile[field], `profile.${field}`, true);
|
|
203
|
+
else if (NUMBER_PROFILE_FIELDS.has(field)) integer(profile[field], `profile.${field}`, field === 'yearsExperience' ? 0 : 0, field === 'yearsExperience' ? 80 : 100);
|
|
204
|
+
else string(profile[field], `profile.${field}`, 1000);
|
|
205
|
+
}
|
|
206
|
+
if (!MODES.has(profile.submissionMode)) throw new Error('profile.submissionMode must be review-each or routine-auto.');
|
|
207
|
+
if (profile.manualReviewMinScore > profile.autoSubmitMinScore) throw new Error('profile.manualReviewMinScore must not exceed profile.autoSubmitMinScore.');
|
|
208
|
+
for (const field of STRING_PROFILE_FIELDS) if (profile[field] != null) string(profile[field], `profile.${field}`, 2000);
|
|
209
|
+
for (const field of ARRAY_PROFILE_FIELDS) if (profile[field] != null) stringArray(profile[field], `profile.${field}`);
|
|
210
|
+
for (const field of NUMBER_PROFILE_FIELDS) if (profile[field] != null) integer(profile[field], `profile.${field}`, 0, field === 'yearsExperience' ? 80 : 100);
|
|
211
|
+
const normalized = Object.fromEntries(Object.entries(profile).filter(([key]) => STRING_PROFILE_FIELDS.has(key) || ARRAY_PROFILE_FIELDS.has(key) || NUMBER_PROFILE_FIELDS.has(key) || OBJECT_PROFILE_FIELDS.has(key)));
|
|
212
|
+
if (profile.compensationFloor != null) normalized.compensationFloor = compensationFloor(profile.compensationFloor);
|
|
213
|
+
return normalized;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function profileStatus(input) {
|
|
217
|
+
const profile = object(input, 'profile');
|
|
218
|
+
const missing = REQUIRED_PROFILE.filter((field) => profile[field] == null || profile[field] === '' || (Array.isArray(profile[field]) && profile[field].length === 0));
|
|
219
|
+
const legacyFields = Object.keys(profile).filter((field) => LEGACY_PROFILE_FIELDS.has(field));
|
|
220
|
+
let valid = false;
|
|
221
|
+
let validationError = null;
|
|
222
|
+
if (missing.length === 0) {
|
|
223
|
+
try { validateProfile(profile); valid = true; } catch (error) { validationError = error.message; }
|
|
224
|
+
}
|
|
225
|
+
return { configured: valid, missing, legacyFields, ...(validationError ? { validationError } : {}), fields: Object.keys(profile).sort() };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function migrateProfile(existingInput, overridesInput = {}) {
|
|
229
|
+
const existing = object(existingInput, 'existing profile');
|
|
230
|
+
const overrides = object(overridesInput, 'profile migration');
|
|
231
|
+
const migrated = { ...DEFAULT_TARGETING, ...existing, ...overrides };
|
|
232
|
+
if (migrated.targetCompensation == null && existing.salaryPreference != null) migrated.targetCompensation = existing.salaryPreference;
|
|
233
|
+
for (const field of LEGACY_PROFILE_FIELDS) delete migrated[field];
|
|
234
|
+
return validateProfile(migrated);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function scoreJob(input, target) {
|
|
238
|
+
const job = object(input, 'job');
|
|
239
|
+
const profile = validateProfile(target);
|
|
240
|
+
const title = string(job.title, 'job.title', 300);
|
|
241
|
+
const company = string(job.company, 'job.company', 300);
|
|
242
|
+
const description = string(job.description, 'job.description', 40000);
|
|
243
|
+
const source = string(job.source, 'job.source', 40).toLowerCase();
|
|
244
|
+
const eligibility = string(job.eligibility, 'job.eligibility', 40).toLowerCase();
|
|
245
|
+
const postingStatus = string(job.postingStatus ?? 'unclear', 'job.postingStatus', 40).toLowerCase();
|
|
246
|
+
const seniority = string(job.seniority ?? 'unspecified', 'job.seniority', 40).toLowerCase();
|
|
247
|
+
const roleFamily = string(job.roleFamily ?? 'other', 'job.roleFamily', 80).toLowerCase();
|
|
248
|
+
const workMode = string(job.workMode ?? (job.remote === true ? 'remote' : 'unspecified'), 'job.workMode', 40).toLowerCase();
|
|
249
|
+
if (!SOURCES.has(source)) throw new Error('job.source is invalid.');
|
|
250
|
+
if (!ELIGIBILITY.has(eligibility)) throw new Error('job.eligibility must be eligible, unclear, or ineligible.');
|
|
251
|
+
if (!POSTING_STATUS.has(postingStatus)) throw new Error('job.postingStatus must be active, closed, or unclear.');
|
|
252
|
+
if (!JOB_SENIORITIES.has(seniority)) throw new Error('job.seniority is invalid.');
|
|
253
|
+
if (!ROLE_FAMILIES.has(roleFamily)) throw new Error('job.roleFamily is invalid.');
|
|
254
|
+
if (!WORK_MODES.has(workMode)) throw new Error('job.workMode is invalid.');
|
|
255
|
+
|
|
256
|
+
const gates = [];
|
|
257
|
+
const result = (decision, score, reasons, gaps, mustHaveCoverage = null, autoEligible = false) => ({ score, decision, autoEligible, mustHaveCoverage, gates, reasons, gaps });
|
|
258
|
+
const excludedCompany = terms(profile.excludedCompanies ?? []).some((name) => company.toLowerCase().includes(name));
|
|
259
|
+
if (eligibility === 'ineligible' || excludedCompany) {
|
|
260
|
+
const gap = excludedCompany ? 'Company is excluded by the candidate.' : 'Posting is explicitly ineligible.';
|
|
261
|
+
gates.push({ name: excludedCompany ? 'company' : 'eligibility', status: 'fail', reason: gap });
|
|
262
|
+
return result('exclude', 0, [], [gap]);
|
|
263
|
+
}
|
|
264
|
+
if (eligibility === 'unclear') {
|
|
265
|
+
const gap = 'Work eligibility or authorization needs candidate confirmation.';
|
|
266
|
+
gates.push({ name: 'eligibility', status: 'ask', reason: gap });
|
|
267
|
+
return result('ask', 0, [], [gap]);
|
|
268
|
+
}
|
|
269
|
+
gates.push({ name: 'eligibility', status: 'pass', reason: 'Posting is explicitly eligible.' });
|
|
270
|
+
|
|
271
|
+
if (postingStatus === 'closed') {
|
|
272
|
+
const gap = 'Posting or application channel is closed or stale.';
|
|
273
|
+
gates.push({ name: 'posting-status', status: 'fail', reason: gap });
|
|
274
|
+
return result('exclude', 0, [], [gap]);
|
|
275
|
+
}
|
|
276
|
+
if (postingStatus === 'unclear') {
|
|
277
|
+
const gap = 'Posting status or application channel needs verification.';
|
|
278
|
+
gates.push({ name: 'posting-status', status: 'ask', reason: gap });
|
|
279
|
+
return result('ask', 0, [], [gap]);
|
|
280
|
+
}
|
|
281
|
+
gates.push({ name: 'posting-status', status: 'pass', reason: 'Direct application channel is active.' });
|
|
282
|
+
|
|
283
|
+
const text = `${title}\n${description}`.toLowerCase();
|
|
284
|
+
const locations = Array.isArray(job.locations) ? job.locations.join(' ').toLowerCase() : '';
|
|
285
|
+
const reasons = [];
|
|
286
|
+
const gaps = [];
|
|
287
|
+
|
|
288
|
+
const excludedLocation = terms(profile.excludedLocations ?? []).some((place) => locations.includes(place));
|
|
289
|
+
if (excludedLocation) {
|
|
290
|
+
const gap = 'Posting is in an excluded location.';
|
|
291
|
+
gates.push({ name: 'location', status: 'fail', reason: gap });
|
|
292
|
+
return result('exclude', 0, reasons, [...gaps, gap]);
|
|
293
|
+
}
|
|
294
|
+
if (!terms(profile.workModes).includes(workMode)) {
|
|
295
|
+
if (workMode === 'unspecified') {
|
|
296
|
+
const gap = 'Work mode or location needs verification.';
|
|
297
|
+
gates.push({ name: 'work-mode', status: 'ask', reason: gap });
|
|
298
|
+
return result('ask', 0, reasons, [...gaps, gap]);
|
|
299
|
+
}
|
|
300
|
+
const gap = 'Posting work mode is incompatible with the candidate target.';
|
|
301
|
+
gates.push({ name: 'work-mode', status: 'fail', reason: gap });
|
|
302
|
+
return result('exclude', 0, reasons, [...gaps, gap]);
|
|
303
|
+
}
|
|
304
|
+
gates.push({ name: 'work-mode', status: 'pass', reason: `Work mode matches: ${workMode}.` });
|
|
305
|
+
|
|
306
|
+
if (!terms(profile.seniority).includes(seniority)) {
|
|
307
|
+
const gap = `Seniority is outside the candidate target: ${seniority}.`;
|
|
308
|
+
gates.push({ name: 'seniority', status: 'fail', reason: gap });
|
|
309
|
+
return result(seniority === 'unspecified' ? 'ask' : 'skip', 0, reasons, [...gaps, gap]);
|
|
310
|
+
}
|
|
311
|
+
gates.push({ name: 'seniority', status: 'pass', reason: `Seniority matches: ${seniority}.` });
|
|
312
|
+
|
|
313
|
+
if (!Array.isArray(job.mustHaves) || job.mustHaves.length === 0) {
|
|
314
|
+
const gap = 'Structured must-have evidence is missing.';
|
|
315
|
+
gates.push({ name: 'must-have-evidence', status: 'ask', reason: gap });
|
|
316
|
+
return result('ask', 0, reasons, [...gaps, gap]);
|
|
317
|
+
}
|
|
318
|
+
const mustHaves = job.mustHaves.map((item, index) => {
|
|
319
|
+
const requirement = string(object(item, `job.mustHaves[${index}]`).requirement, `job.mustHaves[${index}].requirement`, 500);
|
|
320
|
+
const status = string(item.status, `job.mustHaves[${index}].status`, 40).toLowerCase();
|
|
321
|
+
if (!MUST_HAVE_STATUS.has(status)) throw new Error(`job.mustHaves[${index}].status is invalid.`);
|
|
322
|
+
if (item.evidence != null) string(item.evidence, `job.mustHaves[${index}].evidence`, 2000);
|
|
323
|
+
return { requirement, status };
|
|
324
|
+
});
|
|
325
|
+
if (mustHaves.some((item) => item.status === 'unclear')) {
|
|
326
|
+
const gap = 'One or more must-have requirements need evidence verification.';
|
|
327
|
+
gates.push({ name: 'must-have-evidence', status: 'ask', reason: gap });
|
|
328
|
+
return result('ask', 0, reasons, [...gaps, gap]);
|
|
329
|
+
}
|
|
330
|
+
const mustHaveCoverage = Math.round(100 * mustHaves.reduce((sum, item) => sum + (item.status === 'met' ? 1 : item.status === 'partial' ? 0.5 : 0), 0) / mustHaves.length);
|
|
331
|
+
if (mustHaveCoverage < profile.minMustHaveCoverage) {
|
|
332
|
+
const gap = `Must-have evidence coverage is ${mustHaveCoverage}%, below ${profile.minMustHaveCoverage}%.`;
|
|
333
|
+
gates.push({ name: 'must-have-evidence', status: 'fail', reason: gap });
|
|
334
|
+
return result('skip', 0, reasons, [...gaps, gap], mustHaveCoverage);
|
|
335
|
+
}
|
|
336
|
+
gates.push({ name: 'must-have-evidence', status: 'pass', reason: `Must-have evidence coverage is ${mustHaveCoverage}%.` });
|
|
337
|
+
|
|
338
|
+
if (profile.compensationFloor && job.salaryMaximum != null && String(job.salaryCurrency ?? '').toUpperCase() === profile.compensationFloor.currency) {
|
|
339
|
+
integer(job.salaryMaximum, 'job.salaryMaximum', 0, 100_000_000);
|
|
340
|
+
if (job.salaryMaximum < profile.compensationFloor.amount) {
|
|
341
|
+
const gap = 'Published compensation maximum is below the configured floor.';
|
|
342
|
+
gates.push({ name: 'compensation', status: 'fail', reason: gap });
|
|
343
|
+
return result('skip', 0, reasons, [...gaps, gap], mustHaveCoverage);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
let score = 0;
|
|
348
|
+
if (terms(profile.roleFamilies).includes(roleFamily)) { score += 25; reasons.push(`Role family: ${roleFamily}.`); }
|
|
349
|
+
else gaps.push('Role family does not directly match the target.');
|
|
350
|
+
score += 15;
|
|
351
|
+
reasons.push(`Seniority: ${seniority}.`);
|
|
352
|
+
score += Math.round(mustHaveCoverage * 0.4);
|
|
353
|
+
reasons.push(`Skills evidence: ${mustHaveCoverage}% of must-haves.`);
|
|
354
|
+
const locationMatches = matchesAny(locations, profile.targetLocations);
|
|
355
|
+
if (locationMatches.length || workMode === 'remote') {
|
|
356
|
+
score += 10;
|
|
357
|
+
reasons.push(job.remote === true ? 'Remote-compatible.' : `Target location: ${locationMatches.join(', ')}.`);
|
|
358
|
+
} else gaps.push('Location or work mode is not an explicit match.');
|
|
359
|
+
const industryMatches = matchesAny(text, profile.industries ?? []);
|
|
360
|
+
if (industryMatches.length) { score += 5; reasons.push(`Industry: ${industryMatches[0]}.`); }
|
|
361
|
+
if (profile.compensationFloor && job.salaryMaximum != null && String(job.salaryCurrency ?? '').toUpperCase() === profile.compensationFloor.currency) {
|
|
362
|
+
score += 5;
|
|
363
|
+
reasons.push('Published compensation meets the configured floor.');
|
|
364
|
+
} else gaps.push('Published compensation is unavailable or not directly comparable.');
|
|
365
|
+
|
|
366
|
+
let experienceMismatch = false;
|
|
367
|
+
if (job.experienceMin != null) integer(job.experienceMin, 'job.experienceMin', 0, 80);
|
|
368
|
+
if (job.experienceMax != null) integer(job.experienceMax, 'job.experienceMax', 0, 80);
|
|
369
|
+
if ((job.experienceMax != null && profile.yearsExperience > job.experienceMax + 2) || (job.experienceMin != null && profile.yearsExperience + 2 < job.experienceMin)) {
|
|
370
|
+
experienceMismatch = true;
|
|
371
|
+
score = Math.min(score, profile.autoSubmitMinScore - 1);
|
|
372
|
+
gaps.push('Explicit experience range is materially misaligned.');
|
|
373
|
+
gates.push({ name: 'experience', status: 'warn', reason: 'Experience mismatch requires manual review.' });
|
|
374
|
+
}
|
|
375
|
+
const finalScore = Math.min(score, 100);
|
|
376
|
+
const decision = finalScore >= profile.manualReviewMinScore ? 'review' : 'skip';
|
|
377
|
+
const autoEligible = decision === 'review' && ['senior', 'staff'].includes(seniority) && !experienceMismatch
|
|
378
|
+
&& finalScore >= profile.autoSubmitMinScore && mustHaveCoverage >= profile.minMustHaveCoverage;
|
|
379
|
+
return result(decision, finalScore, reasons, gaps, mustHaveCoverage, autoEligible);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function validateLedgerEntry(input) {
|
|
383
|
+
const entry = object(input, 'entry');
|
|
384
|
+
const normalized = {
|
|
385
|
+
id: string(entry.id, 'entry.id', 180),
|
|
386
|
+
company: string(entry.company, 'entry.company', 300),
|
|
387
|
+
role: string(entry.role, 'entry.role', 300),
|
|
388
|
+
url: string(entry.url, 'entry.url', 2048),
|
|
389
|
+
source: string(entry.source, 'entry.source', 40).toLowerCase(),
|
|
390
|
+
score: entry.score,
|
|
391
|
+
status: string(entry.status, 'entry.status', 40).toLowerCase(),
|
|
392
|
+
submittedAt: string(entry.submittedAt, 'entry.submittedAt', 80),
|
|
393
|
+
approval: string(entry.approval, 'entry.approval', 80),
|
|
394
|
+
answers: entry.answers ?? {},
|
|
395
|
+
};
|
|
396
|
+
if (entry.employerJobId != null) normalized.employerJobId = string(entry.employerJobId, 'entry.employerJobId', 300);
|
|
397
|
+
if (!SOURCES.has(normalized.source)) throw new Error('entry.source is invalid.');
|
|
398
|
+
if (!Number.isInteger(normalized.score) || normalized.score < 0 || normalized.score > 100) throw new Error('entry.score must be an integer from 0 to 100.');
|
|
399
|
+
if (normalized.status !== 'submitted') throw new Error('New ledger entries must have status submitted.');
|
|
400
|
+
if (!APPROVALS.has(normalized.approval)) throw new Error('entry.approval is invalid.');
|
|
401
|
+
if (Number.isNaN(Date.parse(normalized.submittedAt))) throw new Error('entry.submittedAt must be an ISO date.');
|
|
402
|
+
object(normalized.answers, 'entry.answers');
|
|
403
|
+
for (const [key, value] of Object.entries(normalized.answers)) {
|
|
404
|
+
string(key, 'answer key', 200);
|
|
405
|
+
string(value, `answer ${key}`, 5000);
|
|
406
|
+
}
|
|
407
|
+
return normalized;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function validateSubmissionTelemetry(input) {
|
|
411
|
+
if (input == null) return {};
|
|
412
|
+
const value = object(input, 'entry.telemetry');
|
|
413
|
+
const allowed = new Set(['durationBucket', 'fieldsFilled', 'shortAnswerCount', 'resumeUploaded']);
|
|
414
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown entry.telemetry property: ${key}.`);
|
|
415
|
+
if (value.durationBucket != null && !TELEMETRY_DURATIONS.has(value.durationBucket)) throw new Error('entry.telemetry.durationBucket is invalid.');
|
|
416
|
+
for (const [key, max] of [['fieldsFilled', 500], ['shortAnswerCount', 100]]) {
|
|
417
|
+
if (value[key] != null && (!Number.isInteger(value[key]) || value[key] < 0 || value[key] > max)) throw new Error(`entry.telemetry.${key} must be an integer from 0 to ${max}.`);
|
|
418
|
+
}
|
|
419
|
+
if (value.resumeUploaded != null && typeof value.resumeUploaded !== 'boolean') throw new Error('entry.telemetry.resumeUploaded must be a Boolean.');
|
|
420
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item != null));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function normalizedText(value) {
|
|
424
|
+
return String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function canonicalApplicationKey(entry, fallback = '') {
|
|
428
|
+
if (entry.employerJobId) return `job:${normalizedText(entry.company)}:${String(entry.employerJobId).toLowerCase()}`;
|
|
429
|
+
if (entry.company && entry.role) return `legacy-role:${normalizedText(entry.company)}:${normalizedText(entry.role)}`;
|
|
430
|
+
if (entry.url) return `url:${normalizeUrl(entry.url)}`;
|
|
431
|
+
return `id:${entry.id ?? fallback}`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function businessDaysBetween(startValue, endValue) {
|
|
435
|
+
const start = new Date(startValue);
|
|
436
|
+
const end = new Date(endValue);
|
|
437
|
+
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end <= start) return 0;
|
|
438
|
+
let days = 0;
|
|
439
|
+
for (const date = new Date(start); date < end; date.setUTCDate(date.getUTCDate() + 1)) {
|
|
440
|
+
const weekday = date.getUTCDay();
|
|
441
|
+
if (weekday !== 0 && weekday !== 6) days += 1;
|
|
442
|
+
}
|
|
443
|
+
return days;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function buildReview(entries, outcomeEntries = [], acknowledgements = [], now = new Date()) {
|
|
447
|
+
const submissions = entries.filter((entry) => !Number.isNaN(Date.parse(entry.submittedAt)));
|
|
448
|
+
const explicitOutcomes = outcomeEntries.length > 0;
|
|
449
|
+
const outcomes = explicitOutcomes ? outcomeEntries : entries.filter((entry) => ['interview', 'rejected', 'offer', 'withdrawn'].includes(entry.status));
|
|
450
|
+
const groups = new Map();
|
|
451
|
+
submissions.forEach((entry, index) => {
|
|
452
|
+
const key = canonicalApplicationKey(entry, String(index));
|
|
453
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
454
|
+
groups.get(key).push(entry);
|
|
455
|
+
});
|
|
456
|
+
const canonical = [...groups.values()].map((group) => [...group].sort((a, b) => Date.parse(a.submittedAt) - Date.parse(b.submittedAt))[0]);
|
|
457
|
+
const outcomesById = new Map();
|
|
458
|
+
for (const outcome of outcomes) {
|
|
459
|
+
if (!outcomesById.has(outcome.id)) outcomesById.set(outcome.id, []);
|
|
460
|
+
outcomesById.get(outcome.id).push(outcome);
|
|
461
|
+
}
|
|
462
|
+
const canonicalOutcomes = [];
|
|
463
|
+
const matureCanonicalOutcomes = [];
|
|
464
|
+
const canonicalInterviewDetails = [];
|
|
465
|
+
for (const group of groups.values()) {
|
|
466
|
+
const candidates = explicitOutcomes
|
|
467
|
+
? group.flatMap((entry) => outcomesById.get(entry.id) ?? [])
|
|
468
|
+
: group.filter((entry) => ['interview', 'rejected', 'offer', 'withdrawn'].includes(entry.status));
|
|
469
|
+
if (candidates.length) {
|
|
470
|
+
const ordered = [...candidates].sort((a, b) => (Date.parse(b.occurredAt ?? 0) || 0) - (Date.parse(a.occurredAt ?? 0) || 0));
|
|
471
|
+
const latest = ordered[0];
|
|
472
|
+
const canonicalApplication = [...group].sort((a, b) => Date.parse(a.submittedAt) - Date.parse(b.submittedAt))[0];
|
|
473
|
+
canonicalOutcomes.push(latest);
|
|
474
|
+
const latestInterviewDetail = ordered.find((entry) => entry.interviewQuality);
|
|
475
|
+
if (latestInterviewDetail) canonicalInterviewDetails.push({
|
|
476
|
+
...latestInterviewDetail,
|
|
477
|
+
source: canonicalApplication.source,
|
|
478
|
+
score: canonicalApplication.score,
|
|
479
|
+
});
|
|
480
|
+
if (businessDaysBetween(canonicalApplication.submittedAt, now) >= 10) matureCanonicalOutcomes.push(latest);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const maturedApplications = canonical.filter((entry) => businessDaysBetween(entry.submittedAt, now) >= 10).length;
|
|
484
|
+
const lastAck = acknowledgements.length ? acknowledgements[acknowledgements.length - 1] : {};
|
|
485
|
+
const submittedSinceLastReview = Math.max(0, canonical.length - (lastAck.uniqueSubmissionCount ?? 0));
|
|
486
|
+
const hygieneDue = submittedSinceLastReview >= 10;
|
|
487
|
+
const outcomeDue = maturedApplications - (lastAck.maturedApplicationCount ?? 0) >= 20;
|
|
488
|
+
const reviewReasons = [...(hygieneDue ? ['submission-hygiene'] : []), ...(outcomeDue ? ['outcome-effectiveness'] : [])];
|
|
489
|
+
const outcomeCounts = Object.fromEntries(['interview', 'rejected', 'offer', 'withdrawn'].map((status) => [status, canonicalOutcomes.filter((entry) => entry.status === status).length]));
|
|
490
|
+
const matureOutcomeCounts = Object.fromEntries(['interview', 'rejected', 'offer', 'withdrawn'].map((status) => [status, matureCanonicalOutcomes.filter((entry) => entry.status === status).length]));
|
|
491
|
+
const reasonCounts = {};
|
|
492
|
+
for (const outcome of canonicalOutcomes) for (const reason of outcome.reasons ?? []) reasonCounts[reason.category] = (reasonCounts[reason.category] ?? 0) + 1;
|
|
493
|
+
const interviewQualityCounts = Object.fromEntries([...INTERVIEW_QUALITIES].map((quality) => [quality, canonicalInterviewDetails.filter((entry) => entry.interviewQuality === quality).length]));
|
|
494
|
+
const failurePointCounts = Object.fromEntries([...FAILURE_POINTS].map((point) => [point, canonicalInterviewDetails.filter((entry) => entry.failurePoint === point).length]));
|
|
495
|
+
const interviewLearningSegmentCounts = new Map();
|
|
496
|
+
for (const detail of canonicalInterviewDetails) {
|
|
497
|
+
const score = Number(detail.score);
|
|
498
|
+
const lower = Number.isFinite(score) ? Math.floor(Math.max(0, Math.min(100, score)) / 10) * 10 : null;
|
|
499
|
+
const fitScoreBand = lower == null ? 'unknown' : `${lower}-${Math.min(100, lower + 9)}`;
|
|
500
|
+
const segment = {
|
|
501
|
+
source: detail.source ?? 'other',
|
|
502
|
+
fitScoreBand,
|
|
503
|
+
interviewQuality: detail.interviewQuality,
|
|
504
|
+
failurePoint: detail.failurePoint ?? 'unknown',
|
|
505
|
+
};
|
|
506
|
+
const key = JSON.stringify(segment);
|
|
507
|
+
interviewLearningSegmentCounts.set(key, (interviewLearningSegmentCounts.get(key) ?? 0) + 1);
|
|
508
|
+
}
|
|
509
|
+
const interviewLearningSegments = [...interviewLearningSegmentCounts.entries()]
|
|
510
|
+
.map(([key, count]) => ({ ...JSON.parse(key), count }))
|
|
511
|
+
.sort((a, b) => a.source.localeCompare(b.source) || a.fitScoreBand.localeCompare(b.fitScoreBand) || a.interviewQuality.localeCompare(b.interviewQuality) || a.failurePoint.localeCompare(b.failurePoint));
|
|
512
|
+
const rate = (count) => maturedApplications ? Math.round((1000 * count) / maturedApplications) / 10 : 0;
|
|
513
|
+
return {
|
|
514
|
+
reviewDue: reviewReasons.length > 0,
|
|
515
|
+
reviewReasons,
|
|
516
|
+
submittedTotal: canonical.length,
|
|
517
|
+
uniqueSubmittedTotal: canonical.length,
|
|
518
|
+
rawSubmissionRows: submissions.length,
|
|
519
|
+
duplicateSubmissionRows: submissions.length - canonical.length,
|
|
520
|
+
submittedSinceLastReview,
|
|
521
|
+
maturedApplications,
|
|
522
|
+
outcomeCounts,
|
|
523
|
+
matureOutcomeCounts,
|
|
524
|
+
reasonCounts,
|
|
525
|
+
interviewQualityCounts,
|
|
526
|
+
failurePointCounts,
|
|
527
|
+
interviewLearningSegments,
|
|
528
|
+
conversionRates: Object.fromEntries(Object.entries(matureOutcomeCounts).map(([status, count]) => [status, rate(count)])),
|
|
529
|
+
autoAppliedChanges: false,
|
|
530
|
+
nextStep: reviewReasons.length
|
|
531
|
+
? 'Propose evidence-based targeting and answer-guidance changes for candidate approval.'
|
|
532
|
+
: 'Continue recording confirmed submissions and outcomes.',
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function decodeKeychain(value) {
|
|
537
|
+
const trimmed = value.trim();
|
|
538
|
+
return /^[0-9a-f]+$/i.test(trimmed) && trimmed.length % 2 === 0 ? Buffer.from(trimmed, 'hex').toString('utf8') : value;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function keychainProfileRaw() {
|
|
542
|
+
if (process.platform !== 'darwin') throw new Error('Secure profile storage currently requires macOS Keychain.');
|
|
543
|
+
const value = execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', KEYCHAIN_ACCOUNT, '-w'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
544
|
+
try { return object(JSON.parse(decodeKeychain(value)), 'profile'); } catch { throw new Error('The Keychain profile is missing or unreadable. Run profile set again.'); }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function keychainProfile() {
|
|
548
|
+
try { return validateProfile(keychainProfileRaw()); } catch { throw new Error('The Keychain profile needs migration. Run profile check, then profile migrate --stdin.'); }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function ensureStateDir() {
|
|
552
|
+
const dir = stateDir();
|
|
553
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
554
|
+
await chmod(dir, 0o700);
|
|
555
|
+
return dir;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function stdin() {
|
|
559
|
+
const chunks = [];
|
|
560
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
561
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function jsonStdin() {
|
|
565
|
+
try { return JSON.parse(await stdin()); } catch { throw new Error('Expected one JSON object on standard input.'); }
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function jsonLines(file) {
|
|
569
|
+
try {
|
|
570
|
+
return (await readFile(file, 'utf8')).split('\n').filter(Boolean).map((line, index) => {
|
|
571
|
+
try { return JSON.parse(line); } catch { throw new Error(`Invalid JSON on line ${index + 1} of ${basename(file)}.`); }
|
|
572
|
+
});
|
|
573
|
+
} catch (error) {
|
|
574
|
+
if (error.code === 'ENOENT') return [];
|
|
575
|
+
throw error;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function withStateLock(name, action) {
|
|
580
|
+
const dir = await ensureStateDir();
|
|
581
|
+
const lockPath = join(dir, `.${name}.lock`);
|
|
582
|
+
let handle;
|
|
583
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
584
|
+
try {
|
|
585
|
+
handle = await open(lockPath, 'wx', 0o600);
|
|
586
|
+
break;
|
|
587
|
+
} catch (error) {
|
|
588
|
+
if (error.code !== 'EEXIST') throw error;
|
|
589
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 10));
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (!handle) throw new Error(`Could not acquire ${name} ledger lock.`);
|
|
593
|
+
try { return await action(dir); }
|
|
594
|
+
finally {
|
|
595
|
+
await handle.close();
|
|
596
|
+
await unlink(lockPath).catch((error) => { if (error.code !== 'ENOENT') throw error; });
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function storeProfile(profileInput) {
|
|
601
|
+
if (process.platform !== 'darwin') throw new Error('Secure profile storage currently requires macOS Keychain.');
|
|
602
|
+
const profile = validateProfile(profileInput);
|
|
603
|
+
const raw = JSON.stringify(profile);
|
|
604
|
+
try {
|
|
605
|
+
execFileSync('security', ['add-generic-password', '-a', KEYCHAIN_ACCOUNT, '-s', KEYCHAIN_SERVICE, '-U', '-w', raw], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
606
|
+
} catch {
|
|
607
|
+
throw new Error('Keychain could not store the profile. Unlock macOS Keychain and retry; no profile data was logged.');
|
|
608
|
+
}
|
|
609
|
+
return profile;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function profileSet(profileInput) {
|
|
613
|
+
const profile = await storeProfile(profileInput);
|
|
614
|
+
return { stored: true, fields: Object.keys(profile).sort() };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function profileMigrate(overrides) {
|
|
618
|
+
const before = keychainProfileRaw();
|
|
619
|
+
const profile = migrateProfile(before, overrides);
|
|
620
|
+
await storeProfile(profile);
|
|
621
|
+
return {
|
|
622
|
+
migrated: true,
|
|
623
|
+
fields: Object.keys(profile).sort(),
|
|
624
|
+
addedFields: Object.keys(profile).filter((field) => !(field in before)).sort(),
|
|
625
|
+
mappedLegacyFields: before.salaryPreference != null && profile.targetCompensation != null ? ['salaryPreference'] : [],
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function importResume(source) {
|
|
630
|
+
let bytes;
|
|
631
|
+
let sourceLabel;
|
|
632
|
+
if (/^https:\/\/docs\.google\.com\/document\/d\//i.test(source)) {
|
|
633
|
+
const match = new URL(source).pathname.match(/^\/document\/d\/([A-Za-z0-9_-]+)/);
|
|
634
|
+
if (!match) throw new Error('Invalid Google Docs resume URL.');
|
|
635
|
+
const exportUrl = `https://docs.google.com/document/d/${match[1]}/export?format=pdf`;
|
|
636
|
+
const response = await fetch(exportUrl, { redirect: 'follow' });
|
|
637
|
+
if (!response.ok) throw new Error(`Resume export failed with HTTP ${response.status}.`);
|
|
638
|
+
bytes = Buffer.from(await response.arrayBuffer());
|
|
639
|
+
sourceLabel = source;
|
|
640
|
+
} else {
|
|
641
|
+
const local = resolve(source);
|
|
642
|
+
if (!local.toLowerCase().endsWith('.pdf')) throw new Error('Local resume must be a PDF.');
|
|
643
|
+
bytes = await readFile(local);
|
|
644
|
+
sourceLabel = local;
|
|
645
|
+
}
|
|
646
|
+
if (bytes.length < 1000 || !bytes.subarray(0, 4).equals(Buffer.from('%PDF'))) throw new Error('Resume source did not contain a valid PDF.');
|
|
647
|
+
const dir = await ensureStateDir();
|
|
648
|
+
const temporary = join(dir, `resume-${process.pid}.pdf`);
|
|
649
|
+
const target = join(dir, 'resume.pdf');
|
|
650
|
+
await writeFile(temporary, bytes, { mode: 0o600 });
|
|
651
|
+
await rename(temporary, target);
|
|
652
|
+
await chmod(target, 0o600);
|
|
653
|
+
const metadata = { source: sourceLabel, importedAt: new Date().toISOString(), sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length };
|
|
654
|
+
await writeFile(join(dir, 'resume.json'), `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
|
|
655
|
+
return { path: target, sha256: metadata.sha256, bytes: metadata.bytes };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function duplicateResult(entries, candidate) {
|
|
659
|
+
const candidateCompany = normalizedText(candidate.company);
|
|
660
|
+
const candidateRole = normalizedText(candidate.role);
|
|
661
|
+
const candidateUrl = normalizeUrl(candidate.url);
|
|
662
|
+
const sameCompanyRole = (entry) => candidateCompany && candidateRole && normalizedText(entry.company) === candidateCompany && normalizedText(entry.role) === candidateRole;
|
|
663
|
+
const hard = entries.find((entry) => entry.id === candidate.id
|
|
664
|
+
|| (candidate.employerJobId && entry.employerJobId && normalizedText(entry.company) === candidateCompany && entry.employerJobId.toLowerCase() === String(candidate.employerJobId).toLowerCase())
|
|
665
|
+
|| normalizeUrl(entry.url) === candidateUrl);
|
|
666
|
+
const possible = hard ? null : entries.find(sameCompanyRole);
|
|
667
|
+
const match = hard ?? possible;
|
|
668
|
+
return {
|
|
669
|
+
duplicate: Boolean(hard),
|
|
670
|
+
possibleDuplicate: Boolean(possible),
|
|
671
|
+
reason: hard ? (hard.id === candidate.id ? 'id' : candidate.employerJobId && hard.employerJobId ? 'employer-job-id' : 'url') : possible ? 'company-role' : null,
|
|
672
|
+
match: match ? { id: match.id, company: match.company, role: match.role, submittedAt: match.submittedAt } : null,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async function ledgerCheck(candidate) {
|
|
677
|
+
object(candidate, 'candidate');
|
|
678
|
+
const entries = await jsonLines(join(await ensureStateDir(), 'applications.ndjson'));
|
|
679
|
+
string(candidate.url, 'candidate.url', 2048);
|
|
680
|
+
return duplicateResult(entries, candidate);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function ledgerAdd(entryInput, duplicateOverride) {
|
|
684
|
+
const entry = validateLedgerEntry(entryInput);
|
|
685
|
+
return withStateLock('applications', async (dir) => {
|
|
686
|
+
const file = join(dir, 'applications.ndjson');
|
|
687
|
+
const entries = await jsonLines(file);
|
|
688
|
+
const duplicate = duplicateResult(entries, entry);
|
|
689
|
+
if (duplicate.duplicate) throw new Error('This application is already recorded.');
|
|
690
|
+
if (duplicate.possibleDuplicate && duplicateOverride !== 'NEW REQUISITION CONFIRMED') throw new Error('A possible same-company role duplicate requires NEW REQUISITION CONFIRMED.');
|
|
691
|
+
await appendFile(file, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
692
|
+
await chmod(file, 0o600);
|
|
693
|
+
return { recorded: entry.id, review: buildReview([...entries, entry]) };
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async function ledgerOutcome(outcomeInput) {
|
|
698
|
+
const input = object(outcomeInput, 'outcome');
|
|
699
|
+
const allowed = new Set(['id', 'status', 'occurredAt', 'note', 'reasons', 'interviewQuality', 'failurePoint']);
|
|
700
|
+
for (const key of Object.keys(input)) if (!allowed.has(key)) throw new Error(`Unknown outcome property: ${key}.`);
|
|
701
|
+
const status = string(input.status, 'outcome.status', 40).toLowerCase();
|
|
702
|
+
if (!['interview', 'rejected', 'offer', 'withdrawn'].includes(status)) throw new Error('Invalid outcome status.');
|
|
703
|
+
const occurredAt = string(input.occurredAt ?? new Date().toISOString(), 'outcome.occurredAt', 80);
|
|
704
|
+
if (Number.isNaN(Date.parse(occurredAt))) throw new Error('outcome.occurredAt must be an ISO date.');
|
|
705
|
+
const reasonCategories = new Set(['eligibility', 'closed-stale', 'level-compensation', 'must-have-gap', 'generic-resume-screen', 'interview-stage', 'unknown']);
|
|
706
|
+
const evidenceLevels = new Set(['explicit', 'inferred']);
|
|
707
|
+
const reasons = input.reasons == null ? [] : input.reasons.map((item, index) => {
|
|
708
|
+
const reason = object(item, `outcome.reasons[${index}]`);
|
|
709
|
+
const category = string(reason.category, `outcome.reasons[${index}].category`, 80);
|
|
710
|
+
const evidence = string(reason.evidence, `outcome.reasons[${index}].evidence`, 40);
|
|
711
|
+
if (!reasonCategories.has(category)) throw new Error(`outcome.reasons[${index}].category is invalid.`);
|
|
712
|
+
if (!evidenceLevels.has(evidence)) throw new Error(`outcome.reasons[${index}].evidence is invalid.`);
|
|
713
|
+
return { category, evidence };
|
|
714
|
+
});
|
|
715
|
+
const interviewQuality = input.interviewQuality == null ? null : string(input.interviewQuality, 'outcome.interviewQuality', 40).toLowerCase();
|
|
716
|
+
if (interviewQuality != null && !INTERVIEW_QUALITIES.has(interviewQuality)) throw new Error('outcome.interviewQuality is invalid.');
|
|
717
|
+
const failurePoint = input.failurePoint == null ? null : string(input.failurePoint, 'outcome.failurePoint', 40).toLowerCase();
|
|
718
|
+
if (failurePoint != null && !FAILURE_POINTS.has(failurePoint)) throw new Error('outcome.failurePoint is invalid.');
|
|
719
|
+
if (failurePoint != null && interviewQuality == null) throw new Error('outcome.failurePoint requires outcome.interviewQuality.');
|
|
720
|
+
const event = {
|
|
721
|
+
id: string(input.id, 'outcome.id', 180), status, occurredAt,
|
|
722
|
+
...(typeof input.note === 'string' ? { note: input.note.slice(0, 2000) } : {}),
|
|
723
|
+
...(reasons.length ? { reasons } : {}),
|
|
724
|
+
...(interviewQuality ? { interviewQuality } : {}),
|
|
725
|
+
...(failurePoint ? { failurePoint } : {}),
|
|
726
|
+
};
|
|
727
|
+
const storage = await withStateLock('outcomes', async (dir) => {
|
|
728
|
+
const file = join(dir, 'outcomes.ndjson');
|
|
729
|
+
const existing = await jsonLines(file);
|
|
730
|
+
const sameOccurrence = existing.filter((item) => item.id === event.id && item.status === event.status && item.occurredAt === event.occurredAt);
|
|
731
|
+
const detailKey = (item) => JSON.stringify({ reasons: item.reasons ?? [], interviewQuality: item.interviewQuality ?? null, failurePoint: item.failurePoint ?? null });
|
|
732
|
+
const duplicate = sameOccurrence.some((item) => detailKey(item) === detailKey(event));
|
|
733
|
+
if (duplicate) return { stored: false, enriched: false };
|
|
734
|
+
const enriched = sameOccurrence.length > 0;
|
|
735
|
+
await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
736
|
+
await chmod(file, 0o600);
|
|
737
|
+
return { stored: true, enriched };
|
|
738
|
+
});
|
|
739
|
+
const applications = await jsonLines(join(await ensureStateDir(), 'applications.ndjson'));
|
|
740
|
+
return { result: { recorded: storage.stored, duplicate: !storage.stored, enriched: storage.enriched, recordedOutcome: event.id, status }, application: applications.find((entry) => entry.id === event.id) ?? null, event };
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async function ledgerReview() {
|
|
744
|
+
const dir = await ensureStateDir();
|
|
745
|
+
const applications = await jsonLines(join(dir, 'applications.ndjson'));
|
|
746
|
+
const outcomes = await jsonLines(join(dir, 'outcomes.ndjson'));
|
|
747
|
+
const acknowledgements = await jsonLines(join(dir, 'reviews.ndjson'));
|
|
748
|
+
return buildReview(applications, outcomes, acknowledgements);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function ledgerReviewAcknowledge(input) {
|
|
752
|
+
const value = object(input, 'review acknowledgement');
|
|
753
|
+
const reviewedAt = string(value.reviewedAt ?? new Date().toISOString(), 'reviewedAt', 80);
|
|
754
|
+
if (Number.isNaN(Date.parse(reviewedAt))) throw new Error('reviewedAt must be an ISO date.');
|
|
755
|
+
const review = await ledgerReview();
|
|
756
|
+
const event = { reviewedAt, uniqueSubmissionCount: review.uniqueSubmittedTotal, maturedApplicationCount: review.maturedApplications };
|
|
757
|
+
await withStateLock('reviews', async (dir) => {
|
|
758
|
+
const file = join(dir, 'reviews.ndjson');
|
|
759
|
+
await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
760
|
+
await chmod(file, 0o600);
|
|
761
|
+
});
|
|
762
|
+
return { acknowledged: true, ...event };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function print(value) {
|
|
766
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function outcomeTelemetry(application, outcome) {
|
|
770
|
+
if (!application) return null;
|
|
771
|
+
const identity = await jobIdentity(application.url);
|
|
772
|
+
return {
|
|
773
|
+
event: 'outcome_recorded',
|
|
774
|
+
properties: {
|
|
775
|
+
...identity,
|
|
776
|
+
company: application.company,
|
|
777
|
+
title: application.role,
|
|
778
|
+
ats: sourceToAts(application.source),
|
|
779
|
+
outcome: outcome.status,
|
|
780
|
+
daysSinceSubmission: Math.max(0, Math.min(3650, Math.floor((Date.parse(outcome.occurredAt) - Date.parse(application.submittedAt)) / 86_400_000))),
|
|
781
|
+
...(outcome.interviewQuality ? { interviewQuality: outcome.interviewQuality } : {}),
|
|
782
|
+
...(outcome.failurePoint ? { failurePoint: outcome.failurePoint } : {}),
|
|
783
|
+
},
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function reviewTelemetry(review) {
|
|
788
|
+
return {
|
|
789
|
+
event: 'review_generated',
|
|
790
|
+
properties: {
|
|
791
|
+
submissionCount: review.submittedTotal,
|
|
792
|
+
interviewCount: review.outcomeCounts.interview,
|
|
793
|
+
rejectionCount: review.outcomeCounts.rejected,
|
|
794
|
+
offerCount: review.outcomeCounts.offer,
|
|
795
|
+
withdrawalCount: review.outcomeCounts.withdrawn,
|
|
796
|
+
reviewDue: review.reviewDue,
|
|
797
|
+
},
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async function executeCommand([area, action, value], telemetry, session) {
|
|
802
|
+
const domainEvents = [];
|
|
803
|
+
let result;
|
|
804
|
+
if (area === 'profile' && action === 'set' && value === '--stdin') {
|
|
805
|
+
const profile = await jsonStdin();
|
|
806
|
+
result = await profileSet(profile);
|
|
807
|
+
} else if (area === 'profile' && action === 'migrate' && value === '--stdin') {
|
|
808
|
+
result = await profileMigrate(await jsonStdin());
|
|
809
|
+
} else if (area === 'profile' && action === 'check') {
|
|
810
|
+
result = { ...profileStatus(keychainProfileRaw()), required: REQUIRED_PROFILE };
|
|
811
|
+
} else if (area === 'profile' && action === 'field' && value) {
|
|
812
|
+
if (![...STRING_PROFILE_FIELDS, ...ARRAY_PROFILE_FIELDS, ...NUMBER_PROFILE_FIELDS, ...OBJECT_PROFILE_FIELDS].includes(value)) throw new Error('Profile field is not allowed.');
|
|
813
|
+
result = { [value]: keychainProfile()[value] ?? null };
|
|
814
|
+
} else if (area === 'resume' && action === 'import' && value) result = await importResume(value);
|
|
815
|
+
else if (area === 'score' && action === '--stdin') {
|
|
816
|
+
const job = await jsonStdin();
|
|
817
|
+
result = scoreJob(job, job.target ?? keychainProfile());
|
|
818
|
+
const event = await telemetryJobAssessed(job, result);
|
|
819
|
+
if (event) domainEvents.push(event);
|
|
820
|
+
} else if (area === 'ledger' && action === 'check' && value === '--stdin') result = await ledgerCheck(await jsonStdin());
|
|
821
|
+
else if (area === 'ledger' && action === 'add' && value === '--stdin') {
|
|
822
|
+
const input = await jsonStdin();
|
|
823
|
+
const telemetryDetails = validateSubmissionTelemetry(input.telemetry);
|
|
824
|
+
const entry = validateLedgerEntry(input);
|
|
825
|
+
result = await ledgerAdd(entry, input.duplicateOverride);
|
|
826
|
+
domainEvents.push(await telemetryApplicationSubmitted(entry, telemetryDetails));
|
|
827
|
+
} else if (area === 'ledger' && action === 'outcome' && value === '--stdin') {
|
|
828
|
+
const outcome = await ledgerOutcome(await jsonStdin());
|
|
829
|
+
result = outcome.result;
|
|
830
|
+
const event = outcome.result.recorded && !outcome.result.enriched ? await outcomeTelemetry(outcome.application, outcome.event) : null;
|
|
831
|
+
if (event) domainEvents.push(event);
|
|
832
|
+
} else if (area === 'ledger' && action === 'review') {
|
|
833
|
+
result = await ledgerReview();
|
|
834
|
+
domainEvents.push(reviewTelemetry(result));
|
|
835
|
+
} else if (area === 'ledger' && action === 'review-ack' && value === '--stdin') {
|
|
836
|
+
result = await ledgerReviewAcknowledge(await jsonStdin());
|
|
837
|
+
} else throw new Error('Usage: profile set|migrate --stdin; profile check|field <name>; resume import <url-or-pdf>; score --stdin; ledger check|add|outcome|review-ack --stdin; ledger review; telemetry status|enable|disable|reset|preview --stdin|record --stdin');
|
|
838
|
+
for (const event of domainEvents) await telemetry.record(event, session);
|
|
839
|
+
return result;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async function recordInstallationStart(telemetry, session) {
|
|
843
|
+
if (!session.installationEventPending) return;
|
|
844
|
+
let submissionMode = 'unconfigured';
|
|
845
|
+
try { submissionMode = keychainProfile().submissionMode; } catch {}
|
|
846
|
+
await telemetry.record({
|
|
847
|
+
event: 'installation_started',
|
|
848
|
+
properties: {
|
|
849
|
+
osFamily: ({ darwin: 'macos', linux: 'linux', win32: 'windows' })[platform()] ?? 'other',
|
|
850
|
+
nodeMajor: Number(process.versions.node.split('.')[0]),
|
|
851
|
+
submissionMode,
|
|
852
|
+
},
|
|
853
|
+
}, session);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async function main(args) {
|
|
857
|
+
const [area, action, value] = args;
|
|
858
|
+
const telemetry = new TelemetryClient({ stateDir: stateDir() });
|
|
859
|
+
if (area === 'telemetry') {
|
|
860
|
+
if (['status', 'enable', 'disable', 'reset'].includes(action) && value == null) return print(await telemetry.configure(action));
|
|
861
|
+
if (action === 'preview' && value === '--stdin') return print(await telemetry.preview(await jsonStdin()));
|
|
862
|
+
if (action === 'record' && value === '--stdin') {
|
|
863
|
+
const session = await telemetry.beginCommand('telemetry');
|
|
864
|
+
await recordInstallationStart(telemetry, session);
|
|
865
|
+
return print(await telemetry.record(await jsonStdin(), session, { strict: true }));
|
|
866
|
+
}
|
|
867
|
+
throw new Error('Usage: telemetry status|enable|disable|reset|preview --stdin|record --stdin');
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const command = commandCategory(args);
|
|
871
|
+
const session = await telemetry.beginCommand(command);
|
|
872
|
+
await recordInstallationStart(telemetry, session);
|
|
873
|
+
const started = Date.now();
|
|
874
|
+
try {
|
|
875
|
+
const result = await executeCommand(args, telemetry, session);
|
|
876
|
+
await telemetry.record({ event: 'command_completed', properties: { command, result: 'success', durationBucket: durationBucket(Date.now() - started) } }, session);
|
|
877
|
+
return print(result);
|
|
878
|
+
} catch (error) {
|
|
879
|
+
await telemetry.record({ event: 'skill_error', properties: { errorCode: telemetryErrorCode(error), stage: telemetryStage(command), recoverable: true } }, session);
|
|
880
|
+
await telemetry.record({ event: 'command_completed', properties: { command, result: 'error', durationBucket: durationBucket(Date.now() - started) } }, session);
|
|
881
|
+
throw error;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
886
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
887
|
+
process.stderr.write(`Error: ${error.message}\n`);
|
|
888
|
+
process.exitCode = 1;
|
|
889
|
+
});
|
|
890
|
+
}
|