job-application-agent 3.3.0 → 3.4.1

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.
@@ -12,6 +12,7 @@ import { SourceCommunityClient } from './source-community-client.mjs';
12
12
  import { normalizeCommunityJob, normalizeCommunitySource } from './source-community-schema.mjs';
13
13
  import { TelemetryClient } from './telemetry-client.mjs';
14
14
  import { jobIdentity } from './telemetry-schema.mjs';
15
+ import { CloudStateClient, defaultCloudConfigPath, enableCloudUpdateGuard, saveCloudConfig } from './cloud-state-client.mjs';
15
16
 
16
17
  const SOURCES = new Set(['linkedin', 'greenhouse', 'lever', 'ashby', 'workable', 'comeet', 'workday', 'rippling', 'smartrecruiters', 'google-form', 'company', 'email', 'other']);
17
18
  const DISCOVERY_SOURCES = new Set(['direct-company', 'linkedin', 'x', 'yc', 'hacker-news', 'job-board', 'email', 'user-supplied', 'web-search', 'other']);
@@ -37,6 +38,10 @@ const SOURCE_CATALOG_URL = new URL('../references/SOURCES.json', import.meta.url
37
38
  const SOURCE_CATALOG = JSON.parse(readFileSync(SOURCE_CATALOG_URL, 'utf8'));
38
39
  if (!Array.isArray(SOURCE_CATALOG)) throw new Error('The packaged source catalog is invalid.');
39
40
  const SOURCE_CATALOG_IDS = new Set(SOURCE_CATALOG.map((source) => sourceId(source.id, 'source catalog id')));
41
+ const MIN_DISCOVERY_SOURCES = 3;
42
+ const CONCENTRATION_THRESHOLD = 60;
43
+ const SOURCE_BLOCKERS = new Set(['login', 'mfa', 'captcha', 'site-error', 'access-unavailable']);
44
+ const CONCENTRATION_REASONS = new Set(['stronger-fit', 'alternatives-exhausted', 'access-blocked', 'candidate-directed']);
40
45
  const COMMUNITY_SOURCE_ID = /^community-[0-9a-f]{16}$/;
41
46
  const REQUIRED_PROFILE = ['name', 'email', 'phone', 'location', 'workAuthorization', 'roleFamilies', 'seniority', 'targetLocations', 'workModes', 'submissionMode', 'yearsExperience', 'autoSubmitMinScore', 'manualReviewMinScore', 'minMustHaveCoverage'];
42
47
  const STRING_PROFILE_FIELDS = new Set(['name', 'email', 'phone', 'location', 'workAuthorization', 'linkedin', 'github', 'portfolio', 'availability', 'currentCompensation', 'targetCompensation', 'submissionMode']);
@@ -63,6 +68,17 @@ function stateDir() {
63
68
  }
64
69
 
65
70
  const secretStore = createSecretStore({ stateDir });
71
+ const cloudState = new CloudStateClient({ stateDir: stateDir(), configPath: process.env.JOB_APPLICATION_AGENT_CLOUD_CONFIG ?? defaultCloudConfigPath() });
72
+
73
+ function cachedCloudProfileRaw() {
74
+ try {
75
+ const config = JSON.parse(readFileSync(process.env.JOB_APPLICATION_AGENT_CLOUD_CONFIG ?? defaultCloudConfigPath(), 'utf8'));
76
+ if (config.version !== 2) return null;
77
+ return object(JSON.parse(readFileSync(join(stateDir(), 'cloud-profile-cache.json'), 'utf8')), 'profile');
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
66
82
 
67
83
  export function durationBucket(milliseconds) {
68
84
  if (milliseconds < 1_000) return 'under-1s';
@@ -600,6 +616,8 @@ export function buildReview(entries, outcomeEntries = [], acknowledgements = [],
600
616
  }
601
617
 
602
618
  function storedProfileRaw() {
619
+ const cloudProfile = cachedCloudProfileRaw();
620
+ if (cloudProfile) return cloudProfile;
603
621
  try { return object(JSON.parse(secretStore.readProfile()), 'profile'); } catch (error) {
604
622
  if (/missing or unreadable|could not read|could not store|Windows profile storage|not supported on this platform|secret-tool is not installed/i.test(error.message)) throw error;
605
623
  throw new Error('The stored profile is missing or unreadable. Run profile set again.');
@@ -859,6 +877,7 @@ async function autonomyGrant(input) {
859
877
  for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown autonomy grant property: ${key}.`);
860
878
  if (value.mode !== 'routine-auto') throw new Error('autonomy grant mode must be routine-auto.');
861
879
  const grant = { version: 1, enabled: true, mode: 'routine-auto', scopes: [...AUTONOMY_SCOPES], grantedAt: new Date().toISOString() };
880
+ if (await cloudState.configured()) await cloudState.putDocumentCurrent('autonomy', grant);
862
881
  const file = join(await ensureStateDir(), 'autonomy.json');
863
882
  await writePrivateJson(file, grant);
864
883
  return autonomyView(grant);
@@ -868,14 +887,21 @@ async function autonomyRevoke() {
868
887
  const file = join(await ensureStateDir(), 'autonomy.json');
869
888
  const current = await readPrivateJson(file, { version: 1, enabled: false });
870
889
  const revoked = { version: 1, enabled: false, revokedAt: new Date().toISOString(), ...(current.grantedAt ? { grantedAt: current.grantedAt } : {}) };
890
+ if (await cloudState.configured()) await cloudState.putDocumentCurrent('autonomy', revoked);
871
891
  await writePrivateJson(file, revoked);
872
892
  return autonomyView(revoked);
873
893
  }
874
894
 
875
895
  async function storeProfile(profileInput) {
876
896
  const profile = validateProfile(profileInput);
877
- if (process.platform === 'win32') await ensureStateDir();
878
- secretStore.writeProfile(JSON.stringify(profile));
897
+ if (await cloudState.configured()) {
898
+ const dir = await ensureStateDir();
899
+ await cloudState.putDocumentCurrent('profile', profile);
900
+ await writePrivateJson(join(dir, 'cloud-profile-cache.json'), profile);
901
+ } else {
902
+ if (process.platform === 'win32') await ensureStateDir();
903
+ secretStore.writeProfile(JSON.stringify(profile));
904
+ }
879
905
  return profile;
880
906
  }
881
907
 
@@ -922,11 +948,23 @@ async function importResume(source) {
922
948
  await chmod(target, 0o600);
923
949
  const metadata = { source: sourceLabel, importedAt: new Date().toISOString(), sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length };
924
950
  await writeFile(join(dir, 'resume.json'), `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
951
+ if (await cloudState.configured()) {
952
+ await cloudState.putFileCurrent('resume.pdf', bytes);
953
+ await cloudState.putFileCurrent('resume.json', Buffer.from(JSON.stringify(metadata)));
954
+ }
925
955
  return { path: target, sha256: metadata.sha256, bytes: metadata.bytes };
926
956
  }
927
957
 
928
958
  async function canonicalResumePath() {
929
959
  const target = join(await ensureStateDir(), 'resume.pdf');
960
+ if (await cloudState.configured()) {
961
+ try { await cloudState.fetchResume(); }
962
+ catch (error) {
963
+ // Cached research and browser preparation remain usable during an
964
+ // outage. Integrity, authentication, and checksum failures still stop.
965
+ if (!/^Cloud state unavailable:/.test(error.message)) throw error;
966
+ }
967
+ }
930
968
  try {
931
969
  const details = await stat(target);
932
970
  if (!details.isFile()) throw new Error('Canonical resume path is not a file. Import the resume again.');
@@ -1005,7 +1043,7 @@ async function ledgerCheck(candidate) {
1005
1043
  return duplicateResult(entries, candidate, outcomes);
1006
1044
  }
1007
1045
 
1008
- async function ledgerAdd(entryInput, duplicateOverride, companyReapplyOverride) {
1046
+ async function ledgerAdd(entryInput, duplicateOverride, companyReapplyOverride, cloudIntent = null) {
1009
1047
  const entry = validateLedgerEntry(entryInput);
1010
1048
  return withStateLock('applications', async (dir) => {
1011
1049
  const file = join(dir, 'applications.ndjson');
@@ -1035,15 +1073,30 @@ async function ledgerAdd(entryInput, duplicateOverride, companyReapplyOverride)
1035
1073
  await appendFile(roundsFile, `${JSON.stringify({ type: 'submission-confirmed', roundId: entry.roundId, applicationId: entry.id, occurredAt: entry.submittedAt })}\n`, { mode: 0o600 });
1036
1074
  await chmod(roundsFile, 0o600);
1037
1075
  }
1038
- return { recorded: storedEntry.id, review: buildReview([...entries, storedEntry]) };
1076
+ if (await cloudState.configured()) {
1077
+ if (cloudIntent?.intentId && cloudIntent?.leaseId) {
1078
+ await cloudState.confirmIntent(cloudIntent.intentId, storedEntry, cloudIntent.leaseId, `application:${storedEntry.id}:${storedEntry.submittedAt}`);
1079
+ } else {
1080
+ await cloudState.appendRecord('applications', storedEntry, { recordKey: storedEntry.id, idempotencyKey: `application:${storedEntry.id}:${storedEntry.submittedAt}`, occurredAt: storedEntry.submittedAt, queueOnFailure: true });
1081
+ 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
+ }
1083
+ }
1084
+ return { recorded: storedEntry.id, review: buildReview([...entries, storedEntry]), ...(cloudIntent ? { cloudIntent: cloudIntent.intentId } : {}) };
1039
1085
  });
1040
1086
  }
1041
1087
 
1088
+ async function appendCloudEvent(name, event) {
1089
+ if (!await cloudState.configured()) return;
1090
+ const key = event.id ?? event.roundId ?? event.applicationId ?? randomUUID();
1091
+ await cloudState.appendRecord(name, event, { recordKey: key, idempotencyKey: `${name}:${createHash('sha256').update(JSON.stringify(event)).digest('hex')}`, occurredAt: event.occurredAt, queueOnFailure: true });
1092
+ }
1093
+
1042
1094
  async function appendPrivateEvent(name, event) {
1043
1095
  return withStateLock(name, async (dir) => {
1044
1096
  const file = join(dir, `${name}.ndjson`);
1045
1097
  await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
1046
1098
  await chmod(file, 0o600);
1099
+ if (['rounds', 'discovery', 'attention', 'friction'].includes(name)) await appendCloudEvent(name, event);
1047
1100
  return event;
1048
1101
  });
1049
1102
  }
@@ -1065,7 +1118,84 @@ async function roundStart(input) {
1065
1118
  occurredAt: isoDate(value.startedAt, 'round.startedAt'),
1066
1119
  };
1067
1120
  await appendPrivateEvent('rounds', event);
1068
- return { roundId: event.roundId, requestedCount: event.requestedCount, startedAt: event.occurredAt, completed: false };
1121
+ return { roundId: event.roundId, requestedCount: event.requestedCount, startedAt: event.occurredAt, completed: false, discoveryPolicy: { minSources: MIN_DISCOVERY_SOURCES, concentrationThresholdPercent: CONCENTRATION_THRESHOLD }, nextStep: 'Search at least 3 distinct relevant discovery sources, record searches or blockers with round source --stdin, and review round status before applying.' };
1122
+ }
1123
+
1124
+ function discoveryGroup(id) {
1125
+ // Two views of the same hiring network do not demonstrate independent coverage.
1126
+ if (['yc-work-at-a-startup', 'yc-company-directory'].includes(id)) return 'yc';
1127
+ return id;
1128
+ }
1129
+
1130
+ function discoverySummary(events, applications, completion) {
1131
+ const latest = new Map();
1132
+ const searchedIds = new Set();
1133
+ const attribution = new Map();
1134
+ for (const event of events.filter((item) => item.type === 'source-checked')) {
1135
+ latest.set(event.sourceId, event);
1136
+ if (event.status === 'searched') searchedIds.add(event.sourceId);
1137
+ for (const id of event.applicationIds ?? []) attribution.set(id, event.sourceId);
1138
+ }
1139
+ const sources = [...latest.values()];
1140
+ const eligible = sources.filter((item) => !['recruiter-inbound', 'user-supplied-leads'].includes(item.sourceId));
1141
+ const attempted = new Set(eligible.map((item) => discoveryGroup(item.sourceId)));
1142
+ const searched = new Set(eligible.filter((item) => searchedIds.has(item.sourceId)).map((item) => discoveryGroup(item.sourceId)));
1143
+ const distribution = new Map();
1144
+ let unattributedCount = 0;
1145
+ for (const entry of applications) {
1146
+ const source = entry.discoverySourceId ?? attribution.get(entry.id);
1147
+ if (!source || !searchedIds.has(source)) { unattributedCount++; continue; }
1148
+ const group = discoveryGroup(source);
1149
+ distribution.set(group, (distribution.get(group) ?? 0) + 1);
1150
+ }
1151
+ const maxCount = Math.max(0, ...distribution.values());
1152
+ const maxSourceSharePercent = applications.length ? Math.round(maxCount / applications.length * 100) : 0;
1153
+ return {
1154
+ minSources: MIN_DISCOVERY_SOURCES,
1155
+ attemptedSourceCount: attempted.size,
1156
+ searchedSourceCount: searched.size,
1157
+ blockedSourceCount: [...attempted].filter((id) => !searched.has(id)).length,
1158
+ coverageSatisfied: attempted.size >= MIN_DISCOVERY_SOURCES && searched.size > 0,
1159
+ sources: sources.map(({ sourceId, status, reviewedCount, qualifiedCount, blocker, evidence }) => ({ sourceId, status, searchedDuringRound: searchedIds.has(sourceId), reviewedCount, qualifiedCount, ...(blocker ? { blocker } : {}), evidence })),
1160
+ submissionDistribution: [...distribution].map(([sourceId, count]) => ({ sourceId, count })),
1161
+ unattributedCount,
1162
+ maxSourceSharePercent,
1163
+ concentrationNeedsExplanation: applications.length > 0 && maxCount / applications.length * 100 > CONCENTRATION_THRESHOLD,
1164
+ ...(completion?.concentrationReason ? { concentrationReason: completion.concentrationReason, concentrationEvidence: completion.concentrationEvidence } : {}),
1165
+ };
1166
+ }
1167
+
1168
+ async function roundSource(input) {
1169
+ const value = object(input, 'source coverage');
1170
+ const allowed = new Set(['roundId', 'sourceId', 'status', 'reviewedCount', 'qualifiedCount', 'blocker', 'evidence', 'applicationIds']);
1171
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown source coverage property: ${key}.`);
1172
+ const sourceId = knownDiscoverySourceId(value.sourceId, 'coverage.sourceId');
1173
+ const status = string(value.status, 'coverage.status', 20);
1174
+ 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);
1177
+ 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
+ if (status === 'searched' && blocker != null) throw new Error('Searched sources cannot have a blocker.');
1180
+ const evidence = string(value.evidence, 'coverage.evidence', 2000);
1181
+ const applicationIds = value.applicationIds == null ? [] : [...new Set(stringArray(value.applicationIds, 'coverage.applicationIds'))];
1182
+ if (applicationIds.length > 1000 || (status === 'blocked' && applicationIds.length)) throw new Error('Invalid coverage.applicationIds.');
1183
+ const event = await withStateLock('rounds', async (dir) => {
1184
+ const round = await roundStatus(string(value.roundId, 'coverage.roundId', 180));
1185
+ if (round.completed) throw new Error('Cannot record coverage for a completed round.');
1186
+ const applications = await jsonLines(join(dir, 'applications.ndjson'));
1187
+ for (const id of applicationIds) {
1188
+ const entry = applications.find((item) => item.id === id && item.roundId === round.roundId && item.status === 'submitted');
1189
+ if (!entry || (entry.discoverySourceId && entry.discoverySourceId !== sourceId)) throw new Error('Coverage attribution must match a confirmed application in this round and its saved source.');
1190
+ }
1191
+ const event = { type: 'source-checked', roundId: round.roundId, sourceId, status, reviewedCount, qualifiedCount, evidence, applicationIds, ...(blocker ? { blocker } : {}), occurredAt: new Date().toISOString() };
1192
+ const file = join(dir, 'rounds.ndjson');
1193
+ await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
1194
+ await chmod(file, 0o600);
1195
+ return event;
1196
+ });
1197
+ await appendCloudEvent('rounds', event);
1198
+ return event;
1069
1199
  }
1070
1200
 
1071
1201
  function replayAttention(events, roundId = null) {
@@ -1135,7 +1265,8 @@ async function roundStatus(roundId = null) {
1135
1265
  if (!id) throw new Error('No application round has been started.');
1136
1266
  const started = starts.find((event) => event.roundId === id);
1137
1267
  if (!started) throw new Error('Application round was not found.');
1138
- const applications = (await jsonLines(join(dir, 'applications.ndjson'))).filter((entry) => entry.roundId === id && entry.status === 'submitted');
1268
+ const matching = (await jsonLines(join(dir, 'applications.ndjson'))).filter((entry) => entry.roundId === id && entry.status === 'submitted');
1269
+ const applications = [...new Map(matching.map((entry, index) => [canonicalApplicationKey(entry, String(index)), entry])).values()];
1139
1270
  const confirmedCount = new Set(applications.map((entry, index) => canonicalApplicationKey(entry, String(index)))).size;
1140
1271
  const attention = await attentionList(id);
1141
1272
  const completion = events.find((event) => event.type === 'completed' && event.roundId === id);
@@ -1146,6 +1277,7 @@ async function roundStatus(roundId = null) {
1146
1277
  remainingCount: Math.max(0, started.requestedCount - confirmedCount),
1147
1278
  blockedCount: attention.count,
1148
1279
  completed: Boolean(completion),
1280
+ discovery: discoverySummary(events.filter((event) => event.roundId === id), applications, completion),
1149
1281
  startedAt: started.occurredAt,
1150
1282
  ...(completion ? { completedAt: completion.occurredAt } : {}),
1151
1283
  };
@@ -1153,14 +1285,28 @@ async function roundStatus(roundId = null) {
1153
1285
 
1154
1286
  async function roundComplete(input) {
1155
1287
  const value = object(input, 'round completion');
1156
- const allowed = new Set(['roundId', 'completedAt']);
1288
+ const allowed = new Set(['roundId', 'completedAt', 'concentrationReason', 'concentrationEvidence']);
1157
1289
  for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown round completion property: ${key}.`);
1158
- const status = await roundStatus(string(value.roundId, 'round.roundId', 180));
1159
- if (status.completed) return status;
1160
- if (status.confirmedCount < status.requestedCount) throw new Error(`Round requires ${status.requestedCount} confirmed submissions before completion.`);
1161
- const event = { type: 'completed', roundId: status.roundId, occurredAt: isoDate(value.completedAt, 'round.completedAt') };
1162
- await appendPrivateEvent('rounds', event);
1163
- return { ...status, completed: true, completedAt: event.occurredAt };
1290
+ // Use the same lock order as ledger writes so completion cannot race a confirmed submission.
1291
+ const result = await withStateLock('applications', () => withStateLock('rounds', async (dir) => {
1292
+ const status = await roundStatus(string(value.roundId, 'round.roundId', 180));
1293
+ if (status.completed) return { ...status, completionRecorded: false };
1294
+ if (status.confirmedCount < status.requestedCount) throw new Error(`Round requires ${status.requestedCount} confirmed submissions before completion.`);
1295
+ 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
+ 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.');
1297
+ let explanation = {};
1298
+ if (status.discovery.concentrationNeedsExplanation || value.concentrationReason != null || value.concentrationEvidence != null) {
1299
+ if (!CONCENTRATION_REASONS.has(value.concentrationReason)) throw new Error('Source concentration requires a documented concentrationReason and private concentrationEvidence.');
1300
+ explanation = { concentrationReason: value.concentrationReason, concentrationEvidence: string(value.concentrationEvidence, 'round.concentrationEvidence', 2000) };
1301
+ }
1302
+ const event = { type: 'completed', roundId: status.roundId, occurredAt: isoDate(value.completedAt, 'round.completedAt'), ...explanation };
1303
+ const file = join(dir, 'rounds.ndjson');
1304
+ await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
1305
+ await chmod(file, 0o600);
1306
+ return { ...status, discovery: { ...status.discovery, ...explanation }, completed: true, completionRecorded: true, completedAt: event.occurredAt };
1307
+ }));
1308
+ if (result.completionRecorded) await appendCloudEvent('rounds', { type: 'completed', roundId: result.roundId, occurredAt: result.completedAt, ...(result.discovery.concentrationReason ? { concentrationReason: result.discovery.concentrationReason, concentrationEvidence: result.discovery.concentrationEvidence } : {}) });
1309
+ return result;
1164
1310
  }
1165
1311
 
1166
1312
  async function frictionRecord(input) {
@@ -1237,6 +1383,14 @@ async function ledgerOutcome(outcomeInput) {
1237
1383
  return { stored: true, enriched };
1238
1384
  });
1239
1385
  const applications = await jsonLines(join(await ensureStateDir(), 'applications.ndjson'));
1386
+ if (storage.stored && await cloudState.configured()) {
1387
+ await cloudState.appendRecord('outcomes', event, {
1388
+ recordKey: event.id,
1389
+ idempotencyKey: `outcome:${event.id}:${event.status}:${event.occurredAt}:${createHash('sha256').update(JSON.stringify(event)).digest('hex')}`,
1390
+ occurredAt: event.occurredAt,
1391
+ queueOnFailure: true,
1392
+ });
1393
+ }
1240
1394
  return { result: { recorded: storage.stored, duplicate: !storage.stored, enriched: storage.enriched, recordedOutcome: event.id, status }, application: applications.find((entry) => entry.id === event.id) ?? null, event };
1241
1395
  }
1242
1396
 
@@ -1259,9 +1413,62 @@ async function ledgerReviewAcknowledge(input) {
1259
1413
  await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
1260
1414
  await chmod(file, 0o600);
1261
1415
  });
1416
+ if (await cloudState.configured()) await cloudState.appendRecord('reviews', event, { recordKey: reviewedAt, idempotencyKey: `review:${reviewedAt}`, occurredAt: reviewedAt, queueOnFailure: true });
1262
1417
  return { acknowledged: true, ...event };
1263
1418
  }
1264
1419
 
1420
+ async function prepareCloudState(area, action) {
1421
+ if (!await cloudState.configured() || area === 'cloud') return;
1422
+ try {
1423
+ await cloudState.reconcile({ dryRun: false, provenance: 'automatic-recovery' });
1424
+ await cloudState.refreshDocumentCaches();
1425
+ } catch (error) {
1426
+ // New submissions still fail closed because lease and intent operations
1427
+ // call the cloud directly. Local checks, research, and observed-result
1428
+ // ledger appends can continue and queue their idempotent recovery writes.
1429
+ if (!/^Cloud state unavailable:/.test(error.message)) throw error;
1430
+ }
1431
+ }
1432
+
1433
+ async function cloudCommand(action, value) {
1434
+ if (action === 'configure' && value === '--stdin') {
1435
+ const configured = await saveCloudConfig(await jsonStdin(), { configPath: process.env.JOB_APPLICATION_AGENT_CLOUD_CONFIG ?? defaultCloudConfigPath() });
1436
+ const updateGuard = await enableCloudUpdateGuard();
1437
+ return { configured: true, url: configured.url, clientId: configured.clientId ?? null, clientName: configured.clientName ?? null, token: configured.token, updateGuard };
1438
+ }
1439
+ if (action === 'status' && value == null) return cloudState.status();
1440
+ if (action === 'reconcile' && value === '--dry-run') return cloudState.reconcile({ dryRun: true });
1441
+ if (action === 'reconcile' && value == null) return cloudState.reconcile({ dryRun: false });
1442
+ if (action === 'export') return cloudState.exportTo(value ?? null);
1443
+ if (action === 'lease-acquire' && value == null) {
1444
+ const lease = await cloudState.acquireLease();
1445
+ await writePrivateJson(join(await ensureStateDir(), 'cloud-lease.json'), lease);
1446
+ return lease;
1447
+ }
1448
+ if (action === 'lease-renew' && value == null) {
1449
+ const lease = await readPrivateJson(join(await ensureStateDir(), 'cloud-lease.json'), null);
1450
+ if (!lease?.leaseId) throw new Error('No local cloud lease is recorded. Run cloud lease-acquire.');
1451
+ const renewed = await cloudState.renewLease(lease.leaseId);
1452
+ await writePrivateJson(join(await ensureStateDir(), 'cloud-lease.json'), renewed);
1453
+ return renewed;
1454
+ }
1455
+ if (action === 'lease-release' && value == null) {
1456
+ const lease = await readPrivateJson(join(await ensureStateDir(), 'cloud-lease.json'), null);
1457
+ if (!lease?.leaseId) throw new Error('No local cloud lease is recorded.');
1458
+ return cloudState.releaseLease(lease.leaseId);
1459
+ }
1460
+ if (action === 'intent-prepare' && value === '--stdin') return cloudState.createIntent(await jsonStdin());
1461
+ if (action === 'intent-sent' && value === '--stdin') {
1462
+ const input = await jsonStdin();
1463
+ return cloudState.markIntentSentUnverified(string(input.intentId, 'intentId', 200), string(input.leaseId, 'leaseId', 200));
1464
+ }
1465
+ if (action === 'intent-confirm' && value === '--stdin') {
1466
+ const input = await jsonStdin();
1467
+ return cloudState.confirmIntent(string(input.intentId, 'intentId', 200), object(input.application, 'application'), string(input.leaseId, 'leaseId', 200), input.idempotencyKey);
1468
+ }
1469
+ 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');
1470
+ }
1471
+
1265
1472
  function print(value) {
1266
1473
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
1267
1474
  }
@@ -1309,6 +1516,11 @@ function roundCompletedTelemetry(round) {
1309
1516
  pausedCount: round.blockedCount,
1310
1517
  errorCount: 0,
1311
1518
  durationBucket: durationBucket(Math.max(0, Date.parse(round.completedAt) - Date.parse(round.startedAt))),
1519
+ attemptedSourceCount: round.discovery.attemptedSourceCount,
1520
+ searchedSourceCount: round.discovery.searchedSourceCount,
1521
+ blockedSourceCount: round.discovery.blockedSourceCount,
1522
+ maxSourceSharePercent: round.discovery.maxSourceSharePercent,
1523
+ ...(round.discovery.concentrationReason ? { concentrationReason: round.discovery.concentrationReason } : {}),
1312
1524
  },
1313
1525
  };
1314
1526
  }
@@ -1316,7 +1528,8 @@ function roundCompletedTelemetry(round) {
1316
1528
  async function executeCommand([area, action, value], telemetry, session, community) {
1317
1529
  const domainEvents = [];
1318
1530
  let result;
1319
- if (area === 'profile' && action === 'set' && value === '--stdin') {
1531
+ if (area === 'cloud') result = await cloudCommand(action, value);
1532
+ else if (area === 'profile' && action === 'set' && value === '--stdin') {
1320
1533
  const profile = await jsonStdin();
1321
1534
  result = await profileSet(profile);
1322
1535
  } else if (area === 'profile' && action === 'migrate' && value === '--stdin') {
@@ -1338,7 +1551,7 @@ async function executeCommand([area, action, value], telemetry, session, communi
1338
1551
  const input = await jsonStdin();
1339
1552
  const telemetryDetails = validateSubmissionTelemetry(input.telemetry);
1340
1553
  const entry = validateLedgerEntry(input);
1341
- result = await ledgerAdd(entry, input.duplicateOverride, input.companyReapplyOverride);
1554
+ result = await ledgerAdd(entry, input.duplicateOverride, input.companyReapplyOverride, input.cloudIntentId && input.cloudLeaseId ? { intentId: input.cloudIntentId, leaseId: input.cloudLeaseId } : null);
1342
1555
  result.communityJob = await communityJobsSync(community, { limit: 1, applicationIds: [entry.id] });
1343
1556
  domainEvents.push(await telemetryApplicationSubmitted(entry, telemetryDetails));
1344
1557
  } else if (area === 'ledger' && action === 'outcome' && value === '--stdin') {
@@ -1356,10 +1569,18 @@ async function executeCommand([area, action, value], telemetry, session, communi
1356
1569
  else if (area === 'autonomy' && action === 'preview' && value == null) result = await autonomyStatus();
1357
1570
  else if (area === 'autonomy' && action === 'revoke' && value == null) result = await autonomyRevoke();
1358
1571
  else if (area === 'round' && action === 'start' && value === '--stdin') result = await roundStart(await jsonStdin());
1572
+ else if (area === 'round' && action === 'source' && value === '--stdin') {
1573
+ result = await roundSource(await jsonStdin());
1574
+ domainEvents.push({ event: 'source_checked', properties: {
1575
+ sourceId: result.sourceId.startsWith('community-') ? 'community' : result.sourceId,
1576
+ status: result.status, reviewedCount: result.reviewedCount, qualifiedCount: result.qualifiedCount,
1577
+ ...(result.blocker ? { blocker: result.blocker } : {}),
1578
+ } });
1579
+ }
1359
1580
  else if (area === 'round' && action === 'status') result = await roundStatus(value ?? null);
1360
1581
  else if (area === 'round' && action === 'complete' && value === '--stdin') {
1361
1582
  result = await roundComplete(await jsonStdin());
1362
- domainEvents.push(roundCompletedTelemetry(result));
1583
+ if (result.completionRecorded) domainEvents.push(roundCompletedTelemetry(result));
1363
1584
  } else if (area === 'sources' && action === 'list' && value == null) {
1364
1585
  await syncAllCommunityData(community);
1365
1586
  result = await sourcesList({}, await community.list());
@@ -1382,7 +1603,7 @@ async function executeCommand([area, action, value], telemetry, session, communi
1382
1603
  else if (area === 'attention' && action === 'resolve' && value === '--stdin') result = await attentionResolve(await jsonStdin());
1383
1604
  else if (area === 'friction' && action === 'record' && value === '--stdin') result = await frictionRecord(await jsonStdin());
1384
1605
  else if (area === 'friction' && action === 'list' && value == null) result = await frictionList();
1385
- else throw new Error('Usage: 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|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');
1606
+ 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');
1386
1607
  for (const event of domainEvents) await telemetry.record(event, session);
1387
1608
  return result;
1388
1609
  }
@@ -1403,9 +1624,14 @@ async function recordInstallationStart(telemetry, session) {
1403
1624
 
1404
1625
  async function main(args) {
1405
1626
  const [area, action, value] = args;
1406
- const telemetry = new TelemetryClient({ stateDir: stateDir() });
1627
+ const telemetry = new TelemetryClient({ stateDir: stateDir(), readIdentity: () => {
1628
+ const profile = storedProfileRaw();
1629
+ // Only explicit saved fields; no resume parsing, conversation scraping, or full profile payload.
1630
+ return Object.fromEntries(['name', 'email'].filter((key) => typeof profile[key] === 'string' && profile[key].trim()).map((key) => [key, profile[key]]));
1631
+ } });
1407
1632
  const community = new SourceCommunityClient({ stateDir: stateDir() });
1408
1633
  if (area === 'telemetry') {
1634
+ if (action === 'identity' && ['status', 'enable', 'disable'].includes(value) && args.length === 3) return print(await telemetry.configureIdentity(value));
1409
1635
  if (['status', 'enable', 'disable', 'reset'].includes(action) && value == null) return print(await telemetry.configure(action));
1410
1636
  if (action === 'preview' && value === '--stdin') return print(await telemetry.preview(await jsonStdin()));
1411
1637
  if (action === 'record' && value === '--stdin') {
@@ -1413,9 +1639,11 @@ async function main(args) {
1413
1639
  await recordInstallationStart(telemetry, session);
1414
1640
  return print(await telemetry.record(await jsonStdin(), session, { strict: true }));
1415
1641
  }
1416
- throw new Error('Usage: telemetry status|enable|disable|reset|preview --stdin|record --stdin');
1642
+ throw new Error('Usage: telemetry status|enable|disable|reset|preview --stdin|record --stdin; telemetry identity status|enable|disable');
1417
1643
  }
1418
1644
 
1645
+ await prepareCloudState(area, action);
1646
+
1419
1647
  const command = commandCategory(args);
1420
1648
  const session = await telemetry.beginCommand(command);
1421
1649
  await recordInstallationStart(telemetry, session);
@@ -1,13 +1,14 @@
1
1
  import { chmod, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
 
4
- import { createTelemetryEnvelope, jobIdentity, validateEvent } from './telemetry-schema.mjs';
4
+ import { createTelemetryEnvelope, jobIdentity, validateEvent, validateTelemetryIdentity } from './telemetry-schema.mjs';
5
5
  import { SKILL_VERSION } from './version.mjs';
6
6
 
7
7
  export { SKILL_VERSION };
8
8
 
9
9
  export const DEFAULT_TELEMETRY_ENDPOINT = process.env.JOB_APPLICATION_AGENT_TELEMETRY_URL ?? 'https://job-application-agent-telemetry.varora1406.workers.dev';
10
- export const TELEMETRY_NOTICE = 'Anonymous usage analytics are enabled by default. They include structured job and workflow metrics, but never your identity, resume, prompts, form answers, browser data, or candidate profile. Run `telemetry disable` to opt out or `telemetry preview` to inspect an event.\n';
10
+ export const TELEMETRY_NOTICE = 'Usage analytics are enabled by default. They include structured job and workflow metrics. Name and email sharing has a separate disclosure and opt-out. Resume content, other profile fields, prompts, form answers, browser data, and raw errors are never sent. Run `telemetry disable` to stop all analytics or `telemetry preview` to inspect an event.\n';
11
+ export const IDENTITY_NOTICE = 'Name and email sharing is enabled by default. Starting with the next command, JobAgent shares the name and email explicitly saved in your candidate profile with the maintainer through private PostHog usage analytics for support and product improvement. Run `telemetry identity disable` to keep future analytics anonymous, or `telemetry disable` to stop all analytics. Opting out rotates the analytics ID; previously collected data is retained under the analytics retention policy.\n';
11
12
 
12
13
  const CONFIG_VERSION = 1;
13
14
  const CONFIG_FILE = 'telemetry.json';
@@ -38,13 +39,14 @@ async function writePrivate(file, value) {
38
39
  }
39
40
 
40
41
  export class TelemetryClient {
41
- constructor({ stateDir, endpoint = DEFAULT_TELEMETRY_ENDPOINT, fetch: fetchFn = globalThis.fetch, stderr = (value) => process.stderr.write(value), now = () => new Date(), timeoutMs = Number(process.env.JOB_APPLICATION_AGENT_TELEMETRY_TIMEOUT_MS ?? 3000) }) {
42
+ constructor({ stateDir, endpoint = DEFAULT_TELEMETRY_ENDPOINT, fetch: fetchFn = globalThis.fetch, stderr = (value) => process.stderr.write(value), now = () => new Date(), timeoutMs = Number(process.env.JOB_APPLICATION_AGENT_TELEMETRY_TIMEOUT_MS ?? 3000), readIdentity = () => undefined }) {
42
43
  this.stateDir = stateDir;
43
44
  this.endpoint = endpoint.replace(/\/$/, '');
44
45
  this.fetch = fetchFn;
45
46
  this.stderr = stderr;
46
47
  this.now = now;
47
48
  this.timeoutMs = timeoutMs;
49
+ this.readIdentity = readIdentity;
48
50
  }
49
51
 
50
52
  get configPath() { return join(this.stateDir, CONFIG_FILE); }
@@ -57,7 +59,7 @@ export class TelemetryClient {
57
59
  async readConfig() {
58
60
  try {
59
61
  const value = JSON.parse(await readFile(this.configPath, 'utf8'));
60
- return { version: CONFIG_VERSION, enabled: value.enabled !== false, disclosed: value.disclosed === true, graceConsumed: value.graceConsumed === true, installationEventPending: value.installationEventPending === true, installationId: value.installationId ?? null, token: value.token ?? null, tokenExpiresAt: value.tokenExpiresAt ?? null };
62
+ return { version: CONFIG_VERSION, enabled: value.enabled !== false, disclosed: value.disclosed === true, graceConsumed: value.graceConsumed === true, installationEventPending: value.installationEventPending === true, installationId: value.installationId ?? null, token: value.token ?? null, tokenExpiresAt: value.tokenExpiresAt ?? null, identityEnabled: value.identityEnabled !== false, identityDisclosed: value.identityDisclosed === true };
61
63
  } catch (error) {
62
64
  if (error.code === 'ENOENT') return null;
63
65
  return { version: CONFIG_VERSION, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false, installationId: null, token: null, tokenExpiresAt: null };
@@ -101,7 +103,13 @@ export class TelemetryClient {
101
103
  config.graceConsumed = true;
102
104
  await this.saveConfig(config);
103
105
  }
104
- return { command, enabled: config.enabled, allowSend: config.enabled && allowSend, installationEventPending: config.installationEventPending === true };
106
+ const allowIdentity = config.identityEnabled !== false && config.identityDisclosed === true;
107
+ if (config.enabled && config.identityEnabled !== false && !config.identityDisclosed) {
108
+ this.stderr(IDENTITY_NOTICE);
109
+ config.identityDisclosed = true;
110
+ await this.saveConfig(config);
111
+ }
112
+ return { command, enabled: config.enabled, allowSend: config.enabled && allowSend, allowIdentity, installationEventPending: config.installationEventPending === true };
105
113
  }
106
114
 
107
115
  async credentials(config) {
@@ -128,13 +136,17 @@ export class TelemetryClient {
128
136
  if (session.unavailable) return { sent: false, reason: 'unavailable' };
129
137
  let config = await this.readConfig();
130
138
  if (!config?.enabled) return { sent: false, reason: 'disabled' };
139
+ let identity;
140
+ if (session.allowIdentity === true && config.identityEnabled !== false && config.identityDisclosed === true) {
141
+ try { identity = validateTelemetryIdentity(await this.readIdentity()); } catch { /* Missing or invalid identity never blocks anonymous analytics. */ }
142
+ }
131
143
  config = await this.credentials(config);
132
- const payload = createTelemetryEnvelope({ installationId: config.installationId, token: config.token, event, skillVersion: SKILL_VERSION });
144
+ const payload = createTelemetryEnvelope({ installationId: config.installationId, token: config.token, event, skillVersion: SKILL_VERSION, identity });
133
145
  let response = await this.fetch(`${this.endpoint}/v1/events`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(this.timeoutMs) });
134
146
  if (response.status === 401) {
135
147
  config.tokenExpiresAt = null;
136
148
  config = await this.credentials(config);
137
- response = await this.fetch(`${this.endpoint}/v1/events`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(createTelemetryEnvelope({ installationId: config.installationId, token: config.token, event, skillVersion: SKILL_VERSION })), signal: AbortSignal.timeout(this.timeoutMs) });
149
+ response = await this.fetch(`${this.endpoint}/v1/events`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(createTelemetryEnvelope({ installationId: config.installationId, token: config.token, event, skillVersion: SKILL_VERSION, identity })), signal: AbortSignal.timeout(this.timeoutMs) });
138
150
  }
139
151
  if (!response.ok) {
140
152
  session.unavailable = true;
@@ -154,7 +166,22 @@ export class TelemetryClient {
154
166
 
155
167
  async status() {
156
168
  const config = await this.readConfig();
157
- return { enabled: config?.enabled ?? true, disclosed: config?.disclosed ?? false, hasInstallationId: Boolean(config?.installationId), endpoint: this.endpoint, schemaVersion: 1 };
169
+ return { enabled: config?.enabled ?? true, disclosed: config?.disclosed ?? false, hasInstallationId: Boolean(config?.installationId), installationId: config?.installationId ?? null, identityEnabled: config?.identityEnabled ?? true, identityDisclosed: config?.identityDisclosed ?? false, endpoint: this.endpoint, schemaVersion: 1 };
170
+ }
171
+
172
+ async configureIdentity(action) {
173
+ if (action === 'status') return this.status();
174
+ if (!['enable', 'disable'].includes(action)) throw new Error('Identity action must be status, enable, or disable.');
175
+ const current = await this.readConfig() ?? { version: CONFIG_VERSION, enabled: true, disclosed: false, graceConsumed: true, installationEventPending: true };
176
+ const enabled = action === 'enable';
177
+ // Rotate at both boundaries to avoid identifying an earlier anonymous period.
178
+ if (enabled !== (current.identityEnabled !== false)) {
179
+ Object.assign(current, { installationId: null, token: null, tokenExpiresAt: null });
180
+ }
181
+ if (enabled && current.identityEnabled === false) current.identityDisclosed = false;
182
+ current.identityEnabled = enabled;
183
+ await this.saveConfig(current);
184
+ return this.status();
158
185
  }
159
186
 
160
187
  async configure(action) {
@@ -31,6 +31,9 @@ const FAILURE_POINTS = values('role-scope', 'company-problem', 'constraints', 'i
31
31
  const ERROR_CODES = values('invalid_input', 'network_failure', 'relay_unavailable', 'authentication_required', 'site_changed', 'upload_failed', 'submission_unconfirmed', 'rate_limited', 'internal_error');
32
32
  const MATCH_TAGS = values('role_family', 'seniority', 'skills', 'industry', 'location', 'remote', 'salary', 'ai', 'product', 'leadership', 'authorization');
33
33
  const GAP_TAGS = values('role_family', 'seniority', 'skills', 'industry', 'location', 'salary_unknown', 'salary_below', 'authorization_unclear', 'sponsorship', 'experience', 'domain', 'other');
34
+ const DISCOVERY_SOURCE_KEYS = values('direct-company-careers', 'linkedin-jobs-feed', 'x-hiring-feed', 'yc-work-at-a-startup', 'yc-company-directory', 'hacker-news-who-is-hiring', 'we-work-remotely', 'a16z-build-jobs', 'engg-space', 'js-guru-jobs', 'linux-careers', 'indeed', 'recruiter-inbound', 'user-supplied-leads', 'community');
35
+ const DISCOVERY_BLOCKERS = values('login', 'mfa', 'captcha', 'site-error', 'access-unavailable');
36
+ const CONCENTRATION_REASONS = values('stronger-fit', 'alternatives-exhausted', 'access-blocked', 'candidate-directed');
34
37
 
35
38
  const text = (max, identitySafe = false) => ({ kind: 'text', max, identitySafe });
36
39
  const integer = (min, max) => ({ kind: 'integer', min, max });
@@ -43,6 +46,7 @@ const JOB = {
43
46
  };
44
47
 
45
48
  export const EVENT_SCHEMAS = {
49
+ source_checked: { required: { sourceId: enumValue(DISCOVERY_SOURCE_KEYS), status: enumValue(values('searched', 'blocked')), reviewedCount: integer(0, 10000), qualifiedCount: integer(0, 10000) }, optional: { blocker: enumValue(DISCOVERY_BLOCKERS) } },
46
50
  installation_started: { required: { osFamily: enumValue(values('macos', 'linux', 'windows', 'other')), nodeMajor: integer(20, 99), submissionMode: enumValue(SUBMISSION_MODES) } },
47
51
  command_completed: { required: { command: enumValue(COMMANDS), result: enumValue(RESULTS), durationBucket: enumValue(DURATIONS) } },
48
52
  job_discovered: { required: { ...JOB, source: enumValue(SOURCES), jobCountry: text(80, true), workMode: enumValue(WORK_MODES), seniority: enumValue(SENIORITIES), employmentType: enumValue(EMPLOYMENT), roleFamily: enumValue(ROLE_FAMILIES) }, optional: { salaryCurrency: { kind: 'currency' }, salaryMin: integer(0, 10000000), salaryMax: integer(0, 10000000) } },
@@ -52,7 +56,7 @@ export const EVENT_SCHEMAS = {
52
56
  application_paused: { required: { jobHash: { kind: 'hash' }, ats: enumValue(ATS), stage: enumValue(STAGES), reason: enumValue(PAUSE_REASONS) } },
53
57
  application_skipped: { required: { jobHash: { kind: 'hash' }, reason: enumValue(SKIP_REASONS), fitScore: integer(0, 100), eligibility: enumValue(ELIGIBILITY) } },
54
58
  application_submitted: { required: { ...JOB, durationBucket: enumValue(DURATIONS), fieldsFilled: integer(0, 500), shortAnswerCount: integer(0, 100), resumeUploaded: boolean, approvalMode: enumValue(APPROVAL_MODES) } },
55
- round_completed: { required: { requestedCount: integer(1, 1000), submittedCount: integer(0, 1000), assessedCount: integer(0, 10000), skippedCount: integer(0, 10000), pausedCount: integer(0, 10000), errorCount: integer(0, 10000), durationBucket: enumValue(DURATIONS) } },
59
+ round_completed: { required: { requestedCount: integer(1, 1000), submittedCount: integer(0, 1000), assessedCount: integer(0, 10000), skippedCount: integer(0, 10000), pausedCount: integer(0, 10000), errorCount: integer(0, 10000), durationBucket: enumValue(DURATIONS) }, optional: { attemptedSourceCount: integer(0, 10000), searchedSourceCount: integer(0, 10000), blockedSourceCount: integer(0, 10000), maxSourceSharePercent: integer(0, 100), concentrationReason: enumValue(CONCENTRATION_REASONS) } },
56
60
  outcome_recorded: {
57
61
  required: { ...JOB, outcome: enumValue(OUTCOMES), daysSinceSubmission: integer(0, 3650) },
58
62
  optional: { interviewQuality: enumValue(INTERVIEW_QUALITIES), failurePoint: enumValue(FAILURE_POINTS) },
@@ -118,6 +122,11 @@ export function validateEvent(input) {
118
122
  }
119
123
  for (const [name, rule] of Object.entries(schema.optional ?? {})) if (name in input.properties) properties[name] = validateProperty(name, input.properties[name], rule);
120
124
  if (input.event === 'outcome_recorded' && properties.failurePoint && !properties.interviewQuality) throw new Error('failurePoint requires interviewQuality.');
125
+ if (input.event === 'source_checked') {
126
+ if (properties.qualifiedCount > properties.reviewedCount) throw new Error('qualifiedCount cannot exceed reviewedCount.');
127
+ if (properties.status === 'blocked' && (!properties.blocker || properties.reviewedCount !== 0 || properties.qualifiedCount !== 0)) throw new Error('Blocked sources require a blocker and zero counts.');
128
+ if (properties.status === 'searched' && properties.blocker) throw new Error('Searched sources cannot have a blocker.');
129
+ }
121
130
  const result = { event: input.event, properties };
122
131
  if (new TextEncoder().encode(JSON.stringify(result)).length > TELEMETRY_MAX_BYTES) throw new Error('Telemetry event exceeds 4 KB.');
123
132
  return result;
@@ -142,19 +151,37 @@ export async function jobIdentity(value) {
142
151
  return { jobHash: [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''), domain: new URL(canonical).hostname };
143
152
  }
144
153
 
145
- export function createTelemetryEnvelope({ installationId, token, event, properties, skillVersion }) {
154
+ // Identity is a separate allowlisted envelope field, never arbitrary event input.
155
+ export function validateTelemetryIdentity(input) {
156
+ if (!input || Array.isArray(input) || typeof input !== 'object') throw new Error('Telemetry identity must be an object.');
157
+ for (const key of Object.keys(input)) if (!['name', 'email'].includes(key)) throw new Error('Unknown telemetry identity property.');
158
+ const identity = {};
159
+ for (const [key, max] of [['name', 160], ['email', 254]]) {
160
+ if (!(key in input)) continue;
161
+ const value = input[key];
162
+ if (typeof value !== 'string' || !value.trim() || value.length > max || /[\x00-\x1f\x7f]/.test(value)) throw new Error('Invalid telemetry identity field.');
163
+ const normalized = value.trim();
164
+ if (key === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) throw new Error('Invalid telemetry identity email.');
165
+ identity[key] = normalized;
166
+ }
167
+ if (!Object.keys(identity).length) throw new Error('Telemetry identity must include name or email.');
168
+ return identity;
169
+ }
170
+
171
+ export function createTelemetryEnvelope({ installationId, token, event, properties, skillVersion, identity }) {
146
172
  if (!UUID.test(installationId)) throw new Error('installationId must be an anonymous UUID.');
147
173
  if (typeof token !== 'string' || token.length < 8 || token.length > 2048) throw new Error('token is invalid.');
148
174
  if (typeof skillVersion !== 'string' || !VERSION.test(skillVersion)) throw new Error('skillVersion is invalid.');
149
175
  const safe = validateEvent(typeof event === 'string' ? { event, properties } : event);
150
176
  const envelope = { schemaVersion: TELEMETRY_SCHEMA_VERSION, skillVersion, installationId, token, ...safe };
177
+ if (identity !== undefined) envelope.identity = validateTelemetryIdentity(identity);
151
178
  if (new TextEncoder().encode(JSON.stringify(envelope)).length > TELEMETRY_MAX_BYTES) throw new Error('Telemetry payload exceeds 4 KB.');
152
179
  return envelope;
153
180
  }
154
181
 
155
182
  export function validateTelemetryEnvelope(input) {
156
183
  if (!input || Array.isArray(input) || typeof input !== 'object') throw new Error('Telemetry payload must be an object.');
157
- const allowed = new Set(['schemaVersion', 'skillVersion', 'installationId', 'token', 'event', 'properties']);
184
+ const allowed = new Set(['schemaVersion', 'skillVersion', 'installationId', 'token', 'event', 'properties', 'identity']);
158
185
  for (const key of Object.keys(input)) if (!allowed.has(key)) throw new Error(`Unknown telemetry envelope property: ${key}.`);
159
186
  if (input.schemaVersion !== TELEMETRY_SCHEMA_VERSION) throw new Error('Unsupported telemetry schema version.');
160
187
  return createTelemetryEnvelope(input);