job-application-agent 3.7.1 → 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
 
@@ -132,7 +136,7 @@ node scripts/job-application.mjs ledger review
132
136
  node scripts/job-application.mjs ledger review-ack --stdin
133
137
  node scripts/job-application.mjs autonomy grant --stdin
134
138
  node scripts/job-application.mjs autonomy status|preview|revoke
135
- node scripts/job-application.mjs round start|source|complete --stdin
139
+ node scripts/job-application.mjs round start|source|confirm|complete --stdin
136
140
  node scripts/job-application.mjs round status [round-id]
137
141
  node scripts/job-application.mjs sources list [--stdin]
138
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
 
@@ -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']);
@@ -1501,7 +1502,16 @@ async function roundStatus(roundId = null) {
1501
1502
  const delivery = deliveryProjection(matching, await jsonLines(join(dir, 'delivery.ndjson')));
1502
1503
  const effectiveKeys = new Set(matching.filter((entry,i) => delivery.applications[i].counted).map(canonicalApplicationKey));
1503
1504
  const effectiveApplications = applications.filter(entry => effectiveKeys.has(canonicalApplicationKey(entry)));
1504
- 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;
1505
1515
  const audit = started.discoveryPolicyVersion === 2 ? discoveryProjection(await jsonLines(join(dir, 'discovery.ndjson')), { roundId: id }) : null;
1506
1516
  const attention = await attentionList(id);
1507
1517
  const completion = events.find((event) => event.type === 'completed' && event.roundId === id);
@@ -1514,7 +1524,9 @@ async function roundStatus(roundId = null) {
1514
1524
  completed: Boolean(completion),
1515
1525
  discoveryPolicyVersion: started.discoveryPolicyVersion ?? 1,
1516
1526
  recordedSubmissionCount: delivery.recordedSubmissionCount,
1517
- effectiveSubmissionCount: confirmedCount,
1527
+ applyConfirmationCount: outreachCounts.applyConfirmationCount,
1528
+ outreachConfirmationCount: outreachCounts.outreachConfirmationCount,
1529
+ effectiveSubmissionCount: delivery.effectiveSubmissionCount,
1518
1530
  failedDeliveryCount: delivery.failedDeliveryCount,
1519
1531
  receiptUnknownEmailCount: delivery.receiptUnknownEmailCount,
1520
1532
  shortfallCount: Math.max(0, started.requestedCount - confirmedCount),
@@ -1554,6 +1566,44 @@ async function roundComplete(input) {
1554
1566
  return result;
1555
1567
  }
1556
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
+
1557
1607
  async function frictionRecord(input) {
1558
1608
  const value = object(input, 'friction event');
1559
1609
  const allowed = new Set(['stage', 'ats', 'errorCode', 'reproducible', 'general', 'observedAt']);
@@ -1751,12 +1801,13 @@ function reviewTelemetry(review) {
1751
1801
  }
1752
1802
 
1753
1803
  function roundCompletedTelemetry(round) {
1804
+ const applicationCount = round.applyConfirmationCount ?? round.effectiveSubmissionCount ?? 0;
1754
1805
  return {
1755
1806
  event: 'round_completed',
1756
1807
  properties: {
1757
1808
  requestedCount: round.requestedCount,
1758
- submittedCount: round.confirmedCount,
1759
- assessedCount: round.confirmedCount,
1809
+ submittedCount: applicationCount,
1810
+ assessedCount: applicationCount,
1760
1811
  skippedCount: 0,
1761
1812
  pausedCount: round.blockedCount,
1762
1813
  errorCount: 0,
@@ -1828,6 +1879,7 @@ async function executeCommand([area, action, value], telemetry, session, communi
1828
1879
  } });
1829
1880
  }
1830
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());
1831
1883
  else if (area === 'round' && action === 'complete' && value === '--stdin') {
1832
1884
  result = await roundComplete(await jsonStdin());
1833
1885
  if (result.completionRecorded) domainEvents.push(roundCompletedTelemetry(result));
@@ -1853,7 +1905,7 @@ async function executeCommand([area, action, value], telemetry, session, communi
1853
1905
  else if (area === 'attention' && action === 'resolve' && value === '--stdin') result = await attentionResolve(await jsonStdin());
1854
1906
  else if (area === 'friction' && action === 'record' && value === '--stdin') result = await frictionRecord(await jsonStdin());
1855
1907
  else if (area === 'friction' && action === 'list' && value == null) result = await frictionList();
1856
- 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');
1857
1909
  for (const event of domainEvents) await telemetry.record(event, session);
1858
1910
  return result;
1859
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
  }
@@ -0,0 +1,267 @@
1
+ import { appendFile, chmod, mkdir, open, readFile, unlink } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import { deliveryProjection } from './application-accounting.mjs';
4
+ import { CloudStateClient, defaultCloudConfigPath } from './cloud-state-client.mjs';
5
+ import { OUTREACH_CAPABILITY, readOutreach } from './outreach-domain.mjs';
6
+ import { withLocalOutreach } from './outreach-store.mjs';
7
+
8
+ export const OUTREACH_ROUND_CHANNEL = 'outreach';
9
+
10
+ export function companyCountKey(value) {
11
+ return String(value ?? '').normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
12
+ }
13
+
14
+ export function hasActiveSentVerified(item) {
15
+ if (!item || item.cleared) return false;
16
+ const attempts = Array.isArray(item.attempts) ? item.attempts : [];
17
+ if (attempts.length > 0) return attempts.some((attempt) => attempt.delivery === 'sent-verified');
18
+ return item.delivery === 'sent-verified';
19
+ }
20
+
21
+ export function invalidatedOutreachIds(items = []) {
22
+ return items.filter((item) => item?.id && !item.cleared && !hasActiveSentVerified(item)).map((item) => item.id);
23
+ }
24
+
25
+ export function outreachConfirmationEvents(roundEvents, roundId) {
26
+ return roundEvents.filter((event) => event.type === 'submission-confirmed' && event.roundId === roundId && typeof event.outreachId === 'string' && event.outreachId && event.channel === OUTREACH_ROUND_CHANNEL);
27
+ }
28
+
29
+ export function countedApplyCompanies(applications, delivery) {
30
+ const companies = new Set();
31
+ applications.forEach((entry, index) => {
32
+ if (delivery.applications[index]?.counted) companies.add(companyCountKey(entry.company));
33
+ });
34
+ return companies;
35
+ }
36
+
37
+ export function sentVerifiedFromOutreachItems(items = []) {
38
+ return items.flatMap((item) => {
39
+ if (!hasActiveSentVerified(item)) return [];
40
+ const company = item.content?.assessment?.company?.name;
41
+ if (!company) return [];
42
+ return [{
43
+ id: item.id,
44
+ company,
45
+ role: item.content?.assessment?.role ?? null,
46
+ companyKey: companyCountKey(company),
47
+ }];
48
+ });
49
+ }
50
+
51
+ export function sentVerifiedFromOutreachState(state, now = new Date().toISOString()) {
52
+ if (!state?.opportunities) return [];
53
+ return sentVerifiedFromOutreachItems(Object.keys(state.opportunities).map((id) => readOutreach(state, 'show', { id }, now)));
54
+ }
55
+
56
+ export function resolveOutreachRoundId(roundEvents, { outreachId, roundId = null } = {}) {
57
+ const attached = roundEvents.find((event) => event.type === 'submission-confirmed' && event.outreachId === outreachId && event.channel === OUTREACH_ROUND_CHANNEL);
58
+ if (attached) {
59
+ if (roundId && attached.roundId !== roundId) {
60
+ throw new Error(`Outreach ${outreachId} is already attached to another round.`);
61
+ }
62
+ return attached.roundId;
63
+ }
64
+ if (roundId) return roundId;
65
+ const completed = new Set(roundEvents.filter((event) => event.type === 'completed').map((event) => event.roundId));
66
+ const open = roundEvents.filter((event) => event.type === 'started' && !completed.has(event.roundId));
67
+ return open.at(-1)?.roundId ?? null;
68
+ }
69
+
70
+ export function projectOutreachRoundCounts({ applications, delivery, roundEvents, sentVerified = [], roundId, invalidatedIds = [] }) {
71
+ const applyConfirmationCount = delivery.effectiveSubmissionCount;
72
+ const applyCompanies = countedApplyCompanies(applications, delivery);
73
+ const confirmations = outreachConfirmationEvents(roundEvents, roundId);
74
+ const invalidated = new Set(invalidatedIds);
75
+ const countedCompanies = new Set(applyCompanies);
76
+ const countedOutreachIds = [];
77
+ for (const event of confirmations) {
78
+ if (invalidated.has(event.outreachId)) continue;
79
+ const companyKey = companyCountKey(event.company);
80
+ if (countedCompanies.has(companyKey)) continue;
81
+ countedCompanies.add(companyKey);
82
+ countedOutreachIds.push(event.outreachId);
83
+ }
84
+ const outreachConfirmationCount = countedOutreachIds.length;
85
+ return {
86
+ applyConfirmationCount,
87
+ outreachConfirmationCount,
88
+ confirmedCount: applyConfirmationCount + outreachConfirmationCount,
89
+ countedOutreachIds,
90
+ };
91
+ }
92
+
93
+ function alreadyCountedOutreachCompany(confirmations, companyKey) {
94
+ return confirmations.some((event) => companyCountKey(event.company) === companyKey);
95
+ }
96
+
97
+ function confirmationItem(event) {
98
+ if (!event?.company) return undefined;
99
+ return { id: event.outreachId, company: event.company, companyKey: companyCountKey(event.company) };
100
+ }
101
+
102
+ function shouldCountOutreach({ sentVerified, applications, delivery, confirmations, outreachId }) {
103
+ const recorded = confirmations.find((event) => event.outreachId === outreachId);
104
+ if (recorded) {
105
+ return { count: false, reason: 'already-recorded', item: sentVerified.find((entry) => entry.id === outreachId) ?? confirmationItem(recorded) };
106
+ }
107
+ const item = sentVerified.find((entry) => entry.id === outreachId);
108
+ if (!item) return { count: false, reason: 'not-sent-verified' };
109
+ if (countedApplyCompanies(applications, delivery).has(item.companyKey)) return { count: false, reason: 'already-applied', item };
110
+ if (alreadyCountedOutreachCompany(confirmations, item.companyKey)) return { count: false, reason: 'already-counted-outreach', item };
111
+ return { count: true, item };
112
+ }
113
+
114
+ async function jsonLines(file) {
115
+ try {
116
+ return (await readFile(file, 'utf8')).split('\n').filter(Boolean).map((line, index) => {
117
+ try { return JSON.parse(line); } catch { throw new Error(`Invalid JSON on line ${index + 1} of ${basename(file)}.`); }
118
+ });
119
+ } catch (error) {
120
+ if (error.code === 'ENOENT') return [];
121
+ throw error;
122
+ }
123
+ }
124
+
125
+ async function withFileLock(directory, name, action) {
126
+ await mkdir(directory, { recursive: true, mode: 0o700 });
127
+ await chmod(directory, 0o700);
128
+ const lockPath = join(directory, `.${name}.lock`);
129
+ let handle;
130
+ for (let attempt = 0; attempt < 100; attempt += 1) {
131
+ try {
132
+ handle = await open(lockPath, 'wx', 0o600);
133
+ break;
134
+ } catch (error) {
135
+ if (error.code !== 'EEXIST') throw error;
136
+ await new Promise((resolve) => setTimeout(resolve, 10));
137
+ }
138
+ }
139
+ if (!handle) throw new Error(`Could not acquire ${name} ledger lock.`);
140
+ try { return await action(); }
141
+ finally {
142
+ await handle.close();
143
+ await unlink(lockPath).catch((error) => { if (error.code !== 'ENOENT') throw error; });
144
+ }
145
+ }
146
+
147
+ async function snapshotItemsFromCache(directory) {
148
+ try {
149
+ const cache = JSON.parse(await readFile(join(directory, 'outreach-cloud-cache.json'), 'utf8'));
150
+ return Array.isArray(cache?.snapshot?.items) ? cache.snapshot.items : [];
151
+ } catch (error) {
152
+ if (error.code === 'ENOENT') return [];
153
+ throw error;
154
+ }
155
+ }
156
+
157
+ function isOutreachMigrationRequired(error) {
158
+ return /\(409\): Outreach backend migration required/i.test(error.message) || /Outreach backend migration required/i.test(error.message);
159
+ }
160
+
161
+ function authoritativeSnapshotError(cause) {
162
+ const error = new Error('Outreach snapshot is unavailable; attachment requires authoritative state.');
163
+ if (cause) error.cause = cause;
164
+ return error;
165
+ }
166
+
167
+ export async function loadOutreachSnapshotItems(directory, cloudClient, { allowCache = true } = {}) {
168
+ const cloud = cloudClient ?? new CloudStateClient({ stateDir: directory, configPath: process.env.JOB_APPLICATION_AGENT_CLOUD_CONFIG ?? defaultCloudConfigPath() });
169
+ if (await cloud.configured()) {
170
+ try {
171
+ if (typeof cloud.status === 'function') {
172
+ const status = await cloud.status();
173
+ if (!status.capabilities?.includes(OUTREACH_CAPABILITY)) return [];
174
+ }
175
+ const snapshot = await (await cloud.request('/v2/outreach/snapshot')).json();
176
+ return Array.isArray(snapshot.items) ? snapshot.items : [];
177
+ } catch (error) {
178
+ if (isOutreachMigrationRequired(error)) return [];
179
+ if (!/^Cloud state unavailable:/.test(error.message)) throw error;
180
+ if (!allowCache) throw authoritativeSnapshotError(error);
181
+ }
182
+ if (!allowCache) throw authoritativeSnapshotError();
183
+ return snapshotItemsFromCache(directory);
184
+ }
185
+ try {
186
+ return await withLocalOutreach(directory, (state) => ({
187
+ result: Object.keys(state.opportunities).map((id) => readOutreach(state, 'show', { id })),
188
+ }));
189
+ } catch (error) {
190
+ if (error.code === 'ENOENT') return [];
191
+ const wrapped = new Error(`Outreach store is unavailable: ${error.message}`);
192
+ wrapped.cause = error;
193
+ throw wrapped;
194
+ }
195
+ }
196
+
197
+ export async function loadSentVerifiedOutreach(directory, cloudClient, options) {
198
+ return sentVerifiedFromOutreachItems(await loadOutreachSnapshotItems(directory, cloudClient, options));
199
+ }
200
+
201
+ export async function loadOutreachRoundView(directory, cloudClient, options) {
202
+ const items = await loadOutreachSnapshotItems(directory, cloudClient, options);
203
+ return {
204
+ sentVerified: sentVerifiedFromOutreachItems(items),
205
+ invalidatedIds: invalidatedOutreachIds(items),
206
+ };
207
+ }
208
+
209
+ export async function confirmOutreachTowardRound({
210
+ directory,
211
+ outreachId,
212
+ roundId = null,
213
+ cloudClient = null,
214
+ requireRoundId = false,
215
+ occurredAt = new Date().toISOString(),
216
+ } = {}) {
217
+ const id = String(outreachId ?? '').trim();
218
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,99}$/.test(id)) throw new Error('Invalid opaque outreach ID.');
219
+ const cloud = cloudClient ?? new CloudStateClient({ stateDir: directory, configPath: process.env.JOB_APPLICATION_AGENT_CLOUD_CONFIG ?? defaultCloudConfigPath() });
220
+ return withFileLock(directory, 'rounds', async () => {
221
+ if (await cloud.configured()) {
222
+ try {
223
+ await cloud.reconcile({ dryRun: false, provenance: 'outreach-round-attach', streams: ['rounds'] });
224
+ } catch (error) {
225
+ if (!/^Cloud state unavailable:/.test(error.message)) throw error;
226
+ }
227
+ }
228
+ const roundEvents = await jsonLines(join(directory, 'rounds.ndjson'));
229
+ const targetRoundId = resolveOutreachRoundId(roundEvents, { outreachId: id, roundId });
230
+ if (!targetRoundId) return { counted: false, reason: requireRoundId ? 'round-not-found' : 'no-active-round', outreachId: id, roundId: null };
231
+ const started = roundEvents.find((event) => event.type === 'started' && event.roundId === targetRoundId);
232
+ if (!started) throw new Error('Application round was not found.');
233
+ if (roundEvents.some((event) => event.type === 'completed' && event.roundId === targetRoundId)) {
234
+ const existing = outreachConfirmationEvents(roundEvents, targetRoundId).some((event) => event.outreachId === id);
235
+ return { counted: existing, reason: existing ? 'already-recorded' : 'round-completed', outreachId: id, roundId: targetRoundId };
236
+ }
237
+ const applications = (await jsonLines(join(directory, 'applications.ndjson'))).filter((entry) => entry.roundId === targetRoundId && entry.status === 'submitted');
238
+ const delivery = deliveryProjection(applications, await jsonLines(join(directory, 'delivery.ndjson')));
239
+ const sentVerified = await loadSentVerifiedOutreach(directory, cloud, { allowCache: false });
240
+ const confirmations = outreachConfirmationEvents(roundEvents, targetRoundId);
241
+ const decision = shouldCountOutreach({ sentVerified, applications, delivery, confirmations, outreachId: id });
242
+ if (!decision.count) {
243
+ return { counted: decision.reason === 'already-recorded', reason: decision.reason, outreachId: id, roundId: targetRoundId, company: decision.item?.company ?? null };
244
+ }
245
+ const event = {
246
+ type: 'submission-confirmed',
247
+ roundId: targetRoundId,
248
+ outreachId: id,
249
+ channel: OUTREACH_ROUND_CHANNEL,
250
+ company: decision.item.company,
251
+ ...(decision.item.role ? { role: decision.item.role } : {}),
252
+ occurredAt,
253
+ };
254
+ const file = join(directory, 'rounds.ndjson');
255
+ await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
256
+ await chmod(file, 0o600);
257
+ if (await cloud.configured()) {
258
+ await cloud.appendRecord('rounds', event, {
259
+ recordKey: targetRoundId,
260
+ idempotencyKey: `round-confirmation:${targetRoundId}:outreach:${id}`,
261
+ occurredAt,
262
+ queueOnFailure: true,
263
+ });
264
+ }
265
+ return { counted: true, reason: 'recorded', outreachId: id, roundId: targetRoundId, company: decision.item.company };
266
+ });
267
+ }
@@ -1 +1 @@
1
- export const SKILL_VERSION = '3.7.1';
1
+ export const SKILL_VERSION = '3.7.2';
@@ -86,6 +86,18 @@ sqliteTest('reconcile includes the owner-only discovery review ledger', async ()
86
86
  assert.deepEqual((await ctx.client.listStream('discovery'))[0].value, lead);
87
87
  });
88
88
 
89
+ sqliteTest('targeted reconcile only syncs the requested stream', async () => {
90
+ const ctx = await setup();
91
+ await writeFile(join(ctx.stateDir, 'applications.ndjson'), `${JSON.stringify({ id: 'app-1', company: 'A', submittedAt: '2026-01-01T00:00:00.000Z' })}\n`);
92
+ await writeFile(join(ctx.stateDir, 'rounds.ndjson'), `${JSON.stringify({ type: 'started', roundId: 'round-1', requestedCount: 1, occurredAt: '2026-01-01T00:00:00.000Z' })}\n`);
93
+ const report = await ctx.client.reconcile({ dryRun: false, streams: ['rounds'] });
94
+ assert.equal(report.imported, 1);
95
+ assert.ok(report.streams.rounds);
96
+ assert.equal(report.streams.applications, undefined);
97
+ assert.equal((await ctx.bindings.DB.prepare("SELECT COUNT(*) AS count FROM records WHERE stream = 'applications'").first()).count, 0);
98
+ assert.equal((await ctx.bindings.DB.prepare("SELECT COUNT(*) AS count FROM records WHERE stream = 'rounds'").first()).count, 1);
99
+ });
100
+
89
101
  sqliteTest('resume download verifies checksum and creates a private path cache', async () => {
90
102
  const ctx = await setup();
91
103
  const bytes = Buffer.from('%PDF-1.7\ncloud-resume-fixture');
@@ -0,0 +1,489 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import test from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import { deliveryProjection } from '../scripts/application-accounting.mjs';
10
+ import { assessment, handoff } from './fixtures/outreach.mjs';
11
+ import {
12
+ OUTREACH_ROUND_CHANNEL,
13
+ companyCountKey,
14
+ confirmOutreachTowardRound,
15
+ invalidatedOutreachIds,
16
+ loadSentVerifiedOutreach,
17
+ projectOutreachRoundCounts,
18
+ resolveOutreachRoundId,
19
+ sentVerifiedFromOutreachItems,
20
+ } from '../scripts/outreach-round.mjs';
21
+
22
+ const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
23
+
24
+ function application(roundId, company = 'Example') {
25
+ return {
26
+ id: `app-${company.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
27
+ company,
28
+ role: 'Staff Product Engineer',
29
+ url: `https://jobs.fixture.example/${company.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
30
+ employerJobId: `example:${company}`,
31
+ source: 'ashby',
32
+ discoverySource: 'linkedin',
33
+ applicationChannel: 'ashby',
34
+ roundId,
35
+ score: 86,
36
+ status: 'submitted',
37
+ submittedAt: '2026-09-14T10:00:00.000Z',
38
+ approval: 'STANDING AUTHORIZATION',
39
+ answers: {},
40
+ };
41
+ }
42
+
43
+ function verifiedItem(id, company) {
44
+ return {
45
+ id,
46
+ delivery: 'sent-verified',
47
+ content: { assessment: { company: { name: company }, role: 'Staff Product Engineer' } },
48
+ };
49
+ }
50
+
51
+ test('sent-verified outreach increments a round when no apply exists', () => {
52
+ const delivery = deliveryProjection([], []);
53
+ const counts = projectOutreachRoundCounts({
54
+ applications: [],
55
+ delivery,
56
+ roundEvents: [{ type: 'submission-confirmed', roundId: 'round-1', outreachId: 'noon', channel: OUTREACH_ROUND_CHANNEL, company: 'Noon' }],
57
+ sentVerified: sentVerifiedFromOutreachItems([verifiedItem('noon', 'Noon')]),
58
+ roundId: 'round-1',
59
+ });
60
+ assert.equal(counts.applyConfirmationCount, 0);
61
+ assert.equal(counts.outreachConfirmationCount, 1);
62
+ assert.equal(counts.confirmedCount, 1);
63
+ });
64
+
65
+ test('apply plus outreach at the same company counts once', () => {
66
+ const applications = [application('round-1', 'Tricog')];
67
+ const delivery = deliveryProjection(applications, []);
68
+ const counts = projectOutreachRoundCounts({
69
+ applications,
70
+ delivery,
71
+ roundEvents: [{ type: 'submission-confirmed', roundId: 'round-1', outreachId: 'tricog', channel: OUTREACH_ROUND_CHANNEL, company: 'Tricog' }],
72
+ sentVerified: sentVerifiedFromOutreachItems([verifiedItem('tricog', 'Tricog')]),
73
+ roundId: 'round-1',
74
+ });
75
+ assert.equal(counts.applyConfirmationCount, 1);
76
+ assert.equal(counts.outreachConfirmationCount, 0);
77
+ assert.equal(counts.confirmedCount, 1);
78
+ });
79
+
80
+ test('cleared outreach still counts from the durable confirmation company', () => {
81
+ const counts = projectOutreachRoundCounts({
82
+ applications: [],
83
+ delivery: deliveryProjection([], []),
84
+ roundEvents: [{ type: 'submission-confirmed', roundId: 'round-1', outreachId: 'noon', channel: OUTREACH_ROUND_CHANNEL, company: 'Noon' }],
85
+ sentVerified: sentVerifiedFromOutreachItems([{
86
+ id: 'noon',
87
+ delivery: 'sent-verified',
88
+ cleared: true,
89
+ content: { assessment: { company: { name: 'Noon' }, role: 'Staff Product Engineer' } },
90
+ }]),
91
+ roundId: 'round-1',
92
+ });
93
+ assert.equal(counts.applyConfirmationCount, 0);
94
+ assert.equal(counts.outreachConfirmationCount, 1);
95
+ assert.equal(counts.confirmedCount, 1);
96
+ });
97
+
98
+ test('linked company names share one count key', () => {
99
+ assert.equal(companyCountKey('Made Card'), companyCountKey('MadeCard'));
100
+ const applications = [application('round-1', 'MadeCard')];
101
+ const delivery = deliveryProjection(applications, []);
102
+ const counts = projectOutreachRoundCounts({
103
+ applications,
104
+ delivery,
105
+ roundEvents: [{ type: 'submission-confirmed', roundId: 'round-1', outreachId: 'made', channel: OUTREACH_ROUND_CHANNEL, company: 'Made Card' }],
106
+ sentVerified: sentVerifiedFromOutreachItems([verifiedItem('made', 'Made Card')]),
107
+ roundId: 'round-1',
108
+ });
109
+ assert.equal(counts.applyConfirmationCount, 1);
110
+ assert.equal(counts.outreachConfirmationCount, 0);
111
+ assert.equal(counts.confirmedCount, 1);
112
+ });
113
+
114
+ test('explicit confirm cannot reassign an attached outreach to another round', () => {
115
+ assert.throws(() => resolveOutreachRoundId([
116
+ { type: 'started', roundId: 'round-a', requestedCount: 30, occurredAt: '2026-09-14T10:00:00.000Z' },
117
+ { type: 'submission-confirmed', roundId: 'round-a', outreachId: 'noon', channel: OUTREACH_ROUND_CHANNEL, company: 'Noon' },
118
+ { type: 'started', roundId: 'round-b', requestedCount: 30, occurredAt: '2026-09-14T11:00:00.000Z' },
119
+ ], { outreachId: 'noon', roundId: 'round-b' }), /already attached to another round/);
120
+ });
121
+
122
+ test('apply-only still increments without outreach', () => {
123
+ const applications = [application('round-1', 'MadeCard')];
124
+ const delivery = deliveryProjection(applications, []);
125
+ const counts = projectOutreachRoundCounts({
126
+ applications,
127
+ delivery,
128
+ roundEvents: [],
129
+ sentVerified: [],
130
+ roundId: 'round-1',
131
+ });
132
+ assert.equal(counts.applyConfirmationCount, 1);
133
+ assert.equal(counts.outreachConfirmationCount, 0);
134
+ assert.equal(counts.confirmedCount, 1);
135
+ });
136
+
137
+ async function fixture(t, label) {
138
+ const directory = await mkdtemp(join(tmpdir(), `outreach-round-${label}-`));
139
+ t.after(() => rm(directory, { recursive: true, force: true }));
140
+ await writeFile(join(directory, 'telemetry.json'), JSON.stringify({
141
+ version: 1,
142
+ enabled: false,
143
+ disclosed: true,
144
+ graceConsumed: true,
145
+ installationEventPending: false,
146
+ }));
147
+ const env = {
148
+ ...process.env,
149
+ JOB_APPLICATION_AGENT_STATE_DIR: directory,
150
+ JOB_APPLICATION_AGENT_CLOUD_CONFIG: join(directory, 'cloud-config.json'),
151
+ JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL: 'http://127.0.0.1:9',
152
+ };
153
+ const cli = (args, input) => JSON.parse(execFileSync(process.execPath, [script, ...args], {
154
+ env,
155
+ encoding: 'utf8',
156
+ ...(input === undefined ? {} : { input: JSON.stringify(input) }),
157
+ }));
158
+ return { directory, env, cli };
159
+ }
160
+
161
+ function recordVerifiedOutreach(cli, { id, company, domain }) {
162
+ cli(['outreach', 'policy', 'enable', '--stdin'], { operationId: `enable-${id}`, timezone: 'Asia/Kolkata' });
163
+ cli(['outreach', 'assess', '--stdin'], assessment(id, {
164
+ operationId: `assess-${id}`,
165
+ company: { name: company, domain, aliases: [] },
166
+ recipient: { account: `https://www.linkedin.com/in/${id}-recruiter`, aliases: [] },
167
+ }));
168
+ cli(['outreach', 'draft', '--stdin'], {
169
+ operationId: `draft-${id}`,
170
+ id,
171
+ text: 'Your product engineering role fits my experience. Would a brief conversation be useful?',
172
+ claimRefs: ['experience'],
173
+ purpose: 'initial',
174
+ });
175
+ const recheckedAt = new Date().toISOString();
176
+ cli(['outreach', 'handoff', '--stdin'], handoff({
177
+ operationId: `handoff-${id}`,
178
+ id,
179
+ recheckedAt,
180
+ history: { kind: 'user-reported', noPriorPitch: true, checkedAt: recheckedAt },
181
+ }));
182
+ const occurredAt = new Date().toISOString();
183
+ return cli(['outreach', 'record', '--stdin'], {
184
+ operationId: `sent-${id}`,
185
+ id,
186
+ attemptId: `handoff-${id}`,
187
+ type: 'sent-verified',
188
+ occurredAt,
189
+ evidence: 'Visible sent message in the conversation.',
190
+ messageRef: `visible-message-${id}`,
191
+ });
192
+ }
193
+
194
+ test('CLI sent-verified outreach increments the active round', async (t) => {
195
+ const { cli } = await fixture(t, 'outreach-only');
196
+ const { roundId } = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
197
+ const recorded = recordVerifiedOutreach(cli, { id: 'noon', company: 'Noon', domain: 'noon.com' });
198
+ assert.equal(recorded.delivery, 'sent-verified');
199
+ assert.equal(recorded.roundConfirmation.counted, true);
200
+ assert.equal(recorded.roundConfirmation.roundId, roundId);
201
+ const status = cli(['round', 'status', roundId]);
202
+ assert.equal(status.applyConfirmationCount, 0);
203
+ assert.equal(status.outreachConfirmationCount, 1);
204
+ assert.equal(status.confirmedCount, 1);
205
+ assert.equal(status.effectiveSubmissionCount, 0);
206
+ const again = cli(['round', 'confirm', '--stdin'], { roundId, outreachId: 'noon' });
207
+ assert.equal(again.confirmations[0].reason, 'already-recorded');
208
+ assert.equal(again.confirmedCount, 1);
209
+ });
210
+
211
+ test('CLI apply plus outreach for the same company counts once', async (t) => {
212
+ const { cli } = await fixture(t, 'apply-and-outreach');
213
+ const { roundId } = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
214
+ cli(['ledger', 'add', '--stdin'], application(roundId, 'Tricog'));
215
+ const recorded = recordVerifiedOutreach(cli, { id: 'tricog', company: 'Tricog', domain: 'tricog.com' });
216
+ assert.equal(recorded.roundConfirmation.counted, false);
217
+ assert.equal(recorded.roundConfirmation.reason, 'already-applied');
218
+ const status = cli(['round', 'status', roundId]);
219
+ assert.equal(status.applyConfirmationCount, 1);
220
+ assert.equal(status.outreachConfirmationCount, 0);
221
+ assert.equal(status.confirmedCount, 1);
222
+ assert.equal(status.effectiveSubmissionCount, 1);
223
+ });
224
+
225
+ test('CLI apply-only still increments the round', async (t) => {
226
+ const { cli } = await fixture(t, 'apply-only');
227
+ const { roundId } = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
228
+ cli(['ledger', 'add', '--stdin'], application(roundId, 'MadeCard'));
229
+ const status = cli(['round', 'status', roundId]);
230
+ assert.equal(status.applyConfirmationCount, 1);
231
+ assert.equal(status.outreachConfirmationCount, 0);
232
+ assert.equal(status.confirmedCount, 1);
233
+ assert.equal(status.effectiveSubmissionCount, 1);
234
+ });
235
+
236
+ test('later progression records do not auto-attach a historical send', async (t) => {
237
+ const { cli } = await fixture(t, 'reply-does-not-count');
238
+ const recorded = recordVerifiedOutreach(cli, { id: 'noon', company: 'Noon', domain: 'noon.com' });
239
+ assert.equal(recorded.roundConfirmation.counted, false);
240
+ assert.equal(recorded.roundConfirmation.reason, 'no-active-round');
241
+ const { roundId } = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
242
+ const reply = cli(['outreach', 'record', '--stdin'], {
243
+ operationId: 'reply-noon',
244
+ id: 'noon',
245
+ type: 'replied',
246
+ occurredAt: new Date().toISOString(),
247
+ evidence: 'Recruiter replied in the conversation.',
248
+ replyTone: 'neutral',
249
+ });
250
+ assert.equal(reply.delivery, 'sent-verified');
251
+ assert.equal(reply.roundConfirmation, undefined);
252
+ const status = cli(['round', 'status', roundId]);
253
+ assert.equal(status.outreachConfirmationCount, 0);
254
+ assert.equal(status.confirmedCount, 0);
255
+ });
256
+
257
+ test('batch confirm validates every ID before committing any', async (t) => {
258
+ const { cli } = await fixture(t, 'batch-validate');
259
+ recordVerifiedOutreach(cli, { id: 'noon', company: 'Noon', domain: 'noon.com' });
260
+ const { roundId } = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
261
+ assert.throws(
262
+ () => cli(['round', 'confirm', '--stdin'], { roundId, outreachIds: ['noon', 'missing'] }),
263
+ /not sent-verified/,
264
+ );
265
+ const status = cli(['round', 'status', roundId]);
266
+ assert.equal(status.outreachConfirmationCount, 0);
267
+ assert.equal(status.confirmedCount, 0);
268
+ });
269
+
270
+ test('CLI delivery correction drops confirmedCount after a counted send', async (t) => {
271
+ const { cli } = await fixture(t, 'delivery-correction');
272
+ const { roundId } = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
273
+ recordVerifiedOutreach(cli, { id: 'noon', company: 'Noon', domain: 'noon.com' });
274
+ assert.equal(cli(['round', 'status', roundId]).confirmedCount, 1);
275
+ cli(['outreach', 'record', '--stdin'], {
276
+ operationId: 'correct-noon',
277
+ id: 'noon',
278
+ attemptId: 'handoff-noon',
279
+ type: 'not-sent',
280
+ occurredAt: new Date().toISOString(),
281
+ evidence: 'Visible conversation shows the message was not sent.',
282
+ supersedes: ['sent-noon'],
283
+ });
284
+ const after = cli(['round', 'status', roundId]);
285
+ assert.equal(after.confirmedCount, 0);
286
+ assert.equal(after.outreachConfirmationCount, 0);
287
+ });
288
+
289
+ test('sent-verified confirm then clear leaves confirmedCount unchanged', async (t) => {
290
+ const { cli } = await fixture(t, 'clear-keeps-count');
291
+ const { roundId } = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
292
+ const recorded = recordVerifiedOutreach(cli, { id: 'noon', company: 'Noon', domain: 'noon.com' });
293
+ assert.equal(recorded.roundConfirmation.counted, true);
294
+ const before = cli(['round', 'status', roundId]);
295
+ assert.equal(before.confirmedCount, 1);
296
+ assert.equal(before.outreachConfirmationCount, 1);
297
+ cli(['outreach', 'clear', '--stdin'], { operationId: 'clear-noon', ids: ['noon'] });
298
+ const after = cli(['round', 'status', roundId]);
299
+ assert.equal(after.confirmedCount, before.confirmedCount);
300
+ assert.equal(after.outreachConfirmationCount, 1);
301
+ assert.equal(after.applyConfirmationCount, 0);
302
+ });
303
+
304
+ test('explicit confirm rejects moving an attached outreach to another round', async (t) => {
305
+ const { cli } = await fixture(t, 'no-reassign');
306
+ const first = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
307
+ recordVerifiedOutreach(cli, { id: 'noon', company: 'Noon', domain: 'noon.com' });
308
+ const second = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
309
+ assert.throws(
310
+ () => cli(['round', 'confirm', '--stdin'], { roundId: second.roundId, outreachId: 'noon' }),
311
+ /already attached to another round/,
312
+ );
313
+ assert.equal(cli(['round', 'status', first.roundId]).confirmedCount, 1);
314
+ assert.equal(cli(['round', 'status', second.roundId]).confirmedCount, 0);
315
+ });
316
+
317
+ test('auto-attach reconciles authenticated cloud rounds before choosing a destination', async (t) => {
318
+ const directory = await mkdtemp(join(tmpdir(), 'outreach-round-reconcile-'));
319
+ t.after(() => rm(directory, { recursive: true, force: true }));
320
+ const started = { type: 'started', roundId: 'cloud-round', requestedCount: 30, occurredAt: '2026-09-14T10:00:00.000Z' };
321
+ const reconcileCalls = [];
322
+ const cloudClient = {
323
+ configured: async () => true,
324
+ status: async () => ({ configured: true, capabilities: ['outreach-tracking-v1'] }),
325
+ request: async () => ({ json: async () => ({ items: [verifiedItem('noon', 'Noon')] }) }),
326
+ reconcile: async (options) => {
327
+ reconcileCalls.push(options);
328
+ await writeFile(join(directory, 'rounds.ndjson'), `${JSON.stringify(started)}\n`);
329
+ },
330
+ appendRecord: async () => {},
331
+ };
332
+ const result = await confirmOutreachTowardRound({
333
+ directory,
334
+ outreachId: 'noon',
335
+ cloudClient,
336
+ occurredAt: '2026-09-14T11:00:00.000Z',
337
+ });
338
+ assert.deepEqual(reconcileCalls, [{ dryRun: false, provenance: 'outreach-round-attach', streams: ['rounds'] }]);
339
+ assert.equal(result.counted, true);
340
+ assert.equal(result.roundId, 'cloud-round');
341
+ assert.equal(result.company, 'Noon');
342
+ });
343
+
344
+ test('unmigrated outreach backend is an empty verified set', async (t) => {
345
+ const directory = await mkdtemp(join(tmpdir(), 'outreach-round-unmigrated-'));
346
+ t.after(() => rm(directory, { recursive: true, force: true }));
347
+ const missingCapability = await loadSentVerifiedOutreach(directory, {
348
+ configured: async () => true,
349
+ status: async () => ({ configured: true, capabilities: ['application-accounting-v1'] }),
350
+ request: async () => {
351
+ throw new Error('Cloud state request failed (409): Outreach backend migration required');
352
+ },
353
+ });
354
+ assert.deepEqual(missingCapability, []);
355
+ const migrationConflict = await loadSentVerifiedOutreach(directory, {
356
+ configured: async () => true,
357
+ status: async () => ({ configured: true, capabilities: ['application-accounting-v1', 'outreach-tracking-v1'] }),
358
+ request: async () => {
359
+ throw new Error('Cloud state request failed (409): Outreach backend migration required');
360
+ },
361
+ });
362
+ assert.deepEqual(migrationConflict, []);
363
+ });
364
+
365
+ test('local outreach read failures stay visible to round status', async (t) => {
366
+ const directory = await mkdtemp(join(tmpdir(), 'outreach-round-locked-'));
367
+ t.after(() => rm(directory, { recursive: true, force: true }));
368
+ await writeFile(join(directory, 'outreach.lock'), 'stale-lock');
369
+ await assert.rejects(
370
+ () => loadSentVerifiedOutreach(directory, { configured: async () => false }),
371
+ /Outreach store is unavailable/,
372
+ );
373
+ });
374
+
375
+ test('delivery correction invalidates a counted confirmation; clear does not', () => {
376
+ const confirmation = { type: 'submission-confirmed', roundId: 'round-1', outreachId: 'noon', channel: OUTREACH_ROUND_CHANNEL, company: 'Noon' };
377
+ const failed = {
378
+ id: 'noon',
379
+ delivery: 'failed',
380
+ cleared: false,
381
+ attempts: [{ id: 'handoff-noon', purpose: 'initial', delivery: 'failed' }],
382
+ content: { assessment: { company: { name: 'Noon' }, role: 'Staff Product Engineer' } },
383
+ };
384
+ const failedCounts = projectOutreachRoundCounts({
385
+ applications: [],
386
+ delivery: deliveryProjection([], []),
387
+ roundEvents: [confirmation],
388
+ sentVerified: sentVerifiedFromOutreachItems([failed]),
389
+ invalidatedIds: invalidatedOutreachIds([failed]),
390
+ roundId: 'round-1',
391
+ });
392
+ assert.equal(failedCounts.outreachConfirmationCount, 0);
393
+ assert.equal(failedCounts.confirmedCount, 0);
394
+
395
+ const cleared = {
396
+ id: 'noon',
397
+ delivery: 'sent-verified',
398
+ cleared: true,
399
+ attempts: [{ id: 'handoff-noon', purpose: 'initial', delivery: 'sent-verified' }],
400
+ content: { assessment: { company: { name: 'Noon' }, role: 'Staff Product Engineer' } },
401
+ };
402
+ const clearedCounts = projectOutreachRoundCounts({
403
+ applications: [],
404
+ delivery: deliveryProjection([], []),
405
+ roundEvents: [confirmation],
406
+ sentVerified: sentVerifiedFromOutreachItems([cleared]),
407
+ invalidatedIds: invalidatedOutreachIds([cleared]),
408
+ roundId: 'round-1',
409
+ });
410
+ assert.equal(clearedCounts.outreachConfirmationCount, 1);
411
+ assert.equal(clearedCounts.confirmedCount, 1);
412
+ });
413
+
414
+ test('attached company identity stays on the confirmation event', () => {
415
+ const applications = [application('round-1', 'Acme')];
416
+ const delivery = deliveryProjection(applications, []);
417
+ const renamed = verifiedItem('acme', 'Acme Holdings');
418
+ const counts = projectOutreachRoundCounts({
419
+ applications,
420
+ delivery,
421
+ roundEvents: [{ type: 'submission-confirmed', roundId: 'round-1', outreachId: 'acme', channel: OUTREACH_ROUND_CHANNEL, company: 'Acme' }],
422
+ sentVerified: sentVerifiedFromOutreachItems([renamed]),
423
+ invalidatedIds: invalidatedOutreachIds([renamed]),
424
+ roundId: 'round-1',
425
+ });
426
+ assert.equal(counts.applyConfirmationCount, 1);
427
+ assert.equal(counts.outreachConfirmationCount, 0);
428
+ assert.equal(counts.confirmedCount, 1);
429
+ });
430
+
431
+ test('non-ASCII company names keep distinct count keys', () => {
432
+ assert.equal(companyCountKey('Made Card'), companyCountKey('MadeCard'));
433
+ assert.notEqual(companyCountKey('ソニー'), '');
434
+ assert.notEqual(companyCountKey('トヨタ'), '');
435
+ assert.notEqual(companyCountKey('ソニー'), companyCountKey('トヨタ'));
436
+ });
437
+
438
+ test('an earlier sent-verified attempt still qualifies after a later pending follow-up', () => {
439
+ const item = {
440
+ id: 'noon',
441
+ delivery: 'pending-handoff',
442
+ attempts: [
443
+ { id: 'handoff-noon', purpose: 'initial', delivery: 'sent-verified' },
444
+ { id: 'follow-noon', purpose: 'follow-up', delivery: 'pending-handoff' },
445
+ ],
446
+ content: { assessment: { company: { name: 'Noon' }, role: 'Staff Product Engineer' } },
447
+ };
448
+ const verified = sentVerifiedFromOutreachItems([item]);
449
+ assert.equal(verified.length, 1);
450
+ assert.equal(verified[0].id, 'noon');
451
+ });
452
+
453
+ test('confirm refuses a cached outreach snapshot when cloud is unavailable', async (t) => {
454
+ const directory = await mkdtemp(join(tmpdir(), 'outreach-round-no-cache-confirm-'));
455
+ t.after(() => rm(directory, { recursive: true, force: true }));
456
+ await writeFile(join(directory, 'rounds.ndjson'), `${JSON.stringify({ type: 'started', roundId: 'round-1', requestedCount: 30, occurredAt: '2026-09-14T10:00:00.000Z' })}\n`);
457
+ await writeFile(join(directory, 'outreach-cloud-cache.json'), JSON.stringify({
458
+ snapshot: { items: [verifiedItem('noon', 'Noon')] },
459
+ }));
460
+ const cloudClient = {
461
+ configured: async () => true,
462
+ status: async () => ({ configured: true, capabilities: ['outreach-tracking-v1'] }),
463
+ request: async () => {
464
+ throw new Error('Cloud state unavailable: network down');
465
+ },
466
+ reconcile: async () => {
467
+ throw new Error('Cloud state unavailable: network down');
468
+ },
469
+ appendRecord: async () => {
470
+ throw new Error('should not append from cache');
471
+ },
472
+ };
473
+ await assert.rejects(
474
+ () => confirmOutreachTowardRound({ directory, outreachId: 'noon', cloudClient }),
475
+ /authoritative state/,
476
+ );
477
+ const cachedStatus = await loadSentVerifiedOutreach(directory, cloudClient, { allowCache: true });
478
+ assert.equal(cachedStatus.length, 1);
479
+ });
480
+
481
+ test('round attachment failure does not disguise a committed send', async (t) => {
482
+ const { directory, cli } = await fixture(t, 'attachment-failed');
483
+ const { roundId } = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
484
+ await writeFile(join(directory, 'rounds.ndjson'), `${JSON.stringify({ type: 'started', roundId, requestedCount: 30, occurredAt: '2026-09-14T10:00:00.000Z' })}\nnot-json\n`);
485
+ const recorded = recordVerifiedOutreach(cli, { id: 'noon', company: 'Noon', domain: 'noon.com' });
486
+ assert.equal(recorded.delivery, 'sent-verified');
487
+ assert.equal(recorded.roundConfirmation.counted, false);
488
+ assert.equal(recorded.roundConfirmation.reason, 'attachment-failed');
489
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-application-agent",
3
- "version": "3.7.1",
3
+ "version": "3.7.2",
4
4
  "description": "A privacy-first Agent Skill and CLI for evidence-based job discovery, application completion, and outcome tracking.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -44,7 +44,7 @@
44
44
  "check:native-artifacts": "node scripts/ci/validate-native-artifacts.mjs",
45
45
  "privacy-audit": "node --test job-application-agent/tests/privacy-audit.test.mjs job-application-agent/tests/outreach-cli.test.mjs",
46
46
  "smoke:package": "node scripts/smoke-package.mjs",
47
- "check": "node --check job-application-agent/scripts/job-application.mjs && node --check job-application-agent/scripts/attention-runner-poll.mjs && node --check job-application-agent/scripts/attention-resume-submit.mjs && node --check job-application-agent/scripts/session-binding.mjs && node --check job-application-agent/scripts/novnc-display-guard.mjs && node --check job-application-agent/scripts/ats/submit-adapters.mjs && node --check job-application-agent/scripts/cloud-state-client.mjs && node --check job-application-agent/scripts/secret-store.mjs && node --check job-application-agent/scripts/source-community-client.mjs && node --check job-application-agent/scripts/source-community-schema.mjs && node --check job-application-agent/scripts/telemetry-client.mjs && node --check job-application-agent/scripts/telemetry-schema.mjs && node --check job-application-agent/scripts/version.mjs && node --check telemetry-worker/src/worker.mjs && node --check telemetry-worker/src/public-stats.mjs && node --check telemetry-worker/public/dashboard.js && node --check installer/src/cli.mjs && node --check installer/src/installer.mjs && node --check installer/src/runner.mjs && node --check installer/src/scheduler.mjs && node --check scripts/ci/classify-paths.mjs && node --check scripts/ci/validate-native-artifacts.mjs && node --check bin/job-application-agent.mjs && node --check state-worker/src/worker.mjs && node --check state-worker/src/backup.mjs && node --check state-worker/src/cli.mjs && node --check state-worker/bin/sync.mjs && node --check job-application-agent/scripts/application-accounting.mjs && node --check job-application-agent/scripts/outreach-domain.mjs && node --check job-application-agent/scripts/outreach-store.mjs && node --check job-application-agent/scripts/outreach-cli.mjs && node --check state-worker/src/outreach.mjs && node --check job-application-agent/scripts/ats/answer-inject.mjs && node --check job-application-agent/scripts/attention-questions.mjs && node --check job-application-agent/scripts/captcha-vendor.mjs",
47
+ "check": "node --check job-application-agent/scripts/job-application.mjs && node --check job-application-agent/scripts/attention-runner-poll.mjs && node --check job-application-agent/scripts/attention-resume-submit.mjs && node --check job-application-agent/scripts/session-binding.mjs && node --check job-application-agent/scripts/novnc-display-guard.mjs && node --check job-application-agent/scripts/ats/submit-adapters.mjs && node --check job-application-agent/scripts/cloud-state-client.mjs && node --check job-application-agent/scripts/secret-store.mjs && node --check job-application-agent/scripts/source-community-client.mjs && node --check job-application-agent/scripts/source-community-schema.mjs && node --check job-application-agent/scripts/telemetry-client.mjs && node --check job-application-agent/scripts/telemetry-schema.mjs && node --check job-application-agent/scripts/version.mjs && node --check telemetry-worker/src/worker.mjs && node --check telemetry-worker/src/public-stats.mjs && node --check telemetry-worker/public/dashboard.js && node --check installer/src/cli.mjs && node --check installer/src/installer.mjs && node --check installer/src/runner.mjs && node --check installer/src/scheduler.mjs && node --check scripts/ci/classify-paths.mjs && node --check scripts/ci/validate-native-artifacts.mjs && node --check bin/job-application-agent.mjs && node --check state-worker/src/worker.mjs && node --check state-worker/src/backup.mjs && node --check state-worker/src/cli.mjs && node --check state-worker/bin/sync.mjs && node --check job-application-agent/scripts/application-accounting.mjs && node --check job-application-agent/scripts/outreach-domain.mjs && node --check job-application-agent/scripts/outreach-store.mjs && node --check job-application-agent/scripts/outreach-cli.mjs && node --check job-application-agent/scripts/outreach-round.mjs && node --check state-worker/src/outreach.mjs && node --check job-application-agent/scripts/ats/answer-inject.mjs && node --check job-application-agent/scripts/attention-questions.mjs && node --check job-application-agent/scripts/captcha-vendor.mjs",
48
48
  "prepack": "npm run check && npm test && npm run privacy-audit"
49
49
  },
50
50
  "engines": {