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
@@ -0,0 +1,73 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+ import { outreachCache, runOutreach } from '../scripts/outreach-cli.mjs';
10
+ import { migrateLegacyStateDir, legacyMacStateDir, resolveStateDir } from '../scripts/secret-store.mjs';
11
+ const exec = promisify(execFile);
12
+ const cli = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
13
+
14
+ test('CLI outreach success and errors bypass telemetry, identity, and community network paths', async () => {
15
+ const dir = await mkdtemp(join(tmpdir(), 'outreach-cli-'));
16
+ try {
17
+ const trap = join(dir, 'network-trap.mjs');
18
+ await writeFile(trap, "import { appendFileSync } from 'node:fs'; globalThis.fetch = async () => { appendFileSync(process.env.NETWORK_MARKER, 'called'); throw new Error('NETWORK_TRAP'); };\n");
19
+ const run = args => exec(process.execPath, ['--import', pathToFileURL(trap).href, cli, 'outreach', ...args], { env: { ...process.env, NETWORK_MARKER: join(dir, 'network-called'), JOB_APPLICATION_AGENT_STATE_DIR: dir, JOB_APPLICATION_AGENT_CLOUD_CONFIG: join(dir, 'absent.json') } });
20
+ const result = await run(['policy', 'status']);
21
+ assert.equal(JSON.parse(result.stdout).enabled, false);
22
+ await assert.rejects(run(['invented']), error => !error.stderr.includes('NETWORK_TRAP') && /outreach/i.test(error.stderr));
23
+ for (const name of ['telemetry.json', 'source-sharing.json', 'telemetry-identity.json']) await assert.rejects(readFile(join(dir, name)), { code: 'ENOENT' });
24
+ await assert.rejects(readFile(join(dir, 'network-called')), { code: 'ENOENT' });
25
+ } finally { await rm(dir, { recursive: true, force: true }); }
26
+ });
27
+
28
+ test('late snapshots cannot undo mutation invalidation or a newer clear snapshot', async () => {
29
+ const dir = await mkdtemp(join(tmpdir(), 'outreach-cache-race-'));
30
+ try {
31
+ const { generation } = await outreachCache(dir, 'synthetic-binding', 'read');
32
+ await outreachCache(dir, 'synthetic-binding', 'invalidate');
33
+ const rejected = await outreachCache(dir, 'synthetic-binding', 'write', { generation, snapshot: { revision: 1, privateText: 'old' } });
34
+ assert.equal(rejected.snapshot, undefined);
35
+ await outreachCache(dir, 'synthetic-binding', 'write', { generation: rejected.generation, snapshot: { revision: 3, cleared: true } });
36
+ const current = await outreachCache(dir, 'synthetic-binding', 'write', { generation: rejected.generation, snapshot: { revision: 2, privateText: 'old' } });
37
+ assert.equal(current.snapshot, undefined);
38
+ assert.equal(current.generation, rejected.generation + 1);
39
+ assert.equal(JSON.stringify(current).includes('old'), false);
40
+ } finally { await rm(dir, { recursive: true, force: true }); }
41
+ });
42
+
43
+
44
+ test('outreach first-run migrates legacy state before creating its database', async () => {
45
+ const home = await mkdtemp(join(tmpdir(), 'outreach-legacy-'));
46
+ try {
47
+ const options = { home, env: {}, plat: 'darwin' };
48
+ const legacy = legacyMacStateDir(home), destination = resolveStateDir(options);
49
+ await mkdir(legacy, { recursive: true });
50
+ await writeFile(join(legacy, 'applications.ndjson'), '{"id":"legacy"}\n');
51
+ await runOutreach(['policy', 'status'], { stateDirectory: destination,
52
+ cloudClient: { config: async () => null }, migrate: directory => migrateLegacyStateDir(directory, options) });
53
+ assert.equal(await readFile(join(destination, 'applications.ndjson'), 'utf8'), '{"id":"legacy"}\n');
54
+ } finally { await rm(home, { recursive: true, force: true }); }
55
+ });
56
+
57
+ test('a lower authoritative revision removes pre-restore sensitive cache before offline use', async () => {
58
+ const dir = await mkdtemp(join(tmpdir(), 'outreach-restored-cache-'));
59
+ try {
60
+ const binding = 'same-worker-and-token';
61
+ const { generation } = await outreachCache(dir, binding, 'read');
62
+ await outreachCache(dir, binding, 'write', { generation, snapshot: { revision: 50, privateText: 'pre-restore' } });
63
+ await outreachCache(dir, binding, 'write', { generation, snapshot: { revision: 3, items: [], policy: { recoveryBlocked: true } } });
64
+ const cache = await outreachCache(dir, binding, 'read');
65
+ assert.equal(cache.snapshot, undefined);
66
+ assert.equal((await readFile(join(dir, 'outreach-cloud-cache.json'), 'utf8')).includes('pre-restore'), false);
67
+ // A delayed pre-restore response cannot refill the invalidated cache.
68
+ await outreachCache(dir, binding, 'write', { generation, snapshot: { revision: 51, privateText: 'pre-restore' } });
69
+ assert.equal((await outreachCache(dir, binding, 'read')).snapshot, undefined);
70
+ await outreachCache(dir, binding, 'write', { generation: cache.generation, snapshot: { revision: 3, items: [] } });
71
+ assert.deepEqual((await outreachCache(dir, binding, 'read')).snapshot.items, []);
72
+ } finally { await rm(dir, { recursive: true, force: true }); }
73
+ });
@@ -0,0 +1,178 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { initialOutreach, mutateOutreach, readOutreach, businessDate } from '../scripts/outreach-domain.mjs';
4
+
5
+ import { assessment, fixture, handoff, now } from './fixtures/outreach.mjs';
6
+
7
+ test('qualified hiring posts can be drafted and handed off without an application or transmission', () => {
8
+ const f = fixture();
9
+ const result = f.run('handoff', handoff());
10
+ assert.equal(result.delivery, 'pending-handoff');
11
+ assert.match(result.copyableText, /product engineering/);
12
+ assert.equal(readOutreach(f.state, 'review', {}, now).sentVerified, 0);
13
+ assert.deepEqual(f.run('handoff', handoff()), result);
14
+ assert.throws(() => f.run('handoff', handoff({ draftRevision: 2 })), /operation.*content/i);
15
+ });
16
+ test('false qualification and missing candidate claim references block handoff or draft', () => {
17
+ const f = fixture();
18
+ assert.throws(() => f.run('draft', { operationId: 'bad', id: 'opportunity-1', text: 'Hello', claimRefs: ['invented'], purpose: 'initial' }), /claim/i);
19
+ f.run('assess', assessment('opportunity-1', { operationId: 'reassess', qualification: { active: true, companyVerified: true, eligible: false, fit: true, affiliation: true, hiringInvolvement: true } }));
20
+ assert.throws(() => f.run('handoff', handoff({ qualificationRevision: 2 })), /qualification/i);
21
+ });
22
+ test('company reservation blocks other channels and does not expire after uncertain handoff', () => {
23
+ const f = fixture(); f.run('handoff', handoff());
24
+ f.run('record', { operationId: 'uncertain', id: 'opportunity-1', type: 'uncertain', attemptId: 'handoff-1', occurredAt: now, evidence: 'User cannot confirm sending.' });
25
+ f.run('assess', assessment('second', { channel: 'x', recipient: { account: 'https://x.com/example_hiring', aliases: [] } }));
26
+ f.run('draft', { operationId: 'draft-2', id: 'second', text: 'Hello', claimRefs: [], purpose: 'initial' });
27
+ assert.throws(() => f.run('handoff', handoff({ operationId: 'handoff-2', id: 'second' })), /reserved|contact/i);
28
+ });
29
+ test('clear removes sensitive material, retains suppression, and refuses resurrection', () => {
30
+ const f = fixture(); f.run('handoff', handoff());
31
+ f.run('clear', { operationId: 'clear', ids: ['opportunity-1'] });
32
+ const serialized = JSON.stringify(f.state);
33
+ for (const privateText of ['example.org', 'example-recruiter', 'canonical-resume', 'brief conversation']) assert.equal(serialized.includes(privateText), false);
34
+ assert.throws(() => f.run('assess', assessment('opportunity-1', { operationId: 'resurrect' })), /cleared/i);
35
+ assert.equal(f.run('handoff', handoff()).cleared, true);
36
+ });
37
+ test('business dates use local calendar weekdays across weekends', () => {
38
+ assert.equal(businessDate('2026-09-18T23:30:00Z', 'Asia/Kolkata', 7), '2026-09-29');
39
+ });
40
+
41
+ test('ranking does not exclude qualified contacts and aliases identify the same recipient', () => {
42
+ const f = fixture();
43
+ f.run('assess', assessment('opportunity-1', { operationId: 'low-rank', ranking: { hiringSignal: 0, responsibility: 0, fit: 0, freshness: 0, relationship: 0 } }));
44
+ f.run('draft', { operationId: 'new-draft', id: 'opportunity-1', text: 'Hello', claimRefs: [], purpose: 'initial' });
45
+ assert.equal(f.run('handoff', handoff({ draftRevision: 2, qualificationRevision: 2 })).score, 0);
46
+ f.run('assess', assessment('other-company', { company: { name: 'Second', domain: 'second.example', aliases: [] }, recipient: { account: 'https://linkedin.com/in/EXAMPLE-RECRUITER/?tracking=1', aliases: [] } }));
47
+ f.run('draft', { operationId: 'other-draft', id: 'other-company', text: 'Hello', claimRefs: [], purpose: 'initial' });
48
+ assert.throws(() => f.run('handoff', handoff({ operationId: 'other-handoff', id: 'other-company' })), /reserved/);
49
+ });
50
+
51
+ test('follow-up depends on actual send dates, and replies cancel it', () => {
52
+ const f = fixture(); f.run('handoff', handoff());
53
+ f.run('record', { operationId: 'sent', id: 'opportunity-1', attemptId: 'handoff-1', type: 'sent-user-reported', occurredAt: now, evidence: 'User reports manual send.' });
54
+ assert.equal(readOutreach(f.state, 'show', { id: 'opportunity-1' }, '2026-09-24T10:00:00Z').followupDue, false);
55
+ assert.equal(readOutreach(f.state, 'show', { id: 'opportunity-1' }, '2026-09-25T10:00:00Z').followupDue, true);
56
+ f.run('draft', { operationId: 'followup-draft', id: 'opportunity-1', text: 'Following up on the role.', claimRefs: [], purpose: 'follow-up' });
57
+ assert.equal(readOutreach(f.state, 'show', { id: 'opportunity-1' }, '2026-10-20T10:00:00Z').noResponse, false);
58
+ f.run('record', { operationId: 'reply', id: 'opportunity-1', type: 'screen-proposed', occurredAt: now, evidence: 'Recruiter proposed a discussion.' });
59
+ assert.equal(readOutreach(f.state, 'show', { id: 'opportunity-1' }, '2026-10-20T10:00:00Z').followupDue, false);
60
+ assert.equal(readOutreach(f.state, 'review', {}, now).outcomes['screen-scheduled'], 0);
61
+ assert.throws(() => f.run('record', { operationId: 'bad-schedule', id: 'opportunity-1', type: 'screen-scheduled', occurredAt: now, evidence: 'They mentioned a discussion.' }), /object/);
62
+ });
63
+
64
+ test('conflicting delivery evidence requires explicit correction; not-sent release can be corrected safely', () => {
65
+ const f = fixture(); f.run('handoff', handoff());
66
+ const record = (operationId, type, extra = {}) => f.run('record', { operationId, id: 'opportunity-1', attemptId: 'handoff-1', type, occurredAt: now, evidence: 'Synthetic observation.', ...extra });
67
+ record('uncertain', 'uncertain');
68
+ assert.equal(record('sent', 'sent-user-reported').delivery, 'conflict');
69
+ assert.equal(record('fixed', 'not-sent', { supersedes: ['uncertain', 'sent'] }).delivery, 'not-sent');
70
+ assert.equal(Object.keys(f.state.reservations).length, 0);
71
+ assert.equal(f.run('handoff', handoff()).copyableText, undefined);
72
+ record('later-proof', 'sent-verified', { supersedes: ['fixed'], messageRef: 'message-1' });
73
+ assert.equal(Object.keys(f.state.reservations).length, 1);
74
+ assert.equal(readOutreach(f.state, 'show', { id: 'opportunity-1' }, now).delivery, 'sent-verified');
75
+ });
76
+
77
+ test('linked applications must match both company and role', () => {
78
+ const f = fixture();
79
+ const input = assessment('applied', { applicationId: 'app-1', source: { kind: 'application', url: 'https://example.org/jobs/1' } });
80
+ assert.throws(() => mutateOutreach(f.state, 'assess', input, { now, applications: [{ id: 'app-1', company: 'Wrong Company', role: input.role, status: 'submitted' }] }), /company/i);
81
+ const result = mutateOutreach(f.state, 'assess', input, { now, applications: [{ id: 'app-1', company: 'Example', role: input.role, status: 'submitted' }] });
82
+ assert.equal(result.result.eligible, true);
83
+ });
84
+
85
+ test('common fabricated application and commitment claims are rejected; disabled grants stop handoff', () => {
86
+ const f = fixture();
87
+ for (const [index, text] of ['I applied for this role.', 'I am available immediately.', 'I built AI agents.'].entries()) {
88
+ assert.throws(() => f.run('draft', { operationId: `invalid-${index}`, id: 'opportunity-1', text, claimRefs: [], purpose: 'initial' }), /claim|commitment|evidence/);
89
+ }
90
+ f.run('policy-disable', { operationId: 'disable' });
91
+ assert.throws(() => f.run('handoff', handoff()), /disabled/);
92
+ });
93
+
94
+ test('recipient and company suppression cannot be overridden by an exception', () => {
95
+ const f = fixture();
96
+ f.run('suppress', { operationId: 'stop', id: 'opportunity-1', scope: 'company', reason: 'Candidate requested no contact.' });
97
+ assert.throws(() => f.run('handoff', handoff({ exception: { approvedByUser: true, reason: 'Attempted bypass.' } })), /suppressed/);
98
+ });
99
+
100
+ test('company suppression cancels due suggestions for other opportunities', () => {
101
+ const f = fixture(); f.run('handoff', handoff());
102
+ f.run('record', { operationId: 'sent', id: 'opportunity-1', attemptId: 'handoff-1', type: 'sent-user-reported', occurredAt: now, evidence: 'User reports sending.' });
103
+ f.run('assess', assessment('other'));
104
+ f.run('suppress', { operationId: 'stop-other', id: 'other', scope: 'company', reason: 'Company-wide stop.' });
105
+ assert.equal(readOutreach(f.state, 'show', { id: 'opportunity-1' }, '2026-10-01T10:00:00Z').followupDue, false);
106
+ });
107
+
108
+ test('one follow-up can close as no response only after it was sent', () => {
109
+ const f = fixture(); f.run('handoff', handoff());
110
+ f.run('record', { operationId: 'initial-sent', id: 'opportunity-1', type: 'sent-user-reported', attemptId: 'handoff-1', occurredAt: now, evidence: 'User reports sending.' });
111
+ f.run('draft', { operationId: 'followup', id: 'opportunity-1', text: 'Following up on the role.', claimRefs: [], purpose: 'follow-up' });
112
+ const later = '2026-09-25T10:00:00Z';
113
+ let state = mutateOutreach(f.state, 'handoff', handoff({ operationId: 'followup-handoff', draftRevision: 2, recheckedAt: later, history: { kind: 'verified', noPriorPitch: false, checkedAt: later } }), { now: later }).state;
114
+ assert.equal(readOutreach(state, 'show', { id: 'opportunity-1' }, '2026-10-20T10:00:00Z').noResponse, false);
115
+ state = mutateOutreach(state, 'record', { operationId: 'followup-sent', id: 'opportunity-1', type: 'sent-user-reported', attemptId: 'followup-handoff', occurredAt: later, evidence: 'User reports follow-up.' }, { now: later }).state;
116
+ assert.equal(readOutreach(state, 'show', { id: 'opportunity-1' }, '2026-10-06T10:00:00Z').noResponse, true);
117
+ assert.equal(readOutreach(state, 'show', { id: 'opportunity-1' }, '2026-10-06T10:00:00Z').followupDue, false);
118
+ });
119
+
120
+ test('progression corrections cannot supersede delivery evidence and bypass an unresolved reservation', () => {
121
+ const f = fixture(); f.run('handoff', handoff());
122
+ f.run('record', { operationId: 'sent', id: 'opportunity-1', type: 'sent-user-reported', attemptId: 'handoff-1', occurredAt: now, evidence: 'User reports sending.' });
123
+ assert.throws(() => f.run('record', { operationId: 'bad-correction', id: 'opportunity-1', type: 'replied', supersedes: ['sent'], occurredAt: now, evidence: 'Wrong category.' }), /category|delivery/i);
124
+ f.run('record', { operationId: 'uncertain-correction', id: 'opportunity-1', type: 'uncertain', attemptId: 'handoff-1', supersedes: ['sent'], occurredAt: now, evidence: 'Send could not be verified.' });
125
+ f.run('assess', assessment('second'));
126
+ f.run('draft', { operationId: 'second-draft', id: 'second', text: 'Hello', claimRefs: [], purpose: 'initial' });
127
+ assert.throws(() => f.run('handoff', handoff({ operationId: 'second-handoff', id: 'second', exception: { approvedByUser: true, reason: 'Try another contact.' } })), /unresolved/);
128
+ });
129
+
130
+ test('valid opaque IDs may match Object.prototype property names', () => {
131
+ const f = fixture();
132
+ assert.throws(() => f.run('clear', { operationId: 'bad-clear', ids: ['constructor'] }), /not found/);
133
+ assert.equal(Object.hasOwn(f.state.reservations, 'clear-constructor'), false);
134
+ f.run('assess', assessment('constructor', { operationId: 'toString' }));
135
+ f.run('draft', { operationId: 'constructor', id: 'constructor', text: 'Hello', claimRefs: [], purpose: 'initial' });
136
+ assert.equal(readOutreach(f.state, 'show', { id: 'constructor' }, now).content.drafts.length, 1);
137
+ assert.equal(readOutreach(f.state, 'show', { id: 'constructor' }, now).cleared, false);
138
+ assert.equal(f.run('draft', { operationId: 'constructor', id: 'constructor', text: 'Hello', claimRefs: [], purpose: 'initial' }).cleared, false);
139
+ });
140
+
141
+ test('clearing another opportunity cannot overwrite a pending handoff reservation', () => {
142
+ const f = fixture();
143
+ f.run('handoff', handoff({ operationId: 'clear-other' }));
144
+ f.run('assess', assessment('other', { company: { name: 'Other', domain: 'other.org', aliases: [] }, recipient: { account: 'https://x.com/other', aliases: [] }, channel: 'x' }));
145
+ f.run('clear', { operationId: 'clear-op', ids: ['other'] });
146
+ assert.equal(f.state.reservations['clear-other'].pending, true);
147
+ f.run('assess', assessment('retry'));
148
+ f.run('draft', { operationId: 'retry-draft', id: 'retry', text: 'Hello', claimRefs: [], purpose: 'initial' });
149
+ assert.throws(() => f.run('handoff', handoff({ operationId: 'retry-handoff', id: 'retry' })), /unresolved/);
150
+ });
151
+
152
+ test('correcting rejection removes only its derived suppression', () => {
153
+ const f = fixture();
154
+ f.run('record', { operationId: 'rejection', id: 'opportunity-1', type: 'rejected', occurredAt: now, evidence: 'Mistaken rejection.' });
155
+ assert.equal(readOutreach(f.state, 'show', { id: 'opportunity-1' }, now).suppressed, true);
156
+ f.run('record', { operationId: 'correction', id: 'opportunity-1', type: 'replied', supersedes: ['rejection'], occurredAt: now, evidence: 'Actually a reply.' });
157
+ assert.equal(readOutreach(f.state, 'show', { id: 'opportunity-1' }, now).suppressed, false);
158
+ f.run('suppress', { operationId: 'explicit', id: 'opportunity-1', scope: 'recipient', reason: 'User requests stop.' });
159
+ f.run('record', { operationId: 'correction-2', id: 'opportunity-1', type: 'referred', supersedes: ['correction'], occurredAt: now, evidence: 'Referral completed.' });
160
+ assert.equal(readOutreach(f.state, 'show', { id: 'opportunity-1' }, now).suppressed, true);
161
+ });
162
+
163
+ test('company suppression does not suppress a recipient at an unrelated company', () => {
164
+ const f = fixture();
165
+ f.run('suppress', { operationId: 'stop-company', id: 'opportunity-1', scope: 'company', reason: 'Stop this company.' });
166
+ f.run('assess', assessment('new-company', { company: { name: 'Other', domain: 'other.org', aliases: [] } }));
167
+ assert.equal(readOutreach(f.state, 'show', { id: 'new-company' }, now).suppressed, false);
168
+ });
169
+
170
+ test('handoff retries recheck current linked application outcomes', () => {
171
+ const f = fixture();
172
+ const context = { now, applications: [{ id: 'app-1', company: 'Example', role: 'Staff Product Engineer', status: 'submitted' }] };
173
+ let state = mutateOutreach(f.state, 'assess', assessment('opportunity-1', { operationId: 'linked', applicationId: 'app-1' }), context).state;
174
+ state = mutateOutreach(state, 'draft', { operationId: 'linked-draft', id: 'opportunity-1', text: 'Hello', claimRefs: [], purpose: 'initial' }, context).state;
175
+ const input = handoff({ qualificationRevision: 2, draftRevision: 2 });
176
+ state = mutateOutreach(state, 'handoff', input, context).state;
177
+ assert.throws(() => mutateOutreach(state, 'handoff', input, { ...context, outcomes: [{ id: 'app-1', status: 'rejected' }] }), /hiring outcome/);
178
+ });
@@ -11,6 +11,8 @@ const forbiddenProperties = [
11
11
  'agentResponse', 'jobDescription', 'formQuestion', 'draftedAnswer', 'note', 'password', 'mfa',
12
12
  'captcha', 'legalAnswer', 'demographicAnswer', 'browserData', 'ipAddress', 'requestHeaders',
13
13
  'userAgent', 'rawError',
14
+ 'recipient', 'recipientAccount', 'message', 'sentText', 'claimRefs', 'evidence',
15
+ 'outreachId', 'opportunityId', 'companyFingerprint', 'recipientFingerprint',
14
16
  ];
15
17
 
16
18
  test('privacy audit rejects every prohibited free-form or identity property', () => {
@@ -0,0 +1,66 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import test from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import { buildReview } from '../scripts/job-application.mjs';
10
+
11
+ const now = new Date('2026-09-14T12:00:00Z');
12
+ const applications = (count, submittedAt = '2026-09-14T10:00:00Z') => Array.from({ length: count }, (_, index) => ({
13
+ id: `application-${index}`, company: `Company ${index}`, role: 'Engineer',
14
+ url: `https://example.test/jobs/${index}`, source: 'email', status: 'submitted', submittedAt,
15
+ }));
16
+ const failure = {
17
+ version: 1, id: 'late-bounce', applicationId: 'application-0', attemptId: 'initial:application-0',
18
+ type: 'delivery-failed', occurredAt: '2026-09-14T11:00:00Z',
19
+ evidenceType: 'final-delivery-failure', evidence: 'Final failure matched to the original application email.',
20
+ };
21
+
22
+ test('late failure does not delay hygiene review after ten more canonical submissions', () => {
23
+ const entries = applications(20);
24
+ const acknowledgements = [{ uniqueSubmissionCount: 10, maturedApplicationCount: 0 }];
25
+ const review = buildReview(entries, [], acknowledgements, now, [failure]);
26
+ assert.equal(review.effectiveSubmissionCount, 19);
27
+ assert.equal(review.submittedSinceLastReview, 10);
28
+ assert.equal(review.reviewDue, true);
29
+ assert.ok(review.reviewReasons.includes('submission-hygiene'));
30
+ });
31
+
32
+ test('late failure does not delay mature review while conversion still uses effective applications', () => {
33
+ const entries = applications(40, '2026-01-01T10:00:00Z');
34
+ const acknowledgements = [{ uniqueSubmissionCount: 40, maturedApplicationCount: 20 }];
35
+ const outcomes = [{ id: 'application-1', status: 'interview', occurredAt: '2026-02-01T10:00:00Z' }];
36
+ const review = buildReview(entries, outcomes, acknowledgements, now, [failure]);
37
+ assert.equal(review.maturedApplications, 39);
38
+ assert.equal(review.conversionRates.interview, 2.6);
39
+ assert.equal(review.reviewDue, true);
40
+ assert.deepEqual(review.reviewReasons, ['outcome-effectiveness']);
41
+ });
42
+
43
+ test('review acknowledgement checkpoints recorded canonical counts after delivery failure', async (t) => {
44
+ const dir = await mkdtemp(join(tmpdir(), 'review-cadence-'));
45
+ t.after(() => rm(dir, { recursive: true, force: true }));
46
+ const entries = applications(20, '2026-01-01T10:00:00Z');
47
+ // A duplicate row must not inflate either checkpoint.
48
+ entries.push({ ...entries[1], id: 'duplicate-row' });
49
+ await writeFile(join(dir, 'applications.ndjson'), entries.map(JSON.stringify).join('\n') + '\n');
50
+ await writeFile(join(dir, 'delivery.ndjson'), JSON.stringify(failure) + '\n');
51
+ await writeFile(join(dir, 'telemetry.json'), JSON.stringify({ enabled: false, disclosed: true }));
52
+ const env = {
53
+ ...process.env, JOB_APPLICATION_AGENT_STATE_DIR: dir,
54
+ JOB_APPLICATION_AGENT_CLOUD_CONFIG: join(dir, 'absent.json'),
55
+ JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL: 'http://127.0.0.1:9',
56
+ };
57
+ const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
58
+ const ack = JSON.parse(execFileSync(process.execPath, [script, 'ledger', 'review-ack', '--stdin'], {
59
+ env, encoding: 'utf8', input: JSON.stringify({ reviewedAt: now.toISOString() }),
60
+ }));
61
+ assert.equal(ack.uniqueSubmissionCount, 20);
62
+ assert.equal(ack.maturedApplicationCount, 20);
63
+ const stored = JSON.parse((await readFile(join(dir, 'reviews.ndjson'), 'utf8')).trim());
64
+ assert.equal(stored.uniqueSubmissionCount, 20);
65
+ assert.equal(stored.maturedApplicationCount, 20);
66
+ });
@@ -0,0 +1,102 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+
7
+ import {
8
+ FILL_DISPLAY,
9
+ FILL_VNC_PORT,
10
+ FORBIDDEN_VNC_PORT,
11
+ assertSameTab,
12
+ createSessionBinding,
13
+ extractSessionBindingFields,
14
+ readSessionBindingFile,
15
+ sessionBindingPath,
16
+ validateSessionBinding,
17
+ writeSessionBindingFile,
18
+ } from "../scripts/session-binding.mjs";
19
+
20
+ test("validateSessionBinding requires fill display and VNC 5900", () => {
21
+ const ok = validateSessionBinding({
22
+ attentionId: "attention-1",
23
+ jobUrl: "https://jobs.ashbyhq.com/acme/application",
24
+ browserProfilePath: "/tmp/jaa-chrome",
25
+ });
26
+ assert.equal(ok.ok, true);
27
+ assert.equal(ok.binding.display, FILL_DISPLAY);
28
+ assert.equal(ok.binding.vncPort, FILL_VNC_PORT);
29
+
30
+ const badPort = validateSessionBinding({
31
+ attentionId: "attention-1",
32
+ jobUrl: "https://jobs.example.com/a",
33
+ browserProfilePath: "/tmp/p",
34
+ vncPort: FORBIDDEN_VNC_PORT,
35
+ });
36
+ assert.equal(badPort.ok, false);
37
+ assert.match(badPort.error, /5901/);
38
+
39
+ const badDisplay = validateSessionBinding({
40
+ attentionId: "attention-1",
41
+ jobUrl: "https://jobs.example.com/a",
42
+ browserProfilePath: "/tmp/p",
43
+ display: ":1",
44
+ });
45
+ assert.equal(badDisplay.ok, false);
46
+ assert.match(badDisplay.error, /forbidden|display/i);
47
+ });
48
+
49
+ test("assertSameTab detects drift and accepts same application path", () => {
50
+ const same = assertSameTab(
51
+ "https://jobs.ashbyhq.com/acme/job/123",
52
+ "https://jobs.ashbyhq.com/acme/job/123?utm_source=x",
53
+ );
54
+ assert.equal(same.ok, true);
55
+
56
+ const drift = assertSameTab(
57
+ "https://jobs.ashbyhq.com/acme/job/123",
58
+ "https://jobs.ashbyhq.com/acme",
59
+ );
60
+ assert.equal(drift.ok, false);
61
+ assert.equal(drift.reason, "tab_drift");
62
+
63
+ const hintFail = assertSameTab(
64
+ "https://jobs.ashbyhq.com/acme/job/123",
65
+ "https://jobs.ashbyhq.com/acme/job/123",
66
+ { urlContains: "/application" },
67
+ );
68
+ assert.equal(hintFail.ok, false);
69
+ });
70
+
71
+ test("write/read session binding stays local under state dir", async (t) => {
72
+ const dir = await mkdtemp(join(tmpdir(), "jaa-binding-"));
73
+ t.after(() => rm(dir, { recursive: true, force: true }));
74
+
75
+ const binding = createSessionBinding({
76
+ attentionId: "attention-abc",
77
+ jobUrl: "https://jobs.ashbyhq.com/acme/application",
78
+ browserProfilePath: "/home/runner/.jaa-chrome-fill",
79
+ applicationId: "app-1",
80
+ roundId: "round-1",
81
+ tabHint: { urlContains: "/application" },
82
+ });
83
+ const path = sessionBindingPath(dir, binding.attentionId);
84
+ await writeSessionBindingFile(path, binding);
85
+ const loaded = await readSessionBindingFile(path);
86
+ assert.equal(loaded.attentionId, "attention-abc");
87
+ assert.equal(loaded.vncPort, 5900);
88
+ const raw = JSON.parse(await readFile(path, "utf8"));
89
+ assert.equal(raw.browserProfilePath, "/home/runner/.jaa-chrome-fill");
90
+ });
91
+
92
+ test("extractSessionBindingFields pulls local-only keys", () => {
93
+ const fields = extractSessionBindingFields({
94
+ url: "https://example.com",
95
+ browserProfilePath: "/tmp/p",
96
+ display: ":99",
97
+ vncPort: 5900,
98
+ });
99
+ assert.equal(fields.browserProfilePath, "/tmp/p");
100
+ assert.equal(fields.display, ":99");
101
+ assert.equal(extractSessionBindingFields({ url: "https://example.com" }), null);
102
+ });
@@ -19,14 +19,39 @@ test('documents durable autonomy, resumable rounds, attention and friction contr
19
19
  const skill = await readFile(new URL('../SKILL.md', import.meta.url), 'utf8');
20
20
  const autonomy = await readFile(new URL('../references/AUTONOMY.md', import.meta.url), 'utf8');
21
21
  const runs = await readFile(new URL('../references/RUNS.md', import.meta.url), 'utf8');
22
+ const agentBox = await readFile(new URL('../references/agent-box/README.md', import.meta.url), 'utf8');
22
23
 
23
24
  assert.match(skill, /autonomy status/);
24
25
  assert.match(skill, /round status/);
25
26
  assert.match(skill, /attention list/);
26
27
  assert.match(skill, /friction record/);
28
+ assert.match(skill, /attention-runner-poll/);
29
+ assert.match(skill, /attention-resume-submit/);
30
+ assert.match(skill, /session-binding/);
31
+ assert.match(skill, /DISPLAY=:99/);
32
+ assert.match(skill, /5900/);
27
33
  assert.match(autonomy, /never.*merge.*publish/i);
28
34
  assert.match(autonomy, /CAPTCHA/i);
29
35
  assert.match(runs, /visible.*confirmation/i);
30
36
  assert.match(runs, /discoverySource/);
31
37
  assert.match(runs, /applicationChannel/);
38
+ assert.match(runs, /Resume → submit/);
39
+ assert.match(runs, /attention-runner-poll/);
40
+ assert.match(runs, /submit if possible/i);
41
+ assert.match(agentBox, /localhost:5900/);
42
+ assert.match(agentBox, /5901/);
43
+ assert.match(agentBox, /DISPLAY=:99/);
44
+ });
45
+
46
+ test('documents Free.ai as optional LLM assist only', async () => {
47
+ const skill = await readFile(new URL('../SKILL.md', import.meta.url), 'utf8');
48
+ const freeAi = await readFile(new URL('../references/FREE_AI.md', import.meta.url), 'utf8');
49
+
50
+ assert.match(skill, /FREE_AI\.md/);
51
+ assert.match(skill, /does not call Free\.ai/i);
52
+ assert.match(freeAi, /https:\/\/api\.free\.ai\/v1/);
53
+ assert.match(freeAi, /FREE_AI_API_KEY/);
54
+ assert.match(freeAi, /qwen7b/);
55
+ assert.match(freeAi, /not the hosted/i);
56
+ assert.match(freeAi, /does not read `FREE_AI_API_KEY`/i);
32
57
  });
@@ -71,6 +71,11 @@ function submission(index, roundId, overrides = {}) {
71
71
  };
72
72
  }
73
73
 
74
+ function recordLeads(env, roundId, sourceId, entries, otherCount = 0) {
75
+ for (const app of entries) cli(env, ['round','lead','--stdin'], { roundId, sourceId, applicationId: app.id, url: app.url, employerJobId: app.employerJobId, company: app.company, role: app.role, disposition:'qualified', observedAt:app.submittedAt, evidence:'Synthetic role meets the target requirements.' });
76
+ for (let i=0;i<otherCount;i++) cli(env, ['round','lead','--stdin'], { roundId, sourceId, url:`https://other.fixture.example/${sourceId}/${i}`, company:`Other ${i}`, disposition:'no-relevant-opening', observedAt:'2026-08-01T00:00:00Z', evidence:'No target role on this careers page.' });
77
+ }
78
+
74
79
  test('persists a scoped autonomy grant and revokes future routine transmissions', async (t) => {
75
80
  const { directory, env } = await fixture(t, 'autonomy');
76
81
 
@@ -122,6 +127,7 @@ test('counts only unique confirmed ledger submissions for an explicit round', as
122
127
  assert.equal(status.completed, false);
123
128
 
124
129
  for (const sourceId of ['linkedin-jobs-feed', 'indeed', 'hacker-news-who-is-hiring']) {
130
+ recordLeads(env, started.roundId, sourceId, sourceId === 'linkedin-jobs-feed' ? Array.from({ length:30 },(_,i)=>submission(i,started.roundId)) : [], sourceId === 'linkedin-jobs-feed' ? 0 : 30);
125
131
  cli(env, ['round', 'source', '--stdin'], {
126
132
  roundId: started.roundId, sourceId, status: 'searched', reviewedCount: 30, qualifiedCount: sourceId === 'linkedin-jobs-feed' ? 30 : 0,
127
133
  evidence: 'Reviewed matching postings against the unchanged target; other leads did not qualify.',
@@ -142,6 +148,8 @@ test('round completion requires distinct source coverage and explains concentrat
142
148
  const { roundId } = cli(env, ['round', 'start', '--stdin'], { requestedCount: 2 });
143
149
  for (const i of [1, 2]) cli(env, ['ledger', 'add', '--stdin'], submission(i, roundId, { discoverySourceId: 'linkedin-jobs-feed' }));
144
150
  assert.match(cliFailure(env, ['round', 'complete', '--stdin'], { roundId }).stderr, /3 distinct discovery sources/i);
151
+ recordLeads(env,roundId,'linkedin-jobs-feed',[submission(1,roundId),submission(2,roundId)],8);
152
+ recordLeads(env,roundId,'indeed',[],10);
145
153
  const check = { roundId, sourceId: 'linkedin-jobs-feed', status: 'searched', reviewedCount: 10, qualifiedCount: 2, evidence: 'Reviewed ten relevant postings using the target constraints.' };
146
154
  cli(env, ['round', 'source', '--stdin'], check);
147
155
  cli(env, ['round', 'source', '--stdin'], check);
@@ -169,7 +177,10 @@ test('discovery coverage rejects invalid reports and requires attribution withou
169
177
  for (const bad of [{ sourceId: 'imaginary-board' }, { qualifiedCount: 5 }, { status: 'blocked' }, { applicationIds: ['not-in-round'] }, { evidence: '' }, { privateProfile: 'forbidden' }]) {
170
178
  assert.equal(cliFailure(env, ['round', 'source', '--stdin'], { ...check, ...bad }).status, 1);
171
179
  }
172
- for (const sourceId of ['linkedin-jobs-feed', 'indeed', 'hacker-news-who-is-hiring']) cli(env, ['round', 'source', '--stdin'], { ...check, sourceId });
180
+ for (const sourceId of ['linkedin-jobs-feed', 'indeed', 'hacker-news-who-is-hiring']) {
181
+ recordLeads(env,roundId,sourceId,[submission(1,roundId)],3);
182
+ cli(env, ['round', 'source', '--stdin'], { ...check, sourceId });
183
+ }
173
184
  assert.match(cliFailure(env, ['round', 'complete', '--stdin'], { roundId }).stderr, /attribution/i);
174
185
  cli(env, ['round', 'source', '--stdin'], { ...check, applicationIds: ['round-role-1'] });
175
186
  assert.equal(cli(env, ['round', 'status', roundId]).discovery.unattributedCount, 0);
@@ -180,7 +191,11 @@ test('source diversity is independent of ATS and concentration explanations are
180
191
  const { env } = await fixture(t, 'balanced-discovery');
181
192
  const { roundId } = cli(env, ['round', 'start', '--stdin'], { requestedCount: 5 });
182
193
  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.' });
194
+ for (const sourceId of ids) {
195
+ const entries=Array.from({length:5},(_,i)=>submission(i+1,roundId)).filter((_,i)=>ids[i%3]===sourceId);
196
+ recordLeads(env,roundId,sourceId,entries,1);
197
+ cli(env, ['round', 'source', '--stdin'], { roundId, sourceId, status: 'searched', evidence: 'Reviewed relevant postings and verified target fit.' });
198
+ }
184
199
  for (let i = 1; i <= 5; i++) cli(env, ['ledger', 'add', '--stdin'], submission(i, roundId, { discoverySourceId: ids[(i - 1) % 3], applicationChannel: 'ashby' }));
185
200
  const completed = cli(env, ['round', 'complete', '--stdin'], { roundId });
186
201
  assert.equal(completed.discovery.maxSourceSharePercent, 40);
@@ -197,6 +212,7 @@ test('blocked-only attempts and two views of the same network do not satisfy dis
197
212
  cli(env, ['round', 'source', '--stdin'], { ...check, sourceId: 'indeed' });
198
213
  assert.equal(cli(env, ['round', 'status', roundId]).discovery.coverageSatisfied, false);
199
214
  const { blocker, ...searched } = check;
215
+ recordLeads(env,roundId,'indeed',[submission(1,roundId)]);
200
216
  cli(env, ['round', 'source', '--stdin'], { ...searched, sourceId: 'indeed', status: 'searched', reviewedCount: 1, qualifiedCount: 1 });
201
217
  cli(env, ['round', 'source', '--stdin'], { ...check, sourceId: 'indeed' });
202
218
  assert.equal(cli(env, ['round', 'status', roundId]).discovery.coverageSatisfied, true);
@@ -723,7 +739,18 @@ test('replays and prioritizes an owner-only attention queue', async (t) => {
723
739
  stage: 'submission',
724
740
  blocker: 'captcha',
725
741
  requiredActions: ['complete-captcha'],
726
- });
742
+ browserProfilePath: join(directory, 'chrome-fill'),
743
+ display: ':99',
744
+ vncPort: 5900,
745
+ tabHint: { urlContains: '/captcha' },
746
+ });
747
+
748
+ assert.ok(captcha.sessionBinding?.binding?.browserProfilePath);
749
+ assert.equal(captcha.sessionBinding.binding.vncPort, 5900);
750
+ assert.equal(captcha.sessionBinding.binding.display, ':99');
751
+ const attentionRaw = await readFile(join(directory, 'attention.ndjson'), 'utf8');
752
+ assert.doesNotMatch(attentionRaw, /browserProfilePath/);
753
+ assert.ok((await readFile(captcha.sessionBinding.path, 'utf8')).includes('chrome-fill'));
727
754
 
728
755
  const before = cli(env, ['attention', 'list']);
729
756
  assert.deepEqual(before.items.map((item) => item.id), [captcha.id, judgment.id]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-application-agent",
3
- "version": "3.4.2",
3
+ "version": "3.6.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",
@@ -42,9 +42,9 @@
42
42
  "scripts": {
43
43
  "test": "node --test",
44
44
  "check:native-artifacts": "node scripts/ci/validate-native-artifacts.mjs",
45
- "privacy-audit": "node --test job-application-agent/tests/privacy-audit.test.mjs",
45
+ "privacy-audit": "node --test job-application-agent/tests/privacy-audit.test.mjs job-application-agent/tests/outreach-cli.test.mjs",
46
46
  "smoke:package": "node scripts/smoke-package.mjs",
47
- "check": "node --check job-application-agent/scripts/job-application.mjs && node --check job-application-agent/scripts/cloud-state-client.mjs && node --check job-application-agent/scripts/secret-store.mjs && node --check job-application-agent/scripts/source-community-client.mjs && node --check job-application-agent/scripts/source-community-schema.mjs && node --check job-application-agent/scripts/telemetry-client.mjs && node --check job-application-agent/scripts/telemetry-schema.mjs && node --check job-application-agent/scripts/version.mjs && node --check telemetry-worker/src/worker.mjs && node --check telemetry-worker/src/public-stats.mjs && node --check telemetry-worker/public/dashboard.js && node --check installer/src/cli.mjs && node --check installer/src/installer.mjs && node --check installer/src/runner.mjs && node --check installer/src/scheduler.mjs && node --check scripts/ci/classify-paths.mjs && node --check scripts/ci/validate-native-artifacts.mjs && node --check bin/job-application-agent.mjs && node --check state-worker/src/worker.mjs && node --check state-worker/src/backup.mjs && node --check state-worker/src/cli.mjs && node --check state-worker/bin/sync.mjs",
47
+ "check": "node --check job-application-agent/scripts/job-application.mjs && node --check job-application-agent/scripts/attention-runner-poll.mjs && node --check job-application-agent/scripts/attention-resume-submit.mjs && node --check job-application-agent/scripts/session-binding.mjs && node --check job-application-agent/scripts/novnc-display-guard.mjs && node --check job-application-agent/scripts/ats/submit-adapters.mjs && node --check job-application-agent/scripts/cloud-state-client.mjs && node --check job-application-agent/scripts/secret-store.mjs && node --check job-application-agent/scripts/source-community-client.mjs && node --check job-application-agent/scripts/source-community-schema.mjs && node --check job-application-agent/scripts/telemetry-client.mjs && node --check job-application-agent/scripts/telemetry-schema.mjs && node --check job-application-agent/scripts/version.mjs && node --check telemetry-worker/src/worker.mjs && node --check telemetry-worker/src/public-stats.mjs && node --check telemetry-worker/public/dashboard.js && node --check installer/src/cli.mjs && node --check installer/src/installer.mjs && node --check installer/src/runner.mjs && node --check installer/src/scheduler.mjs && node --check scripts/ci/classify-paths.mjs && node --check scripts/ci/validate-native-artifacts.mjs && node --check bin/job-application-agent.mjs && node --check state-worker/src/worker.mjs && node --check state-worker/src/backup.mjs && node --check state-worker/src/cli.mjs && node --check state-worker/bin/sync.mjs && node --check job-application-agent/scripts/application-accounting.mjs && node --check job-application-agent/scripts/outreach-domain.mjs && node --check job-application-agent/scripts/outreach-store.mjs && node --check job-application-agent/scripts/outreach-cli.mjs && node --check state-worker/src/outreach.mjs && node --check job-application-agent/scripts/ats/answer-inject.mjs && node --check job-application-agent/scripts/attention-questions.mjs && node --check job-application-agent/scripts/captcha-vendor.mjs",
48
48
  "prepack": "npm run check && npm test && npm run privacy-audit"
49
49
  },
50
50
  "engines": {
@@ -52,5 +52,8 @@
52
52
  },
53
53
  "devDependencies": {
54
54
  "yaml": "2.9.0"
55
+ },
56
+ "dependencies": {
57
+ "sql.js": "1.13.0"
55
58
  }
56
59
  }