job-application-agent 3.4.2 → 3.6.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.
Files changed (48) hide show
  1. package/README.md +23 -0
  2. package/installer/src/cli.mjs +8 -1
  3. package/installer/src/installer.mjs +8 -0
  4. package/job-application-agent/SKILL.md +32 -2
  5. package/job-application-agent/capabilities.json +3 -1
  6. package/job-application-agent/references/ACCOUNTING.md +104 -0
  7. package/job-application-agent/references/AUTONOMY.md +2 -0
  8. package/job-application-agent/references/CLOUD_STATE.md +2 -0
  9. package/job-application-agent/references/FREE_AI.md +39 -0
  10. package/job-application-agent/references/OUTREACH.md +210 -0
  11. package/job-application-agent/references/RUNS.md +35 -0
  12. package/job-application-agent/references/SCHEMAS.md +12 -2
  13. package/job-application-agent/references/agent-box/README.md +77 -0
  14. package/job-application-agent/references/agent-box/novnc.service.example +16 -0
  15. package/job-application-agent/scripts/application-accounting.mjs +246 -0
  16. package/job-application-agent/scripts/ats/answer-inject.mjs +203 -0
  17. package/job-application-agent/scripts/ats/submit-adapters.mjs +194 -0
  18. package/job-application-agent/scripts/attention-questions.mjs +111 -0
  19. package/job-application-agent/scripts/attention-resume-submit.mjs +450 -0
  20. package/job-application-agent/scripts/attention-runner-poll.mjs +328 -0
  21. package/job-application-agent/scripts/captcha-vendor.mjs +328 -0
  22. package/job-application-agent/scripts/cloud-state-client.mjs +62 -8
  23. package/job-application-agent/scripts/job-application.mjs +243 -27
  24. package/job-application-agent/scripts/novnc-display-guard.mjs +300 -0
  25. package/job-application-agent/scripts/outreach-cli.mjs +95 -0
  26. package/job-application-agent/scripts/outreach-domain.mjs +287 -0
  27. package/job-application-agent/scripts/outreach-store.mjs +72 -0
  28. package/job-application-agent/scripts/session-binding.mjs +474 -0
  29. package/job-application-agent/scripts/version.mjs +1 -1
  30. package/job-application-agent/tests/accounting-cli.test.mjs +86 -0
  31. package/job-application-agent/tests/accounting-cloud-client.test.mjs +183 -0
  32. package/job-application-agent/tests/accounting-retry-cli.test.mjs +96 -0
  33. package/job-application-agent/tests/accounting-source-race.test.mjs +109 -0
  34. package/job-application-agent/tests/answer-inject-captcha.test.mjs +169 -0
  35. package/job-application-agent/tests/application-accounting.test.mjs +315 -0
  36. package/job-application-agent/tests/attention-resume-submit.test.mjs +119 -0
  37. package/job-application-agent/tests/attention-runner-poll.test.mjs +135 -0
  38. package/job-application-agent/tests/fixtures/outreach.mjs +22 -0
  39. package/job-application-agent/tests/job-application.test.mjs +5 -0
  40. package/job-application-agent/tests/novnc-display-guard.test.mjs +50 -0
  41. package/job-application-agent/tests/outreach-cli.test.mjs +73 -0
  42. package/job-application-agent/tests/outreach.test.mjs +178 -0
  43. package/job-application-agent/tests/privacy-audit.test.mjs +2 -0
  44. package/job-application-agent/tests/review-cadence.test.mjs +66 -0
  45. package/job-application-agent/tests/session-binding.test.mjs +102 -0
  46. package/job-application-agent/tests/skill-contract.test.mjs +25 -0
  47. package/job-application-agent/tests/workflow-state.test.mjs +30 -3
  48. package/package.json +6 -3
@@ -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,
@@ -341,8 +391,12 @@ export class CloudStateClient {
341
391
  for (const item of status.documents ?? []) documents[item.name] = await this.getDocument(item.name);
342
392
  const streams = {};
343
393
  for (const stream of Object.keys(CLOUD_STREAM_FILES)) streams[stream] = await this.listStream(stream);
394
+ const outreach = status.capabilities?.includes('outreach-tracking-v1') ? {
395
+ snapshot: await (await this.request('/v2/outreach/snapshot')).json(),
396
+ deletionManifest: await (await this.request('/v2/outreach/deletions')).json(),
397
+ } : undefined;
344
398
  const output = path ?? join(this.stateDir, `cloud-export-${new Date().toISOString().replaceAll(':', '-')}.json`);
345
- await privateWrite(output, `${JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), backend: status.backend, documents, streams, files: status.files ?? [] })}\n`);
399
+ await privateWrite(output, `${JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), backend: status.backend, documents, streams, files: status.files ?? [], ...(outreach ? { outreach } : {}) })}\n`);
346
400
  return { path: output, documents: Object.keys(documents).length, records: Object.values(streams).reduce((sum, rows) => sum + rows.length, 0) };
347
401
  }
348
402
  }
@@ -353,7 +407,7 @@ async function readNdjson(path) {
353
407
  }
354
408
 
355
409
  function key(value) {
356
- return JSON.stringify(value);
410
+ return stableJson(value);
357
411
  }
358
412
 
359
413
  function multiset(values) {
@@ -7,12 +7,25 @@ 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';
13
14
  import { TelemetryClient } from './telemetry-client.mjs';
14
15
  import { jobIdentity } from './telemetry-schema.mjs';
15
16
  import { CloudStateClient, defaultCloudConfigPath, enableCloudUpdateGuard, saveCloudConfig } from './cloud-state-client.mjs';
17
+ import {
18
+ SESSION_BINDING_ATTENTION_KEYS,
19
+ createSessionBinding,
20
+ extractSessionBindingFields,
21
+ sessionBindingPath,
22
+ writeSessionBindingFile,
23
+ } from './session-binding.mjs';
24
+ import {
25
+ detectAiAssistanceDiscouraged,
26
+ extractNarrativeQuestionsFromText,
27
+ normalizeAttentionQuestions,
28
+ } from './attention-questions.mjs';
16
29
 
17
30
  const SOURCES = new Set(['linkedin', 'greenhouse', 'lever', 'ashby', 'workable', 'comeet', 'workday', 'rippling', 'smartrecruiters', 'google-form', 'company', 'email', 'other']);
18
31
  const DISCOVERY_SOURCES = new Set(['direct-company', 'linkedin', 'x', 'yc', 'hacker-news', 'job-board', 'email', 'user-supplied', 'web-search', 'other']);
@@ -506,12 +519,7 @@ function rolesLikelySame(left, right) {
506
519
  return shared / Math.min(leftTokens.size, rightTokens.size) >= 0.75;
507
520
  }
508
521
 
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
- }
522
+ const canonicalApplicationKey = accountingApplicationKey;
515
523
 
516
524
  function businessDaysBetween(startValue, endValue) {
517
525
  const start = new Date(startValue);
@@ -525,8 +533,10 @@ function businessDaysBetween(startValue, endValue) {
525
533
  return days;
526
534
  }
527
535
 
528
- export function buildReview(entries, outcomeEntries = [], acknowledgements = [], now = new Date()) {
536
+ export function buildReview(entries, outcomeEntries = [], acknowledgements = [], now = new Date(), deliveryEvents = []) {
529
537
  const submissions = entries.filter((entry) => !Number.isNaN(Date.parse(entry.submittedAt)));
538
+ const delivery = deliveryProjection(submissions, deliveryEvents);
539
+ const effectiveKeys = new Set(submissions.filter((entry, i) => delivery.applications[i].counted).map(canonicalApplicationKey));
530
540
  const explicitOutcomes = outcomeEntries.length > 0;
531
541
  const outcomes = explicitOutcomes ? outcomeEntries : entries.filter((entry) => ['interview', 'rejected', 'offer', 'withdrawn'].includes(entry.status));
532
542
  const groups = new Map();
@@ -535,7 +545,8 @@ export function buildReview(entries, outcomeEntries = [], acknowledgements = [],
535
545
  if (!groups.has(key)) groups.set(key, []);
536
546
  groups.get(key).push(entry);
537
547
  });
538
- const canonical = [...groups.values()].map((group) => [...group].sort((a, b) => Date.parse(a.submittedAt) - Date.parse(b.submittedAt))[0]);
548
+ const recordedCanonical = [...groups.values()].map(group => [...group].sort((a, b) => Date.parse(a.submittedAt) - Date.parse(b.submittedAt))[0]);
549
+ const canonical = [...groups].filter(([key]) => effectiveKeys.has(key)).map(([,group]) => [...group].sort((a, b) => Date.parse(a.submittedAt) - Date.parse(b.submittedAt))[0]);
539
550
  const outcomesById = new Map();
540
551
  for (const outcome of outcomes) {
541
552
  if (!outcomesById.has(outcome.id)) outcomesById.set(outcome.id, []);
@@ -544,7 +555,7 @@ export function buildReview(entries, outcomeEntries = [], acknowledgements = [],
544
555
  const canonicalOutcomes = [];
545
556
  const matureCanonicalOutcomes = [];
546
557
  const canonicalInterviewDetails = [];
547
- for (const group of groups.values()) {
558
+ for (const [groupKey, group] of groups) {
548
559
  const candidates = explicitOutcomes
549
560
  ? group.flatMap((entry) => outcomesById.get(entry.id) ?? [])
550
561
  : group.filter((entry) => ['interview', 'rejected', 'offer', 'withdrawn'].includes(entry.status));
@@ -559,14 +570,15 @@ export function buildReview(entries, outcomeEntries = [], acknowledgements = [],
559
570
  source: canonicalApplication.source,
560
571
  score: canonicalApplication.score,
561
572
  });
562
- if (businessDaysBetween(canonicalApplication.submittedAt, now) >= 10) matureCanonicalOutcomes.push(latest);
573
+ if (effectiveKeys.has(groupKey) && businessDaysBetween(canonicalApplication.submittedAt, now) >= 10) matureCanonicalOutcomes.push(latest);
563
574
  }
564
575
  }
565
576
  const maturedApplications = canonical.filter((entry) => businessDaysBetween(entry.submittedAt, now) >= 10).length;
566
577
  const lastAck = acknowledgements.length ? acknowledgements[acknowledgements.length - 1] : {};
567
- const submittedSinceLastReview = Math.max(0, canonical.length - (lastAck.uniqueSubmissionCount ?? 0));
578
+ const recordedMaturedApplicationCount = recordedCanonical.filter(entry => businessDaysBetween(entry.submittedAt, now) >= 10).length;
579
+ const submittedSinceLastReview = Math.max(0, recordedCanonical.length - (lastAck.uniqueSubmissionCount ?? 0));
568
580
  const hygieneDue = submittedSinceLastReview >= 10;
569
- const outcomeDue = maturedApplications - (lastAck.maturedApplicationCount ?? 0) >= 20;
581
+ const outcomeDue = recordedMaturedApplicationCount - (lastAck.maturedApplicationCount ?? 0) >= 20;
570
582
  const reviewReasons = [...(hygieneDue ? ['submission-hygiene'] : []), ...(outcomeDue ? ['outcome-effectiveness'] : [])];
571
583
  const outcomeCounts = Object.fromEntries(['interview', 'rejected', 'offer', 'withdrawn'].map((status) => [status, canonicalOutcomes.filter((entry) => entry.status === status).length]));
572
584
  const matureOutcomeCounts = Object.fromEntries(['interview', 'rejected', 'offer', 'withdrawn'].map((status) => [status, matureCanonicalOutcomes.filter((entry) => entry.status === status).length]));
@@ -598,9 +610,14 @@ export function buildReview(entries, outcomeEntries = [], acknowledgements = [],
598
610
  submittedTotal: canonical.length,
599
611
  uniqueSubmittedTotal: canonical.length,
600
612
  rawSubmissionRows: submissions.length,
601
- duplicateSubmissionRows: submissions.length - canonical.length,
613
+ duplicateSubmissionRows: submissions.length - groups.size,
614
+ recordedSubmissionCount: groups.size,
615
+ effectiveSubmissionCount: canonical.length,
616
+ failedDeliveryCount: delivery.failedDeliveryCount,
617
+ receiptUnknownEmailCount: delivery.receiptUnknownEmailCount,
602
618
  submittedSinceLastReview,
603
619
  maturedApplications,
620
+ recordedMaturedApplicationCount,
604
621
  outcomeCounts,
605
622
  matureOutcomeCounts,
606
623
  reasonCounts,
@@ -1081,7 +1098,7 @@ async function ledgerAdd(entryInput, duplicateOverride, companyReapplyOverride,
1081
1098
  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
1099
  }
1083
1100
  }
1084
- return { recorded: storedEntry.id, review: buildReview([...entries, storedEntry]), ...(cloudIntent ? { cloudIntent: cloudIntent.intentId } : {}) };
1101
+ return { recorded: storedEntry.id, review: buildReview([...entries, storedEntry], outcomes, [], new Date(), await jsonLines(join(dir, 'delivery.ndjson'))), ...(cloudIntent ? { cloudIntent: cloudIntent.intentId } : {}) };
1085
1102
  });
1086
1103
  }
1087
1104
 
@@ -1107,6 +1124,63 @@ function isoDate(value, label) {
1107
1124
  return result;
1108
1125
  }
1109
1126
 
1127
+ async function appendAccounting(stream, event, { cloudConfirmed = false } = {}) {
1128
+ const file = join(await ensureStateDir(), `${stream}.ndjson`);
1129
+ const previous = await jsonLines(file);
1130
+ const matches = previous.filter(e => e.id === event.id);
1131
+ if (matches.some(e => stableJson(e) !== stableJson(event))) throw new Error('Conflicting accounting event ID.');
1132
+ if (matches.length) return { recorded: false, duplicate: true, event };
1133
+ await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
1134
+ await chmod(file, 0o600);
1135
+ if (!cloudConfirmed && await cloudState.configured()) await cloudState.appendRecord(stream, event, { recordKey: event.id, idempotencyKey: `accounting:${event.id}`, occurredAt: event.occurredAt ?? event.observedAt, queueOnFailure: true });
1136
+ return { recorded: true, duplicate: false, event };
1137
+ }
1138
+
1139
+ async function recordDelivery(input, retry = false) {
1140
+ const { cloudIntentId, cloudLeaseId, ...raw } = object(input, 'delivery');
1141
+ const event = validateDelivery({ ...raw, ...(retry ? { type: 'retry-confirmed' } : {}) });
1142
+ if (!retry && event.type === 'retry-confirmed') throw new Error('Use ledger retry for replacement transmissions.');
1143
+ return withStateLock('applications', () => withStateLock('delivery', async dir => {
1144
+ const applications = await jsonLines(join(dir, 'applications.ndjson'));
1145
+ const events = await jsonLines(join(dir, 'delivery.ndjson'));
1146
+ const cloudRetry = retry && await cloudState.configured();
1147
+ 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.');
1148
+ // Cloud transmission eligibility was checked at intent preparation. The
1149
+ // Worker verifies that intent before we persist the observed transmission.
1150
+ validateDeliveryReferences(event, applications, events, { preparedRetry: cloudRetry });
1151
+ let cloudConfirmed = false;
1152
+ if (cloudRetry && !events.some(e => e.id === event.id)) {
1153
+ await cloudState.confirmRetry(cloudIntentId, event, cloudLeaseId);
1154
+ cloudConfirmed = true;
1155
+ }
1156
+ const result = await appendAccounting('delivery', event, { cloudConfirmed });
1157
+ return { ...result, delivery: deliveryProjection(applications, [...events, event]) };
1158
+ }));
1159
+ }
1160
+ async function deliveryHistory(applicationId) {
1161
+ const dir = await ensureStateDir();
1162
+ const apps = (await jsonLines(join(dir, 'applications.ndjson'))).filter(a => !applicationId || a.id === applicationId);
1163
+ const events = (await jsonLines(join(dir, 'delivery.ndjson'))).filter(e => !applicationId || e.applicationId === applicationId);
1164
+ return { ...deliveryProjection(apps, events), events };
1165
+ }
1166
+ async function recordLead(input) {
1167
+ const event = validateLead(input);
1168
+ knownDiscoverySourceId(event.sourceId, 'lead.sourceId');
1169
+ return withStateLock('rounds', async dir => {
1170
+ const round = await roundStatus(event.roundId);
1171
+ const events = await jsonLines(join(dir, 'discovery.ndjson'));
1172
+ if (round.completed && !event.supersedes && !events.some(e => e.id === event.id)) throw new Error('Completed rounds accept only corrections to existing leads.');
1173
+ validateLeadReferences(event, events);
1174
+ return appendAccounting('discovery', event);
1175
+ });
1176
+ }
1177
+ async function leadHistory(roundId) {
1178
+ const dir = await ensureStateDir();
1179
+ const id = roundId ?? (await roundStatus()).roundId;
1180
+ const history = (await jsonLines(join(dir, 'discovery.ndjson'))).filter(e => e.roundId === id);
1181
+ return { ...discoveryProjection(history, { roundId: id }), history };
1182
+ }
1183
+
1110
1184
  async function roundStart(input) {
1111
1185
  const value = object(input, 'round');
1112
1186
  const allowed = new Set(['requestedCount', 'startedAt']);
@@ -1114,6 +1188,7 @@ async function roundStart(input) {
1114
1188
  const event = {
1115
1189
  type: 'started',
1116
1190
  roundId: `round-${new Date().toISOString().slice(0, 10)}-${randomUUID()}`,
1191
+ discoveryPolicyVersion: 2,
1117
1192
  requestedCount: integer(value.requestedCount, 'round.requestedCount', 1, 1000),
1118
1193
  occurredAt: isoDate(value.startedAt, 'round.startedAt'),
1119
1194
  };
@@ -1127,7 +1202,7 @@ function discoveryGroup(id) {
1127
1202
  return id;
1128
1203
  }
1129
1204
 
1130
- function discoverySummary(events, applications, completion) {
1205
+ function discoverySummary(events, applications, completion, audit = null) {
1131
1206
  const latest = new Map();
1132
1207
  const searchedIds = new Set();
1133
1208
  const attribution = new Map();
@@ -1136,7 +1211,10 @@ function discoverySummary(events, applications, completion) {
1136
1211
  if (event.status === 'searched') searchedIds.add(event.sourceId);
1137
1212
  for (const id of event.applicationIds ?? []) attribution.set(id, event.sourceId);
1138
1213
  }
1139
- const sources = [...latest.values()];
1214
+ const sources = [...latest.values()].map(report => {
1215
+ const leads = audit?.leads.filter(lead => lead.sourceId === report.sourceId) ?? [];
1216
+ return audit ? { ...report, reviewedCount: leads.length, qualifiedCount: leads.filter(lead => !lead.conflict && lead.disposition === 'qualified').length, accounting: 'per-lead' } : { ...report, accounting: 'legacy-unverified' };
1217
+ });
1140
1218
  const eligible = sources.filter((item) => !['recruiter-inbound', 'user-supplied-leads'].includes(item.sourceId));
1141
1219
  const attempted = new Set(eligible.map((item) => discoveryGroup(item.sourceId)));
1142
1220
  const searched = new Set(eligible.filter((item) => searchedIds.has(item.sourceId)).map((item) => discoveryGroup(item.sourceId)));
@@ -1172,17 +1250,23 @@ async function roundSource(input) {
1172
1250
  const sourceId = knownDiscoverySourceId(value.sourceId, 'coverage.sourceId');
1173
1251
  const status = string(value.status, 'coverage.status', 20);
1174
1252
  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);
1253
+ const roundId = string(value.roundId, 'coverage.roundId', 180);
1177
1254
  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
1255
  if (status === 'searched' && blocker != null) throw new Error('Searched sources cannot have a blocker.');
1180
1256
  const evidence = string(value.evidence, 'coverage.evidence', 2000);
1181
1257
  const applicationIds = value.applicationIds == null ? [] : [...new Set(stringArray(value.applicationIds, 'coverage.applicationIds'))];
1182
1258
  if (applicationIds.length > 1000 || (status === 'blocked' && applicationIds.length)) throw new Error('Invalid coverage.applicationIds.');
1183
1259
  const event = await withStateLock('rounds', async (dir) => {
1184
- const round = await roundStatus(string(value.roundId, 'coverage.roundId', 180));
1260
+ const round = await roundStatus(roundId);
1185
1261
  if (round.completed) throw new Error('Cannot record coverage for a completed round.');
1262
+ const audit = discoveryProjection(await jsonLines(join(dir, 'discovery.ndjson')), { roundId: value.roundId });
1263
+ const leads = audit.leads.filter(lead => lead.sourceId === sourceId);
1264
+ const derivedReviewed = status === 'blocked' ? 0 : leads.length;
1265
+ const derivedQualified = status === 'blocked' ? 0 : leads.filter(lead => !lead.conflict && lead.disposition === 'qualified').length;
1266
+ const reviewedCount = round.discoveryPolicyVersion === 2 ? derivedReviewed : integer(value.reviewedCount, 'coverage.reviewedCount', 0, 10000);
1267
+ const qualifiedCount = round.discoveryPolicyVersion === 2 ? derivedQualified : integer(value.qualifiedCount, 'coverage.qualifiedCount', 0, reviewedCount);
1268
+ 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.');
1269
+ if (status === 'blocked' && (!SOURCE_BLOCKERS.has(blocker) || reviewedCount !== 0 || qualifiedCount !== 0)) throw new Error('Blocked sources require a documented blocker and zero counts.');
1186
1270
  const applications = await jsonLines(join(dir, 'applications.ndjson'));
1187
1271
  for (const id of applicationIds) {
1188
1272
  const entry = applications.find((item) => item.id === id && item.roundId === round.roundId && item.status === 'submitted');
@@ -1217,12 +1301,75 @@ function replayAttention(events, roundId = null) {
1217
1301
  async function attentionList(roundId = null) {
1218
1302
  const events = await jsonLines(join(await ensureStateDir(), 'attention.ndjson'));
1219
1303
  const items = replayAttention(events, roundId);
1304
+ const dir = await ensureStateDir();
1305
+ const apps = await jsonLines(join(dir, 'applications.ndjson'));
1306
+ const delivery = deliveryProjection(apps, await jsonLines(join(dir, 'delivery.ndjson')));
1307
+ for (const item of delivery.applications.filter(a => a.failed || a.conflict)) {
1308
+ const app = apps.find(a => a.id === item.applicationId);
1309
+ if (roundId && app.roundId !== roundId) continue;
1310
+ 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 });
1311
+ }
1312
+ const discovery = discoveryProjection(await jsonLines(join(dir, 'discovery.ndjson')), { roundId });
1313
+ 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
1314
  return { count: items.length, items };
1221
1315
  }
1222
1316
 
1317
+ async function attentionNotifyHook(event, context = {}) {
1318
+ const notifyUrl = process.env.ATTENTION_NOTIFY_URL?.trim();
1319
+ const notifySecret = process.env.ATTENTION_NOTIFY_SECRET?.trim();
1320
+ if (!notifyUrl || !notifySecret) return { attempted: false, reason: 'notify_unconfigured' };
1321
+
1322
+ let email = '';
1323
+ try { email = String(storedProfileRaw()?.email ?? '').trim().toLowerCase(); } catch { email = ''; }
1324
+ if (!email) {
1325
+ console.error('[attention-notify] profile email missing; skip notify fail-closed');
1326
+ return { attempted: true, ok: false, error: 'profile_email_missing' };
1327
+ }
1328
+
1329
+ const company = String(context.company ?? '').trim() || 'Company';
1330
+ const role = String(context.role ?? '').trim() || 'Role';
1331
+ try {
1332
+ const response = await fetch(notifyUrl, {
1333
+ method: 'POST',
1334
+ headers: {
1335
+ authorization: `Bearer ${notifySecret}`,
1336
+ 'content-type': 'application/json',
1337
+ },
1338
+ body: JSON.stringify({
1339
+ attentionId: event.id,
1340
+ email,
1341
+ company,
1342
+ role,
1343
+ url: event.url,
1344
+ stage: event.stage,
1345
+ blocker: event.blocker,
1346
+ requiredActions: event.requiredActions,
1347
+ questions: event.questions ?? [],
1348
+ aiAssistanceDiscouraged: Boolean(event.aiAssistanceDiscouraged),
1349
+ postingText: context.postingText ?? '',
1350
+ }),
1351
+ });
1352
+ const body = await response.json().catch(() => ({}));
1353
+ if (!response.ok) {
1354
+ console.error(`[attention-notify] site notify failed (${response.status}): ${body?.error ?? 'unknown'}`);
1355
+ return { attempted: true, ok: false, error: body?.error ?? 'notify_failed', status: response.status };
1356
+ }
1357
+ return { attempted: true, ok: true, magicLinkUrl: body?.magicLinkUrl ?? null, emailId: body?.emailId ?? null };
1358
+ } catch (error) {
1359
+ console.error(`[attention-notify] site notify request failed: ${error instanceof Error ? error.message : 'unknown'}`);
1360
+ return { attempted: true, ok: false, error: 'notify_request_failed' };
1361
+ }
1362
+ }
1363
+
1223
1364
  async function attentionAdd(input) {
1224
1365
  const value = object(input, 'attention item');
1225
- const allowed = new Set(['roundId', 'applicationId', 'url', 'stage', 'blocker', 'requiredActions', 'createdAt']);
1366
+ const allowed = new Set([
1367
+ 'roundId', 'applicationId', 'url', 'stage', 'blocker', 'requiredActions', 'createdAt', 'company', 'role',
1368
+ // P1.5 judgment packaging (prompts only — never candidate responses).
1369
+ 'questions', 'postingText', 'aiAssistanceDiscouraged',
1370
+ // Local-only session binding hooks — never appended to the cloud attention event.
1371
+ ...SESSION_BINDING_ATTENTION_KEYS,
1372
+ ]);
1226
1373
  for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown attention property: ${key}.`);
1227
1374
  const stage = string(value.stage, 'attention.stage', 40).toLowerCase();
1228
1375
  const blocker = string(value.blocker, 'attention.blocker', 60).toLowerCase();
@@ -1230,19 +1377,62 @@ async function attentionAdd(input) {
1230
1377
  if (!ATTENTION_BLOCKERS.has(blocker)) throw new Error('attention.blocker is invalid.');
1231
1378
  const requiredActions = stringArray(value.requiredActions, 'attention.requiredActions', true).map((item) => item.toLowerCase());
1232
1379
  if (requiredActions.length > 8 || requiredActions.some((item) => !REQUIRED_ACTIONS.has(item))) throw new Error('attention.requiredActions must contain only documented actions.');
1380
+ const applicationId = string(value.applicationId, 'attention.applicationId', 180);
1381
+ let company = typeof value.company === 'string' ? value.company.trim() : '';
1382
+ let role = typeof value.role === 'string' ? value.role.trim() : '';
1383
+ if (!company || !role) {
1384
+ try {
1385
+ const apps = await jsonLines(join(await ensureStateDir(), 'applications.ndjson'));
1386
+ const match = apps.find((entry) => entry.id === applicationId);
1387
+ if (match) {
1388
+ company = company || String(match.company ?? '').trim();
1389
+ role = role || String(match.role ?? match.title ?? '').trim();
1390
+ }
1391
+ } catch { /* best-effort context for notify only */ }
1392
+ }
1393
+ const postingText = typeof value.postingText === 'string' ? value.postingText.slice(0, 20_000) : '';
1394
+ let questions = normalizeAttentionQuestions(value.questions);
1395
+ if (!questions.length && postingText && requiredActions.includes('provide-judgment')) {
1396
+ questions = extractNarrativeQuestionsFromText(postingText);
1397
+ }
1398
+ const aiAssistanceDiscouraged = value.aiAssistanceDiscouraged === true
1399
+ || detectAiAssistanceDiscouraged(postingText);
1400
+ const localBindingFields = extractSessionBindingFields(value);
1233
1401
  const event = {
1234
1402
  type: 'opened',
1235
1403
  id: `attention-${randomUUID()}`,
1236
1404
  roundId: string(value.roundId, 'attention.roundId', 180),
1237
- applicationId: string(value.applicationId, 'attention.applicationId', 180),
1405
+ applicationId,
1238
1406
  url: string(value.url, 'attention.url', 2048),
1239
1407
  stage,
1240
1408
  blocker,
1241
1409
  requiredActions: [...new Set(requiredActions)],
1410
+ // Prompts only — never store candidate responses on the attention queue.
1411
+ ...(questions.length ? { questions } : {}),
1412
+ ...(aiAssistanceDiscouraged ? { aiAssistanceDiscouraged: true } : {}),
1242
1413
  createdAt: isoDate(value.createdAt, 'attention.createdAt'),
1243
1414
  };
1244
1415
  await appendPrivateEvent('attention', event);
1245
- return event;
1416
+
1417
+ let sessionBinding = null;
1418
+ if (localBindingFields) {
1419
+ const binding = createSessionBinding({
1420
+ attentionId: event.id,
1421
+ jobUrl: event.url,
1422
+ applicationId: event.applicationId,
1423
+ roundId: event.roundId,
1424
+ createdAt: event.createdAt,
1425
+ questions: event.questions,
1426
+ aiAssistanceDiscouraged: event.aiAssistanceDiscouraged,
1427
+ ...localBindingFields,
1428
+ });
1429
+ const path = sessionBindingPath(await ensureStateDir(), event.id);
1430
+ sessionBinding = { path, binding: await writeSessionBindingFile(path, binding) };
1431
+ }
1432
+
1433
+ const notify = await attentionNotifyHook(event, { company, role, postingText });
1434
+ const base = sessionBinding ? { ...event, sessionBinding } : event;
1435
+ return notify.attempted ? { ...base, notify } : base;
1246
1436
  }
1247
1437
 
1248
1438
  async function attentionResolve(input) {
@@ -1251,6 +1441,7 @@ async function attentionResolve(input) {
1251
1441
  for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown attention resolution property: ${key}.`);
1252
1442
  const id = string(value.id, 'attention.id', 180);
1253
1443
  const current = await attentionList();
1444
+ 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
1445
  if (!current.items.some((item) => item.id === id)) throw new Error('Attention item is not active.');
1255
1446
  const event = { type: 'resolved', id, resolvedAt: isoDate(value.resolvedAt, 'attention.resolvedAt') };
1256
1447
  await appendPrivateEvent('attention', event);
@@ -1267,7 +1458,11 @@ async function roundStatus(roundId = null) {
1267
1458
  if (!started) throw new Error('Application round was not found.');
1268
1459
  const matching = (await jsonLines(join(dir, 'applications.ndjson'))).filter((entry) => entry.roundId === id && entry.status === 'submitted');
1269
1460
  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;
1461
+ const delivery = deliveryProjection(matching, await jsonLines(join(dir, 'delivery.ndjson')));
1462
+ const effectiveKeys = new Set(matching.filter((entry,i) => delivery.applications[i].counted).map(canonicalApplicationKey));
1463
+ const effectiveApplications = applications.filter(entry => effectiveKeys.has(canonicalApplicationKey(entry)));
1464
+ const confirmedCount = delivery.effectiveSubmissionCount;
1465
+ const audit = started.discoveryPolicyVersion === 2 ? discoveryProjection(await jsonLines(join(dir, 'discovery.ndjson')), { roundId: id }) : null;
1271
1466
  const attention = await attentionList(id);
1272
1467
  const completion = events.find((event) => event.type === 'completed' && event.roundId === id);
1273
1468
  return {
@@ -1277,7 +1472,16 @@ async function roundStatus(roundId = null) {
1277
1472
  remainingCount: Math.max(0, started.requestedCount - confirmedCount),
1278
1473
  blockedCount: attention.count,
1279
1474
  completed: Boolean(completion),
1280
- discovery: discoverySummary(events.filter((event) => event.roundId === id), applications, completion),
1475
+ discoveryPolicyVersion: started.discoveryPolicyVersion ?? 1,
1476
+ recordedSubmissionCount: delivery.recordedSubmissionCount,
1477
+ effectiveSubmissionCount: confirmedCount,
1478
+ failedDeliveryCount: delivery.failedDeliveryCount,
1479
+ receiptUnknownEmailCount: delivery.receiptUnknownEmailCount,
1480
+ shortfallCount: Math.max(0, started.requestedCount - confirmedCount),
1481
+ needsRecovery: Boolean(completion) && confirmedCount < started.requestedCount,
1482
+ discovery: { ...discoverySummary(events.filter((event) => event.roundId === id), effectiveApplications, completion, audit),
1483
+ ...(audit ? { reviewedCount: audit.reviewedCount, qualifiedCount: audit.qualifiedCount, uniqueLeadCount: audit.uniqueLeadCount, dispositionCounts: audit.dispositionCounts, conflicts: audit.conflicts,
1484
+ 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
1485
  startedAt: started.occurredAt,
1282
1486
  ...(completion ? { completedAt: completion.occurredAt } : {}),
1283
1487
  };
@@ -1294,6 +1498,7 @@ async function roundComplete(input) {
1294
1498
  if (status.confirmedCount < status.requestedCount) throw new Error(`Round requires ${status.requestedCount} confirmed submissions before completion.`);
1295
1499
  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
1500
  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.');
1501
+ 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
1502
  let explanation = {};
1298
1503
  if (status.discovery.concentrationNeedsExplanation || value.concentrationReason != null || value.concentrationEvidence != null) {
1299
1504
  if (!CONCENTRATION_REASONS.has(value.concentrationReason)) throw new Error('Source concentration requires a documented concentrationReason and private concentrationEvidence.');
@@ -1399,7 +1604,7 @@ async function ledgerReview() {
1399
1604
  const applications = await jsonLines(join(dir, 'applications.ndjson'));
1400
1605
  const outcomes = await jsonLines(join(dir, 'outcomes.ndjson'));
1401
1606
  const acknowledgements = await jsonLines(join(dir, 'reviews.ndjson'));
1402
- return buildReview(applications, outcomes, acknowledgements);
1607
+ return buildReview(applications, outcomes, acknowledgements, new Date(), await jsonLines(join(dir, 'delivery.ndjson')));
1403
1608
  }
1404
1609
 
1405
1610
  async function ledgerReviewAcknowledge(input) {
@@ -1407,7 +1612,7 @@ async function ledgerReviewAcknowledge(input) {
1407
1612
  const reviewedAt = string(value.reviewedAt ?? new Date().toISOString(), 'reviewedAt', 80);
1408
1613
  if (Number.isNaN(Date.parse(reviewedAt))) throw new Error('reviewedAt must be an ISO date.');
1409
1614
  const review = await ledgerReview();
1410
- const event = { reviewedAt, uniqueSubmissionCount: review.uniqueSubmittedTotal, maturedApplicationCount: review.maturedApplications };
1615
+ const event = { reviewedAt, uniqueSubmissionCount: review.recordedSubmissionCount, maturedApplicationCount: review.recordedMaturedApplicationCount };
1411
1616
  await withStateLock('reviews', async (dir) => {
1412
1617
  const file = join(dir, 'reviews.ndjson');
1413
1618
  await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
@@ -1547,6 +1752,9 @@ async function executeCommand([area, action, value], telemetry, session, communi
1547
1752
  const event = await telemetryJobAssessed(job, result);
1548
1753
  if (event) domainEvents.push(event);
1549
1754
  } else if (area === 'ledger' && action === 'check' && value === '--stdin') result = await ledgerCheck(await jsonStdin());
1755
+ else if (area === 'ledger' && action === 'delivery' && value === '--stdin') result = await recordDelivery(await jsonStdin());
1756
+ else if (area === 'ledger' && action === 'retry' && value === '--stdin') result = await recordDelivery(await jsonStdin(), true);
1757
+ else if (area === 'ledger' && action === 'deliveries') result = await deliveryHistory(value);
1550
1758
  else if (area === 'ledger' && action === 'add' && value === '--stdin') {
1551
1759
  const input = await jsonStdin();
1552
1760
  const telemetryDetails = validateSubmissionTelemetry(input.telemetry);
@@ -1569,6 +1777,8 @@ async function executeCommand([area, action, value], telemetry, session, communi
1569
1777
  else if (area === 'autonomy' && action === 'preview' && value == null) result = await autonomyStatus();
1570
1778
  else if (area === 'autonomy' && action === 'revoke' && value == null) result = await autonomyRevoke();
1571
1779
  else if (area === 'round' && action === 'start' && value === '--stdin') result = await roundStart(await jsonStdin());
1780
+ else if (area === 'round' && action === 'lead' && value === '--stdin') result = await recordLead(await jsonStdin());
1781
+ else if (area === 'round' && action === 'leads') result = await leadHistory(value);
1572
1782
  else if (area === 'round' && action === 'source' && value === '--stdin') {
1573
1783
  result = await roundSource(await jsonStdin());
1574
1784
  domainEvents.push({ event: 'source_checked', properties: {
@@ -1624,6 +1834,12 @@ async function recordInstallationStart(telemetry, session) {
1624
1834
 
1625
1835
  async function main(args) {
1626
1836
  const [area, action, value] = args;
1837
+ // Outreach is private-only, including errors: never initialize telemetry or
1838
+ // community clients, flush their queues, or run generic reconciliation here.
1839
+ if (area === 'outreach') {
1840
+ const { runOutreach } = await import('./outreach-cli.mjs');
1841
+ return print(await runOutreach(args.slice(1)));
1842
+ }
1627
1843
  const telemetry = new TelemetryClient({ stateDir: stateDir(), readIdentity: () => {
1628
1844
  const profile = storedProfileRaw();
1629
1845
  // Only explicit saved fields; no resume parsing, conversation scraping, or full profile payload.