job-application-agent 3.2.1 → 3.4.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.
@@ -26,7 +26,7 @@ test('new installations disclose and send the first event immediately', async (t
26
26
  assert.equal(session.installationEventPending, true);
27
27
  await client.record({ event: 'installation_started', properties: { osFamily: 'macos', nodeMajor: 24, submissionMode: 'unconfigured' } }, session);
28
28
  const result = await client.record({ event: 'command_completed', properties: { command: 'search', result: 'success', durationBucket: '1-5s' } }, session);
29
- assert.match(notice, /anonymous usage analytics/i);
29
+ assert.match(notice, /usage analytics/i);
30
30
  assert.equal(result.sent, true);
31
31
  assert.equal(relay.requests.length, 3);
32
32
  assert.equal((await client.beginCommand('search')).installationEventPending, false);
@@ -68,7 +68,7 @@ test('a pre-disclosure telemetry config cannot send on its disclosure command',
68
68
  let notice = '';
69
69
  const client = new TelemetryClient({ stateDir: directory, endpoint: 'https://relay.example.com', fetch: relay.fetch, stderr: (value) => { notice += value; } });
70
70
  const session = await client.beginCommand('search');
71
- assert.match(notice, /anonymous usage analytics/i);
71
+ assert.match(notice, /usage analytics/i);
72
72
  assert.equal(session.allowSend, false);
73
73
  assert.equal(relay.requests.length, 0);
74
74
  });
@@ -130,3 +130,92 @@ test('strict record rejects invalid schema while automatic telemetry stays best
130
130
  await assert.rejects(() => client.record(invalid, session, { strict: true }), /durationBucket/i);
131
131
  assert.deepEqual(await client.record(invalid, session), { sent: false, reason: 'invalid' });
132
132
  });
133
+
134
+ const usageEvent = { event: 'command_completed', properties: { command: 'profile', result: 'success', durationBucket: 'under-1s' } };
135
+
136
+ test('identity sharing defaults on but waits until the command after disclosure, including upgrades', async (t) => {
137
+ for (const legacy of [false, true]) {
138
+ const directory = await mkdtemp(join(tmpdir(), 'telemetry-identity-'));
139
+ t.after(() => rm(directory, { recursive: true, force: true }));
140
+ if (legacy) await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: true, disclosed: true, graceConsumed: true }));
141
+ const relay = fakeRelay();
142
+ let notice = '';
143
+ const client = new TelemetryClient({ stateDir: directory, fetch: relay.fetch, stderr: (value) => { notice += value; }, readIdentity: () => ({ name: 'Test Candidate', email: 'candidate@example.com' }) });
144
+ const first = await client.beginCommand('profile');
145
+ await client.record(usageEvent, first);
146
+ assert.match(notice, /name and email/i);
147
+ assert.match(notice, /telemetry identity disable/);
148
+ assert.equal(relay.requests.at(-1).body.identity, undefined);
149
+ await client.record(usageEvent); // A missing session must not bypass the disclosure gate.
150
+ assert.equal(relay.requests.at(-1).body.identity, undefined);
151
+ await client.record(usageEvent, await client.beginCommand('profile'));
152
+ assert.deepEqual(relay.requests.at(-1).body.identity, { name: 'Test Candidate', email: 'candidate@example.com' });
153
+ assert.equal((await client.status()).identityEnabled, true);
154
+ assert.equal(JSON.stringify(await client.readConfig()).includes('candidate@example.com'), false);
155
+ }
156
+ });
157
+
158
+ test('identity opt-out rotates credentials and stops identity sharing even in an already started command', async (t) => {
159
+ const directory = await mkdtemp(join(tmpdir(), 'telemetry-identity-optout-'));
160
+ t.after(() => rm(directory, { recursive: true, force: true }));
161
+ const relay = fakeRelay();
162
+ const client = new TelemetryClient({ stateDir: directory, fetch: relay.fetch, stderr: () => {}, readIdentity: () => ({ name: 'Test Candidate', email: 'candidate@example.com' }) });
163
+ await client.beginCommand('profile');
164
+ const session = await client.beginCommand('profile');
165
+ await client.record(usageEvent, session);
166
+ assert.ok(relay.requests.at(-1).body.identity);
167
+ const before = relay.requests.length;
168
+ const status = await client.configureIdentity('disable');
169
+ assert.equal(status.enabled, true);
170
+ assert.equal(status.identityEnabled, false);
171
+ assert.equal(status.hasInstallationId, false);
172
+ assert.equal(relay.requests.length, before);
173
+ await client.record(usageEvent, session);
174
+ assert.equal(relay.requests.at(-1).body.identity, undefined);
175
+ assert.deepEqual(relay.requests.at(-2).body, {});
176
+ const anonymousId = (await client.status()).installationId;
177
+ await client.configureIdentity('disable');
178
+ assert.equal((await client.status()).installationId, anonymousId);
179
+ await client.configure('disable');
180
+ await client.configure('enable');
181
+ assert.equal((await client.status()).identityEnabled, false);
182
+ });
183
+
184
+ test('disabled telemetry and identity opt-out never read identity; malformed identity does not stop usage', async (t) => {
185
+ const directory = await mkdtemp(join(tmpdir(), 'telemetry-identity-failure-'));
186
+ t.after(() => rm(directory, { recursive: true, force: true }));
187
+ const relay = fakeRelay();
188
+ let reads = 0;
189
+ const client = new TelemetryClient({ stateDir: directory, fetch: relay.fetch, stderr: () => {}, readIdentity: () => { reads++; return { name: 'Test', email: 'bad', resume: 'private' }; } });
190
+ await client.configure('disable');
191
+ await client.record(usageEvent, await client.beginCommand('profile'));
192
+ assert.equal(reads, 0);
193
+ await client.configure('enable');
194
+ await client.beginCommand('profile');
195
+ assert.equal((await client.record(usageEvent, await client.beginCommand('profile'))).sent, true);
196
+ assert.equal(relay.requests.at(-1).body.identity, undefined);
197
+ await client.configureIdentity('disable');
198
+ const before = reads;
199
+ await client.record(usageEvent, await client.beginCommand('profile'));
200
+ assert.equal(reads, before);
201
+ });
202
+
203
+ test('identity re-enable discloses again and rotates the anonymous interval without enabling telemetry', async (t) => {
204
+ const directory = await mkdtemp(join(tmpdir(), 'telemetry-identity-resume-'));
205
+ t.after(() => rm(directory, { recursive: true, force: true }));
206
+ const relay = fakeRelay();
207
+ const client = new TelemetryClient({ stateDir: directory, fetch: relay.fetch, stderr: () => {}, readIdentity: () => ({ email: 'candidate@example.com' }) });
208
+ await client.configureIdentity('disable');
209
+ await client.record(usageEvent, await client.beginCommand('profile'));
210
+ assert.equal(relay.requests.at(-1).body.identity, undefined);
211
+ await client.configure('disable');
212
+ const status = await client.configureIdentity('enable');
213
+ assert.equal(status.enabled, false);
214
+ assert.equal(status.hasInstallationId, false);
215
+ assert.equal(status.identityDisclosed, false);
216
+ await client.configure('enable');
217
+ await client.record(usageEvent, await client.beginCommand('profile'));
218
+ assert.equal(relay.requests.at(-1).body.identity, undefined);
219
+ await client.record(usageEvent, await client.beginCommand('profile'));
220
+ assert.deepEqual(relay.requests.at(-1).body.identity, { email: 'candidate@example.com' });
221
+ });
@@ -6,8 +6,20 @@ import {
6
6
  createTelemetryEnvelope,
7
7
  jobIdentity,
8
8
  validateEvent,
9
+ validateTelemetryEnvelope,
9
10
  } from '../scripts/telemetry-schema.mjs';
10
11
 
12
+ test('identity envelope accepts only bounded name and email and keeps old anonymous envelopes compatible', () => {
13
+ const input = { installationId: '11111111-1111-4111-8111-111111111111', token: 'signed-token', skillVersion: '3.3.0', event: 'command_completed', properties: { command: 'profile', result: 'success', durationBucket: 'under-1s' } };
14
+ const envelope = createTelemetryEnvelope({ ...input, identity: { name: ' Test Candidate ', email: ' candidate@example.com ' } });
15
+ assert.deepEqual(envelope.identity, { name: 'Test Candidate', email: 'candidate@example.com' });
16
+ assert.deepEqual(validateTelemetryEnvelope(envelope), envelope);
17
+ assert.equal(validateTelemetryEnvelope(createTelemetryEnvelope(input)).identity, undefined);
18
+ for (const identity of [{}, { name: 'x', phone: 'private' }, { email: 'invalid' }, { name: 'x'.repeat(161) }, { email: 'a\nb@example.com' }, { name: 'Test\nPrivate' }, { name: 'Test', resume: 'private' }]) {
19
+ assert.throws(() => validateTelemetryEnvelope({ ...envelope, identity }), /identity/i);
20
+ }
21
+ });
22
+
11
23
  const baseJob = {
12
24
  company: 'Example AI',
13
25
  title: 'Staff Product Engineer',
@@ -16,6 +28,16 @@ const baseJob = {
16
28
  ats: 'greenhouse',
17
29
  };
18
30
 
31
+ test('source coverage telemetry excludes local evidence and community identifiers', () => {
32
+ const event = { event: 'source_checked', properties: { sourceId: 'linkedin-jobs-feed', status: 'searched', reviewedCount: 10, qualifiedCount: 2 } };
33
+ assert.equal(validateEvent(event).properties.sourceId, 'linkedin-jobs-feed');
34
+ assert.throws(() => validateEvent({ ...event, properties: { ...event.properties, evidence: 'private query and notes' } }), /unknown/i);
35
+ assert.throws(() => validateEvent({ ...event, properties: { ...event.properties, sourceId: 'community-abcdef1234567890' } }), /sourceId/i);
36
+ assert.throws(() => validateEvent({ ...event, properties: { ...event.properties, qualifiedCount: 11 } }), /qualifiedCount/i);
37
+ assert.throws(() => validateEvent({ ...event, properties: { ...event.properties, status: 'blocked' } }), /block/i);
38
+ assert.equal(validateEvent({ event: 'source_checked', properties: { sourceId: 'community', status: 'blocked', reviewedCount: 0, qualifiedCount: 0, blocker: 'captcha' } }).properties.blocker, 'captcha');
39
+ });
40
+
19
41
  test('canonicalizes job URLs and hashes the destination without query data', async () => {
20
42
  assert.equal(canonicalizeJobUrl('https://Jobs.Example.com/role/123/?utm_source=x#apply'), 'https://jobs.example.com/role/123');
21
43
  const first = await jobIdentity('https://jobs.example.com/role/123?ref=friend');
@@ -121,15 +121,87 @@ test('counts only unique confirmed ledger submissions for an explicit round', as
121
121
  assert.equal(status.blockedCount, 1);
122
122
  assert.equal(status.completed, false);
123
123
 
124
- const completed = cli(env, ['round', 'complete', '--stdin'], { roundId: started.roundId });
124
+ for (const sourceId of ['linkedin-jobs-feed', 'indeed', 'hacker-news-who-is-hiring']) {
125
+ cli(env, ['round', 'source', '--stdin'], {
126
+ roundId: started.roundId, sourceId, status: 'searched', reviewedCount: 30, qualifiedCount: sourceId === 'linkedin-jobs-feed' ? 30 : 0,
127
+ evidence: 'Reviewed matching postings against the unchanged target; other leads did not qualify.',
128
+ applicationIds: sourceId === 'linkedin-jobs-feed' ? Array.from({ length: 30 }, (_, i) => `round-role-${i}`) : [],
129
+ });
130
+ }
131
+ const completed = cli(env, ['round', 'complete', '--stdin'], { roundId: started.roundId, concentrationReason: 'stronger-fit', concentrationEvidence: 'The other searched sources had no qualifying roles; all selected roles met the target.' });
125
132
  assert.equal(completed.completed, true);
126
133
  assert.equal(cli(env, ['round', 'status', started.roundId]).completed, true);
127
134
  const roundEvents = (await readFile(join(directory, 'rounds.ndjson'), 'utf8')).trim().split('\n').map(JSON.parse);
128
135
  assert.equal(roundEvents.filter((event) => event.type === 'submission-confirmed').length, 30);
129
- assert.equal(roundEvents.length, 32);
136
+ assert.equal(roundEvents.length, 35);
130
137
  if (process.platform !== 'win32') assert.equal((await stat(join(directory, 'rounds.ndjson'))).mode & 0o777, 0o600);
131
138
  });
132
139
 
140
+ test('round completion requires distinct source coverage and explains concentration without imposing submission quotas', async (t) => {
141
+ const { env } = await fixture(t, 'source-coverage');
142
+ const { roundId } = cli(env, ['round', 'start', '--stdin'], { requestedCount: 2 });
143
+ for (const i of [1, 2]) cli(env, ['ledger', 'add', '--stdin'], submission(i, roundId, { discoverySourceId: 'linkedin-jobs-feed' }));
144
+ assert.match(cliFailure(env, ['round', 'complete', '--stdin'], { roundId }).stderr, /3 distinct discovery sources/i);
145
+ const check = { roundId, sourceId: 'linkedin-jobs-feed', status: 'searched', reviewedCount: 10, qualifiedCount: 2, evidence: 'Reviewed ten relevant postings using the target constraints.' };
146
+ cli(env, ['round', 'source', '--stdin'], check);
147
+ cli(env, ['round', 'source', '--stdin'], check);
148
+ assert.equal(cli(env, ['round', 'status', roundId]).discovery.attemptedSourceCount, 1);
149
+ cli(env, ['round', 'source', '--stdin'], { ...check, sourceId: 'indeed', qualifiedCount: 0 });
150
+ cli(env, ['round', 'source', '--stdin'], { ...check, sourceId: 'hacker-news-who-is-hiring', status: 'blocked', reviewedCount: 0, qualifiedCount: 0, blocker: 'site-error' });
151
+ const status = cli(env, ['round', 'status', roundId]);
152
+ assert.equal(status.discovery.coverageSatisfied, true);
153
+ assert.equal(status.discovery.searchedSourceCount, 2);
154
+ assert.equal(status.discovery.blockedSourceCount, 1);
155
+ assert.equal(status.discovery.maxSourceSharePercent, 100);
156
+ assert.match(cliFailure(env, ['round', 'complete', '--stdin'], { roundId }).stderr, /concentration/i);
157
+ const completed = cli(env, ['round', 'complete', '--stdin'], { roundId, concentrationReason: 'stronger-fit', concentrationEvidence: 'Only LinkedIn produced qualifying roles; Indeed had none and the third source was unavailable.' });
158
+ assert.equal(completed.completed, true);
159
+ assert.equal(cli(env, ['round', 'status', roundId]).discovery.concentrationReason, 'stronger-fit');
160
+ assert.match(cliFailure(env, ['round', 'source', '--stdin'], check).stderr, /completed round/i);
161
+ });
162
+
163
+ test('discovery coverage rejects invalid reports and requires attribution without rewriting confirmed ledger rows', async (t) => {
164
+ const { env, directory } = await fixture(t, 'source-validation');
165
+ const { roundId } = cli(env, ['round', 'start', '--stdin'], { requestedCount: 1 });
166
+ cli(env, ['ledger', 'add', '--stdin'], submission(1, roundId));
167
+ const before = await readFile(join(directory, 'applications.ndjson'), 'utf8');
168
+ const check = { roundId, sourceId: 'linkedin-jobs-feed', status: 'searched', reviewedCount: 4, qualifiedCount: 1, evidence: 'Checked relevant listings against the candidate target.' };
169
+ for (const bad of [{ sourceId: 'imaginary-board' }, { qualifiedCount: 5 }, { status: 'blocked' }, { applicationIds: ['not-in-round'] }, { evidence: '' }, { privateProfile: 'forbidden' }]) {
170
+ assert.equal(cliFailure(env, ['round', 'source', '--stdin'], { ...check, ...bad }).status, 1);
171
+ }
172
+ for (const sourceId of ['linkedin-jobs-feed', 'indeed', 'hacker-news-who-is-hiring']) cli(env, ['round', 'source', '--stdin'], { ...check, sourceId });
173
+ assert.match(cliFailure(env, ['round', 'complete', '--stdin'], { roundId }).stderr, /attribution/i);
174
+ cli(env, ['round', 'source', '--stdin'], { ...check, applicationIds: ['round-role-1'] });
175
+ assert.equal(cli(env, ['round', 'status', roundId]).discovery.unattributedCount, 0);
176
+ assert.equal(await readFile(join(directory, 'applications.ndjson'), 'utf8'), before);
177
+ });
178
+
179
+ test('source diversity is independent of ATS and concentration explanations are unnecessary for a balanced mix', async (t) => {
180
+ const { env } = await fixture(t, 'balanced-discovery');
181
+ const { roundId } = cli(env, ['round', 'start', '--stdin'], { requestedCount: 5 });
182
+ const ids = ['linkedin-jobs-feed', 'indeed', 'hacker-news-who-is-hiring'];
183
+ for (const sourceId of ids) cli(env, ['round', 'source', '--stdin'], { roundId, sourceId, status: 'searched', reviewedCount: 3, qualifiedCount: 2, evidence: 'Reviewed relevant postings and verified target fit.' });
184
+ for (let i = 1; i <= 5; i++) cli(env, ['ledger', 'add', '--stdin'], submission(i, roundId, { discoverySourceId: ids[(i - 1) % 3], applicationChannel: 'ashby' }));
185
+ const completed = cli(env, ['round', 'complete', '--stdin'], { roundId });
186
+ assert.equal(completed.discovery.maxSourceSharePercent, 40);
187
+ assert.equal(completed.discovery.concentrationNeedsExplanation, false);
188
+ assert.equal(cli(env, ['round', 'complete', '--stdin'], { roundId }).completionRecorded, false);
189
+ });
190
+
191
+ test('blocked-only attempts and two views of the same network do not satisfy discovery', async (t) => {
192
+ const { env } = await fixture(t, 'blocked-discovery');
193
+ const { roundId } = cli(env, ['round', 'start', '--stdin'], { requestedCount: 1 });
194
+ const check = { roundId, status: 'blocked', reviewedCount: 0, qualifiedCount: 0, blocker: 'login', evidence: 'Source requires an authenticated session that is unavailable.' };
195
+ for (const sourceId of ['linkedin-jobs-feed', 'yc-work-at-a-startup', 'yc-company-directory']) cli(env, ['round', 'source', '--stdin'], { ...check, sourceId });
196
+ assert.equal(cli(env, ['round', 'status', roundId]).discovery.attemptedSourceCount, 2);
197
+ cli(env, ['round', 'source', '--stdin'], { ...check, sourceId: 'indeed' });
198
+ assert.equal(cli(env, ['round', 'status', roundId]).discovery.coverageSatisfied, false);
199
+ const { blocker, ...searched } = check;
200
+ cli(env, ['round', 'source', '--stdin'], { ...searched, sourceId: 'indeed', status: 'searched', reviewedCount: 1, qualifiedCount: 1 });
201
+ cli(env, ['round', 'source', '--stdin'], { ...check, sourceId: 'indeed' });
202
+ assert.equal(cli(env, ['round', 'status', roundId]).discovery.coverageSatisfied, true);
203
+ });
204
+
133
205
  test('ships a filterable global discovery source catalog and tracks source attribution', async (t) => {
134
206
  const { directory, env } = await fixture(t, 'source-catalog');
135
207
  const catalog = cli(env, ['sources', 'list']);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-application-agent",
3
- "version": "3.2.1",
3
+ "version": "3.4.0",
4
4
  "description": "A privacy-first Agent Skill and CLI for evidence-based job discovery, application completion, and outcome tracking.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,7 +38,7 @@
38
38
  "check:native-artifacts": "node scripts/ci/validate-native-artifacts.mjs",
39
39
  "privacy-audit": "node --test job-application-agent/tests/privacy-audit.test.mjs",
40
40
  "smoke:package": "node scripts/smoke-package.mjs",
41
- "check": "node --check job-application-agent/scripts/job-application.mjs && node --check job-application-agent/scripts/secret-store.mjs && node --check job-application-agent/scripts/source-community-client.mjs && node --check job-application-agent/scripts/source-community-schema.mjs && node --check job-application-agent/scripts/telemetry-client.mjs && node --check job-application-agent/scripts/telemetry-schema.mjs && node --check job-application-agent/scripts/version.mjs && node --check telemetry-worker/src/worker.mjs && node --check telemetry-worker/src/public-stats.mjs && node --check telemetry-worker/public/dashboard.js && node --check installer/src/cli.mjs && node --check installer/src/installer.mjs && node --check installer/src/runner.mjs && node --check installer/src/scheduler.mjs && node --check scripts/ci/classify-paths.mjs && node --check scripts/ci/validate-native-artifacts.mjs && node --check bin/job-application-agent.mjs",
41
+ "check": "node --check job-application-agent/scripts/job-application.mjs && node --check job-application-agent/scripts/secret-store.mjs && node --check job-application-agent/scripts/source-community-client.mjs && node --check job-application-agent/scripts/source-community-schema.mjs && node --check job-application-agent/scripts/telemetry-client.mjs && node --check job-application-agent/scripts/telemetry-schema.mjs && node --check job-application-agent/scripts/version.mjs && node --check telemetry-worker/src/worker.mjs && node --check telemetry-worker/src/public-stats.mjs && node --check telemetry-worker/public/dashboard.js && node --check installer/src/cli.mjs && node --check installer/src/installer.mjs && node --check installer/src/runner.mjs && node --check installer/src/scheduler.mjs && node --check scripts/ci/classify-paths.mjs && node --check scripts/ci/validate-native-artifacts.mjs && node --check bin/job-application-agent.mjs && node --check state-worker/src/worker.mjs && node --check state-worker/src/cli.mjs && node --check state-worker/bin/sync.mjs",
42
42
  "prepack": "npm run check && npm test && npm run privacy-audit"
43
43
  },
44
44
  "engines": {