job-application-agent 3.4.1 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { ACCOUNTING_CAPABILITY, stableJson, validateDelivery, validateLead } from './application-accounting.mjs';
1
2
  import { createHash, randomUUID } from 'node:crypto';
2
3
  import { appendFile, chmod, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
3
4
  import { homedir, platform } from 'node:os';
@@ -8,6 +9,7 @@ export const CLOUD_STREAM_FILES = Object.freeze({
8
9
  outcomes: 'outcomes.ndjson',
9
10
  rounds: 'rounds.ndjson',
10
11
  discovery: 'discovery.ndjson',
12
+ delivery: 'delivery.ndjson',
11
13
  attention: 'attention.ndjson',
12
14
  reviews: 'reviews.ndjson',
13
15
  friction: 'friction.ndjson',
@@ -93,6 +95,7 @@ export async function enableCloudUpdateGuard({ home = homedir(), agentHome = pro
93
95
  catch (error) { if (error.code === 'ENOENT') return { guarded: false, reason: 'managed-install-not-found' }; throw error; }
94
96
  const required = new Set(config.requiredCapabilities ?? []);
95
97
  required.add('cloud-state-v2');
98
+ required.add(ACCOUNTING_CAPABILITY);
96
99
  await privateWrite(path, `${JSON.stringify({ ...config, requiredCapabilities: [...required].sort() }, null, 2)}\n`);
97
100
  return { guarded: true, capability: 'cloud-state-v2' };
98
101
  }
@@ -159,6 +162,23 @@ export class CloudStateClient {
159
162
  return { configured: true, url: config.url, configuredClient: { id: config.clientId ?? null, name: config.clientName ?? null, token: tokenSuffix(config.token) }, pendingLocalWrites: pending.length, ...remote };
160
163
  }
161
164
 
165
+ async requireAccounting() {
166
+ const config = await this.config(true);
167
+ if (!config) return;
168
+ const backend = hash(`${config.url}:${config.token}`);
169
+ try {
170
+ const status = await this.status();
171
+ if (!status.configured) return;
172
+ if (!status.capabilities?.includes(ACCOUNTING_CAPABILITY)) throw new Error('Private backend upgrade required: application-accounting-v1 is missing.');
173
+ await ensurePrivateDirectory(this.stateDir);
174
+ await privateWrite(join(this.stateDir, 'cloud-accounting-capability.json'), JSON.stringify({ supported: true, backend }));
175
+ } catch (error) {
176
+ if (!/^Cloud state unavailable:/.test(error.message)) throw error;
177
+ try { if (JSON.parse(await readFile(join(this.stateDir, 'cloud-accounting-capability.json'), 'utf8')).backend === backend) return; } catch {}
178
+ throw error;
179
+ }
180
+ }
181
+
162
182
  async getDocument(name) {
163
183
  const response = await this.request(`/v2/documents/${encodeURIComponent(name)}`);
164
184
  return response.json();
@@ -199,7 +219,7 @@ export class CloudStateClient {
199
219
  if (forbidden) throw new Error(`Cloud record contains forbidden field: ${forbidden}`);
200
220
  const payload = {
201
221
  recordKey: String(recordKey ?? value?.id ?? value?.roundId ?? randomUUID()),
202
- idempotencyKey: String(idempotencyKey ?? `${stream}:${hash(JSON.stringify(value))}`),
222
+ idempotencyKey: String(idempotencyKey ?? `${stream}:${hash(stableJson(value))}`),
203
223
  occurredAt: occurredAt ?? value?.occurredAt ?? value?.submittedAt ?? new Date().toISOString(),
204
224
  provenance,
205
225
  value,
@@ -227,8 +247,20 @@ export class CloudStateClient {
227
247
  }
228
248
 
229
249
  async pendingWrites() {
230
- try { return (await readFile(join(this.stateDir, 'cloud-pending.ndjson'), 'utf8')).split('\n').filter(Boolean).map(JSON.parse); }
231
- catch (error) { if (error.code === 'ENOENT') return []; throw error; }
250
+ const events = await readNdjson(join(this.stateDir, 'cloud-pending.ndjson'));
251
+ const receipts = new Set((await readNdjson(join(this.stateDir, 'cloud-pending-receipts.ndjson'))).map(event => event.key));
252
+ return events.filter(event => !receipts.has(hash(stableJson(event))));
253
+ }
254
+
255
+ async flushAccountingWrites(stream) {
256
+ if (!['delivery','discovery'].includes(stream)) return;
257
+ for (const event of (await this.pendingWrites()).filter(event => event.type === 'append-record' && event.stream === stream)) {
258
+ await this.appendRecord(stream, event.payload.value, { ...event.payload, queueOnFailure: false });
259
+ const receipt = { key: hash(stableJson(event)) };
260
+ const path = join(this.stateDir, 'cloud-pending-receipts.ndjson');
261
+ await appendFile(path, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
262
+ await chmod(path, 0o600);
263
+ }
232
264
  }
233
265
 
234
266
  async putFile(name, bytes, revision) {
@@ -289,9 +321,15 @@ export class CloudStateClient {
289
321
  }
290
322
 
291
323
  async createIntent(value) {
324
+ await this.requireAccounting();
292
325
  return (await this.request('/v2/intents', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) })).json();
293
326
  }
294
327
 
328
+ async confirmRetry(intentId, delivery, leaseId) {
329
+ await this.requireAccounting();
330
+ return (await this.request(`/v2/intents/${encodeURIComponent(intentId)}/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ delivery, leaseId }) })).json();
331
+ }
332
+
295
333
  async markIntentSentUnverified(intentId, leaseId) {
296
334
  return (await this.request(`/v2/intents/${encodeURIComponent(intentId)}/sent-unverified`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ leaseId }) })).json();
297
335
  }
@@ -301,11 +339,23 @@ export class CloudStateClient {
301
339
  }
302
340
 
303
341
  async reconcile({ dryRun = true, provenance = 'local-reconcile' } = {}) {
342
+ await this.requireAccounting();
304
343
  const report = { dryRun, streams: {}, imported: 0, downloaded: 0 };
305
344
  for (const [stream, filename] of Object.entries(CLOUD_STREAM_FILES)) {
306
- const local = await readNdjson(join(this.stateDir, filename));
345
+ if (!dryRun) await this.flushAccountingWrites(stream);
346
+ const normalize = value => stream === 'delivery' ? validateDelivery(value) : stream === 'discovery' && value.version === 1 && value.type === 'lead-reviewed' ? validateLead(value) : value;
347
+ let local = (await readNdjson(join(this.stateDir, filename))).map(normalize);
307
348
  const cloudRecords = await this.listStream(stream);
308
- const cloud = cloudRecords.map((record) => record.value);
349
+ let cloud = cloudRecords.map((record) => normalize(record.value));
350
+ if (['delivery','discovery'].includes(stream)) {
351
+ const ids = new Map();
352
+ for (const value of [...local, ...cloud].filter(v => v.version === 1 && (stream === 'delivery' || v.type === 'lead-reviewed'))) {
353
+ if (ids.has(value.id) && stableJson(ids.get(value.id)) !== stableJson(value)) throw new Error('Conflicting accounting event ID during cloud reconciliation.');
354
+ ids.set(value.id, value);
355
+ }
356
+ const unique = values => values.filter((value,index) => value.version !== 1 || (stream === 'discovery' && value.type !== 'lead-reviewed') || values.findIndex(other => other.id === value.id && other.version === 1) === index);
357
+ local = unique(local); cloud = unique(cloud);
358
+ }
309
359
  const localCounts = multiset(local);
310
360
  const cloudCounts = multiset(cloud);
311
361
  const localOnly = multisetDifference(local, cloudCounts);
@@ -314,7 +364,7 @@ export class CloudStateClient {
314
364
  if (!dryRun) {
315
365
  const prepared = localOnly.map(({ value, index }) => ({
316
366
  recordKey: String(value?.id ?? value?.roundId ?? value?.applicationId ?? `${stream}-${index}`),
317
- idempotencyKey: `reconcile:${hash(JSON.stringify(value))}:${index}`,
367
+ idempotencyKey: value.version === 1 && (stream === 'delivery' || (stream === 'discovery' && value.type === 'lead-reviewed')) ? `accounting:${value.id}` : `reconcile:${hash(stableJson(value))}:${index}`,
318
368
  occurredAt: value?.occurredAt ?? value?.submittedAt ?? new Date().toISOString(),
319
369
  provenance,
320
370
  value,
@@ -353,7 +403,7 @@ async function readNdjson(path) {
353
403
  }
354
404
 
355
405
  function key(value) {
356
- return JSON.stringify(value);
406
+ return stableJson(value);
357
407
  }
358
408
 
359
409
  function multiset(values) {
@@ -7,6 +7,7 @@ import { platform } from 'node:os';
7
7
  import { basename, join, resolve } from 'node:path';
8
8
  import { pathToFileURL } from 'node:url';
9
9
 
10
+ import { ACCOUNTING_CAPABILITY, accountingApplicationKey, canonicalUrl, deliveryProjection, discoveryProjection, validateDelivery, validateDeliveryReferences, validateLead, validateLeadReferences, stableJson } from './application-accounting.mjs';
10
11
  import { createSecretStore, migrateLegacyStateDir, resolveStateDir } from './secret-store.mjs';
11
12
  import { SourceCommunityClient } from './source-community-client.mjs';
12
13
  import { normalizeCommunityJob, normalizeCommunitySource } from './source-community-schema.mjs';
@@ -506,12 +507,7 @@ function rolesLikelySame(left, right) {
506
507
  return shared / Math.min(leftTokens.size, rightTokens.size) >= 0.75;
507
508
  }
508
509
 
509
- function canonicalApplicationKey(entry, fallback = '') {
510
- if (entry.employerJobId) return `job:${normalizedText(entry.company)}:${String(entry.employerJobId).toLowerCase()}`;
511
- if (entry.company && entry.role) return `legacy-role:${normalizedText(entry.company)}:${normalizedText(entry.role)}`;
512
- if (entry.url) return `url:${normalizeUrl(entry.url)}`;
513
- return `id:${entry.id ?? fallback}`;
514
- }
510
+ const canonicalApplicationKey = accountingApplicationKey;
515
511
 
516
512
  function businessDaysBetween(startValue, endValue) {
517
513
  const start = new Date(startValue);
@@ -525,8 +521,10 @@ function businessDaysBetween(startValue, endValue) {
525
521
  return days;
526
522
  }
527
523
 
528
- export function buildReview(entries, outcomeEntries = [], acknowledgements = [], now = new Date()) {
524
+ export function buildReview(entries, outcomeEntries = [], acknowledgements = [], now = new Date(), deliveryEvents = []) {
529
525
  const submissions = entries.filter((entry) => !Number.isNaN(Date.parse(entry.submittedAt)));
526
+ const delivery = deliveryProjection(submissions, deliveryEvents);
527
+ const effectiveKeys = new Set(submissions.filter((entry, i) => delivery.applications[i].counted).map(canonicalApplicationKey));
530
528
  const explicitOutcomes = outcomeEntries.length > 0;
531
529
  const outcomes = explicitOutcomes ? outcomeEntries : entries.filter((entry) => ['interview', 'rejected', 'offer', 'withdrawn'].includes(entry.status));
532
530
  const groups = new Map();
@@ -535,7 +533,8 @@ export function buildReview(entries, outcomeEntries = [], acknowledgements = [],
535
533
  if (!groups.has(key)) groups.set(key, []);
536
534
  groups.get(key).push(entry);
537
535
  });
538
- const canonical = [...groups.values()].map((group) => [...group].sort((a, b) => Date.parse(a.submittedAt) - Date.parse(b.submittedAt))[0]);
536
+ const recordedCanonical = [...groups.values()].map(group => [...group].sort((a, b) => Date.parse(a.submittedAt) - Date.parse(b.submittedAt))[0]);
537
+ const canonical = [...groups].filter(([key]) => effectiveKeys.has(key)).map(([,group]) => [...group].sort((a, b) => Date.parse(a.submittedAt) - Date.parse(b.submittedAt))[0]);
539
538
  const outcomesById = new Map();
540
539
  for (const outcome of outcomes) {
541
540
  if (!outcomesById.has(outcome.id)) outcomesById.set(outcome.id, []);
@@ -544,7 +543,7 @@ export function buildReview(entries, outcomeEntries = [], acknowledgements = [],
544
543
  const canonicalOutcomes = [];
545
544
  const matureCanonicalOutcomes = [];
546
545
  const canonicalInterviewDetails = [];
547
- for (const group of groups.values()) {
546
+ for (const [groupKey, group] of groups) {
548
547
  const candidates = explicitOutcomes
549
548
  ? group.flatMap((entry) => outcomesById.get(entry.id) ?? [])
550
549
  : group.filter((entry) => ['interview', 'rejected', 'offer', 'withdrawn'].includes(entry.status));
@@ -559,14 +558,15 @@ export function buildReview(entries, outcomeEntries = [], acknowledgements = [],
559
558
  source: canonicalApplication.source,
560
559
  score: canonicalApplication.score,
561
560
  });
562
- if (businessDaysBetween(canonicalApplication.submittedAt, now) >= 10) matureCanonicalOutcomes.push(latest);
561
+ if (effectiveKeys.has(groupKey) && businessDaysBetween(canonicalApplication.submittedAt, now) >= 10) matureCanonicalOutcomes.push(latest);
563
562
  }
564
563
  }
565
564
  const maturedApplications = canonical.filter((entry) => businessDaysBetween(entry.submittedAt, now) >= 10).length;
566
565
  const lastAck = acknowledgements.length ? acknowledgements[acknowledgements.length - 1] : {};
567
- const submittedSinceLastReview = Math.max(0, canonical.length - (lastAck.uniqueSubmissionCount ?? 0));
566
+ const recordedMaturedApplicationCount = recordedCanonical.filter(entry => businessDaysBetween(entry.submittedAt, now) >= 10).length;
567
+ const submittedSinceLastReview = Math.max(0, recordedCanonical.length - (lastAck.uniqueSubmissionCount ?? 0));
568
568
  const hygieneDue = submittedSinceLastReview >= 10;
569
- const outcomeDue = maturedApplications - (lastAck.maturedApplicationCount ?? 0) >= 20;
569
+ const outcomeDue = recordedMaturedApplicationCount - (lastAck.maturedApplicationCount ?? 0) >= 20;
570
570
  const reviewReasons = [...(hygieneDue ? ['submission-hygiene'] : []), ...(outcomeDue ? ['outcome-effectiveness'] : [])];
571
571
  const outcomeCounts = Object.fromEntries(['interview', 'rejected', 'offer', 'withdrawn'].map((status) => [status, canonicalOutcomes.filter((entry) => entry.status === status).length]));
572
572
  const matureOutcomeCounts = Object.fromEntries(['interview', 'rejected', 'offer', 'withdrawn'].map((status) => [status, matureCanonicalOutcomes.filter((entry) => entry.status === status).length]));
@@ -598,9 +598,14 @@ export function buildReview(entries, outcomeEntries = [], acknowledgements = [],
598
598
  submittedTotal: canonical.length,
599
599
  uniqueSubmittedTotal: canonical.length,
600
600
  rawSubmissionRows: submissions.length,
601
- duplicateSubmissionRows: submissions.length - canonical.length,
601
+ duplicateSubmissionRows: submissions.length - groups.size,
602
+ recordedSubmissionCount: groups.size,
603
+ effectiveSubmissionCount: canonical.length,
604
+ failedDeliveryCount: delivery.failedDeliveryCount,
605
+ receiptUnknownEmailCount: delivery.receiptUnknownEmailCount,
602
606
  submittedSinceLastReview,
603
607
  maturedApplications,
608
+ recordedMaturedApplicationCount,
604
609
  outcomeCounts,
605
610
  matureOutcomeCounts,
606
611
  reasonCounts,
@@ -1081,7 +1086,7 @@ async function ledgerAdd(entryInput, duplicateOverride, companyReapplyOverride,
1081
1086
  if (entry.roundId) await cloudState.appendRecord('rounds', { type: 'submission-confirmed', roundId: entry.roundId, applicationId: entry.id, occurredAt: entry.submittedAt }, { recordKey: entry.roundId, idempotencyKey: `round-confirmation:${entry.roundId}:${entry.id}`, occurredAt: entry.submittedAt, queueOnFailure: true });
1082
1087
  }
1083
1088
  }
1084
- return { recorded: storedEntry.id, review: buildReview([...entries, storedEntry]), ...(cloudIntent ? { cloudIntent: cloudIntent.intentId } : {}) };
1089
+ return { recorded: storedEntry.id, review: buildReview([...entries, storedEntry], outcomes, [], new Date(), await jsonLines(join(dir, 'delivery.ndjson'))), ...(cloudIntent ? { cloudIntent: cloudIntent.intentId } : {}) };
1085
1090
  });
1086
1091
  }
1087
1092
 
@@ -1107,6 +1112,63 @@ function isoDate(value, label) {
1107
1112
  return result;
1108
1113
  }
1109
1114
 
1115
+ async function appendAccounting(stream, event, { cloudConfirmed = false } = {}) {
1116
+ const file = join(await ensureStateDir(), `${stream}.ndjson`);
1117
+ const previous = await jsonLines(file);
1118
+ const matches = previous.filter(e => e.id === event.id);
1119
+ if (matches.some(e => stableJson(e) !== stableJson(event))) throw new Error('Conflicting accounting event ID.');
1120
+ if (matches.length) return { recorded: false, duplicate: true, event };
1121
+ await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
1122
+ await chmod(file, 0o600);
1123
+ if (!cloudConfirmed && await cloudState.configured()) await cloudState.appendRecord(stream, event, { recordKey: event.id, idempotencyKey: `accounting:${event.id}`, occurredAt: event.occurredAt ?? event.observedAt, queueOnFailure: true });
1124
+ return { recorded: true, duplicate: false, event };
1125
+ }
1126
+
1127
+ async function recordDelivery(input, retry = false) {
1128
+ const { cloudIntentId, cloudLeaseId, ...raw } = object(input, 'delivery');
1129
+ const event = validateDelivery({ ...raw, ...(retry ? { type: 'retry-confirmed' } : {}) });
1130
+ if (!retry && event.type === 'retry-confirmed') throw new Error('Use ledger retry for replacement transmissions.');
1131
+ return withStateLock('applications', () => withStateLock('delivery', async dir => {
1132
+ const applications = await jsonLines(join(dir, 'applications.ndjson'));
1133
+ const events = await jsonLines(join(dir, 'delivery.ndjson'));
1134
+ const cloudRetry = retry && await cloudState.configured();
1135
+ if (cloudRetry && !events.some(e => e.id === event.id) && (!cloudIntentId || !cloudLeaseId || event.attemptId !== cloudIntentId)) throw new Error('Cloud retry requires its intent and live lease; attemptId must equal cloudIntentId.');
1136
+ // Cloud transmission eligibility was checked at intent preparation. The
1137
+ // Worker verifies that intent before we persist the observed transmission.
1138
+ validateDeliveryReferences(event, applications, events, { preparedRetry: cloudRetry });
1139
+ let cloudConfirmed = false;
1140
+ if (cloudRetry && !events.some(e => e.id === event.id)) {
1141
+ await cloudState.confirmRetry(cloudIntentId, event, cloudLeaseId);
1142
+ cloudConfirmed = true;
1143
+ }
1144
+ const result = await appendAccounting('delivery', event, { cloudConfirmed });
1145
+ return { ...result, delivery: deliveryProjection(applications, [...events, event]) };
1146
+ }));
1147
+ }
1148
+ async function deliveryHistory(applicationId) {
1149
+ const dir = await ensureStateDir();
1150
+ const apps = (await jsonLines(join(dir, 'applications.ndjson'))).filter(a => !applicationId || a.id === applicationId);
1151
+ const events = (await jsonLines(join(dir, 'delivery.ndjson'))).filter(e => !applicationId || e.applicationId === applicationId);
1152
+ return { ...deliveryProjection(apps, events), events };
1153
+ }
1154
+ async function recordLead(input) {
1155
+ const event = validateLead(input);
1156
+ knownDiscoverySourceId(event.sourceId, 'lead.sourceId');
1157
+ return withStateLock('rounds', async dir => {
1158
+ const round = await roundStatus(event.roundId);
1159
+ const events = await jsonLines(join(dir, 'discovery.ndjson'));
1160
+ if (round.completed && !event.supersedes && !events.some(e => e.id === event.id)) throw new Error('Completed rounds accept only corrections to existing leads.');
1161
+ validateLeadReferences(event, events);
1162
+ return appendAccounting('discovery', event);
1163
+ });
1164
+ }
1165
+ async function leadHistory(roundId) {
1166
+ const dir = await ensureStateDir();
1167
+ const id = roundId ?? (await roundStatus()).roundId;
1168
+ const history = (await jsonLines(join(dir, 'discovery.ndjson'))).filter(e => e.roundId === id);
1169
+ return { ...discoveryProjection(history, { roundId: id }), history };
1170
+ }
1171
+
1110
1172
  async function roundStart(input) {
1111
1173
  const value = object(input, 'round');
1112
1174
  const allowed = new Set(['requestedCount', 'startedAt']);
@@ -1114,6 +1176,7 @@ async function roundStart(input) {
1114
1176
  const event = {
1115
1177
  type: 'started',
1116
1178
  roundId: `round-${new Date().toISOString().slice(0, 10)}-${randomUUID()}`,
1179
+ discoveryPolicyVersion: 2,
1117
1180
  requestedCount: integer(value.requestedCount, 'round.requestedCount', 1, 1000),
1118
1181
  occurredAt: isoDate(value.startedAt, 'round.startedAt'),
1119
1182
  };
@@ -1127,7 +1190,7 @@ function discoveryGroup(id) {
1127
1190
  return id;
1128
1191
  }
1129
1192
 
1130
- function discoverySummary(events, applications, completion) {
1193
+ function discoverySummary(events, applications, completion, audit = null) {
1131
1194
  const latest = new Map();
1132
1195
  const searchedIds = new Set();
1133
1196
  const attribution = new Map();
@@ -1136,7 +1199,10 @@ function discoverySummary(events, applications, completion) {
1136
1199
  if (event.status === 'searched') searchedIds.add(event.sourceId);
1137
1200
  for (const id of event.applicationIds ?? []) attribution.set(id, event.sourceId);
1138
1201
  }
1139
- const sources = [...latest.values()];
1202
+ const sources = [...latest.values()].map(report => {
1203
+ const leads = audit?.leads.filter(lead => lead.sourceId === report.sourceId) ?? [];
1204
+ return audit ? { ...report, reviewedCount: leads.length, qualifiedCount: leads.filter(lead => !lead.conflict && lead.disposition === 'qualified').length, accounting: 'per-lead' } : { ...report, accounting: 'legacy-unverified' };
1205
+ });
1140
1206
  const eligible = sources.filter((item) => !['recruiter-inbound', 'user-supplied-leads'].includes(item.sourceId));
1141
1207
  const attempted = new Set(eligible.map((item) => discoveryGroup(item.sourceId)));
1142
1208
  const searched = new Set(eligible.filter((item) => searchedIds.has(item.sourceId)).map((item) => discoveryGroup(item.sourceId)));
@@ -1172,17 +1238,23 @@ async function roundSource(input) {
1172
1238
  const sourceId = knownDiscoverySourceId(value.sourceId, 'coverage.sourceId');
1173
1239
  const status = string(value.status, 'coverage.status', 20);
1174
1240
  if (!['searched', 'blocked'].includes(status)) throw new Error('coverage.status must be searched or blocked.');
1175
- const reviewedCount = integer(value.reviewedCount, 'coverage.reviewedCount', 0, 10000);
1176
- const qualifiedCount = integer(value.qualifiedCount, 'coverage.qualifiedCount', 0, reviewedCount);
1241
+ const roundId = string(value.roundId, 'coverage.roundId', 180);
1177
1242
  const blocker = value.blocker == null ? null : string(value.blocker, 'coverage.blocker', 40);
1178
- if (status === 'blocked' && (!SOURCE_BLOCKERS.has(blocker) || reviewedCount !== 0 || qualifiedCount !== 0)) throw new Error('Blocked sources require a documented blocker and zero counts.');
1179
1243
  if (status === 'searched' && blocker != null) throw new Error('Searched sources cannot have a blocker.');
1180
1244
  const evidence = string(value.evidence, 'coverage.evidence', 2000);
1181
1245
  const applicationIds = value.applicationIds == null ? [] : [...new Set(stringArray(value.applicationIds, 'coverage.applicationIds'))];
1182
1246
  if (applicationIds.length > 1000 || (status === 'blocked' && applicationIds.length)) throw new Error('Invalid coverage.applicationIds.');
1183
1247
  const event = await withStateLock('rounds', async (dir) => {
1184
- const round = await roundStatus(string(value.roundId, 'coverage.roundId', 180));
1248
+ const round = await roundStatus(roundId);
1185
1249
  if (round.completed) throw new Error('Cannot record coverage for a completed round.');
1250
+ const audit = discoveryProjection(await jsonLines(join(dir, 'discovery.ndjson')), { roundId: value.roundId });
1251
+ const leads = audit.leads.filter(lead => lead.sourceId === sourceId);
1252
+ const derivedReviewed = status === 'blocked' ? 0 : leads.length;
1253
+ const derivedQualified = status === 'blocked' ? 0 : leads.filter(lead => !lead.conflict && lead.disposition === 'qualified').length;
1254
+ const reviewedCount = round.discoveryPolicyVersion === 2 ? derivedReviewed : integer(value.reviewedCount, 'coverage.reviewedCount', 0, 10000);
1255
+ const qualifiedCount = round.discoveryPolicyVersion === 2 ? derivedQualified : integer(value.qualifiedCount, 'coverage.qualifiedCount', 0, reviewedCount);
1256
+ if (round.discoveryPolicyVersion === 2 && ((value.reviewedCount != null && value.reviewedCount !== reviewedCount) || (value.qualifiedCount != null && value.qualifiedCount !== qualifiedCount))) throw new Error('Source count assertions do not match recorded leads.');
1257
+ if (status === 'blocked' && (!SOURCE_BLOCKERS.has(blocker) || reviewedCount !== 0 || qualifiedCount !== 0)) throw new Error('Blocked sources require a documented blocker and zero counts.');
1186
1258
  const applications = await jsonLines(join(dir, 'applications.ndjson'));
1187
1259
  for (const id of applicationIds) {
1188
1260
  const entry = applications.find((item) => item.id === id && item.roundId === round.roundId && item.status === 'submitted');
@@ -1217,6 +1289,16 @@ function replayAttention(events, roundId = null) {
1217
1289
  async function attentionList(roundId = null) {
1218
1290
  const events = await jsonLines(join(await ensureStateDir(), 'attention.ndjson'));
1219
1291
  const items = replayAttention(events, roundId);
1292
+ const dir = await ensureStateDir();
1293
+ const apps = await jsonLines(join(dir, 'applications.ndjson'));
1294
+ const delivery = deliveryProjection(apps, await jsonLines(join(dir, 'delivery.ndjson')));
1295
+ for (const item of delivery.applications.filter(a => a.failed || a.conflict)) {
1296
+ const app = apps.find(a => a.id === item.applicationId);
1297
+ if (roundId && app.roundId !== roundId) continue;
1298
+ items.push({ id: `delivery:${app.id}`, applicationId: app.id, roundId: app.roundId ?? null, url: app.url, stage: 'confirmation', blocker: item.conflict ? 'delivery-conflict' : 'delivery-failed', requiredActions: ['review-delivery'], derived: true });
1299
+ }
1300
+ const discovery = discoveryProjection(await jsonLines(join(dir, 'discovery.ndjson')), { roundId });
1301
+ for (const lead of discovery.leads.filter(l => l.conflict)) items.push({ id: `discovery:${lead.key}`, roundId: lead.roundId, url: lead.url, stage: 'discovery', blocker: 'assessment-conflict', requiredActions: ['review-assessment'], derived: true });
1220
1302
  return { count: items.length, items };
1221
1303
  }
1222
1304
 
@@ -1251,6 +1333,7 @@ async function attentionResolve(input) {
1251
1333
  for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown attention resolution property: ${key}.`);
1252
1334
  const id = string(value.id, 'attention.id', 180);
1253
1335
  const current = await attentionList();
1336
+ if (current.items.some(item => item.id === id && item.derived)) throw new Error('Resolve accounting attention with a delivery correction, verified recovery, or lead revision.');
1254
1337
  if (!current.items.some((item) => item.id === id)) throw new Error('Attention item is not active.');
1255
1338
  const event = { type: 'resolved', id, resolvedAt: isoDate(value.resolvedAt, 'attention.resolvedAt') };
1256
1339
  await appendPrivateEvent('attention', event);
@@ -1267,7 +1350,11 @@ async function roundStatus(roundId = null) {
1267
1350
  if (!started) throw new Error('Application round was not found.');
1268
1351
  const matching = (await jsonLines(join(dir, 'applications.ndjson'))).filter((entry) => entry.roundId === id && entry.status === 'submitted');
1269
1352
  const applications = [...new Map(matching.map((entry, index) => [canonicalApplicationKey(entry, String(index)), entry])).values()];
1270
- const confirmedCount = new Set(applications.map((entry, index) => canonicalApplicationKey(entry, String(index)))).size;
1353
+ const delivery = deliveryProjection(matching, await jsonLines(join(dir, 'delivery.ndjson')));
1354
+ const effectiveKeys = new Set(matching.filter((entry,i) => delivery.applications[i].counted).map(canonicalApplicationKey));
1355
+ const effectiveApplications = applications.filter(entry => effectiveKeys.has(canonicalApplicationKey(entry)));
1356
+ const confirmedCount = delivery.effectiveSubmissionCount;
1357
+ const audit = started.discoveryPolicyVersion === 2 ? discoveryProjection(await jsonLines(join(dir, 'discovery.ndjson')), { roundId: id }) : null;
1271
1358
  const attention = await attentionList(id);
1272
1359
  const completion = events.find((event) => event.type === 'completed' && event.roundId === id);
1273
1360
  return {
@@ -1277,7 +1364,16 @@ async function roundStatus(roundId = null) {
1277
1364
  remainingCount: Math.max(0, started.requestedCount - confirmedCount),
1278
1365
  blockedCount: attention.count,
1279
1366
  completed: Boolean(completion),
1280
- discovery: discoverySummary(events.filter((event) => event.roundId === id), applications, completion),
1367
+ discoveryPolicyVersion: started.discoveryPolicyVersion ?? 1,
1368
+ recordedSubmissionCount: delivery.recordedSubmissionCount,
1369
+ effectiveSubmissionCount: confirmedCount,
1370
+ failedDeliveryCount: delivery.failedDeliveryCount,
1371
+ receiptUnknownEmailCount: delivery.receiptUnknownEmailCount,
1372
+ shortfallCount: Math.max(0, started.requestedCount - confirmedCount),
1373
+ needsRecovery: Boolean(completion) && confirmedCount < started.requestedCount,
1374
+ discovery: { ...discoverySummary(events.filter((event) => event.roundId === id), effectiveApplications, completion, audit),
1375
+ ...(audit ? { reviewedCount: audit.reviewedCount, qualifiedCount: audit.qualifiedCount, uniqueLeadCount: audit.uniqueLeadCount, dispositionCounts: audit.dispositionCounts, conflicts: audit.conflicts,
1376
+ missingLeadApplicationIds: effectiveApplications.filter(app => !audit.leads.some(lead => !lead.conflict && lead.disposition === 'qualified' && lead.applicationId === app.id && lead.sourceId === (app.discoverySourceId ?? events.filter(e => e.type === 'source-checked' && e.applicationIds?.includes(app.id)).at(-1)?.sourceId) && (normalizedText(lead.company) === normalizedText(app.company) && (lead.employerJobId && app.employerJobId ? lead.employerJobId.toLowerCase() === app.employerJobId.toLowerCase() : canonicalUrl(lead.url) === canonicalUrl(app.url))))).map(app => app.id) } : { accounting: 'legacy-unverified' }) },
1281
1377
  startedAt: started.occurredAt,
1282
1378
  ...(completion ? { completedAt: completion.occurredAt } : {}),
1283
1379
  };
@@ -1294,6 +1390,7 @@ async function roundComplete(input) {
1294
1390
  if (status.confirmedCount < status.requestedCount) throw new Error(`Round requires ${status.requestedCount} confirmed submissions before completion.`);
1295
1391
  if (!status.discovery.coverageSatisfied) throw new Error('Round requires attempts across at least 3 distinct discovery sources, including at least one searched source. Record coverage and blockers with round source --stdin.');
1296
1392
  if (status.discovery.unattributedCount) throw new Error('Round requires discovery source attribution for every confirmed submission. Supply missing attribution using round source applicationIds; do not rewrite the ledger.');
1393
+ if (status.discovery.conflicts?.length || status.discovery.missingLeadApplicationIds?.length) throw new Error('Round requires qualified lead records for every submission and resolution of conflicting assessments.');
1297
1394
  let explanation = {};
1298
1395
  if (status.discovery.concentrationNeedsExplanation || value.concentrationReason != null || value.concentrationEvidence != null) {
1299
1396
  if (!CONCENTRATION_REASONS.has(value.concentrationReason)) throw new Error('Source concentration requires a documented concentrationReason and private concentrationEvidence.');
@@ -1399,7 +1496,7 @@ async function ledgerReview() {
1399
1496
  const applications = await jsonLines(join(dir, 'applications.ndjson'));
1400
1497
  const outcomes = await jsonLines(join(dir, 'outcomes.ndjson'));
1401
1498
  const acknowledgements = await jsonLines(join(dir, 'reviews.ndjson'));
1402
- return buildReview(applications, outcomes, acknowledgements);
1499
+ return buildReview(applications, outcomes, acknowledgements, new Date(), await jsonLines(join(dir, 'delivery.ndjson')));
1403
1500
  }
1404
1501
 
1405
1502
  async function ledgerReviewAcknowledge(input) {
@@ -1407,7 +1504,7 @@ async function ledgerReviewAcknowledge(input) {
1407
1504
  const reviewedAt = string(value.reviewedAt ?? new Date().toISOString(), 'reviewedAt', 80);
1408
1505
  if (Number.isNaN(Date.parse(reviewedAt))) throw new Error('reviewedAt must be an ISO date.');
1409
1506
  const review = await ledgerReview();
1410
- const event = { reviewedAt, uniqueSubmissionCount: review.uniqueSubmittedTotal, maturedApplicationCount: review.maturedApplications };
1507
+ const event = { reviewedAt, uniqueSubmissionCount: review.recordedSubmissionCount, maturedApplicationCount: review.recordedMaturedApplicationCount };
1411
1508
  await withStateLock('reviews', async (dir) => {
1412
1509
  const file = join(dir, 'reviews.ndjson');
1413
1510
  await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
@@ -1547,6 +1644,9 @@ async function executeCommand([area, action, value], telemetry, session, communi
1547
1644
  const event = await telemetryJobAssessed(job, result);
1548
1645
  if (event) domainEvents.push(event);
1549
1646
  } else if (area === 'ledger' && action === 'check' && value === '--stdin') result = await ledgerCheck(await jsonStdin());
1647
+ else if (area === 'ledger' && action === 'delivery' && value === '--stdin') result = await recordDelivery(await jsonStdin());
1648
+ else if (area === 'ledger' && action === 'retry' && value === '--stdin') result = await recordDelivery(await jsonStdin(), true);
1649
+ else if (area === 'ledger' && action === 'deliveries') result = await deliveryHistory(value);
1550
1650
  else if (area === 'ledger' && action === 'add' && value === '--stdin') {
1551
1651
  const input = await jsonStdin();
1552
1652
  const telemetryDetails = validateSubmissionTelemetry(input.telemetry);
@@ -1569,6 +1669,8 @@ async function executeCommand([area, action, value], telemetry, session, communi
1569
1669
  else if (area === 'autonomy' && action === 'preview' && value == null) result = await autonomyStatus();
1570
1670
  else if (area === 'autonomy' && action === 'revoke' && value == null) result = await autonomyRevoke();
1571
1671
  else if (area === 'round' && action === 'start' && value === '--stdin') result = await roundStart(await jsonStdin());
1672
+ else if (area === 'round' && action === 'lead' && value === '--stdin') result = await recordLead(await jsonStdin());
1673
+ else if (area === 'round' && action === 'leads') result = await leadHistory(value);
1572
1674
  else if (area === 'round' && action === 'source' && value === '--stdin') {
1573
1675
  result = await roundSource(await jsonStdin());
1574
1676
  domainEvents.push({ event: 'source_checked', properties: {
@@ -1 +1 @@
1
- export const SKILL_VERSION = '3.4.1';
1
+ export const SKILL_VERSION = '3.5.0';
@@ -0,0 +1,86 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync, spawnSync } from 'node:child_process';
3
+ import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import test from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+ const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
9
+ async function fixture(t) {
10
+ const dir = await mkdtemp(join(tmpdir(), 'accounting-cli-'));
11
+ t.after(() => rm(dir, { recursive: true, force: true }));
12
+ await writeFile(join(dir, 'telemetry.json'), JSON.stringify({ enabled: false, disclosed: true }));
13
+ const env = { ...process.env, JOB_APPLICATION_AGENT_STATE_DIR: dir, JOB_APPLICATION_AGENT_CLOUD_CONFIG: join(dir, 'absent.json'), JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL: 'http://127.0.0.1:9' };
14
+ const run = (args, input) => JSON.parse(execFileSync(process.execPath, [script, ...args], { env, encoding: 'utf8', input: JSON.stringify(input) }));
15
+ const fail = (args, input) => spawnSync(process.execPath, [script, ...args], { env, encoding: 'utf8', input: JSON.stringify(input) });
16
+ return { dir, run, fail };
17
+ }
18
+ const application = (roundId) => ({ id: 'app', company: 'Example', role: 'Senior Engineer', url: 'https://example.test/jobs/1', source: 'email', discoverySourceId: 'indeed', score: 90, status: 'submitted', submittedAt: '2026-01-01T00:00:00Z', approval: 'STANDING AUTHORIZATION', roundId });
19
+ const failure = { id: 'bounce-1', applicationId: 'app', attemptId: 'initial:app', type: 'delivery-failed', evidenceType: 'final-delivery-failure', evidence: 'Matched final failure for the original recruiting message.', occurredAt: '2026-01-02T00:00:00Z' };
20
+
21
+ test('late delivery failure corrects completed round and review without rewriting applications', async (t) => {
22
+ const {dir, run} = await fixture(t);
23
+ const roundId = 'legacy-round';
24
+ await writeFile(join(dir, 'rounds.ndjson'), [ { type:'started', roundId, requestedCount:1, occurredAt:'2026-01-01T00:00:00Z'}, {type:'completed',roundId,occurredAt:'2026-01-01T01:00:00Z'} ].map(JSON.stringify).join('\n')+'\n');
25
+ const raw = JSON.stringify(application(roundId))+'\n';
26
+ await writeFile(join(dir, 'applications.ndjson'), raw);
27
+ run(['ledger','delivery','--stdin'], failure);
28
+ run(['ledger','delivery','--stdin'], failure);
29
+ const status = run(['round','status',roundId]);
30
+ assert.equal(status.completed,true);
31
+ assert.equal(status.confirmedCount,0);
32
+ assert.equal(status.shortfallCount,1);
33
+ assert.equal(status.needsRecovery,true);
34
+ assert.equal(run(['ledger','review']).submittedTotal,0);
35
+ assert.equal(await readFile(join(dir,'applications.ndjson'),'utf8'),raw);
36
+ assert.equal((await readFile(join(dir,'delivery.ndjson'),'utf8')).trim().split('\n').length,1);
37
+ });
38
+
39
+ test('new rounds derive source totals and require qualified lead linkage', async (t) => {
40
+ const {run,fail} = await fixture(t);
41
+ const {roundId} = run(['round','start','--stdin'],{requestedCount:1});
42
+ assert.notEqual(fail(['round','source','--stdin'],{roundId,sourceId:'indeed',status:'searched',reviewedCount:20,qualifiedCount:1,evidence:'Unsupported summary'}).status,0);
43
+ const lead = { id:'lead-1',roundId,sourceId:'indeed',company:'Example',role:'Senior Engineer',url:'https://example.test/jobs/1',disposition:'qualified',evidence:'Meets the evidenced target requirements.',applicationId:'app',observedAt:'2026-01-01T00:00:00Z' };
44
+ run(['round','lead','--stdin'],lead);
45
+ run(['round','lead','--stdin'],lead);
46
+ for(const sourceId of ['indeed','linkedin-jobs-feed','hacker-news-who-is-hiring']) run(['round','source','--stdin'],{roundId,sourceId,status:'searched',evidence:'Actual synthetic search'});
47
+ run(['ledger','add','--stdin'],application(roundId));
48
+ const status=run(['round','status',roundId]);
49
+ assert.equal(status.discovery.sources.find(s=>s.sourceId==='indeed').reviewedCount,1);
50
+ assert.equal(run(['round','leads',roundId]).leads.length,1);
51
+ assert.equal(run(['round','complete','--stdin'],{roundId,concentrationReason:'stronger-fit',concentrationEvidence:'Only this source had a qualified role.'}).completed,true);
52
+ run(['round','lead','--stdin'],{...lead,id:'revision',supersedes:lead.id,disposition:'closed-stale'});
53
+ assert.equal(run(['round','status',roundId]).completed,true);
54
+ assert.equal(run(['round','leads',roundId]).qualifiedCount,0);
55
+ assert.notEqual(fail(['round','lead','--stdin'],{...lead,id:'new-lead',url:'https://different.example/job'}).status,0);
56
+ });
57
+
58
+ for (const scenario of [
59
+ { name: 'different requisition IDs at the same URL', application: { employerJobId: 'req-1' }, lead: { employerJobId: 'req-2' }, matched: false },
60
+ { name: 'different companies sharing a requisition ID and URL', application: { employerJobId: 'req-1' }, lead: { employerJobId: 'req-1', company: 'Another Company' }, matched: false },
61
+ { name: 'different companies sharing a URL without requisition IDs', application: {}, lead: { company: 'Another Company' }, matched: false },
62
+ { name: 'matching company and requisition ID across URL aliases', application: { employerJobId: 'req-1' }, lead: { employerJobId: 'req-1', url: 'https://ats.example.test/alias/1' }, matched: true },
63
+ { name: 'matching company and URL when only one requisition ID is known', application: { employerJobId: 'req-1' }, lead: {}, matched: true },
64
+ ]) {
65
+ test(`qualified lead attribution checks ${scenario.name}`, async (t) => {
66
+ const { dir, run, fail } = await fixture(t);
67
+ const { roundId } = run(['round', 'start', '--stdin'], { requestedCount: 1 });
68
+ const app = { ...application(roundId), ...scenario.application };
69
+ await writeFile(join(dir, 'applications.ndjson'), JSON.stringify(app) + '\n');
70
+ run(['round', 'lead', '--stdin'], {
71
+ id: 'qualified-lead', roundId, sourceId: 'indeed', company: 'Example', role: app.role,
72
+ url: app.url, disposition: 'qualified', applicationId: app.id,
73
+ observedAt: '2026-01-01T00:00:00Z', evidence: 'Synthetic verified requisition assessment.',
74
+ ...scenario.lead,
75
+ });
76
+ for (const sourceId of ['indeed', 'linkedin-jobs-feed', 'hacker-news-who-is-hiring']) {
77
+ run(['round', 'source', '--stdin'], { roundId, sourceId, status: 'searched', evidence: 'Synthetic search performed.' });
78
+ }
79
+ const status = run(['round', 'status', roundId]);
80
+ assert.deepEqual(status.discovery.missingLeadApplicationIds, scenario.matched ? [] : [app.id]);
81
+ const completion = fail(['round', 'complete', '--stdin'], {
82
+ roundId, concentrationReason: 'stronger-fit', concentrationEvidence: 'Only the selected source had a qualifying opening.',
83
+ });
84
+ assert.equal(completion.status === 0, scenario.matched, completion.stderr);
85
+ });
86
+ }