job-application-agent 3.1.0 → 3.1.2

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.
@@ -0,0 +1,156 @@
1
+ [
2
+ {
3
+ "id": "direct-company-careers",
4
+ "name": "Direct company careers pages",
5
+ "kind": "direct-employer",
6
+ "jobsUrl": null,
7
+ "regions": ["global"],
8
+ "roleFamilies": ["engineering"],
9
+ "requiresSession": false,
10
+ "access": "public",
11
+ "verification": "direct-employer-or-ats"
12
+ },
13
+ {
14
+ "id": "linkedin-jobs-feed",
15
+ "name": "LinkedIn jobs, posts, and feed",
16
+ "kind": "professional-network",
17
+ "jobsUrl": "https://www.linkedin.com/jobs/",
18
+ "regions": ["global"],
19
+ "roleFamilies": ["engineering"],
20
+ "requiresSession": true,
21
+ "access": "session",
22
+ "verification": "direct-employer-or-ats"
23
+ },
24
+ {
25
+ "id": "x-hiring-feed",
26
+ "name": "X hiring posts and feed",
27
+ "kind": "social-feed",
28
+ "jobsUrl": "https://x.com/home",
29
+ "regions": ["global"],
30
+ "roleFamilies": ["engineering"],
31
+ "requiresSession": true,
32
+ "access": "session",
33
+ "verification": "direct-employer-or-ats"
34
+ },
35
+ {
36
+ "id": "yc-work-at-a-startup",
37
+ "name": "YC Work at a Startup",
38
+ "kind": "startup-network",
39
+ "jobsUrl": "https://www.workatastartup.com/jobs",
40
+ "regions": ["global", "india", "north-america"],
41
+ "roleFamilies": ["engineering"],
42
+ "requiresSession": true,
43
+ "access": "session",
44
+ "verification": "direct-employer-or-ats"
45
+ },
46
+ {
47
+ "id": "yc-company-directory",
48
+ "name": "Y Combinator company hiring directory",
49
+ "kind": "startup-network",
50
+ "jobsUrl": "https://www.ycombinator.com/companies/hiring",
51
+ "regions": ["global", "india", "north-america"],
52
+ "roleFamilies": ["engineering"],
53
+ "requiresSession": false,
54
+ "access": "public",
55
+ "verification": "direct-employer-or-ats"
56
+ },
57
+ {
58
+ "id": "hacker-news-who-is-hiring",
59
+ "name": "Hacker News Who is Hiring",
60
+ "kind": "community-thread",
61
+ "jobsUrl": "https://news.ycombinator.com/submitted?id=whoishiring",
62
+ "regions": ["global"],
63
+ "roleFamilies": ["engineering"],
64
+ "requiresSession": false,
65
+ "access": "public",
66
+ "verification": "direct-employer-or-ats"
67
+ },
68
+ {
69
+ "id": "we-work-remotely",
70
+ "name": "We Work Remotely",
71
+ "kind": "job-board",
72
+ "jobsUrl": "https://weworkremotely.com/remote-jobs/search?term=software+engineer",
73
+ "regions": ["global", "remote"],
74
+ "roleFamilies": ["engineering"],
75
+ "requiresSession": false,
76
+ "access": "public",
77
+ "verification": "direct-employer-or-ats"
78
+ },
79
+ {
80
+ "id": "a16z-build-jobs",
81
+ "name": "a16z Build open roles",
82
+ "kind": "curated-board",
83
+ "jobsUrl": "https://a16zbuild.substack.com/",
84
+ "regions": ["global", "north-america"],
85
+ "roleFamilies": ["engineering"],
86
+ "requiresSession": false,
87
+ "access": "public",
88
+ "verification": "direct-employer-or-ats"
89
+ },
90
+ {
91
+ "id": "engg-space",
92
+ "name": "Engg.space",
93
+ "kind": "curated-board",
94
+ "jobsUrl": "https://www.engg.space/",
95
+ "regions": ["india", "global", "remote"],
96
+ "roleFamilies": ["engineering"],
97
+ "requiresSession": false,
98
+ "access": "public",
99
+ "verification": "direct-employer-or-ats"
100
+ },
101
+ {
102
+ "id": "js-guru-jobs",
103
+ "name": "JS Guru Jobs",
104
+ "kind": "curated-board",
105
+ "jobsUrl": "https://jsgurujobs.com/jobs",
106
+ "regions": ["global", "remote"],
107
+ "roleFamilies": ["engineering"],
108
+ "requiresSession": true,
109
+ "access": "session",
110
+ "verification": "direct-employer-or-ats"
111
+ },
112
+ {
113
+ "id": "linux-careers",
114
+ "name": "Linux Careers",
115
+ "kind": "job-board",
116
+ "jobsUrl": "https://www.linuxcareers.com/jobs",
117
+ "regions": ["global", "remote"],
118
+ "roleFamilies": ["engineering"],
119
+ "requiresSession": false,
120
+ "access": "public",
121
+ "verification": "direct-employer-or-ats"
122
+ },
123
+ {
124
+ "id": "indeed",
125
+ "name": "Indeed",
126
+ "kind": "job-board",
127
+ "jobsUrl": "https://www.indeed.com/",
128
+ "regions": ["global", "india"],
129
+ "roleFamilies": ["engineering"],
130
+ "requiresSession": true,
131
+ "access": "session",
132
+ "verification": "direct-employer-or-ats"
133
+ },
134
+ {
135
+ "id": "recruiter-inbound",
136
+ "name": "Verified recruiter email and direct messages",
137
+ "kind": "inbound",
138
+ "jobsUrl": null,
139
+ "regions": ["global"],
140
+ "roleFamilies": ["engineering"],
141
+ "requiresSession": true,
142
+ "access": "connector-or-session",
143
+ "verification": "direct-employer-or-ats"
144
+ },
145
+ {
146
+ "id": "user-supplied-leads",
147
+ "name": "User-supplied job links and hiring posts",
148
+ "kind": "user-supplied",
149
+ "jobsUrl": null,
150
+ "regions": ["global"],
151
+ "roleFamilies": ["engineering"],
152
+ "requiresSession": false,
153
+ "access": "candidate-provided",
154
+ "verification": "direct-employer-or-ats"
155
+ }
156
+ ]
@@ -0,0 +1,66 @@
1
+ # Discovery source registry
2
+
3
+ The packaged [`SOURCES.json`](SOURCES.json) catalog is the reviewed, versioned list of repeatable discovery surfaces available to every installation. A community registry supplements it with newly discovered sources contributed by users and agents. Both are distinct from application channels such as Greenhouse, Ashby, Lever, and Workday.
4
+
5
+ ## Use the catalog
6
+
7
+ ```text
8
+ node scripts/job-application.mjs sources list
9
+ node scripts/job-application.mjs sources list --stdin
10
+ ```
11
+
12
+ Optional filter input:
13
+
14
+ ```json
15
+ {
16
+ "regions": ["india", "global", "remote"],
17
+ "roleFamilies": ["engineering"],
18
+ "kinds": ["job-board", "startup-network"],
19
+ "requiresSession": false
20
+ }
21
+ ```
22
+
23
+ `sources list` combines the packaged catalog with the live community registry. Public v1 community entries are always maintainer-approved and have `registryStatus: "community-reviewed"`. They include a `communitySourceId` and remain discovery leads, not endorsements or eligibility claims. Resolve every lead to the direct employer or ATS page and verify that posting immediately before assessment and submission. Respect `requiresSession`; do not upload a résumé or transmit profile data merely to unlock discovery unless the candidate has authorized that destination.
24
+
25
+ Store three independent attribution fields when available:
26
+
27
+ - `discoverySource`: coarse compatible channel such as `linkedin`, `x`, `yc`, `job-board`, or `user-supplied`.
28
+ - `discoverySourceId`: stable catalog ID such as `yc-work-at-a-startup` or `hacker-news-who-is-hiring`.
29
+ - `applicationChannel`: actual submission channel such as `greenhouse`, `ashby`, `lever`, `company`, or `email`.
30
+
31
+ `discoverySourceId` remains local in v1 and is not transmitted by telemetry.
32
+
33
+ ## Community sharing
34
+
35
+ Community source sharing is enabled by default and independent of usage analytics. Whenever the user or agent discovers a repeatable public discovery surface—not a one-off company job, recruiter profile, referral link, or personal URL—queue and contribute it:
36
+
37
+ ```text
38
+ node scripts/job-application.mjs sources suggest --stdin
39
+ node scripts/job-application.mjs sources pending
40
+ node scripts/job-application.mjs sources sync
41
+ node scripts/job-application.mjs sources sharing status
42
+ node scripts/job-application.mjs sources sharing disable
43
+ ```
44
+
45
+ Suggestion input:
46
+
47
+ ```json
48
+ {
49
+ "name": "Example Engineering Board",
50
+ "baseUrl": "https://jobs.example.org/openings/engineering",
51
+ "kind": "job-board",
52
+ "regions": ["global"],
53
+ "roleFamilies": ["engineering"],
54
+ "requiresSession": false
55
+ }
56
+ ```
57
+
58
+ The first contribution displays a disclosure and sends during that command. `disable` stops future sharing; `enable` resumes it; `reset` disables sharing and removes its anonymous relay credentials. The source-sharing preference and anonymous credential are stored in an owner-only local file.
59
+
60
+ The client and relay use the same fail-closed source-route classifier. They remove query parameters and fragments; reject embedded credentials, credential-like opaque path segments, identity-like names and paths, personal profiles, local/private hosts, unknown fields, oversized payloads, and known detail routes from Workday, LinkedIn Jobs, Greenhouse, Lever, Ashby, Workable, and SmartRecruiters. Unknown domains are accepted only at the root or on explicit collection, directory, feed, careers, openings, or job-index routes. Only the source name, canonical public base URL, kind, regions, role families, and session requirement are shared.
61
+
62
+ The raw anonymous installation ID authenticates and rate-limits a request but is never stored in the registry. The relay stores a source-scoped HMAC only to deduplicate contributions and help a maintainer prioritize review. One system contributes at most once to a canonical source, and `contributionCount` always means unique contributing systems—not people. It is never identity, trust, authority, or a condition for publication. The first valid contribution owns the canonical metadata; later contributions cannot rewrite it.
63
+
64
+ If sharing is disabled or offline, suggestions stay in the owner-only local queue. `sources pending` reports only these locally unsent suggestions; it does not expose server moderation status. `sources sync` retries locally unsent suggestions, and `sources list` performs a best-effort retry before reading the community registry. A server-accepted contribution is considered delivered even while its source is pending publication. Network failure never blocks discovery or an application.
65
+
66
+ Every accepted community contribution remains in the private pending queue until an owner explicitly approves it with the owner-only D1 moderation commands. Rejected sources remain hidden after later contributions and cannot republish automatically. Maintainer commands and metadata-correction procedures are documented in [`telemetry-worker/COMMUNITY_SOURCE_MODERATION.md`](https://github.com/vaibhavarora14/job-application-agent/blob/main/telemetry-worker/COMMUNITY_SOURCE_MODERATION.md). Never publish candidate identity, job history, prompts, referral parameters, private URLs, contributor hashes, or one-off jobs.
@@ -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 { 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,12 @@ 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')));
30
40
  const REQUIRED_PROFILE = ['name', 'email', 'phone', 'location', 'workAuthorization', 'roleFamilies', 'seniority', 'targetLocations', 'workModes', 'submissionMode', 'yearsExperience', 'autoSubmitMinScore', 'manualReviewMinScore', 'minMustHaveCoverage'];
31
41
  const STRING_PROFILE_FIELDS = new Set(['name', 'email', 'phone', 'location', 'workAuthorization', 'linkedin', 'github', 'portfolio', 'availability', 'currentCompensation', 'targetCompensation', 'submissionMode']);
32
42
  const ARRAY_PROFILE_FIELDS = new Set(['roleFamilies', 'seniority', 'skills', 'targetLocations', 'excludedLocations', 'workModes', 'industries', 'excludedCompanies']);
@@ -78,6 +88,7 @@ export function commandCategory([area, action]) {
78
88
  if (area === 'telemetry') return 'telemetry';
79
89
  if (area === 'autonomy') return 'profile';
80
90
  if (area === 'round') return 'round';
91
+ if (area === 'sources') return 'search';
81
92
  if (area === 'attention' || area === 'friction') return 'batch';
82
93
  return 'other';
83
94
  }
@@ -172,6 +183,12 @@ function stringArray(value, label, required = false) {
172
183
  return value.map((item, index) => string(item, `${label}[${index}]`, 300));
173
184
  }
174
185
 
186
+ function sourceId(value, label) {
187
+ const id = string(value, label, 80).toLowerCase();
188
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) throw new Error(`${label} must be a kebab-case public source ID.`);
189
+ return id;
190
+ }
191
+
175
192
  function integer(value, label, min, max) {
176
193
  if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${label} must be an integer from ${min} to ${max}.`);
177
194
  return value;
@@ -250,6 +267,9 @@ export function scoreJob(input, target) {
250
267
  const description = string(job.description, 'job.description', 40000);
251
268
  const source = string(job.source, 'job.source', 40).toLowerCase();
252
269
  const discoverySource = job.discoverySource == null ? null : string(job.discoverySource, 'job.discoverySource', 40).toLowerCase();
270
+ if (job.discoverySourceId != null && !SOURCE_CATALOG_IDS.has(sourceId(job.discoverySourceId, 'job.discoverySourceId'))) {
271
+ throw new Error('job.discoverySourceId must match an ID in the packaged source catalog.');
272
+ }
253
273
  const applicationChannel = job.applicationChannel == null ? null : string(job.applicationChannel, 'job.applicationChannel', 40).toLowerCase();
254
274
  const eligibility = string(job.eligibility, 'job.eligibility', 40).toLowerCase();
255
275
  const postingStatus = string(job.postingStatus ?? 'unclear', 'job.postingStatus', 40).toLowerCase();
@@ -407,6 +427,10 @@ export function validateLedgerEntry(input) {
407
427
  };
408
428
  if (entry.employerJobId != null) normalized.employerJobId = string(entry.employerJobId, 'entry.employerJobId', 300);
409
429
  if (entry.discoverySource != null) normalized.discoverySource = string(entry.discoverySource, 'entry.discoverySource', 40).toLowerCase();
430
+ if (entry.discoverySourceId != null) {
431
+ normalized.discoverySourceId = sourceId(entry.discoverySourceId, 'entry.discoverySourceId');
432
+ if (!SOURCE_CATALOG_IDS.has(normalized.discoverySourceId)) throw new Error('entry.discoverySourceId must match an ID in the packaged source catalog.');
433
+ }
410
434
  if (entry.applicationChannel != null) normalized.applicationChannel = string(entry.applicationChannel, 'entry.applicationChannel', 40).toLowerCase();
411
435
  if (entry.roundId != null) normalized.roundId = string(entry.roundId, 'entry.roundId', 180);
412
436
  if (!SOURCES.has(normalized.source)) throw new Error('entry.source is invalid.');
@@ -441,6 +465,27 @@ function normalizedText(value) {
441
465
  return String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
442
466
  }
443
467
 
468
+ function comparableRoleTokens(value) {
469
+ const normalized = normalizedText(value)
470
+ .replace(/\bsr\b/g, 'senior')
471
+ .replace(/\bjr\b/g, 'junior')
472
+ .replace(/\bfull stack\b/g, 'fullstack')
473
+ .replace(/\bfront end\b/g, 'frontend')
474
+ .replace(/\bback end\b/g, 'backend');
475
+ const ignored = new Set(['software', 'junior', 'mid', 'senior', 'staff', 'principal', 'lead', 'manager', 'director', 'founding']);
476
+ return new Set(normalized.split(' ')
477
+ .map((token) => token === 'developer' ? 'engineer' : token)
478
+ .filter((token) => token && !ignored.has(token)));
479
+ }
480
+
481
+ function rolesLikelySame(left, right) {
482
+ const leftTokens = comparableRoleTokens(left);
483
+ const rightTokens = comparableRoleTokens(right);
484
+ if (leftTokens.size === 0 || rightTokens.size === 0) return normalizedText(left) === normalizedText(right);
485
+ const shared = [...leftTokens].filter((token) => rightTokens.has(token)).length;
486
+ return shared / Math.min(leftTokens.size, rightTokens.size) >= 0.75;
487
+ }
488
+
444
489
  function canonicalApplicationKey(entry, fallback = '') {
445
490
  if (entry.employerJobId) return `job:${normalizedText(entry.company)}:${String(entry.employerJobId).toLowerCase()}`;
446
491
  if (entry.company && entry.role) return `legacy-role:${normalizedText(entry.company)}:${normalizedText(entry.role)}`;
@@ -593,6 +638,91 @@ async function jsonLines(file) {
593
638
  }
594
639
  }
595
640
 
641
+ async function sourceCatalog() {
642
+ return SOURCE_CATALOG;
643
+ }
644
+
645
+ async function sourcesList(filtersInput = {}, communitySources = []) {
646
+ const filters = object(filtersInput, 'source filters');
647
+ const allowed = new Set(['regions', 'roleFamilies', 'kinds', 'requiresSession']);
648
+ for (const key of Object.keys(filters)) if (!allowed.has(key)) throw new Error(`Unknown source filter: ${key}.`);
649
+ const regions = filters.regions == null ? [] : terms(stringArray(filters.regions, 'source filters.regions'));
650
+ const roleFamilies = filters.roleFamilies == null ? [] : terms(stringArray(filters.roleFamilies, 'source filters.roleFamilies'));
651
+ const kinds = filters.kinds == null ? [] : terms(stringArray(filters.kinds, 'source filters.kinds'));
652
+ if (filters.requiresSession != null && typeof filters.requiresSession !== 'boolean') throw new Error('source filters.requiresSession must be a Boolean.');
653
+ const community = communitySources.map((source) => ({
654
+ id: null,
655
+ communitySourceId: source.sourceId,
656
+ name: source.name,
657
+ kind: source.kind,
658
+ jobsUrl: source.baseUrl,
659
+ regions: source.regions,
660
+ roleFamilies: source.roleFamilies,
661
+ requiresSession: source.requiresSession,
662
+ access: 'community',
663
+ verification: 'direct-employer-or-ats',
664
+ registryStatus: source.registryStatus,
665
+ contributionCount: source.contributionCount,
666
+ }));
667
+ const sources = [...await sourceCatalog(), ...community].filter((source) => {
668
+ if (regions.length && !source.regions.some((value) => regions.includes(value))) return false;
669
+ if (roleFamilies.length && !source.roleFamilies.some((value) => roleFamilies.includes(value))) return false;
670
+ if (kinds.length && !kinds.includes(source.kind)) return false;
671
+ if (filters.requiresSession != null && source.requiresSession !== filters.requiresSession) return false;
672
+ return true;
673
+ });
674
+ return { version: 1, count: sources.length, sources };
675
+ }
676
+
677
+ function sourceSuggestion(input) {
678
+ const value = normalizeCommunitySource(input);
679
+ return {
680
+ type: 'suggested',
681
+ id: `source-suggestion-${randomUUID()}`,
682
+ ...value,
683
+ createdAt: new Date().toISOString(),
684
+ };
685
+ }
686
+
687
+ function shareableSuggestion(suggestion) {
688
+ return Object.fromEntries(['name', 'baseUrl', 'kind', 'regions', 'roleFamilies', 'requiresSession'].map((key) => [key, suggestion[key]]));
689
+ }
690
+
691
+ async function markSourceShared(suggestionId, contribution) {
692
+ if (!contribution.shared) return;
693
+ await appendPrivateEvent('source-contribution-receipts', { suggestionId, sourceId: contribution.sourceId, sharedAt: new Date().toISOString() });
694
+ }
695
+
696
+ async function sourcesSuggest(input, community) {
697
+ const suggestion = sourceSuggestion(input);
698
+ await appendPrivateEvent('source-suggestions', suggestion);
699
+ const contribution = await community.contribute(shareableSuggestion(suggestion));
700
+ await markSourceShared(suggestion.id, contribution);
701
+ return { queued: true, community: contribution, suggestion };
702
+ }
703
+
704
+ async function sourcesPending() {
705
+ const suggestions = await jsonLines(join(await ensureStateDir(), 'source-suggestions.ndjson'));
706
+ const receipts = await jsonLines(join(await ensureStateDir(), 'source-contribution-receipts.ndjson'));
707
+ const shared = new Set(receipts.map((receipt) => receipt.suggestionId));
708
+ const pending = suggestions.filter((suggestion) => !shared.has(suggestion.id));
709
+ return { scope: 'local-unsent', count: pending.length, suggestions: pending };
710
+ }
711
+
712
+ export async function sourcesSync(community, limit = 10) {
713
+ const pending = (await sourcesPending()).suggestions.slice(0, limit);
714
+ let shared = 0;
715
+ let attempted = 0;
716
+ for (const suggestion of pending) {
717
+ attempted += 1;
718
+ const contribution = await community.contribute(shareableSuggestion(suggestion));
719
+ await markSourceShared(suggestion.id, contribution);
720
+ if (contribution.shared) shared += 1;
721
+ else if (contribution.reason === 'disabled' || contribution.reason === 'unavailable') break;
722
+ }
723
+ return { attempted, shared, remaining: (await sourcesPending()).count };
724
+ }
725
+
596
726
  async function withStateLock(name, action) {
597
727
  const dir = await ensureStateDir();
598
728
  const lockPath = join(dir, `.${name}.lock`);
@@ -736,63 +866,105 @@ async function canonicalResumePath() {
736
866
  return { path: target };
737
867
  }
738
868
 
739
- function duplicateResult(entries, candidate) {
869
+ function duplicateResult(entries, candidate, outcomes = [], now = new Date()) {
740
870
  const candidateCompany = normalizedText(candidate.company);
741
871
  const candidateRole = normalizedText(candidate.role);
742
872
  const candidateUrl = normalizeUrl(candidate.url);
743
- const sameCompanyRole = (entry) => candidateCompany && candidateRole && normalizedText(entry.company) === candidateCompany && normalizedText(entry.role) === candidateRole;
744
- const hard = entries.find((entry) => entry.id === candidate.id
745
- || (candidate.employerJobId && entry.employerJobId && normalizedText(entry.company) === candidateCompany && entry.employerJobId.toLowerCase() === String(candidate.employerJobId).toLowerCase())
746
- || normalizeUrl(entry.url) === candidateUrl);
873
+ const sameCompanyRole = (entry) => candidateCompany && candidateRole
874
+ && normalizedText(entry.company) === candidateCompany
875
+ && rolesLikelySame(entry.role, candidate.role);
876
+ const hardId = entries.find((entry) => entry.id === candidate.id);
877
+ const hardEmployerJobId = entries.find((entry) => candidate.employerJobId && entry.employerJobId
878
+ && normalizedText(entry.company) === candidateCompany
879
+ && entry.employerJobId.toLowerCase() === String(candidate.employerJobId).toLowerCase());
880
+ const hardUrl = entries.find((entry) => normalizeUrl(entry.url) === candidateUrl);
881
+ const hard = hardId ?? hardEmployerJobId ?? hardUrl;
882
+ const hardReason = hardId ? 'id' : hardEmployerJobId ? 'employer-job-id' : hardUrl ? 'url' : null;
747
883
  const possible = hard ? null : entries.find(sameCompanyRole);
748
884
  const match = hard ?? possible;
749
- const companyApplications = candidateCompany
750
- ? entries.filter((entry) => normalizedText(entry.company) === candidateCompany).slice(-20).map((entry) => ({
885
+ const sameCompanyEntries = candidateCompany
886
+ ? entries.filter((entry) => normalizedText(entry.company) === candidateCompany)
887
+ : [];
888
+ const companyApplications = sameCompanyEntries.slice(-20).map((entry) => ({
751
889
  id: entry.id,
752
890
  company: entry.company,
753
891
  role: entry.role,
754
892
  submittedAt: entry.submittedAt,
755
893
  ...(entry.employerJobId ? { employerJobId: entry.employerJobId } : {}),
756
- }))
757
- : [];
894
+ }));
895
+ const latestCompanyApplication = [...sameCompanyEntries]
896
+ .filter((entry) => !Number.isNaN(Date.parse(entry.submittedAt)))
897
+ .sort((left, right) => Date.parse(right.submittedAt) - Date.parse(left.submittedAt))[0] ?? null;
898
+ const daysSinceLatest = latestCompanyApplication
899
+ ? Math.max(0, Math.floor((now.getTime() - Date.parse(latestCompanyApplication.submittedAt)) / 86_400_000))
900
+ : null;
901
+ const hasFollowUp = latestCompanyApplication
902
+ ? outcomes.some((outcome) => outcome.id === latestCompanyApplication.id)
903
+ : false;
904
+ let companyReapplyDecision = 'fresh-company';
905
+ if (hard) companyReapplyDecision = 'hard-duplicate';
906
+ else if (possible) companyReapplyDecision = 'same-role-review';
907
+ else if (latestCompanyApplication && hasFollowUp) companyReapplyDecision = 'follow-up-present';
908
+ else if (latestCompanyApplication && daysSinceLatest < COMPANY_REAPPLY_COOLDOWN_DAYS) companyReapplyDecision = 'cooldown-active';
909
+ else if (latestCompanyApplication) companyReapplyDecision = 'eligible-after-cooldown';
758
910
  return {
759
911
  duplicate: Boolean(hard),
760
912
  possibleDuplicate: Boolean(possible),
761
- reason: hard ? (hard.id === candidate.id ? 'id' : candidate.employerJobId && hard.employerJobId ? 'employer-job-id' : 'url') : possible ? 'company-role' : null,
913
+ reason: hardReason ?? (possible ? 'company-role' : null),
762
914
  match: match ? { id: match.id, company: match.company, role: match.role, submittedAt: match.submittedAt } : null,
763
915
  sameCompany: companyApplications.length > 0,
764
916
  companyApplications,
917
+ companyReapply: {
918
+ eligible: companyReapplyDecision === 'eligible-after-cooldown',
919
+ decision: companyReapplyDecision,
920
+ cooldownDays: COMPANY_REAPPLY_COOLDOWN_DAYS,
921
+ latestSubmittedAt: latestCompanyApplication?.submittedAt ?? null,
922
+ daysSinceLatest,
923
+ hasFollowUp,
924
+ },
765
925
  };
766
926
  }
767
927
 
768
928
  async function ledgerCheck(candidate) {
769
929
  object(candidate, 'candidate');
770
- const entries = await jsonLines(join(await ensureStateDir(), 'applications.ndjson'));
930
+ const dir = await ensureStateDir();
931
+ const entries = await jsonLines(join(dir, 'applications.ndjson'));
932
+ const outcomes = await jsonLines(join(dir, 'outcomes.ndjson'));
771
933
  string(candidate.url, 'candidate.url', 2048);
772
- return duplicateResult(entries, candidate);
934
+ return duplicateResult(entries, candidate, outcomes);
773
935
  }
774
936
 
775
- async function ledgerAdd(entryInput, duplicateOverride) {
937
+ async function ledgerAdd(entryInput, duplicateOverride, companyReapplyOverride) {
776
938
  const entry = validateLedgerEntry(entryInput);
777
939
  return withStateLock('applications', async (dir) => {
778
940
  const file = join(dir, 'applications.ndjson');
779
941
  const entries = await jsonLines(file);
942
+ const outcomes = await jsonLines(join(dir, 'outcomes.ndjson'));
780
943
  if (entry.roundId) {
781
944
  const roundEvents = await jsonLines(join(dir, 'rounds.ndjson'));
782
945
  if (!roundEvents.some((event) => event.type === 'started' && event.roundId === entry.roundId)) throw new Error('entry.roundId does not identify a started round.');
783
946
  if (roundEvents.some((event) => event.type === 'completed' && event.roundId === entry.roundId)) throw new Error('entry.roundId identifies a completed round.');
784
947
  }
785
- const duplicate = duplicateResult(entries, entry);
786
- if (duplicate.duplicate) throw new Error('This application is already recorded.');
948
+ const duplicate = duplicateResult(entries, entry, outcomes);
949
+ 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
950
  if (duplicate.possibleDuplicate && duplicateOverride !== 'NEW REQUISITION CONFIRMED') throw new Error('A possible same-company role duplicate requires NEW REQUISITION CONFIRMED.');
788
- await appendFile(file, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
951
+ if (!duplicate.possibleDuplicate && duplicate.companyReapply.decision === 'cooldown-active' && companyReapplyOverride !== COMPANY_REAPPLY_OVERRIDE) {
952
+ throw new Error(`Company reapplication cooldown is active for ${COMPANY_REAPPLY_COOLDOWN_DAYS} full days; use ${COMPANY_REAPPLY_OVERRIDE} only with explicit candidate approval.`);
953
+ }
954
+ if (!duplicate.possibleDuplicate && duplicate.companyReapply.decision === 'follow-up-present' && companyReapplyOverride !== COMPANY_REAPPLY_OVERRIDE) {
955
+ throw new Error(`Company reapplication blocked because the latest application has a recorded follow-up; use ${COMPANY_REAPPLY_OVERRIDE} only with explicit candidate approval.`);
956
+ }
957
+ const storedEntry = ['cooldown-active', 'follow-up-present'].includes(duplicate.companyReapply.decision)
958
+ ? { ...entry, reapplicationApproval: 'candidate-explicit' }
959
+ : entry;
960
+ await appendFile(file, `${JSON.stringify(storedEntry)}\n`, { mode: 0o600 });
789
961
  await chmod(file, 0o600);
790
962
  if (entry.roundId) {
791
963
  const roundsFile = join(dir, 'rounds.ndjson');
792
964
  await appendFile(roundsFile, `${JSON.stringify({ type: 'submission-confirmed', roundId: entry.roundId, applicationId: entry.id, occurredAt: entry.submittedAt })}\n`, { mode: 0o600 });
793
965
  await chmod(roundsFile, 0o600);
794
966
  }
795
- return { recorded: entry.id, review: buildReview([...entries, entry]) };
967
+ return { recorded: storedEntry.id, review: buildReview([...entries, storedEntry]) };
796
968
  });
797
969
  }
798
970
 
@@ -1070,7 +1242,7 @@ function roundCompletedTelemetry(round) {
1070
1242
  };
1071
1243
  }
1072
1244
 
1073
- async function executeCommand([area, action, value], telemetry, session) {
1245
+ async function executeCommand([area, action, value], telemetry, session, community) {
1074
1246
  const domainEvents = [];
1075
1247
  let result;
1076
1248
  if (area === 'profile' && action === 'set' && value === '--stdin') {
@@ -1095,7 +1267,7 @@ async function executeCommand([area, action, value], telemetry, session) {
1095
1267
  const input = await jsonStdin();
1096
1268
  const telemetryDetails = validateSubmissionTelemetry(input.telemetry);
1097
1269
  const entry = validateLedgerEntry(input);
1098
- result = await ledgerAdd(entry, input.duplicateOverride);
1270
+ result = await ledgerAdd(entry, input.duplicateOverride, input.companyReapplyOverride);
1099
1271
  domainEvents.push(await telemetryApplicationSubmitted(entry, telemetryDetails));
1100
1272
  } else if (area === 'ledger' && action === 'outcome' && value === '--stdin') {
1101
1273
  const outcome = await ledgerOutcome(await jsonStdin());
@@ -1116,12 +1288,24 @@ async function executeCommand([area, action, value], telemetry, session) {
1116
1288
  else if (area === 'round' && action === 'complete' && value === '--stdin') {
1117
1289
  result = await roundComplete(await jsonStdin());
1118
1290
  domainEvents.push(roundCompletedTelemetry(result));
1119
- } else if (area === 'attention' && action === 'add' && value === '--stdin') result = await attentionAdd(await jsonStdin());
1291
+ } else if (area === 'sources' && action === 'list' && value == null) {
1292
+ await sourcesSync(community);
1293
+ result = await sourcesList({}, await community.list());
1294
+ } else if (area === 'sources' && action === 'list' && value === '--stdin') {
1295
+ const filters = await jsonStdin();
1296
+ await sourcesSync(community);
1297
+ result = await sourcesList(filters, await community.list());
1298
+ }
1299
+ else if (area === 'sources' && action === 'suggest' && value === '--stdin') result = await sourcesSuggest(await jsonStdin(), community);
1300
+ else if (area === 'sources' && action === 'pending' && value == null) result = await sourcesPending();
1301
+ else if (area === 'sources' && action === 'sync' && value == null) result = await sourcesSync(community);
1302
+ else if (area === 'sources' && action === 'sharing' && ['status', 'enable', 'disable', 'reset'].includes(value)) result = await community.configure(value);
1303
+ else if (area === 'attention' && action === 'add' && value === '--stdin') result = await attentionAdd(await jsonStdin());
1120
1304
  else if (area === 'attention' && action === 'list' && value == null) result = await attentionList();
1121
1305
  else if (area === 'attention' && action === 'resolve' && value === '--stdin') result = await attentionResolve(await jsonStdin());
1122
1306
  else if (area === 'friction' && action === 'record' && value === '--stdin') result = await frictionRecord(await jsonStdin());
1123
1307
  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');
1308
+ 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]|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
1309
  for (const event of domainEvents) await telemetry.record(event, session);
1126
1310
  return result;
1127
1311
  }
@@ -1143,6 +1327,7 @@ async function recordInstallationStart(telemetry, session) {
1143
1327
  async function main(args) {
1144
1328
  const [area, action, value] = args;
1145
1329
  const telemetry = new TelemetryClient({ stateDir: stateDir() });
1330
+ const community = new SourceCommunityClient({ stateDir: stateDir() });
1146
1331
  if (area === 'telemetry') {
1147
1332
  if (['status', 'enable', 'disable', 'reset'].includes(action) && value == null) return print(await telemetry.configure(action));
1148
1333
  if (action === 'preview' && value === '--stdin') return print(await telemetry.preview(await jsonStdin()));
@@ -1159,7 +1344,7 @@ async function main(args) {
1159
1344
  await recordInstallationStart(telemetry, session);
1160
1345
  const started = Date.now();
1161
1346
  try {
1162
- const result = await executeCommand(args, telemetry, session);
1347
+ const result = await executeCommand(args, telemetry, session, community);
1163
1348
  await telemetry.record({ event: 'command_completed', properties: { command, result: 'success', durationBucket: durationBucket(Date.now() - started) } }, session);
1164
1349
  return print(result);
1165
1350
  } catch (error) {
@@ -1169,7 +1354,7 @@ async function main(args) {
1169
1354
  }
1170
1355
  }
1171
1356
 
1172
- if (import.meta.url === `file://${process.argv[1]}`) {
1357
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
1173
1358
  main(process.argv.slice(2)).catch((error) => {
1174
1359
  process.stderr.write(`Error: ${error.message}\n`);
1175
1360
  process.exitCode = 1;