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.
@@ -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 outreach separate from applications and never
41
- classify a proposed screen as scheduled. No scheduled follow-up is created.
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 the internal ledger ID, canonical URL, employer job ID, company, and role when available. Review both requisition duplicate status and same-company history.
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. No operation changes application counts,
177
- round progress, or the existing application-autonomy grant.
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. Minimal outcomes,
187
- audit history, company/contact fingerprints and suppression remain. Do not
188
- describe this as deleting every personal-data trace. Disconnected host caches
189
- and user-managed exports cannot be remotely erased; backups retain old content
190
- until their existing 30-day expiry.
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 only 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`. Filled forms, blockers, drafts, unsent email, and ambiguous confirmations never count. `round complete` rejects an under-target round.
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 as many identifiers as are known.
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
- for (const [stream, filename] of Object.entries(CLOUD_STREAM_FILES)) {
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 hardId = entries.find((entry) => entry.id === candidate.id);
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 possible = hard ? null : entries.find(sameCompanyRole);
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 sameCompanyEntries = candidateCompany
1012
- ? entries.filter((entry) => normalizedText(entry.company) === candidateCompany)
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
- object(candidate, 'candidate');
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
- string(candidate.url, 'candidate.url', 2048);
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 confirmedCount = delivery.effectiveSubmissionCount;
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
- effectiveSubmissionCount: confirmedCount,
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: round.confirmedCount,
1719
- assessedCount: round.confirmedCount,
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
  }