job-application-agent 3.7.0 → 3.7.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.
- package/job-application-agent/SKILL.md +10 -5
- package/job-application-agent/references/ACCOUNTING.md +1 -1
- package/job-application-agent/references/ANALYTICS.md +1 -1
- package/job-application-agent/references/OUTREACH.md +20 -7
- package/job-application-agent/references/RUNS.md +2 -1
- package/job-application-agent/references/SCHEMAS.md +3 -3
- package/job-application-agent/scripts/cloud-state-client.mjs +9 -2
- package/job-application-agent/scripts/job-application.mjs +110 -18
- package/job-application-agent/scripts/outreach-cli.mjs +17 -0
- package/job-application-agent/scripts/outreach-round.mjs +267 -0
- package/job-application-agent/scripts/version.mjs +1 -1
- package/job-application-agent/tests/cloud-state-client.test.mjs +12 -0
- package/job-application-agent/tests/job-application.test.mjs +159 -1
- package/job-application-agent/tests/outreach-round.test.mjs +489 -0
- package/job-application-agent/tests/skill-contract.test.mjs +19 -0
- package/package.json +2 -2
|
@@ -37,12 +37,16 @@ copyable text to the candidate for manual sending, and record actual observation
|
|
|
37
37
|
Do not automate LinkedIn/X access or messaging. Existing application autonomy
|
|
38
38
|
does not enable this module. Outreach commands bypass analytics and community
|
|
39
39
|
transmissions; do not run updater/telemetry/community commands as part of an
|
|
40
|
-
outreach-only workflow. Keep
|
|
41
|
-
|
|
40
|
+
outreach-only workflow. Keep drafting and sending separate from ATS forms.
|
|
41
|
+
A `sent-verified` outreach counts toward the active application round the same
|
|
42
|
+
way a confirmed apply does, unless that company is already a confirmed apply on
|
|
43
|
+
the same round. Clear does not un-confirm. A later `not-sent` or `failed`
|
|
44
|
+
delivery correction does. Never classify a proposed screen as scheduled. No scheduled
|
|
45
|
+
follow-up is created.
|
|
42
46
|
|
|
43
47
|
## Accounting
|
|
44
48
|
|
|
45
|
-
Read [references/ACCOUNTING.md](references/ACCOUNTING.md) before recording delivery evidence, recovery attempts, or per-lead discovery. For new rounds, record each lead with `round lead --stdin` and derive source totals from those records. Email access is optional: visible browser success counts, verified email sends count with receipt unknown, and matched final delivery failures correct effective totals. Preserve historical events and use explicit corrections for conflicts.
|
|
49
|
+
Read [references/ACCOUNTING.md](references/ACCOUNTING.md) before recording delivery evidence, recovery attempts, or per-lead discovery. For new rounds, record each lead with `round lead --stdin` and derive source totals from those records. Email access is optional: visible browser success counts, verified email sends count with receipt unknown, and matched final delivery failures correct effective totals. A sent-verified outreach counts toward the same round `confirmedCount` unless that company already has a counted apply. Preserve historical events and use explicit corrections for conflicts.
|
|
46
50
|
|
|
47
51
|
## Discover and assess
|
|
48
52
|
|
|
@@ -76,7 +80,7 @@ Check `round status` after the initial discovery pass and before submitting. Pre
|
|
|
76
80
|
|
|
77
81
|
1. When private cloud state is configured, run `cloud status`, acquire the application-run lease with `cloud lease-acquire`, and renew it at least every five minutes. A client without the live lease may research and draft but must not submit.
|
|
78
82
|
2. Recheck employer, title, direct domain, posting status, eligibility, and `autoEligible` immediately before submission.
|
|
79
|
-
3. Run `ledger check --stdin` with
|
|
83
|
+
3. Run `ledger check --stdin` with any one identifier set: job URL, internal application id, employer job id plus company, or company+role. Include more identifiers when known. A company+role match is a possible duplicate and returns the stored URL; never treat it as a hard already-applied. Review both requisition duplicate status and same-company history.
|
|
80
84
|
4. Stop on a hard ledger-ID, canonical-URL, employer-job-ID, or requisition duplicate. Treat a same-company/same-role alias as a possible duplicate. Use `duplicateOverride: "NEW REQUISITION CONFIRMED"` only after verifying it is a distinct requisition.
|
|
81
85
|
5. For a genuinely different role at a previously applied company, follow `companyReapply`: proceed automatically only when it returns `eligible-after-cooldown` (15 full days since the latest company application and no recorded outcome). `cooldown-active` and `follow-up-present` require the candidate's explicit approval and `companyReapplyOverride: "CANDIDATE APPROVED EARLY REAPPLICATION"`.
|
|
82
86
|
6. Keep authentication in the existing browser session. Never inspect cookies, local storage, passwords, or session files.
|
|
@@ -95,6 +99,7 @@ Check `round status` after the initial discovery pass and before submitting. Pre
|
|
|
95
99
|
|
|
96
100
|
- Keep `applications.ndjson` and `outcomes.ndjson` append-only. Never delete or rewrite historical rows.
|
|
97
101
|
- Record outcomes with `ledger outcome --stdin`. Use structured rejection reasons and mark each as `explicit` or `inferred`. Do not treat an inference as a candidate fact.
|
|
102
|
+
- Run `ledger check` and `ledger outcome` as two separate CLI processes. Do not combine them in one invocation. When mail has company and role but no URL or id, look up the row with `ledger check` first; that hit is only a possible duplicate and includes the stored URL and id. Then pass the returned `match.id` to `ledger outcome`. If `match.id` is absent, stop and ask; do not guess among fuzzy company+role hits.
|
|
98
103
|
- After an interview, optionally record `interviewQuality` (`promising`, `viable`, `weak`, or `dead`) and a bounded `failurePoint`. Keep free-form interview notes private.
|
|
99
104
|
- Rely on idempotent outcome recording; identical events do not append rows or emit duplicate telemetry.
|
|
100
105
|
- Audit matched delivery failures with authorized email tools when available; otherwise report delivery not audited and continue. Keep delivery failures separate from hiring rejections.
|
|
@@ -131,7 +136,7 @@ node scripts/job-application.mjs ledger review
|
|
|
131
136
|
node scripts/job-application.mjs ledger review-ack --stdin
|
|
132
137
|
node scripts/job-application.mjs autonomy grant --stdin
|
|
133
138
|
node scripts/job-application.mjs autonomy status|preview|revoke
|
|
134
|
-
node scripts/job-application.mjs round start|source|complete --stdin
|
|
139
|
+
node scripts/job-application.mjs round start|source|confirm|complete --stdin
|
|
135
140
|
node scripts/job-application.mjs round status [round-id]
|
|
136
141
|
node scripts/job-application.mjs sources list [--stdin]
|
|
137
142
|
node scripts/job-application.mjs sources jobs [--stdin]
|
|
@@ -6,7 +6,7 @@ Browser applications count after visible ATS success. They do not require email
|
|
|
6
6
|
|
|
7
7
|
Match a final delivery failure to the actual application attempt using its sent message, recipient, timing and returned failure evidence. Temporary delays, unrelated bounces, and hiring rejections are not delivery failures. A failed notification cannot overturn independent browser confirmation. Never guess an alternate address or automatically resend an uncertain transmission.
|
|
8
8
|
|
|
9
|
-
`ledger review`, `round status`, and `round complete` share one effective-count projection. They expose `recordedSubmissionCount`, `effectiveSubmissionCount`, `failedDeliveryCount`, and `receiptUnknownEmailCount`. Existing `submittedTotal`/`confirmedCount` fields now reflect effective submissions, not proven email receipt. Conversion denominators exclude failed applications while historical outcomes remain visible. Review cadence and acknowledgement checkpoints use recorded canonical submissions and recorded mature applications, so a late failure does not postpone the next review. Public usage events remain historical activity metrics.
|
|
9
|
+
`ledger review`, `round status`, and `round complete` share one effective-count projection for ledger applications. They expose `recordedSubmissionCount`, `effectiveSubmissionCount`, `failedDeliveryCount`, and `receiptUnknownEmailCount`. Existing `submittedTotal`/`confirmedCount` fields now reflect effective submissions, not proven email receipt. `round status` also adds sent-verified outreach that is attached to the round and not already covered by a counted apply at the same company, exposed as `applyConfirmationCount` plus `outreachConfirmationCount`. Clearing outreach content never drops that attached count. An explicit delivery correction to `not-sent` or `failed` does: the durable confirmation stays on disk, but `confirmedCount` stops counting that send. Conversion denominators exclude failed applications while historical outcomes remain visible. Review cadence and acknowledgement checkpoints use recorded canonical submissions and recorded mature applications, so a late failure does not postpone the next review. Public usage events remain historical activity metrics.
|
|
10
10
|
|
|
11
11
|
A late failure preserves the original completion event. `shortfallCount` and `needsRecovery` flag the deficit; no run starts or round reopens automatically. Review the derived attention queue before a later authorized recovery.
|
|
12
12
|
|
|
@@ -65,7 +65,7 @@ Company and title values are bounded and rejected when they resemble an email, p
|
|
|
65
65
|
| `application_paused` | Job hash, ATS, stage, bounded reason |
|
|
66
66
|
| `application_skipped` | Job hash, bounded reason, fit score, eligibility |
|
|
67
67
|
| `application_submitted` | Company, title, job hash/domain, ATS, duration, fields filled, short-answer count, resume-upload Boolean, approval mode |
|
|
68
|
-
| `round_completed` | Requested/submitted/assessed/skipped/paused/error counts, duration bucket; optional attempted/searched/blocked source counts, maximum source share percentage, bounded concentration reason |
|
|
68
|
+
| `round_completed` | Requested/submitted/assessed/skipped/paused/error counts, duration bucket; optional attempted/searched/blocked source counts, maximum source share percentage, bounded concentration reason. `submittedCount` and `assessedCount` are ledger application totals only and never include attached outreach. |
|
|
69
69
|
| `outcome_recorded` | Company, title, job hash/domain, ATS, outcome, days since submission, optional bounded interview quality/failure point |
|
|
70
70
|
| `review_generated` | Canonical unique-submission and outcome counts, review-due Boolean |
|
|
71
71
|
| `skill_error` | Stable error code, workflow stage, ATS/job hash when available, recoverable Boolean |
|
|
@@ -173,8 +173,15 @@ are explicitly stale. On reconnect, the cache is replaced by authoritative cloud
|
|
|
173
173
|
data, including clear tombstones. If a lower server revision suggests a restored
|
|
174
174
|
backend or delayed response, the cache is discarded; another online read is
|
|
175
175
|
required before offline inspection is available. Handoffs use atomic database reservations,
|
|
176
|
-
not application submission leases.
|
|
177
|
-
round
|
|
176
|
+
not application submission leases. Recording `sent-verified` increments the
|
|
177
|
+
active application round (or the round already attached to that opportunity)
|
|
178
|
+
when that company does not already have a counted apply on the same round.
|
|
179
|
+
Auto-attach first does a targeted authenticated reconcile of cloud `rounds`
|
|
180
|
+
events so a second or stale host can see the open round. Apply plus outreach
|
|
181
|
+
at the same company is one confirmation, not two. The existing
|
|
182
|
+
application-autonomy grant is unchanged. Use `round confirm --stdin`
|
|
183
|
+
with `{ "roundId":"round-...","outreachId":"opportunity-..." }` to attach an
|
|
184
|
+
already sent-verified opportunity to an open round.
|
|
178
185
|
|
|
179
186
|
Outreach commands bypass telemetry, identity transmission, community sharing,
|
|
180
187
|
and their automatic retry paths—including command errors. Only authenticated
|
|
@@ -183,11 +190,17 @@ evidence stay in sensitive content storage. Audit rows retain opaque IDs,
|
|
|
183
190
|
timestamps, coarse transitions, and private keyed fingerprints.
|
|
184
191
|
|
|
185
192
|
Clear input: `{"operationId":"clear-1","ids":["opportunity-1"]}`.
|
|
186
|
-
Clearing removes sensitive content and blocks resurrection.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
193
|
+
Clearing removes sensitive content and blocks resurrection. It never
|
|
194
|
+
un-confirms a counted round attachment: `confirmedCount` stays on the durable
|
|
195
|
+
`submission-confirmed` event and uses the company already stored there.
|
|
196
|
+
A later `not-sent` or `failed` correction of that send is different: the
|
|
197
|
+
attachment event remains, but the projection drops it from `confirmedCount`.
|
|
198
|
+
New attachments require a live outreach snapshot when cloud state is
|
|
199
|
+
configured; cached reads may still power `round status`. Minimal
|
|
200
|
+
outcomes, audit history, company/contact fingerprints and suppression remain.
|
|
201
|
+
Do not describe this as deleting every personal-data trace. Disconnected host
|
|
202
|
+
caches and user-managed exports cannot be remotely erased; backups retain old
|
|
203
|
+
content until their existing 30-day expiry.
|
|
191
204
|
|
|
192
205
|
Private backups include outreach tables. `cloud export` also includes the
|
|
193
206
|
outreach snapshot and deletion manifest. A manifest must be obtained from the
|
|
@@ -14,6 +14,7 @@ Start each batch with an explicit ID:
|
|
|
14
14
|
node scripts/job-application.mjs round start --stdin
|
|
15
15
|
node scripts/job-application.mjs round source --stdin
|
|
16
16
|
node scripts/job-application.mjs round status [round-id]
|
|
17
|
+
node scripts/job-application.mjs round confirm --stdin
|
|
17
18
|
node scripts/job-application.mjs round complete --stdin
|
|
18
19
|
```
|
|
19
20
|
|
|
@@ -62,7 +63,7 @@ Coverage reports emit bounded `source_checked` analytics automatically. Only all
|
|
|
62
63
|
Before completion, audit final email delivery failures through authorized email tools when available; otherwise report delivery not audited and continue. Verified email sends count with receipt unknown. Use `ledger delivery` to record matched failures, never `ledger outcome rejected`. Effective totals exclude failed attempts; completed rounds retain their completion record and expose a recovery shortfall. See [ACCOUNTING.md](ACCOUNTING.md) for safe linked replacement attempts.
|
|
63
64
|
|
|
64
65
|
|
|
65
|
-
Count
|
|
66
|
+
Count unique applications with a visible employer/ATS confirmation or a verified sent recruiting email that were also added to the ledger with the same `roundId`, plus sent-verified outreach attached to that round when the company is not already a counted apply. Filled forms, blockers, drafts, unsent email, user-reported-only outreach, and ambiguous confirmations never count. Apply plus outreach at the same company is one confirmation. `round complete` rejects an under-target round.
|
|
66
67
|
|
|
67
68
|
Run both company-level and requisition-level duplicate checks before filling and again immediately before transmission. Hard ledger-ID, canonical-URL, employer-job-ID, and requisition duplicates always stop. Same-role aliases require a verified distinct requisition and `NEW REQUISITION CONFIRMED`. A genuinely different role at the same company may proceed automatically only when `companyReapply.decision` is `eligible-after-cooldown`: 15 full days have passed since the latest company application and no outcome has been recorded. `cooldown-active` and `follow-up-present` require explicit candidate approval.
|
|
68
69
|
|
|
@@ -81,7 +81,7 @@ Assessment output retains `review`, `ask`, `skip`, and `exclude`, and adds `auto
|
|
|
81
81
|
|
|
82
82
|
## Duplicate check input
|
|
83
83
|
|
|
84
|
-
Include
|
|
84
|
+
`ledger check` accepts any one identifier set: job URL, internal application id, employer job id plus company, or company+role. Include more identifiers when known. Company-only, role-only, and empty objects are rejected.
|
|
85
85
|
|
|
86
86
|
```json
|
|
87
87
|
{
|
|
@@ -93,7 +93,7 @@ Include as many identifiers as are known.
|
|
|
93
93
|
}
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
The check removes fragments and non-job query parameters while retaining recognized job or requisition identifiers. Matching ledger ID, canonical URL, or same-company employer job ID is a hard duplicate. Same company and role without a shared job ID is a possible duplicate.
|
|
96
|
+
The check removes fragments and non-job query parameters while retaining recognized job or requisition identifiers. Matching ledger ID, canonical URL, or same-company employer job ID is a hard duplicate. Same company and role without a shared job ID is a possible duplicate and includes the stored URL. Never promote a company+role match to a hard already-applied. `ledger add` still requires a real job URL.
|
|
97
97
|
|
|
98
98
|
`ledger check` also returns `companyReapply`. A genuinely different role is `eligible-after-cooldown` only when at least 15 full days have passed since the latest application to that company and no outcome has been recorded for that application. Hard duplicates are never eligible. Same-role matches, `cooldown-active`, and `follow-up-present` remain blocked at `ledger add` unless their exact documented override is present.
|
|
99
99
|
|
|
@@ -155,7 +155,7 @@ Use `round source --stdin` for per-source search/blocker reports and optional at
|
|
|
155
155
|
{ "requestedCount": 30 }
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
-
`round start --stdin` appends a `started` event to owner-only `rounds.ndjson` and returns a generated `roundId`. Add that ID to every confirmed ledger entry. `round complete --stdin` accepts `{ "roundId": "round-..." }`, plus concentration reason and evidence when required, and appends a completion event only after the target count, source coverage, attribution, and concentration requirements are satisfied.
|
|
158
|
+
`round start --stdin` appends a `started` event to owner-only `rounds.ndjson` and returns a generated `roundId`. Add that ID to every confirmed ledger entry. A `sent-verified` outreach also counts toward `confirmedCount` for the active or attached open round when that company is not already a counted apply on the same round. Once attached, that confirmation stays after `outreach clear`; clear wipes message text and PII only. A later delivery correction to `not-sent` or `failed` stops counting that send. `round confirm` and auto-attach require an authoritative outreach snapshot; they do not mutate from a stale offline cache. `round confirm --stdin` accepts `{ "roundId": "round-...", "outreachId": "opportunity-..." }` (or `outreachIds`) to attach already sent-verified outreach without creating a second ledger application. `round complete --stdin` accepts `{ "roundId": "round-..." }`, plus concentration reason and evidence when required, and appends a completion event only after the target count, source coverage, attribution, and concentration requirements are satisfied. Discovery attribution and lead linkage still apply only to ledger applications.
|
|
159
159
|
|
|
160
160
|
## Attention input
|
|
161
161
|
|
|
@@ -338,10 +338,17 @@ export class CloudStateClient {
|
|
|
338
338
|
return (await this.request(`/v2/intents/${encodeURIComponent(intentId)}/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ application, leaseId, idempotencyKey }) })).json();
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
-
async reconcile({ dryRun = true, provenance = 'local-reconcile' } = {}) {
|
|
341
|
+
async reconcile({ dryRun = true, provenance = 'local-reconcile', streams = null } = {}) {
|
|
342
342
|
await this.requireAccounting();
|
|
343
343
|
const report = { dryRun, streams: {}, imported: 0, downloaded: 0 };
|
|
344
|
-
|
|
344
|
+
const selected = streams == null
|
|
345
|
+
? Object.entries(CLOUD_STREAM_FILES)
|
|
346
|
+
: streams.map((stream) => {
|
|
347
|
+
const filename = CLOUD_STREAM_FILES[stream];
|
|
348
|
+
if (!filename) throw new Error(`Unknown cloud stream: ${stream}.`);
|
|
349
|
+
return [stream, filename];
|
|
350
|
+
});
|
|
351
|
+
for (const [stream, filename] of selected) {
|
|
345
352
|
if (!dryRun) await this.flushAccountingWrites(stream);
|
|
346
353
|
const normalize = value => stream === 'delivery' ? validateDelivery(value) : stream === 'discovery' && value.version === 1 && value.type === 'lead-reviewed' ? validateLead(value) : value;
|
|
347
354
|
let local = (await readNdjson(join(this.stateDir, filename))).map(normalize);
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
extractNarrativeQuestionsFromText,
|
|
27
27
|
normalizeAttentionQuestions,
|
|
28
28
|
} from './attention-questions.mjs';
|
|
29
|
+
import { confirmOutreachTowardRound, loadOutreachRoundView, loadSentVerifiedOutreach, outreachConfirmationEvents, projectOutreachRoundCounts, resolveOutreachRoundId } from './outreach-round.mjs';
|
|
29
30
|
|
|
30
31
|
const SOURCES = new Set(['linkedin', 'greenhouse', 'lever', 'ashby', 'workable', 'comeet', 'workday', 'rippling', 'smartrecruiters', 'google-form', 'company', 'email', 'other']);
|
|
31
32
|
const DISCOVERY_SOURCES = new Set(['direct-company', 'linkedin', 'x', 'yc', 'hacker-news', 'job-board', 'email', 'user-supplied', 'web-search', 'other']);
|
|
@@ -208,6 +209,11 @@ function string(value, label, max = 5000) {
|
|
|
208
209
|
return value.trim();
|
|
209
210
|
}
|
|
210
211
|
|
|
212
|
+
function optionalPresentString(candidate, key, max) {
|
|
213
|
+
if (!Object.hasOwn(candidate, key)) return '';
|
|
214
|
+
return string(candidate[key], `candidate.${key}`, max);
|
|
215
|
+
}
|
|
216
|
+
|
|
211
217
|
function stringArray(value, label, required = false) {
|
|
212
218
|
if (!Array.isArray(value) || (required && value.length === 0)) throw new Error(`${label} must be ${required ? 'a non-empty' : 'an'} array of strings.`);
|
|
213
219
|
return value.map((item, index) => string(item, `${label}[${index}]`, 300));
|
|
@@ -481,6 +487,26 @@ export function validateLedgerEntry(input) {
|
|
|
481
487
|
return normalized;
|
|
482
488
|
}
|
|
483
489
|
|
|
490
|
+
export function validateLedgerCheckCandidate(input) {
|
|
491
|
+
const candidate = object(input, 'candidate');
|
|
492
|
+
const id = optionalPresentString(candidate, 'id', 180);
|
|
493
|
+
const url = optionalPresentString(candidate, 'url', 2048);
|
|
494
|
+
const employerJobId = optionalPresentString(candidate, 'employerJobId', 300);
|
|
495
|
+
const company = optionalPresentString(candidate, 'company', 300);
|
|
496
|
+
const role = optionalPresentString(candidate, 'role', 300);
|
|
497
|
+
if (!url && !id && !(employerJobId && company) && !(company && role)) {
|
|
498
|
+
throw new Error('ledger check requires at least one identifier set: url, id, employerJobId+company, or company+role.');
|
|
499
|
+
}
|
|
500
|
+
if (url) normalizeUrl(url);
|
|
501
|
+
return {
|
|
502
|
+
...(id ? { id } : {}),
|
|
503
|
+
...(url ? { url } : {}),
|
|
504
|
+
...(employerJobId ? { employerJobId } : {}),
|
|
505
|
+
...(company ? { company } : {}),
|
|
506
|
+
...(role ? { role } : {}),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
484
510
|
export function validateSubmissionTelemetry(input) {
|
|
485
511
|
if (input == null) return {};
|
|
486
512
|
const value = object(input, 'entry.telemetry');
|
|
@@ -519,6 +545,15 @@ function rolesLikelySame(left, right) {
|
|
|
519
545
|
return shared / Math.min(leftTokens.size, rightTokens.size) >= 0.75;
|
|
520
546
|
}
|
|
521
547
|
|
|
548
|
+
function latestMatchingEntry(entries, predicate) {
|
|
549
|
+
const matches = entries.filter(predicate);
|
|
550
|
+
if (matches.length === 0) return null;
|
|
551
|
+
return [...matches]
|
|
552
|
+
.filter((entry) => !Number.isNaN(Date.parse(entry.submittedAt)))
|
|
553
|
+
.sort((left, right) => Date.parse(right.submittedAt) - Date.parse(left.submittedAt))[0]
|
|
554
|
+
?? matches.at(-1);
|
|
555
|
+
}
|
|
556
|
+
|
|
522
557
|
const canonicalApplicationKey = accountingApplicationKey;
|
|
523
558
|
|
|
524
559
|
function businessDaysBetween(startValue, endValue) {
|
|
@@ -995,21 +1030,28 @@ async function canonicalResumePath() {
|
|
|
995
1030
|
function duplicateResult(entries, candidate, outcomes = [], now = new Date()) {
|
|
996
1031
|
const candidateCompany = normalizedText(candidate.company);
|
|
997
1032
|
const candidateRole = normalizedText(candidate.role);
|
|
998
|
-
const candidateUrl = normalizeUrl(candidate.url);
|
|
1033
|
+
const candidateUrl = candidate.url ? normalizeUrl(candidate.url) : null;
|
|
999
1034
|
const sameCompanyRole = (entry) => candidateCompany && candidateRole
|
|
1000
1035
|
&& normalizedText(entry.company) === candidateCompany
|
|
1001
1036
|
&& rolesLikelySame(entry.role, candidate.role);
|
|
1002
|
-
const
|
|
1037
|
+
const exactCompanyRole = (entry) => candidateCompany && candidateRole
|
|
1038
|
+
&& normalizedText(entry.company) === candidateCompany
|
|
1039
|
+
&& normalizedText(entry.role) === candidateRole;
|
|
1040
|
+
const hardId = candidate.id ? entries.find((entry) => entry.id === candidate.id) : null;
|
|
1003
1041
|
const hardEmployerJobId = entries.find((entry) => candidate.employerJobId && entry.employerJobId
|
|
1004
1042
|
&& normalizedText(entry.company) === candidateCompany
|
|
1005
1043
|
&& entry.employerJobId.toLowerCase() === String(candidate.employerJobId).toLowerCase());
|
|
1006
|
-
const hardUrl = entries.find((entry) => normalizeUrl(entry.url) === candidateUrl);
|
|
1044
|
+
const hardUrl = candidateUrl ? entries.find((entry) => normalizeUrl(entry.url) === candidateUrl) : null;
|
|
1007
1045
|
const hard = hardId ?? hardEmployerJobId ?? hardUrl;
|
|
1008
1046
|
const hardReason = hardId ? 'id' : hardEmployerJobId ? 'employer-job-id' : hardUrl ? 'url' : null;
|
|
1009
|
-
const
|
|
1047
|
+
const exact = hard ? null : latestMatchingEntry(entries, exactCompanyRole);
|
|
1048
|
+
const fuzzyMatches = hard || exact ? [] : entries.filter(sameCompanyRole);
|
|
1049
|
+
const ambiguousCompanyRole = fuzzyMatches.length > 1;
|
|
1050
|
+
const possible = hard ? null : exact ?? (fuzzyMatches.length === 1 ? fuzzyMatches[0] : null);
|
|
1010
1051
|
const match = hard ?? possible;
|
|
1011
|
-
const
|
|
1012
|
-
|
|
1052
|
+
const historyCompany = candidateCompany || normalizedText(hard?.company);
|
|
1053
|
+
const sameCompanyEntries = historyCompany
|
|
1054
|
+
? entries.filter((entry) => normalizedText(entry.company) === historyCompany)
|
|
1013
1055
|
: [];
|
|
1014
1056
|
const companyApplications = sameCompanyEntries.slice(-20).map((entry) => ({
|
|
1015
1057
|
id: entry.id,
|
|
@@ -1029,15 +1071,15 @@ function duplicateResult(entries, candidate, outcomes = [], now = new Date()) {
|
|
|
1029
1071
|
: false;
|
|
1030
1072
|
let companyReapplyDecision = 'fresh-company';
|
|
1031
1073
|
if (hard) companyReapplyDecision = 'hard-duplicate';
|
|
1032
|
-
else if (possible) companyReapplyDecision = 'same-role-review';
|
|
1074
|
+
else if (possible || ambiguousCompanyRole) companyReapplyDecision = 'same-role-review';
|
|
1033
1075
|
else if (latestCompanyApplication && hasFollowUp) companyReapplyDecision = 'follow-up-present';
|
|
1034
1076
|
else if (latestCompanyApplication && daysSinceLatest < COMPANY_REAPPLY_COOLDOWN_DAYS) companyReapplyDecision = 'cooldown-active';
|
|
1035
1077
|
else if (latestCompanyApplication) companyReapplyDecision = 'eligible-after-cooldown';
|
|
1036
1078
|
return {
|
|
1037
1079
|
duplicate: Boolean(hard),
|
|
1038
|
-
possibleDuplicate: Boolean(possible),
|
|
1039
|
-
reason: hardReason ?? (possible ? 'company-role' : null),
|
|
1040
|
-
match: match ? { id: match.id, company: match.company, role: match.role, submittedAt: match.submittedAt } : null,
|
|
1080
|
+
possibleDuplicate: Boolean(possible) || ambiguousCompanyRole,
|
|
1081
|
+
reason: hardReason ?? (possible || ambiguousCompanyRole ? 'company-role' : null),
|
|
1082
|
+
match: match ? { id: match.id, company: match.company, role: match.role, submittedAt: match.submittedAt, url: match.url } : null,
|
|
1041
1083
|
sameCompany: companyApplications.length > 0,
|
|
1042
1084
|
companyApplications,
|
|
1043
1085
|
companyReapply: {
|
|
@@ -1052,12 +1094,11 @@ function duplicateResult(entries, candidate, outcomes = [], now = new Date()) {
|
|
|
1052
1094
|
}
|
|
1053
1095
|
|
|
1054
1096
|
async function ledgerCheck(candidate) {
|
|
1055
|
-
|
|
1097
|
+
const normalized = validateLedgerCheckCandidate(candidate);
|
|
1056
1098
|
const dir = await ensureStateDir();
|
|
1057
1099
|
const entries = await jsonLines(join(dir, 'applications.ndjson'));
|
|
1058
1100
|
const outcomes = await jsonLines(join(dir, 'outcomes.ndjson'));
|
|
1059
|
-
|
|
1060
|
-
return duplicateResult(entries, candidate, outcomes);
|
|
1101
|
+
return duplicateResult(entries, normalized, outcomes);
|
|
1061
1102
|
}
|
|
1062
1103
|
|
|
1063
1104
|
async function ledgerAdd(entryInput, duplicateOverride, companyReapplyOverride, cloudIntent = null) {
|
|
@@ -1461,7 +1502,16 @@ async function roundStatus(roundId = null) {
|
|
|
1461
1502
|
const delivery = deliveryProjection(matching, await jsonLines(join(dir, 'delivery.ndjson')));
|
|
1462
1503
|
const effectiveKeys = new Set(matching.filter((entry,i) => delivery.applications[i].counted).map(canonicalApplicationKey));
|
|
1463
1504
|
const effectiveApplications = applications.filter(entry => effectiveKeys.has(canonicalApplicationKey(entry)));
|
|
1464
|
-
const
|
|
1505
|
+
const outreachView = await loadOutreachRoundView(dir, cloudState);
|
|
1506
|
+
const outreachCounts = projectOutreachRoundCounts({
|
|
1507
|
+
applications: matching,
|
|
1508
|
+
delivery,
|
|
1509
|
+
roundEvents: events,
|
|
1510
|
+
sentVerified: outreachView.sentVerified,
|
|
1511
|
+
invalidatedIds: outreachView.invalidatedIds,
|
|
1512
|
+
roundId: id,
|
|
1513
|
+
});
|
|
1514
|
+
const confirmedCount = outreachCounts.confirmedCount;
|
|
1465
1515
|
const audit = started.discoveryPolicyVersion === 2 ? discoveryProjection(await jsonLines(join(dir, 'discovery.ndjson')), { roundId: id }) : null;
|
|
1466
1516
|
const attention = await attentionList(id);
|
|
1467
1517
|
const completion = events.find((event) => event.type === 'completed' && event.roundId === id);
|
|
@@ -1474,7 +1524,9 @@ async function roundStatus(roundId = null) {
|
|
|
1474
1524
|
completed: Boolean(completion),
|
|
1475
1525
|
discoveryPolicyVersion: started.discoveryPolicyVersion ?? 1,
|
|
1476
1526
|
recordedSubmissionCount: delivery.recordedSubmissionCount,
|
|
1477
|
-
|
|
1527
|
+
applyConfirmationCount: outreachCounts.applyConfirmationCount,
|
|
1528
|
+
outreachConfirmationCount: outreachCounts.outreachConfirmationCount,
|
|
1529
|
+
effectiveSubmissionCount: delivery.effectiveSubmissionCount,
|
|
1478
1530
|
failedDeliveryCount: delivery.failedDeliveryCount,
|
|
1479
1531
|
receiptUnknownEmailCount: delivery.receiptUnknownEmailCount,
|
|
1480
1532
|
shortfallCount: Math.max(0, started.requestedCount - confirmedCount),
|
|
@@ -1514,6 +1566,44 @@ async function roundComplete(input) {
|
|
|
1514
1566
|
return result;
|
|
1515
1567
|
}
|
|
1516
1568
|
|
|
1569
|
+
async function roundConfirm(input) {
|
|
1570
|
+
const value = object(input, 'round confirmation');
|
|
1571
|
+
const allowed = new Set(['roundId', 'outreachId', 'outreachIds']);
|
|
1572
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown round confirmation property: ${key}.`);
|
|
1573
|
+
const roundId = string(value.roundId, 'round.roundId', 180);
|
|
1574
|
+
const ids = [];
|
|
1575
|
+
if (value.outreachId != null) ids.push(string(value.outreachId, 'round.outreachId', 100));
|
|
1576
|
+
if (value.outreachIds != null) {
|
|
1577
|
+
if (!Array.isArray(value.outreachIds) || value.outreachIds.length === 0 || value.outreachIds.length > 50) throw new Error('round.outreachIds must be a bounded array of outreach IDs.');
|
|
1578
|
+
for (const [index, id] of value.outreachIds.entries()) ids.push(string(id, `round.outreachIds[${index}]`, 100));
|
|
1579
|
+
}
|
|
1580
|
+
if (!ids.length) throw new Error('round confirm requires outreachId or outreachIds.');
|
|
1581
|
+
const dir = await ensureStateDir();
|
|
1582
|
+
const uniqueIds = [...new Set(ids)];
|
|
1583
|
+
const roundEvents = await jsonLines(join(dir, 'rounds.ndjson'));
|
|
1584
|
+
const sentVerified = new Set((await loadSentVerifiedOutreach(dir, cloudState, { allowCache: false })).map((item) => item.id));
|
|
1585
|
+
for (const outreachId of uniqueIds) {
|
|
1586
|
+
const targetRoundId = resolveOutreachRoundId(roundEvents, { outreachId, roundId });
|
|
1587
|
+
if (!targetRoundId) throw new Error('Application round was not found.');
|
|
1588
|
+
const alreadyAttached = outreachConfirmationEvents(roundEvents, targetRoundId).some((event) => event.outreachId === outreachId);
|
|
1589
|
+
if (!alreadyAttached && !sentVerified.has(outreachId)) throw new Error(`Outreach ${outreachId} is not sent-verified.`);
|
|
1590
|
+
}
|
|
1591
|
+
const confirmations = [];
|
|
1592
|
+
for (const outreachId of uniqueIds) {
|
|
1593
|
+
const result = await confirmOutreachTowardRound({
|
|
1594
|
+
directory: dir,
|
|
1595
|
+
outreachId,
|
|
1596
|
+
roundId,
|
|
1597
|
+
cloudClient: cloudState,
|
|
1598
|
+
requireRoundId: true,
|
|
1599
|
+
});
|
|
1600
|
+
if (result.reason === 'round-not-found') throw new Error('Application round was not found.');
|
|
1601
|
+
if (result.reason === 'not-sent-verified') throw new Error(`Outreach ${outreachId} is not sent-verified.`);
|
|
1602
|
+
confirmations.push(result);
|
|
1603
|
+
}
|
|
1604
|
+
return { ...await roundStatus(roundId), confirmations };
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1517
1607
|
async function frictionRecord(input) {
|
|
1518
1608
|
const value = object(input, 'friction event');
|
|
1519
1609
|
const allowed = new Set(['stage', 'ats', 'errorCode', 'reproducible', 'general', 'observedAt']);
|
|
@@ -1711,12 +1801,13 @@ function reviewTelemetry(review) {
|
|
|
1711
1801
|
}
|
|
1712
1802
|
|
|
1713
1803
|
function roundCompletedTelemetry(round) {
|
|
1804
|
+
const applicationCount = round.applyConfirmationCount ?? round.effectiveSubmissionCount ?? 0;
|
|
1714
1805
|
return {
|
|
1715
1806
|
event: 'round_completed',
|
|
1716
1807
|
properties: {
|
|
1717
1808
|
requestedCount: round.requestedCount,
|
|
1718
|
-
submittedCount:
|
|
1719
|
-
assessedCount:
|
|
1809
|
+
submittedCount: applicationCount,
|
|
1810
|
+
assessedCount: applicationCount,
|
|
1720
1811
|
skippedCount: 0,
|
|
1721
1812
|
pausedCount: round.blockedCount,
|
|
1722
1813
|
errorCount: 0,
|
|
@@ -1788,6 +1879,7 @@ async function executeCommand([area, action, value], telemetry, session, communi
|
|
|
1788
1879
|
} });
|
|
1789
1880
|
}
|
|
1790
1881
|
else if (area === 'round' && action === 'status') result = await roundStatus(value ?? null);
|
|
1882
|
+
else if (area === 'round' && action === 'confirm' && value === '--stdin') result = await roundConfirm(await jsonStdin());
|
|
1791
1883
|
else if (area === 'round' && action === 'complete' && value === '--stdin') {
|
|
1792
1884
|
result = await roundComplete(await jsonStdin());
|
|
1793
1885
|
if (result.completionRecorded) domainEvents.push(roundCompletedTelemetry(result));
|
|
@@ -1813,7 +1905,7 @@ async function executeCommand([area, action, value], telemetry, session, communi
|
|
|
1813
1905
|
else if (area === 'attention' && action === 'resolve' && value === '--stdin') result = await attentionResolve(await jsonStdin());
|
|
1814
1906
|
else if (area === 'friction' && action === 'record' && value === '--stdin') result = await frictionRecord(await jsonStdin());
|
|
1815
1907
|
else if (area === 'friction' && action === 'list' && value == null) result = await frictionList();
|
|
1816
|
-
else throw new Error('Usage: cloud status|configure --stdin|reconcile [--dry-run]|export [path]|lease-acquire|lease-renew|lease-release|intent-prepare --stdin|intent-sent --stdin|intent-confirm --stdin; 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|source|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');
|
|
1908
|
+
else throw new Error('Usage: cloud status|configure --stdin|reconcile [--dry-run]|export [path]|lease-acquire|lease-renew|lease-release|intent-prepare --stdin|intent-sent --stdin|intent-confirm --stdin; 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|source|confirm|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');
|
|
1817
1909
|
for (const event of domainEvents) await telemetry.record(event, session);
|
|
1818
1910
|
return result;
|
|
1819
1911
|
}
|
|
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { CloudStateClient, defaultCloudConfigPath } from './cloud-state-client.mjs';
|
|
6
6
|
import { migrateLegacyStateDir, resolveStateDir } from './secret-store.mjs';
|
|
7
7
|
import { mutateOutreach, readOutreach, OUTREACH_CAPABILITY } from './outreach-domain.mjs';
|
|
8
|
+
import { confirmOutreachTowardRound } from './outreach-round.mjs';
|
|
8
9
|
import { privateOutreachWrite, withLocalOutreach, withOutreachLock } from './outreach-store.mjs';
|
|
9
10
|
|
|
10
11
|
const READS = new Set(['policy-status', 'list', 'show', 'review']);
|
|
@@ -91,5 +92,21 @@ export async function runOutreach(args, { input: suppliedInput, stateDirectory =
|
|
|
91
92
|
result = await withLocalOutreach(stateDirectory, state => read ? { result: readOutreach(state, action, input) } : mutateOutreach(state, action, input, { ...context, actor: 'local' }));
|
|
92
93
|
}
|
|
93
94
|
if (action === 'policy-enable') await guard();
|
|
95
|
+
if (action === 'record' && input?.type === 'sent-verified' && result?.delivery === 'sent-verified' && input?.id) {
|
|
96
|
+
try {
|
|
97
|
+
result = {
|
|
98
|
+
...result,
|
|
99
|
+
roundConfirmation: await confirmOutreachTowardRound({
|
|
100
|
+
directory: stateDirectory,
|
|
101
|
+
outreachId: input.id,
|
|
102
|
+
cloudClient: cloud,
|
|
103
|
+
occurredAt: input.occurredAt,
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
} catch {
|
|
107
|
+
// The outreach observation already committed; attachment is a secondary result.
|
|
108
|
+
result = { ...result, roundConfirmation: { counted: false, reason: 'attachment-failed', outreachId: input.id } };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
94
111
|
return result;
|
|
95
112
|
}
|