job-application-agent 3.1.1 → 3.2.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/README.md +11 -2
- package/job-application-agent/SKILL.md +29 -19
- package/job-application-agent/references/ANALYTICS.md +4 -0
- package/job-application-agent/references/RUNS.md +2 -1
- package/job-application-agent/references/SCHEMAS.md +8 -3
- package/job-application-agent/references/SOURCES.json +156 -0
- package/job-application-agent/references/SOURCES.md +73 -0
- package/job-application-agent/scripts/job-application.mjs +286 -23
- package/job-application-agent/scripts/source-community-client.mjs +338 -0
- package/job-application-agent/scripts/source-community-schema.mjs +320 -0
- package/job-application-agent/scripts/telemetry-client.mjs +3 -1
- package/job-application-agent/scripts/version.mjs +1 -0
- package/job-application-agent/tests/job-application.test.mjs +12 -9
- package/job-application-agent/tests/privacy-audit.test.mjs +39 -0
- package/job-application-agent/tests/source-community-client.test.mjs +351 -0
- package/job-application-agent/tests/source-community-schema.test.mjs +270 -0
- package/job-application-agent/tests/telemetry-client.test.mjs +1 -1
- package/job-application-agent/tests/version.test.mjs +12 -0
- package/job-application-agent/tests/workflow-state.test.mjs +506 -7
- package/package.json +7 -3
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
4
5
|
import { appendFile, chmod, mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
5
6
|
import { platform } from 'node:os';
|
|
6
7
|
import { basename, join, resolve } from 'node:path';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
7
9
|
|
|
8
10
|
import { createSecretStore, migrateLegacyStateDir, resolveStateDir } from './secret-store.mjs';
|
|
11
|
+
import { SourceCommunityClient } from './source-community-client.mjs';
|
|
12
|
+
import { normalizeCommunityJob, normalizeCommunitySource } from './source-community-schema.mjs';
|
|
9
13
|
import { TelemetryClient } from './telemetry-client.mjs';
|
|
10
14
|
import { jobIdentity } from './telemetry-schema.mjs';
|
|
11
15
|
|
|
@@ -27,6 +31,13 @@ const AUTONOMY_HARD_STOPS = Object.freeze(['authentication', 'mfa', 'captcha', '
|
|
|
27
31
|
const ATTENTION_STAGES = new Set(['discovery', 'assessment', 'application', 'contact', 'resume', 'questions', 'legal', 'demographic', 'review', 'submission', 'confirmation', 'outcome', 'answers', 'upload']);
|
|
28
32
|
const ATTENTION_BLOCKERS = new Set(['authentication', 'mfa', 'captcha', 'legal-attestation', 'demographic', 'government-id', 'ambiguous-authorization', 'ambiguous-compensation', 'unverifiable-claim', 'judgment', 'video', 'upload', 'site-error', 'other']);
|
|
29
33
|
const REQUIRED_ACTIONS = new Set(['sign-in', 'complete-mfa', 'complete-captcha', 'review-legal', 'choose-demographic', 'provide-government-id', 'provide-authorization', 'provide-compensation', 'verify-claim', 'provide-judgment', 'record-video', 'enable-upload', 'retry-site']);
|
|
34
|
+
const COMPANY_REAPPLY_COOLDOWN_DAYS = 15;
|
|
35
|
+
const COMPANY_REAPPLY_OVERRIDE = 'CANDIDATE APPROVED EARLY REAPPLICATION';
|
|
36
|
+
const SOURCE_CATALOG_URL = new URL('../references/SOURCES.json', import.meta.url);
|
|
37
|
+
const SOURCE_CATALOG = JSON.parse(readFileSync(SOURCE_CATALOG_URL, 'utf8'));
|
|
38
|
+
if (!Array.isArray(SOURCE_CATALOG)) throw new Error('The packaged source catalog is invalid.');
|
|
39
|
+
const SOURCE_CATALOG_IDS = new Set(SOURCE_CATALOG.map((source) => sourceId(source.id, 'source catalog id')));
|
|
40
|
+
const COMMUNITY_SOURCE_ID = /^community-[0-9a-f]{16}$/;
|
|
30
41
|
const REQUIRED_PROFILE = ['name', 'email', 'phone', 'location', 'workAuthorization', 'roleFamilies', 'seniority', 'targetLocations', 'workModes', 'submissionMode', 'yearsExperience', 'autoSubmitMinScore', 'manualReviewMinScore', 'minMustHaveCoverage'];
|
|
31
42
|
const STRING_PROFILE_FIELDS = new Set(['name', 'email', 'phone', 'location', 'workAuthorization', 'linkedin', 'github', 'portfolio', 'availability', 'currentCompensation', 'targetCompensation', 'submissionMode']);
|
|
32
43
|
const ARRAY_PROFILE_FIELDS = new Set(['roleFamilies', 'seniority', 'skills', 'targetLocations', 'excludedLocations', 'workModes', 'industries', 'excludedCompanies']);
|
|
@@ -78,6 +89,7 @@ export function commandCategory([area, action]) {
|
|
|
78
89
|
if (area === 'telemetry') return 'telemetry';
|
|
79
90
|
if (area === 'autonomy') return 'profile';
|
|
80
91
|
if (area === 'round') return 'round';
|
|
92
|
+
if (area === 'sources') return 'search';
|
|
81
93
|
if (area === 'attention' || area === 'friction') return 'batch';
|
|
82
94
|
return 'other';
|
|
83
95
|
}
|
|
@@ -172,6 +184,18 @@ function stringArray(value, label, required = false) {
|
|
|
172
184
|
return value.map((item, index) => string(item, `${label}[${index}]`, 300));
|
|
173
185
|
}
|
|
174
186
|
|
|
187
|
+
function sourceId(value, label) {
|
|
188
|
+
const id = string(value, label, 80).toLowerCase();
|
|
189
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) throw new Error(`${label} must be a kebab-case public source ID.`);
|
|
190
|
+
return id;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function knownDiscoverySourceId(value, label) {
|
|
194
|
+
const id = sourceId(value, label);
|
|
195
|
+
if (!SOURCE_CATALOG_IDS.has(id) && !COMMUNITY_SOURCE_ID.test(id)) throw new Error(`${label} must match a packaged or community source ID.`);
|
|
196
|
+
return id;
|
|
197
|
+
}
|
|
198
|
+
|
|
175
199
|
function integer(value, label, min, max) {
|
|
176
200
|
if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${label} must be an integer from ${min} to ${max}.`);
|
|
177
201
|
return value;
|
|
@@ -250,6 +274,7 @@ export function scoreJob(input, target) {
|
|
|
250
274
|
const description = string(job.description, 'job.description', 40000);
|
|
251
275
|
const source = string(job.source, 'job.source', 40).toLowerCase();
|
|
252
276
|
const discoverySource = job.discoverySource == null ? null : string(job.discoverySource, 'job.discoverySource', 40).toLowerCase();
|
|
277
|
+
if (job.discoverySourceId != null) knownDiscoverySourceId(job.discoverySourceId, 'job.discoverySourceId');
|
|
253
278
|
const applicationChannel = job.applicationChannel == null ? null : string(job.applicationChannel, 'job.applicationChannel', 40).toLowerCase();
|
|
254
279
|
const eligibility = string(job.eligibility, 'job.eligibility', 40).toLowerCase();
|
|
255
280
|
const postingStatus = string(job.postingStatus ?? 'unclear', 'job.postingStatus', 40).toLowerCase();
|
|
@@ -407,6 +432,9 @@ export function validateLedgerEntry(input) {
|
|
|
407
432
|
};
|
|
408
433
|
if (entry.employerJobId != null) normalized.employerJobId = string(entry.employerJobId, 'entry.employerJobId', 300);
|
|
409
434
|
if (entry.discoverySource != null) normalized.discoverySource = string(entry.discoverySource, 'entry.discoverySource', 40).toLowerCase();
|
|
435
|
+
if (entry.discoverySourceId != null) {
|
|
436
|
+
normalized.discoverySourceId = knownDiscoverySourceId(entry.discoverySourceId, 'entry.discoverySourceId');
|
|
437
|
+
}
|
|
410
438
|
if (entry.applicationChannel != null) normalized.applicationChannel = string(entry.applicationChannel, 'entry.applicationChannel', 40).toLowerCase();
|
|
411
439
|
if (entry.roundId != null) normalized.roundId = string(entry.roundId, 'entry.roundId', 180);
|
|
412
440
|
if (!SOURCES.has(normalized.source)) throw new Error('entry.source is invalid.');
|
|
@@ -441,6 +469,27 @@ function normalizedText(value) {
|
|
|
441
469
|
return String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
442
470
|
}
|
|
443
471
|
|
|
472
|
+
function comparableRoleTokens(value) {
|
|
473
|
+
const normalized = normalizedText(value)
|
|
474
|
+
.replace(/\bsr\b/g, 'senior')
|
|
475
|
+
.replace(/\bjr\b/g, 'junior')
|
|
476
|
+
.replace(/\bfull stack\b/g, 'fullstack')
|
|
477
|
+
.replace(/\bfront end\b/g, 'frontend')
|
|
478
|
+
.replace(/\bback end\b/g, 'backend');
|
|
479
|
+
const ignored = new Set(['software', 'junior', 'mid', 'senior', 'staff', 'principal', 'lead', 'manager', 'director', 'founding']);
|
|
480
|
+
return new Set(normalized.split(' ')
|
|
481
|
+
.map((token) => token === 'developer' ? 'engineer' : token)
|
|
482
|
+
.filter((token) => token && !ignored.has(token)));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function rolesLikelySame(left, right) {
|
|
486
|
+
const leftTokens = comparableRoleTokens(left);
|
|
487
|
+
const rightTokens = comparableRoleTokens(right);
|
|
488
|
+
if (leftTokens.size === 0 || rightTokens.size === 0) return normalizedText(left) === normalizedText(right);
|
|
489
|
+
const shared = [...leftTokens].filter((token) => rightTokens.has(token)).length;
|
|
490
|
+
return shared / Math.min(leftTokens.size, rightTokens.size) >= 0.75;
|
|
491
|
+
}
|
|
492
|
+
|
|
444
493
|
function canonicalApplicationKey(entry, fallback = '') {
|
|
445
494
|
if (entry.employerJobId) return `job:${normalizedText(entry.company)}:${String(entry.employerJobId).toLowerCase()}`;
|
|
446
495
|
if (entry.company && entry.role) return `legacy-role:${normalizedText(entry.company)}:${normalizedText(entry.role)}`;
|
|
@@ -593,6 +642,158 @@ async function jsonLines(file) {
|
|
|
593
642
|
}
|
|
594
643
|
}
|
|
595
644
|
|
|
645
|
+
async function sourceCatalog() {
|
|
646
|
+
return SOURCE_CATALOG;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
export async function sourcesList(filtersInput = {}, communitySources = []) {
|
|
650
|
+
const filters = object(filtersInput, 'source filters');
|
|
651
|
+
const allowed = new Set(['regions', 'roleFamilies', 'kinds', 'requiresSession']);
|
|
652
|
+
for (const key of Object.keys(filters)) if (!allowed.has(key)) throw new Error(`Unknown source filter: ${key}.`);
|
|
653
|
+
const regions = filters.regions == null ? [] : terms(stringArray(filters.regions, 'source filters.regions'));
|
|
654
|
+
const roleFamilies = filters.roleFamilies == null ? [] : terms(stringArray(filters.roleFamilies, 'source filters.roleFamilies'));
|
|
655
|
+
const kinds = filters.kinds == null ? [] : terms(stringArray(filters.kinds, 'source filters.kinds'));
|
|
656
|
+
if (filters.requiresSession != null && typeof filters.requiresSession !== 'boolean') throw new Error('source filters.requiresSession must be a Boolean.');
|
|
657
|
+
const community = communitySources.map((source) => ({
|
|
658
|
+
id: source.sourceId,
|
|
659
|
+
communitySourceId: source.sourceId,
|
|
660
|
+
name: source.name,
|
|
661
|
+
kind: source.kind,
|
|
662
|
+
jobsUrl: source.baseUrl,
|
|
663
|
+
regions: source.regions,
|
|
664
|
+
roleFamilies: source.roleFamilies,
|
|
665
|
+
requiresSession: source.requiresSession,
|
|
666
|
+
access: 'community',
|
|
667
|
+
verification: 'direct-employer-or-ats',
|
|
668
|
+
registryStatus: source.registryStatus,
|
|
669
|
+
contributionCount: source.contributionCount,
|
|
670
|
+
}));
|
|
671
|
+
const sources = [...await sourceCatalog(), ...community].filter((source) => {
|
|
672
|
+
if (regions.length && !source.regions.some((value) => regions.includes(value))) return false;
|
|
673
|
+
if (roleFamilies.length && !source.roleFamilies.some((value) => roleFamilies.includes(value))) return false;
|
|
674
|
+
if (kinds.length && !kinds.includes(source.kind)) return false;
|
|
675
|
+
if (filters.requiresSession != null && source.requiresSession !== filters.requiresSession) return false;
|
|
676
|
+
return true;
|
|
677
|
+
});
|
|
678
|
+
return { version: 1, count: sources.length, sources };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function sourceSuggestion(input) {
|
|
682
|
+
const value = normalizeCommunitySource(input);
|
|
683
|
+
return {
|
|
684
|
+
type: 'suggested',
|
|
685
|
+
id: `source-suggestion-${randomUUID()}`,
|
|
686
|
+
...value,
|
|
687
|
+
createdAt: new Date().toISOString(),
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function shareableSuggestion(suggestion) {
|
|
692
|
+
return Object.fromEntries(['name', 'baseUrl', 'kind', 'regions', 'roleFamilies', 'requiresSession'].map((key) => [key, suggestion[key]]));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function markSourceShared(suggestionId, contribution) {
|
|
696
|
+
if (!contribution.shared) return;
|
|
697
|
+
await appendPrivateEvent('source-contribution-receipts', { suggestionId, sourceId: contribution.sourceId, sharedAt: new Date().toISOString() });
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function sourcesSuggest(input, community) {
|
|
701
|
+
const suggestion = sourceSuggestion(input);
|
|
702
|
+
await appendPrivateEvent('source-suggestions', suggestion);
|
|
703
|
+
const contribution = await community.contribute(shareableSuggestion(suggestion));
|
|
704
|
+
await markSourceShared(suggestion.id, contribution);
|
|
705
|
+
return { queued: true, community: contribution, suggestion };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function sourcesPending() {
|
|
709
|
+
const suggestions = await jsonLines(join(await ensureStateDir(), 'source-suggestions.ndjson'));
|
|
710
|
+
const receipts = await jsonLines(join(await ensureStateDir(), 'source-contribution-receipts.ndjson'));
|
|
711
|
+
const shared = new Set(receipts.map((receipt) => receipt.suggestionId));
|
|
712
|
+
const pending = suggestions.filter((suggestion) => !shared.has(suggestion.id));
|
|
713
|
+
return { scope: 'local-unsent', count: pending.length, suggestions: pending };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async function communityPendingStatus() {
|
|
717
|
+
return { ...await sourcesPending(), communityJobs: await communityJobsPending() };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
export async function sourcesSync(community, limit = 10) {
|
|
721
|
+
const pending = (await sourcesPending()).suggestions.slice(0, limit);
|
|
722
|
+
let shared = 0;
|
|
723
|
+
let attempted = 0;
|
|
724
|
+
for (const suggestion of pending) {
|
|
725
|
+
attempted += 1;
|
|
726
|
+
const contribution = await community.contribute(shareableSuggestion(suggestion));
|
|
727
|
+
await markSourceShared(suggestion.id, contribution);
|
|
728
|
+
if (contribution.shared) shared += 1;
|
|
729
|
+
else if (contribution.reason === 'disabled' || contribution.reason === 'unavailable') break;
|
|
730
|
+
}
|
|
731
|
+
return { attempted, shared, remaining: (await sourcesPending()).count };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function shareableLedgerJob(entry) {
|
|
735
|
+
return normalizeCommunityJob({
|
|
736
|
+
url: entry.url,
|
|
737
|
+
company: entry.company,
|
|
738
|
+
role: entry.role,
|
|
739
|
+
applicationChannel: entry.applicationChannel ?? entry.source,
|
|
740
|
+
...(entry.discoverySource == null ? {} : { discoverySource: entry.discoverySource }),
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function markCommunityJobShared(applicationId, contribution) {
|
|
745
|
+
if (!contribution.shared) return;
|
|
746
|
+
await appendPrivateEvent('community-job-contribution-receipts', {
|
|
747
|
+
applicationId,
|
|
748
|
+
jobId: contribution.jobId,
|
|
749
|
+
sharedAt: new Date().toISOString(),
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export async function communityJobsPending() {
|
|
754
|
+
const directory = await ensureStateDir();
|
|
755
|
+
const applications = await jsonLines(join(directory, 'applications.ndjson'));
|
|
756
|
+
const receipts = await jsonLines(join(directory, 'community-job-contribution-receipts.ndjson'));
|
|
757
|
+
const shared = new Set(receipts.map((receipt) => receipt.applicationId));
|
|
758
|
+
const pending = [];
|
|
759
|
+
let unshareable = 0;
|
|
760
|
+
for (const entry of applications) {
|
|
761
|
+
if (shared.has(entry.id)) continue;
|
|
762
|
+
try {
|
|
763
|
+
pending.push({ applicationId: entry.id, job: shareableLedgerJob(entry) });
|
|
764
|
+
} catch {
|
|
765
|
+
unshareable += 1;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return { count: pending.length, unshareable, applications: pending };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export async function communityJobsSync(community, { limit = 10, applicationIds = null } = {}) {
|
|
772
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error('Community job sync limit must be between 1 and 100.');
|
|
773
|
+
const selected = applicationIds == null ? null : new Set(applicationIds);
|
|
774
|
+
const state = await communityJobsPending();
|
|
775
|
+
const pending = state.applications.filter((entry) => selected == null || selected.has(entry.applicationId)).slice(0, limit);
|
|
776
|
+
let attempted = 0;
|
|
777
|
+
let shared = 0;
|
|
778
|
+
for (const entry of pending) {
|
|
779
|
+
attempted += 1;
|
|
780
|
+
const contribution = await community.contributeJob(entry.job);
|
|
781
|
+
await markCommunityJobShared(entry.applicationId, contribution);
|
|
782
|
+
if (contribution.shared) shared += 1;
|
|
783
|
+
else if (contribution.reason === 'disabled' || contribution.reason === 'unavailable' || contribution.reason === 'grace') break;
|
|
784
|
+
}
|
|
785
|
+
const remaining = await communityJobsPending();
|
|
786
|
+
return { attempted, shared, remaining: remaining.count, unshareable: remaining.unshareable };
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function syncAllCommunityData(community) {
|
|
790
|
+
const sources = await sourcesSync(community);
|
|
791
|
+
return {
|
|
792
|
+
...sources,
|
|
793
|
+
communityJobs: await communityJobsSync(community),
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
596
797
|
async function withStateLock(name, action) {
|
|
597
798
|
const dir = await ensureStateDir();
|
|
598
799
|
const lockPath = join(dir, `.${name}.lock`);
|
|
@@ -736,63 +937,105 @@ async function canonicalResumePath() {
|
|
|
736
937
|
return { path: target };
|
|
737
938
|
}
|
|
738
939
|
|
|
739
|
-
function duplicateResult(entries, candidate) {
|
|
940
|
+
function duplicateResult(entries, candidate, outcomes = [], now = new Date()) {
|
|
740
941
|
const candidateCompany = normalizedText(candidate.company);
|
|
741
942
|
const candidateRole = normalizedText(candidate.role);
|
|
742
943
|
const candidateUrl = normalizeUrl(candidate.url);
|
|
743
|
-
const sameCompanyRole = (entry) => candidateCompany && candidateRole
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
944
|
+
const sameCompanyRole = (entry) => candidateCompany && candidateRole
|
|
945
|
+
&& normalizedText(entry.company) === candidateCompany
|
|
946
|
+
&& rolesLikelySame(entry.role, candidate.role);
|
|
947
|
+
const hardId = entries.find((entry) => entry.id === candidate.id);
|
|
948
|
+
const hardEmployerJobId = entries.find((entry) => candidate.employerJobId && entry.employerJobId
|
|
949
|
+
&& normalizedText(entry.company) === candidateCompany
|
|
950
|
+
&& entry.employerJobId.toLowerCase() === String(candidate.employerJobId).toLowerCase());
|
|
951
|
+
const hardUrl = entries.find((entry) => normalizeUrl(entry.url) === candidateUrl);
|
|
952
|
+
const hard = hardId ?? hardEmployerJobId ?? hardUrl;
|
|
953
|
+
const hardReason = hardId ? 'id' : hardEmployerJobId ? 'employer-job-id' : hardUrl ? 'url' : null;
|
|
747
954
|
const possible = hard ? null : entries.find(sameCompanyRole);
|
|
748
955
|
const match = hard ?? possible;
|
|
749
|
-
const
|
|
750
|
-
? entries.filter((entry) => normalizedText(entry.company) === candidateCompany)
|
|
956
|
+
const sameCompanyEntries = candidateCompany
|
|
957
|
+
? entries.filter((entry) => normalizedText(entry.company) === candidateCompany)
|
|
958
|
+
: [];
|
|
959
|
+
const companyApplications = sameCompanyEntries.slice(-20).map((entry) => ({
|
|
751
960
|
id: entry.id,
|
|
752
961
|
company: entry.company,
|
|
753
962
|
role: entry.role,
|
|
754
963
|
submittedAt: entry.submittedAt,
|
|
755
964
|
...(entry.employerJobId ? { employerJobId: entry.employerJobId } : {}),
|
|
756
|
-
}))
|
|
757
|
-
|
|
965
|
+
}));
|
|
966
|
+
const latestCompanyApplication = [...sameCompanyEntries]
|
|
967
|
+
.filter((entry) => !Number.isNaN(Date.parse(entry.submittedAt)))
|
|
968
|
+
.sort((left, right) => Date.parse(right.submittedAt) - Date.parse(left.submittedAt))[0] ?? null;
|
|
969
|
+
const daysSinceLatest = latestCompanyApplication
|
|
970
|
+
? Math.max(0, Math.floor((now.getTime() - Date.parse(latestCompanyApplication.submittedAt)) / 86_400_000))
|
|
971
|
+
: null;
|
|
972
|
+
const hasFollowUp = latestCompanyApplication
|
|
973
|
+
? outcomes.some((outcome) => outcome.id === latestCompanyApplication.id)
|
|
974
|
+
: false;
|
|
975
|
+
let companyReapplyDecision = 'fresh-company';
|
|
976
|
+
if (hard) companyReapplyDecision = 'hard-duplicate';
|
|
977
|
+
else if (possible) companyReapplyDecision = 'same-role-review';
|
|
978
|
+
else if (latestCompanyApplication && hasFollowUp) companyReapplyDecision = 'follow-up-present';
|
|
979
|
+
else if (latestCompanyApplication && daysSinceLatest < COMPANY_REAPPLY_COOLDOWN_DAYS) companyReapplyDecision = 'cooldown-active';
|
|
980
|
+
else if (latestCompanyApplication) companyReapplyDecision = 'eligible-after-cooldown';
|
|
758
981
|
return {
|
|
759
982
|
duplicate: Boolean(hard),
|
|
760
983
|
possibleDuplicate: Boolean(possible),
|
|
761
|
-
reason:
|
|
984
|
+
reason: hardReason ?? (possible ? 'company-role' : null),
|
|
762
985
|
match: match ? { id: match.id, company: match.company, role: match.role, submittedAt: match.submittedAt } : null,
|
|
763
986
|
sameCompany: companyApplications.length > 0,
|
|
764
987
|
companyApplications,
|
|
988
|
+
companyReapply: {
|
|
989
|
+
eligible: companyReapplyDecision === 'eligible-after-cooldown',
|
|
990
|
+
decision: companyReapplyDecision,
|
|
991
|
+
cooldownDays: COMPANY_REAPPLY_COOLDOWN_DAYS,
|
|
992
|
+
latestSubmittedAt: latestCompanyApplication?.submittedAt ?? null,
|
|
993
|
+
daysSinceLatest,
|
|
994
|
+
hasFollowUp,
|
|
995
|
+
},
|
|
765
996
|
};
|
|
766
997
|
}
|
|
767
998
|
|
|
768
999
|
async function ledgerCheck(candidate) {
|
|
769
1000
|
object(candidate, 'candidate');
|
|
770
|
-
const
|
|
1001
|
+
const dir = await ensureStateDir();
|
|
1002
|
+
const entries = await jsonLines(join(dir, 'applications.ndjson'));
|
|
1003
|
+
const outcomes = await jsonLines(join(dir, 'outcomes.ndjson'));
|
|
771
1004
|
string(candidate.url, 'candidate.url', 2048);
|
|
772
|
-
return duplicateResult(entries, candidate);
|
|
1005
|
+
return duplicateResult(entries, candidate, outcomes);
|
|
773
1006
|
}
|
|
774
1007
|
|
|
775
|
-
async function ledgerAdd(entryInput, duplicateOverride) {
|
|
1008
|
+
async function ledgerAdd(entryInput, duplicateOverride, companyReapplyOverride) {
|
|
776
1009
|
const entry = validateLedgerEntry(entryInput);
|
|
777
1010
|
return withStateLock('applications', async (dir) => {
|
|
778
1011
|
const file = join(dir, 'applications.ndjson');
|
|
779
1012
|
const entries = await jsonLines(file);
|
|
1013
|
+
const outcomes = await jsonLines(join(dir, 'outcomes.ndjson'));
|
|
780
1014
|
if (entry.roundId) {
|
|
781
1015
|
const roundEvents = await jsonLines(join(dir, 'rounds.ndjson'));
|
|
782
1016
|
if (!roundEvents.some((event) => event.type === 'started' && event.roundId === entry.roundId)) throw new Error('entry.roundId does not identify a started round.');
|
|
783
1017
|
if (roundEvents.some((event) => event.type === 'completed' && event.roundId === entry.roundId)) throw new Error('entry.roundId identifies a completed round.');
|
|
784
1018
|
}
|
|
785
|
-
const duplicate = duplicateResult(entries, entry);
|
|
786
|
-
if (duplicate.duplicate) throw new Error('
|
|
1019
|
+
const duplicate = duplicateResult(entries, entry, outcomes);
|
|
1020
|
+
if (duplicate.duplicate) throw new Error(`Hard duplicate blocked: matching ${duplicate.reason === 'employer-job-id' ? 'employer job ID or requisition' : duplicate.reason === 'url' ? 'canonical URL' : 'ledger ID'}.`);
|
|
787
1021
|
if (duplicate.possibleDuplicate && duplicateOverride !== 'NEW REQUISITION CONFIRMED') throw new Error('A possible same-company role duplicate requires NEW REQUISITION CONFIRMED.');
|
|
788
|
-
|
|
1022
|
+
if (!duplicate.possibleDuplicate && duplicate.companyReapply.decision === 'cooldown-active' && companyReapplyOverride !== COMPANY_REAPPLY_OVERRIDE) {
|
|
1023
|
+
throw new Error(`Company reapplication cooldown is active for ${COMPANY_REAPPLY_COOLDOWN_DAYS} full days; use ${COMPANY_REAPPLY_OVERRIDE} only with explicit candidate approval.`);
|
|
1024
|
+
}
|
|
1025
|
+
if (!duplicate.possibleDuplicate && duplicate.companyReapply.decision === 'follow-up-present' && companyReapplyOverride !== COMPANY_REAPPLY_OVERRIDE) {
|
|
1026
|
+
throw new Error(`Company reapplication blocked because the latest application has a recorded follow-up; use ${COMPANY_REAPPLY_OVERRIDE} only with explicit candidate approval.`);
|
|
1027
|
+
}
|
|
1028
|
+
const storedEntry = ['cooldown-active', 'follow-up-present'].includes(duplicate.companyReapply.decision)
|
|
1029
|
+
? { ...entry, reapplicationApproval: 'candidate-explicit' }
|
|
1030
|
+
: entry;
|
|
1031
|
+
await appendFile(file, `${JSON.stringify(storedEntry)}\n`, { mode: 0o600 });
|
|
789
1032
|
await chmod(file, 0o600);
|
|
790
1033
|
if (entry.roundId) {
|
|
791
1034
|
const roundsFile = join(dir, 'rounds.ndjson');
|
|
792
1035
|
await appendFile(roundsFile, `${JSON.stringify({ type: 'submission-confirmed', roundId: entry.roundId, applicationId: entry.id, occurredAt: entry.submittedAt })}\n`, { mode: 0o600 });
|
|
793
1036
|
await chmod(roundsFile, 0o600);
|
|
794
1037
|
}
|
|
795
|
-
return { recorded:
|
|
1038
|
+
return { recorded: storedEntry.id, review: buildReview([...entries, storedEntry]) };
|
|
796
1039
|
});
|
|
797
1040
|
}
|
|
798
1041
|
|
|
@@ -1070,7 +1313,7 @@ function roundCompletedTelemetry(round) {
|
|
|
1070
1313
|
};
|
|
1071
1314
|
}
|
|
1072
1315
|
|
|
1073
|
-
async function executeCommand([area, action, value], telemetry, session) {
|
|
1316
|
+
async function executeCommand([area, action, value], telemetry, session, community) {
|
|
1074
1317
|
const domainEvents = [];
|
|
1075
1318
|
let result;
|
|
1076
1319
|
if (area === 'profile' && action === 'set' && value === '--stdin') {
|
|
@@ -1095,7 +1338,8 @@ async function executeCommand([area, action, value], telemetry, session) {
|
|
|
1095
1338
|
const input = await jsonStdin();
|
|
1096
1339
|
const telemetryDetails = validateSubmissionTelemetry(input.telemetry);
|
|
1097
1340
|
const entry = validateLedgerEntry(input);
|
|
1098
|
-
result = await ledgerAdd(entry, input.duplicateOverride);
|
|
1341
|
+
result = await ledgerAdd(entry, input.duplicateOverride, input.companyReapplyOverride);
|
|
1342
|
+
result.communityJob = await communityJobsSync(community, { limit: 1, applicationIds: [entry.id] });
|
|
1099
1343
|
domainEvents.push(await telemetryApplicationSubmitted(entry, telemetryDetails));
|
|
1100
1344
|
} else if (area === 'ledger' && action === 'outcome' && value === '--stdin') {
|
|
1101
1345
|
const outcome = await ledgerOutcome(await jsonStdin());
|
|
@@ -1116,12 +1360,29 @@ async function executeCommand([area, action, value], telemetry, session) {
|
|
|
1116
1360
|
else if (area === 'round' && action === 'complete' && value === '--stdin') {
|
|
1117
1361
|
result = await roundComplete(await jsonStdin());
|
|
1118
1362
|
domainEvents.push(roundCompletedTelemetry(result));
|
|
1119
|
-
} else if (area === '
|
|
1363
|
+
} else if (area === 'sources' && action === 'list' && value == null) {
|
|
1364
|
+
await syncAllCommunityData(community);
|
|
1365
|
+
result = await sourcesList({}, await community.list());
|
|
1366
|
+
} else if (area === 'sources' && action === 'list' && value === '--stdin') {
|
|
1367
|
+
const filters = await jsonStdin();
|
|
1368
|
+
await syncAllCommunityData(community);
|
|
1369
|
+
result = await sourcesList(filters, await community.list());
|
|
1370
|
+
}
|
|
1371
|
+
else if (area === 'sources' && action === 'suggest' && value === '--stdin') result = await sourcesSuggest(await jsonStdin(), community);
|
|
1372
|
+
else if (area === 'sources' && action === 'pending' && value == null) result = await communityPendingStatus();
|
|
1373
|
+
else if (area === 'sources' && action === 'sync' && value == null) result = await syncAllCommunityData(community);
|
|
1374
|
+
else if (area === 'sources' && action === 'jobs' && value == null) result = await community.listJobs();
|
|
1375
|
+
else if (area === 'sources' && action === 'jobs' && value === '--stdin') result = await community.listJobs(await jsonStdin());
|
|
1376
|
+
else if (area === 'sources' && action === 'sharing' && ['status', 'enable', 'disable', 'reset'].includes(value)) {
|
|
1377
|
+
result = await community.configure(value);
|
|
1378
|
+
if (value === 'enable') result.communityJobs = await communityJobsSync(community);
|
|
1379
|
+
}
|
|
1380
|
+
else if (area === 'attention' && action === 'add' && value === '--stdin') result = await attentionAdd(await jsonStdin());
|
|
1120
1381
|
else if (area === 'attention' && action === 'list' && value == null) result = await attentionList();
|
|
1121
1382
|
else if (area === 'attention' && action === 'resolve' && value === '--stdin') result = await attentionResolve(await jsonStdin());
|
|
1122
1383
|
else if (area === 'friction' && action === 'record' && value === '--stdin') result = await frictionRecord(await jsonStdin());
|
|
1123
1384
|
else if (area === 'friction' && action === 'list' && value == null) result = await frictionList();
|
|
1124
|
-
else throw new Error('Usage: profile set|migrate --stdin; profile check|field <name>; resume import <url-or-pdf>|path; score --stdin; ledger check|add|outcome|review-ack --stdin; ledger review; autonomy grant --stdin|status|preview|revoke; round start|complete --stdin|status [round-id]; attention add|resolve --stdin|list; friction record --stdin|list; telemetry status|enable|disable|reset|preview --stdin|record --stdin');
|
|
1385
|
+
else throw new Error('Usage: profile set|migrate --stdin; profile check|field <name>; resume import <url-or-pdf>|path; score --stdin; ledger check|add|outcome|review-ack --stdin; ledger review; autonomy grant --stdin|status|preview|revoke; round start|complete --stdin|status [round-id]; sources list [--stdin]|jobs [--stdin]|suggest --stdin|pending|sync|sharing status|enable|disable|reset; attention add|resolve --stdin|list; friction record --stdin|list; telemetry status|enable|disable|reset|preview --stdin|record --stdin');
|
|
1125
1386
|
for (const event of domainEvents) await telemetry.record(event, session);
|
|
1126
1387
|
return result;
|
|
1127
1388
|
}
|
|
@@ -1143,6 +1404,7 @@ async function recordInstallationStart(telemetry, session) {
|
|
|
1143
1404
|
async function main(args) {
|
|
1144
1405
|
const [area, action, value] = args;
|
|
1145
1406
|
const telemetry = new TelemetryClient({ stateDir: stateDir() });
|
|
1407
|
+
const community = new SourceCommunityClient({ stateDir: stateDir() });
|
|
1146
1408
|
if (area === 'telemetry') {
|
|
1147
1409
|
if (['status', 'enable', 'disable', 'reset'].includes(action) && value == null) return print(await telemetry.configure(action));
|
|
1148
1410
|
if (action === 'preview' && value === '--stdin') return print(await telemetry.preview(await jsonStdin()));
|
|
@@ -1159,7 +1421,8 @@ async function main(args) {
|
|
|
1159
1421
|
await recordInstallationStart(telemetry, session);
|
|
1160
1422
|
const started = Date.now();
|
|
1161
1423
|
try {
|
|
1162
|
-
const result = await executeCommand(args, telemetry, session);
|
|
1424
|
+
const result = await executeCommand(args, telemetry, session, community);
|
|
1425
|
+
if (!(area === 'sources' || (area === 'ledger' && action === 'add'))) await communityJobsSync(community).catch(() => null);
|
|
1163
1426
|
await telemetry.record({ event: 'command_completed', properties: { command, result: 'success', durationBucket: durationBucket(Date.now() - started) } }, session);
|
|
1164
1427
|
return print(result);
|
|
1165
1428
|
} catch (error) {
|
|
@@ -1169,7 +1432,7 @@ async function main(args) {
|
|
|
1169
1432
|
}
|
|
1170
1433
|
}
|
|
1171
1434
|
|
|
1172
|
-
if (import.meta.url ===
|
|
1435
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
1173
1436
|
main(process.argv.slice(2)).catch((error) => {
|
|
1174
1437
|
process.stderr.write(`Error: ${error.message}\n`);
|
|
1175
1438
|
process.exitCode = 1;
|