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,287 @@
1
+ import { createHmac, randomBytes } from 'node:crypto';
2
+ import { stableJson } from './application-accounting.mjs';
3
+
4
+ export const OUTREACH_CAPABILITY = 'outreach-tracking-v1';
5
+ export const OUTREACH_TABLES = ['opportunities', 'contents', 'events', 'reservations', 'operations', 'tombstones'];
6
+ const GATES = ['active', 'companyVerified', 'eligible', 'fit', 'affiliation', 'hiringInvolvement'];
7
+ const DELIVERY = ['not-sent', 'uncertain', 'sent-user-reported', 'sent-verified', 'failed'];
8
+ const PROGRESSION = ['replied', 'referral-promised', 'referred', 'screen-proposed', 'screen-scheduled', 'interview', 'rejected', 'closed-no-response'];
9
+ const STOP = ['replied', 'referral-promised', 'referred', 'screen-proposed', 'screen-scheduled', 'interview', 'rejected'];
10
+ const RANKING = { hiringSignal: 4, responsibility: 3, fit: 3, freshness: 2, relationship: 2 };
11
+ const DAY = 86400000;
12
+
13
+ function check(condition, message) { if (!condition) throw new Error(message); }
14
+ function entry(index, key) { return Object.hasOwn(index, key) ? index[key] : undefined; }
15
+ function object(value, name) { check(value && typeof value === 'object' && !Array.isArray(value), `${name} must be an object`); return value; }
16
+ function keys(value, allowed) { object(value, 'input'); check(Object.keys(value).every(k => allowed.includes(k)), 'Unknown outreach property'); }
17
+ function text(value, name, max = 2000) { check(typeof value === 'string' && value.trim().length > 0 && value.length <= max, `${name} is required and must be bounded text`); return value.trim(); }
18
+ function id(value) { check(typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,99}$/.test(value), 'Invalid opaque ID'); return value; }
19
+ function timestamp(value) { check(typeof value === 'string' && /T/.test(value) && Number.isFinite(Date.parse(value)), 'ISO timestamp required'); return new Date(value).toISOString(); }
20
+ function url(value) { const parsed = new URL(text(value, 'URL', 1000)); check(parsed.protocol === 'https:' && !parsed.username && !parsed.password, 'HTTPS URL without credentials required'); return parsed.href; }
21
+ function secretScan(value) {
22
+ if (!value || typeof value !== 'object') return;
23
+ for (const [key, item] of Object.entries(value)) {
24
+ check(!/^(passwords?|cookies?|mfa(code)?|totp|ssn|passport|aadhaar|government.?id|credentials?|session.?token)$/i.test(key), 'Forbidden sensitive field');
25
+ secretScan(item);
26
+ }
27
+ }
28
+ const fingerprint = (state, kind, value) => createHmac('sha256', state.meta.key).update(`${kind}:${value}`).digest('hex');
29
+ export function initialOutreach() {
30
+ return { meta: { revision: 0, key: randomBytes(32).toString('hex'), enabled: false, timezone: 'UTC', recoveryBlocked: false },
31
+ ...Object.fromEntries(OUTREACH_TABLES.map(name => [name, {}])) };
32
+ }
33
+ function localDate(value, timezone) { return new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(value)); }
34
+ export function businessDate(value, timezone, days) {
35
+ const date = new Date(`${localDate(value, timezone)}T12:00:00Z`);
36
+ for (let added = 0; added < days;) { date.setUTCDate(date.getUTCDate() + 1); if (![0, 6].includes(date.getUTCDay())) added++; }
37
+ return date.toISOString().slice(0, 10);
38
+ }
39
+ function recent(value, now) { const t = Date.parse(timestamp(value)); check(t <= Date.parse(now) && t >= Date.parse(now) - DAY, 'Recheck must be within the previous 24 hours'); }
40
+ function activeEvents(state, opportunityId) {
41
+ const events = Object.values(state.events).filter(e => e.opportunityId === opportunityId);
42
+ const superseded = new Set(events.flatMap(e => e.supersedes ?? []));
43
+ return events.filter(e => !superseded.has(e.id));
44
+ }
45
+ function projection(state, opportunityId, now) {
46
+ const opportunity = entry(state.opportunities, opportunityId);
47
+ check(opportunity, 'Opportunity not found');
48
+ const events = activeEvents(state, opportunityId);
49
+ const attempts = events.filter(e => e.type === 'handoff').map(e => {
50
+ const observations = events.filter(o => o.attemptId === e.id && DELIVERY.includes(o.type));
51
+ const types = [...new Set(observations.map(o => o.type))];
52
+ const conflict = types.length > 1 && !(types.length === 2 && types.every(t => t.startsWith('sent-')));
53
+ return { id: e.id, purpose: e.purpose, draftRevision: e.draftRevision,
54
+ delivery: conflict ? 'conflict' : types.includes('sent-verified') ? 'sent-verified' : types[0] ?? 'pending-handoff',
55
+ sentAt: observations.filter(o => o.type.startsWith('sent-')).map(o => o.occurredAt).sort()[0] ?? null };
56
+ });
57
+ const progression = [...new Set(events.filter(e => PROGRESSION.includes(e.type)).map(e => e.type))];
58
+ const suppressed = opportunity.suppressed || Object.values(state.reservations).some(r => r.suppressed && reservationMatches(r, opportunity));
59
+ const stopped = progression.some(p => STOP.includes(p)) || suppressed || opportunity.cleared;
60
+ const initial = attempts.find(a => a.purpose === 'initial' && a.delivery.startsWith('sent-'));
61
+ const followup = attempts.find(a => a.purpose === 'follow-up' && a.delivery.startsWith('sent-'));
62
+ const blocked = attempts.some(a => ['conflict', 'pending-handoff', 'uncertain'].includes(a.delivery));
63
+ const eligible = GATES.every(g => entry(state.contents, opportunityId)?.assessment?.qualification[g] === true);
64
+ const dueOn = initial ? businessDate(initial.sentAt, state.meta.timezone, 7) : null;
65
+ const closeOn = followup ? businessDate(followup.sentAt, state.meta.timezone, 7) : null;
66
+ return { id: opportunityId, cleared: opportunity.cleared, suppressed,
67
+ qualificationRevision: opportunity.qualificationRevision, eligible, score: opportunity.score, attempts,
68
+ delivery: attempts.at(-1)?.delivery ?? 'not-sent', progression,
69
+ followupDueOn: dueOn,
70
+ followupDue: Boolean(!stopped && !blocked && eligible && dueOn && localDate(now, state.meta.timezone) >= dueOn && !attempts.some(a => a.purpose === 'follow-up' && a.delivery !== 'not-sent')),
71
+ noResponse: Boolean(!stopped && !blocked && closeOn && localDate(now, state.meta.timezone) >= closeOn) };
72
+ }
73
+ function identities(state, assessment) {
74
+ const companies = [assessment.company.domain, ...assessment.company.aliases].map(v => text(v, 'company domain', 253).toLowerCase().replace(/^www\./, ''));
75
+ check(companies.every(v => /^(?:[a-z0-9-]+\.)+[a-z]{2,}$/.test(v)), 'Company identity must be an employer domain');
76
+ const recipients = [assessment.recipient.account, ...assessment.recipient.aliases].map((v, index) => {
77
+ const parsed = new URL(url(v));
78
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, '').replace(/^twitter\.com$/, 'x.com');
79
+ const path = parsed.pathname.replace(/\/$/, '').toLowerCase();
80
+ check((host === 'linkedin.com' && /^\/in\/[^/]+$/.test(path)) || (host === 'x.com' && /^\/[a-z0-9_]+$/.test(path)) || assessment.channel === 'email', 'Use a verified recipient profile URL');
81
+ if (index === 0 && assessment.channel !== 'email') check(host === (assessment.channel === 'linkedin' ? 'linkedin.com' : 'x.com'), 'Primary recipient account must match the channel');
82
+ return `https://${host}${path}`;
83
+ });
84
+ return { companies: [...new Set(companies)].map(v => fingerprint(state, 'company', v)), recipients: [...new Set(recipients)].map(v => fingerprint(state, 'recipient', v)) };
85
+ }
86
+ function overlaps(a, b) { return a.some(v => b.includes(v)); }
87
+ function reservationMatches(reservation, opportunity) { return overlaps(reservation.companies, opportunity.companies) || overlaps(reservation.recipients, opportunity.recipients); }
88
+ function requireOpportunity(state, input) { const op = entry(state.opportunities, id(input.id)); check(op, 'Opportunity not found'); check(!op.cleared, 'Opportunity was cleared'); return op; }
89
+ function checkLinkedApplication(content, applications, outcomes) {
90
+ const applicationId = content?.assessment.applicationId;
91
+ if (!applicationId) return;
92
+ check(applications.some(app => app.id === applicationId && app.status === 'submitted'), 'Linked application must still be verified');
93
+ check(!outcomes.some(outcome => outcome.id === applicationId && ['rejected', 'withdrawn', 'offer', 'interview'].includes(outcome.status)), 'Linked application already has a hiring outcome; review the conversation instead');
94
+ }
95
+ function resultFor(state, action, input, now) {
96
+ if (action.startsWith('policy-')) return { enabled: state.meta.enabled, timezone: state.meta.timezone, recoveryBlocked: state.meta.recoveryBlocked, mode: 'draft-and-track' };
97
+ if (action === 'clear') return { cleared: input.ids, backupRetentionDays: 30, disconnectedCachesMayRemain: true };
98
+ const result = projection(state, input.id, now);
99
+ if (action === 'handoff' && !result.cleared && !result.suppressed && result.eligible && state.meta.enabled && !state.meta.recoveryBlocked && !result.progression.some(p => STOP.includes(p)) && result.attempts.find(a => a.id === input.operationId)?.delivery === 'pending-handoff' && result.qualificationRevision === input.qualificationRevision && Date.parse(now) - Date.parse(input.recheckedAt) <= DAY) {
100
+ result.copyableText = entry(state.contents, input.id)?.drafts?.find(d => d.revision === input.draftRevision)?.text;
101
+ }
102
+ return result;
103
+ }
104
+
105
+ export function mutateOutreach(original, action, input, { now = new Date().toISOString(), actor = 'local', applications = [], outcomes = [] } = {}) {
106
+ object(input, 'input'); secretScan(input);
107
+ check(JSON.stringify(input).length <= 32000, 'Outreach input too large');
108
+ const operationId = id(input.operationId);
109
+ const digest = fingerprint(original, 'operation', stableJson({ action, input }));
110
+ const prior = entry(original.operations, operationId);
111
+ if (prior) {
112
+ check(prior.digest === digest, 'Operation ID reused with different content');
113
+ if (action === 'handoff') checkLinkedApplication(entry(original.contents, input.id), applications, outcomes);
114
+ return { state: original, result: resultFor(original, action, input, now) };
115
+ }
116
+ const state = structuredClone(original);
117
+ let event = null;
118
+ const addEvent = (type, extra = {}) => { event = { id: operationId, opportunityId: input.id, type, occurredAt: now, actor, ...extra }; state.events[operationId] = event; };
119
+ if (action === 'policy-enable' || action === 'policy-disable') {
120
+ keys(input, ['operationId', 'timezone', 'recoveryReviewed']);
121
+ if (action === 'policy-enable') {
122
+ check(!state.meta.recoveryBlocked || input.recoveryReviewed === true, 'Restore recovery review required before enabling handoffs');
123
+ if (input.recoveryReviewed === true) state.meta.recoveryBlocked = false;
124
+ text(input.timezone, 'timezone', 100); localDate(now, input.timezone); state.meta.timezone = input.timezone;
125
+ }
126
+ state.meta.enabled = action === 'policy-enable';
127
+ } else if (action === 'assess') {
128
+ keys(input, ['operationId', 'id', 'company', 'recipient', 'role', 'channel', 'source', 'applicationId', 'qualification', 'gateEvidence', 'evidence', 'ranking', 'aliasesVerified']);
129
+ check(state.meta.enabled, 'Outreach is disabled'); id(input.id);
130
+ check(!entry(state.tombstones, input.id), 'Opportunity was cleared');
131
+ keys(input.company, ['name', 'domain', 'aliases']); text(input.company.name, 'company name', 200); keys(input.recipient, ['account', 'aliases']);
132
+ check(Array.isArray(input.company.aliases) && input.company.aliases.length <= 10 && Array.isArray(input.recipient.aliases) && input.recipient.aliases.length <= 10, 'Bounded alias arrays required');
133
+ if (input.company.aliases.length || input.recipient.aliases.length) check(input.aliasesVerified === true, 'Aliases require verified identity evidence');
134
+ text(input.role, 'role', 200); check(['linkedin', 'x', 'email'].includes(input.channel), 'Unsupported channel');
135
+ keys(input.source, ['kind', 'url']); url(input.source.url);
136
+ check(['hiring-post', 'application'].includes(input.source.kind), 'Source must be application or user-shared hiring-post');
137
+ if (input.applicationId) {
138
+ const app = applications.find(a => a.id === input.applicationId && a.status === 'submitted');
139
+ check(app, 'Verified submitted application required');
140
+ check(app.role === input.role, 'Application role must match assessment');
141
+ const normalize = value => String(value).toLowerCase().replace(/[^a-z0-9]/g, '');
142
+ check(normalize(app.company) === normalize(input.company.name), 'Application company must match assessment');
143
+ } else check(input.source.kind === 'hiring-post', 'Application source requires a submitted application link');
144
+ keys(input.qualification, GATES); check(GATES.every(g => [true, false, null].includes(input.qualification[g])), 'All qualification gates required (true, false, or null)');
145
+ check(Array.isArray(input.evidence) && input.evidence.length > 0 && input.evidence.length <= 20, 'Qualification evidence required');
146
+ const evidenceIds = new Set();
147
+ for (const e of input.evidence) {
148
+ keys(e, ['id', 'kind', 'source', 'observedAt', 'text']); id(e.id); check(!evidenceIds.has(e.id), 'Duplicate evidence ID'); evidenceIds.add(e.id);
149
+ check(['candidate', 'role', 'recipient', 'history'].includes(e.kind), 'Invalid evidence kind'); text(e.source, 'evidence source', 1000); timestamp(e.observedAt); text(e.text, 'evidence');
150
+ }
151
+ keys(input.gateEvidence, GATES);
152
+ for (const gate of GATES) check(Array.isArray(input.gateEvidence[gate]) && input.gateEvidence[gate].length > 0 && input.gateEvidence[gate].length <= 20 && input.gateEvidence[gate].every(ref => evidenceIds.has(ref)), 'Every qualification gate needs evidence references');
153
+ keys(input.ranking, Object.keys(RANKING));
154
+ for (const [key, max] of Object.entries(RANKING)) check(Number.isInteger(input.ranking[key]) && input.ranking[key] >= 0 && input.ranking[key] <= max, 'Invalid ranking');
155
+ const existing = entry(state.opportunities, input.id);
156
+ const identity = identities(state, input);
157
+ if (existing) check(overlaps(existing.companies, identity.companies) && overlaps(existing.recipients, identity.recipients), 'Cannot replace opportunity identity; use a new opportunity');
158
+ if (existing) for (const key of ['companies', 'recipients']) identity[key] = [...new Set([...existing[key], ...identity[key]])];
159
+ state.opportunities[input.id] = { ...identity, id: input.id, qualificationRevision: (existing?.qualificationRevision ?? 0) + 1,
160
+ score: Object.values(input.ranking).reduce((a, b) => a + b, 0), cleared: false, suppressed: existing?.suppressed ?? false };
161
+ const content = entry(state.contents, input.id) ?? { drafts: [], evidence: {} };
162
+ content.assessment = structuredClone(input); delete content.assessment.operationId; state.contents[input.id] = content;
163
+ addEvent('assessed');
164
+ } else if (action === 'clear') {
165
+ keys(input, ['operationId', 'ids']); check(Array.isArray(input.ids) && input.ids.length > 0 && input.ids.length <= 50, 'Opportunity IDs required');
166
+ for (const opportunityId of input.ids) {
167
+ const op = entry(state.opportunities, id(opportunityId)); check(op, 'Opportunity not found');
168
+ delete state.contents[opportunityId]; op.cleared = true; op.suppressed = true;
169
+ state.tombstones[opportunityId] = { id: opportunityId, clearedAt: now, actor };
170
+ state.reservations[`clear:${opportunityId}`] = { opportunityId, companies: op.companies, recipients: op.recipients, suppressed: true };
171
+ }
172
+ } else {
173
+ const op = requireOpportunity(state, input); const content = entry(state.contents, input.id);
174
+ if (action === 'draft') {
175
+ keys(input, ['operationId', 'id', 'text', 'claimRefs', 'purpose']); check(state.meta.enabled, 'Outreach is disabled');
176
+ text(input.text, 'draft', 4000); check(['initial', 'follow-up'].includes(input.purpose), 'Draft purpose required');
177
+ check(Array.isArray(input.claimRefs) && input.claimRefs.length <= 20, 'Claim references required');
178
+ check(input.claimRefs.every(ref => content.assessment.evidence.some(e => e.id === ref && e.kind === 'candidate')), 'Candidate claim reference not found');
179
+ if (/\b(?:I|my)\b.*\b(?:experience|built|led|worked|created|delivered|implemented|engineer|developed|shipped|MCP|AI)\b/i.test(input.text)) check(input.claimRefs.length > 0, 'Candidate experience claims require evidence references');
180
+ check(!/\b(I (?:currently work|am employed)|(?:my|a) (?:salary|pay) (?:is|will)|I (?:can|will) (?:start|join|work)|I (?:have|hold) (?:work authorization|a visa)|(?:you referred|we met|we worked))\b/i.test(input.text), 'Draft contains a forbidden employment, familiarity, or commitment claim');
181
+ check(!/\b(available (?:to start|immediately)|authorized to work|(?:need|require) no sponsorship|currently (?:at|employed by)|my current employer)\b/i.test(input.text), 'Draft contains a forbidden employment, availability, or authorization commitment');
182
+ if (!content.assessment.applicationId) check(!/\b(I(?:'ve| have)? applied|my application|submitted (?:an |my )?application)\b/i.test(input.text), 'Cannot claim an application without a verified application link');
183
+ content.drafts.push({ revision: content.drafts.length + 1, qualificationRevision: op.qualificationRevision, text: input.text,
184
+ claimRefs: input.claimRefs, purpose: input.purpose, createdAt: now }); addEvent('drafted');
185
+ } else if (action === 'handoff') {
186
+ keys(input, ['operationId', 'id', 'draftRevision', 'qualificationRevision', 'selectedByUser', 'recheckedAt', 'history', 'exception']);
187
+ check(state.meta.enabled && !state.meta.recoveryBlocked, 'Outreach disabled or restore recovery blocked');
188
+ check(input.selectedByUser === true, 'Exact draft selection by the user required'); recent(input.recheckedAt, now);
189
+ checkLinkedApplication(content, applications, outcomes);
190
+ check(input.qualificationRevision === op.qualificationRevision && GATES.every(g => content.assessment.qualification[g] === true), 'Qualification gates or revision do not match');
191
+ const draft = content.drafts.find(d => d.revision === input.draftRevision);
192
+ check(draft && draft.qualificationRevision === op.qualificationRevision, 'Draft revision must match current qualification');
193
+ keys(input.history, ['kind', 'noPriorPitch', 'checkedAt']);
194
+ check(['verified', 'user-reported'].includes(input.history.kind), 'Conversation history evidence required'); recent(input.history.checkedAt, now);
195
+ check(typeof input.history.noPriorPitch === 'boolean', 'Prior pitch declaration required');
196
+ const view = projection(state, input.id, now);
197
+ check(!view.attempts.some(a => ['pending-handoff', 'uncertain', 'conflict'].includes(a.delivery)), 'Unresolved handoff requires reconciliation');
198
+ const reservations = Object.values(state.reservations).filter(r => reservationMatches(r, op));
199
+ check(!op.suppressed && !reservations.some(r => r.suppressed) && !view.progression.some(p => STOP.includes(p)), 'Contact suppressed or conversation already progressed');
200
+ check(!reservations.some(r => r.pending), 'Company or recipient reserved by an unresolved handoff');
201
+ if (draft.purpose === 'follow-up') check(view.followupDue, 'Follow-up is not due or already used');
202
+ else {
203
+ if (reservations.length || !input.history.noPriorPitch) {
204
+ check(input.exception, 'Additional company or recipient contact requires an explicit user exception');
205
+ keys(input.exception, ['approvedByUser', 'reason']); check(input.exception.approvedByUser === true, 'Additional contact requires explicit user exception'); text(input.exception.reason, 'exception reason');
206
+ }
207
+ }
208
+ content.evidence[operationId] = { history: input.history, recheckedAt: input.recheckedAt, ...(input.exception ? { exception: input.exception } : {}) };
209
+ state.reservations[operationId] = { opportunityId: input.id, companies: op.companies, recipients: op.recipients, pending: true, suppressed: false };
210
+ addEvent('handoff', { purpose: draft.purpose, draftRevision: draft.revision });
211
+ } else if (action === 'record') {
212
+ keys(input, ['operationId', 'id', 'type', 'attemptId', 'occurredAt', 'evidence', 'messageRef', 'supersedes', 'schedule', 'sentText', 'minutesSpent', 'replyTone']);
213
+ check([...DELIVERY, ...PROGRESSION].includes(input.type), 'Unsupported outcome');
214
+ const occurredAt = timestamp(input.occurredAt); check(Date.parse(occurredAt) <= Date.parse(now), 'Future observation is invalid'); text(input.evidence, 'evidence');
215
+ const supersedes = input.supersedes ?? []; check(Array.isArray(supersedes) && supersedes.length <= 20, 'Invalid correction');
216
+ const category = DELIVERY.includes(input.type) ? DELIVERY : PROGRESSION;
217
+ for (const old of supersedes) {
218
+ const previous = entry(state.events, old);
219
+ check(previous?.opportunityId === input.id && category.includes(previous.type), 'Corrections must reference observations in the same delivery/progression category');
220
+ }
221
+ if (DELIVERY.includes(input.type)) {
222
+ const attempt = entry(state.events, input.attemptId); check(attempt?.type === 'handoff' && attempt.opportunityId === input.id, 'Delivery observation requires its handoff');
223
+ check(Date.parse(occurredAt) >= Date.parse(attempt.occurredAt), 'Observation precedes handoff');
224
+ check(supersedes.every(old => entry(state.events, old).attemptId === input.attemptId), 'Delivery corrections must match attempt');
225
+ if (input.type === 'sent-verified') text(input.messageRef, 'visible message reference', 1000);
226
+ if (input.sentText) text(input.sentText, 'actual sent text', 4000);
227
+ }
228
+ if (input.type === 'screen-scheduled') {
229
+ keys(input.schedule, ['at', 'timezone']); timestamp(input.schedule.at); localDate(input.schedule.at, input.schedule.timezone); text(input.schedule.timezone, 'meeting timezone', 100);
230
+ }
231
+ if (input.type === 'closed-no-response') check(projection(state, input.id, now).noResponse, 'No-response timing requires a recorded follow-up send');
232
+ if (input.minutesSpent !== undefined) check(Number.isInteger(input.minutesSpent) && input.minutesSpent >= 0 && input.minutesSpent <= 10080, 'Invalid minutes spent');
233
+ if (input.replyTone !== undefined) check(input.type === 'replied' && ['positive', 'neutral', 'negative'].includes(input.replyTone), 'Reply tone requires a reply');
234
+ content.evidence[operationId] = { text: input.evidence, ...(input.messageRef ? { messageRef: input.messageRef } : {}), ...(input.schedule ? { schedule: input.schedule } : {}), ...(input.sentText ? { sentText: input.sentText } : {}) };
235
+ addEvent(input.type, { occurredAt, ...(input.attemptId ? { attemptId: input.attemptId } : {}), ...(supersedes.length ? { supersedes } : {}), ...(input.minutesSpent !== undefined ? { minutesSpent: input.minutesSpent } : {}), ...(input.replyTone ? { replyTone: input.replyTone } : {}) });
236
+ const view = projection(state, input.id, now);
237
+ if (input.attemptId) {
238
+ const attempt = view.attempts.find(a => a.id === input.attemptId);
239
+ const reservation = entry(state.reservations, input.attemptId) ?? { opportunityId: input.id, companies: op.companies, recipients: op.recipients, pending: true, suppressed: false };
240
+ if (attempt.delivery === 'not-sent') delete state.reservations[input.attemptId];
241
+ else { reservation.pending = ['pending-handoff', 'uncertain', 'conflict'].includes(attempt.delivery); state.reservations[input.attemptId] = reservation; }
242
+ }
243
+ const rejectionKey = `rejected:${input.id}`;
244
+ if (view.progression.includes('rejected')) {
245
+ state.reservations[rejectionKey] = { opportunityId: input.id, companies: op.companies, recipients: op.recipients, suppressed: true };
246
+ } else delete state.reservations[rejectionKey];
247
+ } else if (action === 'suppress') {
248
+ keys(input, ['operationId', 'id', 'scope', 'reason']); check(['company', 'recipient'].includes(input.scope), 'Suppression scope required'); text(input.reason, 'reason');
249
+ op.suppressed = true;
250
+ state.reservations[operationId] = { opportunityId: input.id, companies: input.scope === 'company' ? op.companies : [], recipients: input.scope === 'recipient' ? op.recipients : [], suppressed: true };
251
+ content.evidence[operationId] = { reason: input.reason }; addEvent('suppressed');
252
+ } else throw new Error('Unknown outreach mutation');
253
+ }
254
+ state.operations[operationId] = { digest, action, opportunityId: input.id ?? null };
255
+ state.meta.revision++;
256
+ return { state, result: resultFor(state, action, input, now) };
257
+ }
258
+
259
+ export function readOutreach(state, action, input = {}, now = new Date().toISOString()) {
260
+ if (action === 'policy-status') return resultFor(state, action, input, now);
261
+ if (action === 'show') {
262
+ const view = projection(state, id(input.id), now);
263
+ return { ...view, content: entry(state.contents, input.id) ?? null, events: Object.values(state.events).filter(e => e.opportunityId === input.id) };
264
+ }
265
+ const items = Object.keys(state.opportunities).map(opportunityId => projection(state, opportunityId, now)).sort((a, b) => b.score - a.score);
266
+ if (action === 'list') return { items };
267
+ if (action === 'review') {
268
+ const attempts = items.flatMap(i => i.attempts);
269
+ const sent = attempts.filter(a => a.delivery.startsWith('sent-'));
270
+ const companies = [];
271
+ for (const item of items.filter(i => i.attempts.some(a => a.delivery.startsWith('sent-')))) {
272
+ let group = new Set(state.opportunities[item.id].companies);
273
+ for (let i = companies.length - 1; i >= 0; i--) if ([...group].some(key => companies[i].has(key))) { group = new Set([...group, ...companies[i]]); companies.splice(i, 1); }
274
+ companies.push(group);
275
+ }
276
+ const first = sent.map(a => a.sentAt).sort()[0];
277
+ const events = items.flatMap(item => activeEvents(state, item.id));
278
+ return { opportunities: items.length, companiesContacted: companies.length, sentUserReported: sent.filter(a => a.delivery === 'sent-user-reported').length,
279
+ sentVerified: sent.filter(a => a.delivery === 'sent-verified').length, unresolved: attempts.filter(a => ['conflict', 'pending-handoff', 'uncertain'].includes(a.delivery)).length,
280
+ outcomes: Object.fromEntries(PROGRESSION.map(p => [p, items.filter(i => i.progression.includes(p) || (p === 'closed-no-response' && i.noResponse)).length])),
281
+ sampleSize: items.filter(i => i.attempts.some(a => a.delivery.startsWith('sent-'))).length, sampleUnit: 'contacted-opportunities', cohortAgeDays: first ? Math.floor((Date.parse(now) - Date.parse(first)) / DAY) : 0,
282
+ outcomeReviewDue: Boolean(first && localDate(now, state.meta.timezone) >= businessDate(first, state.meta.timezone, 20)),
283
+ pilotReviewDue: attempts.length >= 10, positiveReplies: new Set(events.filter(e => e.type === 'replied' && e.replyTone === 'positive').map(e => e.opportunityId)).size,
284
+ reportedMinutesSpent: events.reduce((total, e) => total + (e.minutesSpent ?? 0), 0), comparison: 'observational; selection bias; no causal ROI claim' };
285
+ }
286
+ throw new Error('Unknown outreach read');
287
+ }
@@ -0,0 +1,72 @@
1
+ import { createRequire } from 'node:module';
2
+ import { chmod, mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { initialOutreach, OUTREACH_TABLES } from './outreach-domain.mjs';
6
+
7
+ export const OUTREACH_SCHEMA = `
8
+ CREATE TABLE IF NOT EXISTS outreach_meta (id INTEGER PRIMARY KEY CHECK(id = 1), revision INTEGER NOT NULL, payload_json TEXT NOT NULL);
9
+ ${OUTREACH_TABLES.map(t => `CREATE TABLE IF NOT EXISTS outreach_${t} (id TEXT PRIMARY KEY, payload_json TEXT NOT NULL);`).join('\n')}
10
+ `;
11
+
12
+ let sqlite;
13
+ async function runtime() {
14
+ const require = createRequire(import.meta.url);
15
+ if (!sqlite) {
16
+ let initialize;
17
+ try { initialize = require('./runtime/sql-asm.cjs'); }
18
+ catch (error) { if (error.code !== 'MODULE_NOT_FOUND') throw error; initialize = require('sql.js/dist/sql-asm.js'); }
19
+ sqlite = initialize();
20
+ }
21
+ return sqlite;
22
+ }
23
+ export async function privateOutreachWrite(path, bytes) {
24
+ const temporary = `${path}.${randomUUID()}.tmp`;
25
+ const handle = await open(temporary, 'wx', 0o600);
26
+ try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); }
27
+ try { await rename(temporary, path); await chmod(path, 0o600); }
28
+ finally { await unlink(temporary).catch(e => { if (e.code !== 'ENOENT') throw e; }); }
29
+ }
30
+
31
+ // sql.js is pinned and copied with managed skills: no native build or Node minimum change.
32
+ // The exclusive lock covers load, transaction, and atomic fsync/rename of the SQLite file.
33
+ export async function withOutreachLock(directory, name, callback) {
34
+ await mkdir(directory, { recursive: true, mode: 0o700 }); await chmod(directory, 0o700);
35
+ const lockPath = join(directory, name);
36
+ let lock;
37
+ for (let i = 0; i < 100; i++) {
38
+ try { lock = await open(lockPath, 'wx', 0o600); break; }
39
+ catch (error) { if (error.code !== 'EEXIST') throw error; await new Promise(resolve => setTimeout(resolve, 20)); }
40
+ }
41
+ if (!lock) throw new Error(`Outreach storage locked. If a process crashed, verify it has exited before removing ${name}.`);
42
+ try {
43
+ await lock.writeFile(JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString() }));
44
+ return await callback();
45
+ } finally { await lock.close(); await unlink(lockPath); }
46
+ }
47
+ export async function withLocalOutreach(directory, callback) {
48
+ return withOutreachLock(directory, 'outreach.lock', async () => {
49
+ let db;
50
+ try {
51
+ const SQL = await runtime(); const path = join(directory, 'outreach.sqlite');
52
+ let bytes; try { bytes = await readFile(path); } catch (error) { if (error.code !== 'ENOENT') throw error; }
53
+ db = new SQL.Database(bytes); db.run(OUTREACH_SCHEMA); db.run('PRAGMA secure_delete=ON');
54
+ const rows = sql => { const result = db.exec(sql)[0]; return result ? result.values.map(row => Object.fromEntries(result.columns.map((key, i) => [key, row[i]]))) : []; };
55
+ const meta = rows('SELECT payload_json FROM outreach_meta')[0];
56
+ const state = initialOutreach(); if (meta) state.meta = JSON.parse(meta.payload_json);
57
+ for (const table of OUTREACH_TABLES) state[table] = Object.fromEntries(rows(`SELECT id, payload_json FROM outreach_${table}`).map(r => [r.id, JSON.parse(r.payload_json)]));
58
+ const output = await callback(state);
59
+ if (output.state && (output.state !== state || !bytes)) {
60
+ db.run('BEGIN');
61
+ db.run('INSERT OR REPLACE INTO outreach_meta VALUES (1, ?, ?)', [output.state.meta.revision, JSON.stringify(output.state.meta)]);
62
+ for (const table of OUTREACH_TABLES) {
63
+ db.run(`DELETE FROM outreach_${table}`);
64
+ for (const [key, value] of Object.entries(output.state[table])) db.run(`INSERT INTO outreach_${table} VALUES (?, ?)`, [key, JSON.stringify(value)]);
65
+ }
66
+ db.run('COMMIT'); db.run('VACUUM');
67
+ await privateOutreachWrite(path, Buffer.from(db.export()));
68
+ }
69
+ return output.result;
70
+ } finally { db?.close(); }
71
+ });
72
+ }