job-application-agent 3.7.1 → 3.7.2
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.
- package/job-application-agent/SKILL.md +8 -4
- package/job-application-agent/references/ACCOUNTING.md +1 -1
- package/job-application-agent/references/ANALYTICS.md +1 -1
- package/job-application-agent/references/OUTREACH.md +20 -7
- package/job-application-agent/references/RUNS.md +2 -1
- package/job-application-agent/references/SCHEMAS.md +1 -1
- package/job-application-agent/scripts/cloud-state-client.mjs +9 -2
- package/job-application-agent/scripts/job-application.mjs +57 -5
- package/job-application-agent/scripts/outreach-cli.mjs +17 -0
- package/job-application-agent/scripts/outreach-round.mjs +267 -0
- package/job-application-agent/scripts/version.mjs +1 -1
- package/job-application-agent/tests/cloud-state-client.test.mjs +12 -0
- package/job-application-agent/tests/outreach-round.test.mjs +489 -0
- package/package.json +2 -2
|
@@ -37,12 +37,16 @@ copyable text to the candidate for manual sending, and record actual observation
|
|
|
37
37
|
Do not automate LinkedIn/X access or messaging. Existing application autonomy
|
|
38
38
|
does not enable this module. Outreach commands bypass analytics and community
|
|
39
39
|
transmissions; do not run updater/telemetry/community commands as part of an
|
|
40
|
-
outreach-only workflow. Keep
|
|
41
|
-
|
|
40
|
+
outreach-only workflow. Keep drafting and sending separate from ATS forms.
|
|
41
|
+
A `sent-verified` outreach counts toward the active application round the same
|
|
42
|
+
way a confirmed apply does, unless that company is already a confirmed apply on
|
|
43
|
+
the same round. Clear does not un-confirm. A later `not-sent` or `failed`
|
|
44
|
+
delivery correction does. Never classify a proposed screen as scheduled. No scheduled
|
|
45
|
+
follow-up is created.
|
|
42
46
|
|
|
43
47
|
## Accounting
|
|
44
48
|
|
|
45
|
-
Read [references/ACCOUNTING.md](references/ACCOUNTING.md) before recording delivery evidence, recovery attempts, or per-lead discovery. For new rounds, record each lead with `round lead --stdin` and derive source totals from those records. Email access is optional: visible browser success counts, verified email sends count with receipt unknown, and matched final delivery failures correct effective totals. Preserve historical events and use explicit corrections for conflicts.
|
|
49
|
+
Read [references/ACCOUNTING.md](references/ACCOUNTING.md) before recording delivery evidence, recovery attempts, or per-lead discovery. For new rounds, record each lead with `round lead --stdin` and derive source totals from those records. Email access is optional: visible browser success counts, verified email sends count with receipt unknown, and matched final delivery failures correct effective totals. A sent-verified outreach counts toward the same round `confirmedCount` unless that company already has a counted apply. Preserve historical events and use explicit corrections for conflicts.
|
|
46
50
|
|
|
47
51
|
## Discover and assess
|
|
48
52
|
|
|
@@ -132,7 +136,7 @@ node scripts/job-application.mjs ledger review
|
|
|
132
136
|
node scripts/job-application.mjs ledger review-ack --stdin
|
|
133
137
|
node scripts/job-application.mjs autonomy grant --stdin
|
|
134
138
|
node scripts/job-application.mjs autonomy status|preview|revoke
|
|
135
|
-
node scripts/job-application.mjs round start|source|complete --stdin
|
|
139
|
+
node scripts/job-application.mjs round start|source|confirm|complete --stdin
|
|
136
140
|
node scripts/job-application.mjs round status [round-id]
|
|
137
141
|
node scripts/job-application.mjs sources list [--stdin]
|
|
138
142
|
node scripts/job-application.mjs sources jobs [--stdin]
|
|
@@ -6,7 +6,7 @@ Browser applications count after visible ATS success. They do not require email
|
|
|
6
6
|
|
|
7
7
|
Match a final delivery failure to the actual application attempt using its sent message, recipient, timing and returned failure evidence. Temporary delays, unrelated bounces, and hiring rejections are not delivery failures. A failed notification cannot overturn independent browser confirmation. Never guess an alternate address or automatically resend an uncertain transmission.
|
|
8
8
|
|
|
9
|
-
`ledger review`, `round status`, and `round complete` share one effective-count projection. They expose `recordedSubmissionCount`, `effectiveSubmissionCount`, `failedDeliveryCount`, and `receiptUnknownEmailCount`. Existing `submittedTotal`/`confirmedCount` fields now reflect effective submissions, not proven email receipt. Conversion denominators exclude failed applications while historical outcomes remain visible. Review cadence and acknowledgement checkpoints use recorded canonical submissions and recorded mature applications, so a late failure does not postpone the next review. Public usage events remain historical activity metrics.
|
|
9
|
+
`ledger review`, `round status`, and `round complete` share one effective-count projection for ledger applications. They expose `recordedSubmissionCount`, `effectiveSubmissionCount`, `failedDeliveryCount`, and `receiptUnknownEmailCount`. Existing `submittedTotal`/`confirmedCount` fields now reflect effective submissions, not proven email receipt. `round status` also adds sent-verified outreach that is attached to the round and not already covered by a counted apply at the same company, exposed as `applyConfirmationCount` plus `outreachConfirmationCount`. Clearing outreach content never drops that attached count. An explicit delivery correction to `not-sent` or `failed` does: the durable confirmation stays on disk, but `confirmedCount` stops counting that send. Conversion denominators exclude failed applications while historical outcomes remain visible. Review cadence and acknowledgement checkpoints use recorded canonical submissions and recorded mature applications, so a late failure does not postpone the next review. Public usage events remain historical activity metrics.
|
|
10
10
|
|
|
11
11
|
A late failure preserves the original completion event. `shortfallCount` and `needsRecovery` flag the deficit; no run starts or round reopens automatically. Review the derived attention queue before a later authorized recovery.
|
|
12
12
|
|
|
@@ -65,7 +65,7 @@ Company and title values are bounded and rejected when they resemble an email, p
|
|
|
65
65
|
| `application_paused` | Job hash, ATS, stage, bounded reason |
|
|
66
66
|
| `application_skipped` | Job hash, bounded reason, fit score, eligibility |
|
|
67
67
|
| `application_submitted` | Company, title, job hash/domain, ATS, duration, fields filled, short-answer count, resume-upload Boolean, approval mode |
|
|
68
|
-
| `round_completed` | Requested/submitted/assessed/skipped/paused/error counts, duration bucket; optional attempted/searched/blocked source counts, maximum source share percentage, bounded concentration reason |
|
|
68
|
+
| `round_completed` | Requested/submitted/assessed/skipped/paused/error counts, duration bucket; optional attempted/searched/blocked source counts, maximum source share percentage, bounded concentration reason. `submittedCount` and `assessedCount` are ledger application totals only and never include attached outreach. |
|
|
69
69
|
| `outcome_recorded` | Company, title, job hash/domain, ATS, outcome, days since submission, optional bounded interview quality/failure point |
|
|
70
70
|
| `review_generated` | Canonical unique-submission and outcome counts, review-due Boolean |
|
|
71
71
|
| `skill_error` | Stable error code, workflow stage, ATS/job hash when available, recoverable Boolean |
|
|
@@ -173,8 +173,15 @@ are explicitly stale. On reconnect, the cache is replaced by authoritative cloud
|
|
|
173
173
|
data, including clear tombstones. If a lower server revision suggests a restored
|
|
174
174
|
backend or delayed response, the cache is discarded; another online read is
|
|
175
175
|
required before offline inspection is available. Handoffs use atomic database reservations,
|
|
176
|
-
not application submission leases.
|
|
177
|
-
round
|
|
176
|
+
not application submission leases. Recording `sent-verified` increments the
|
|
177
|
+
active application round (or the round already attached to that opportunity)
|
|
178
|
+
when that company does not already have a counted apply on the same round.
|
|
179
|
+
Auto-attach first does a targeted authenticated reconcile of cloud `rounds`
|
|
180
|
+
events so a second or stale host can see the open round. Apply plus outreach
|
|
181
|
+
at the same company is one confirmation, not two. The existing
|
|
182
|
+
application-autonomy grant is unchanged. Use `round confirm --stdin`
|
|
183
|
+
with `{ "roundId":"round-...","outreachId":"opportunity-..." }` to attach an
|
|
184
|
+
already sent-verified opportunity to an open round.
|
|
178
185
|
|
|
179
186
|
Outreach commands bypass telemetry, identity transmission, community sharing,
|
|
180
187
|
and their automatic retry paths—including command errors. Only authenticated
|
|
@@ -183,11 +190,17 @@ evidence stay in sensitive content storage. Audit rows retain opaque IDs,
|
|
|
183
190
|
timestamps, coarse transitions, and private keyed fingerprints.
|
|
184
191
|
|
|
185
192
|
Clear input: `{"operationId":"clear-1","ids":["opportunity-1"]}`.
|
|
186
|
-
Clearing removes sensitive content and blocks resurrection.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
193
|
+
Clearing removes sensitive content and blocks resurrection. It never
|
|
194
|
+
un-confirms a counted round attachment: `confirmedCount` stays on the durable
|
|
195
|
+
`submission-confirmed` event and uses the company already stored there.
|
|
196
|
+
A later `not-sent` or `failed` correction of that send is different: the
|
|
197
|
+
attachment event remains, but the projection drops it from `confirmedCount`.
|
|
198
|
+
New attachments require a live outreach snapshot when cloud state is
|
|
199
|
+
configured; cached reads may still power `round status`. Minimal
|
|
200
|
+
outcomes, audit history, company/contact fingerprints and suppression remain.
|
|
201
|
+
Do not describe this as deleting every personal-data trace. Disconnected host
|
|
202
|
+
caches and user-managed exports cannot be remotely erased; backups retain old
|
|
203
|
+
content until their existing 30-day expiry.
|
|
191
204
|
|
|
192
205
|
Private backups include outreach tables. `cloud export` also includes the
|
|
193
206
|
outreach snapshot and deletion manifest. A manifest must be obtained from the
|
|
@@ -14,6 +14,7 @@ Start each batch with an explicit ID:
|
|
|
14
14
|
node scripts/job-application.mjs round start --stdin
|
|
15
15
|
node scripts/job-application.mjs round source --stdin
|
|
16
16
|
node scripts/job-application.mjs round status [round-id]
|
|
17
|
+
node scripts/job-application.mjs round confirm --stdin
|
|
17
18
|
node scripts/job-application.mjs round complete --stdin
|
|
18
19
|
```
|
|
19
20
|
|
|
@@ -62,7 +63,7 @@ Coverage reports emit bounded `source_checked` analytics automatically. Only all
|
|
|
62
63
|
Before completion, audit final email delivery failures through authorized email tools when available; otherwise report delivery not audited and continue. Verified email sends count with receipt unknown. Use `ledger delivery` to record matched failures, never `ledger outcome rejected`. Effective totals exclude failed attempts; completed rounds retain their completion record and expose a recovery shortfall. See [ACCOUNTING.md](ACCOUNTING.md) for safe linked replacement attempts.
|
|
63
64
|
|
|
64
65
|
|
|
65
|
-
Count
|
|
66
|
+
Count unique applications with a visible employer/ATS confirmation or a verified sent recruiting email that were also added to the ledger with the same `roundId`, plus sent-verified outreach attached to that round when the company is not already a counted apply. Filled forms, blockers, drafts, unsent email, user-reported-only outreach, and ambiguous confirmations never count. Apply plus outreach at the same company is one confirmation. `round complete` rejects an under-target round.
|
|
66
67
|
|
|
67
68
|
Run both company-level and requisition-level duplicate checks before filling and again immediately before transmission. Hard ledger-ID, canonical-URL, employer-job-ID, and requisition duplicates always stop. Same-role aliases require a verified distinct requisition and `NEW REQUISITION CONFIRMED`. A genuinely different role at the same company may proceed automatically only when `companyReapply.decision` is `eligible-after-cooldown`: 15 full days have passed since the latest company application and no outcome has been recorded. `cooldown-active` and `follow-up-present` require explicit candidate approval.
|
|
68
69
|
|
|
@@ -155,7 +155,7 @@ Use `round source --stdin` for per-source search/blocker reports and optional at
|
|
|
155
155
|
{ "requestedCount": 30 }
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
-
`round start --stdin` appends a `started` event to owner-only `rounds.ndjson` and returns a generated `roundId`. Add that ID to every confirmed ledger entry. `round complete --stdin` accepts `{ "roundId": "round-..." }`, plus concentration reason and evidence when required, and appends a completion event only after the target count, source coverage, attribution, and concentration requirements are satisfied.
|
|
158
|
+
`round start --stdin` appends a `started` event to owner-only `rounds.ndjson` and returns a generated `roundId`. Add that ID to every confirmed ledger entry. A `sent-verified` outreach also counts toward `confirmedCount` for the active or attached open round when that company is not already a counted apply on the same round. Once attached, that confirmation stays after `outreach clear`; clear wipes message text and PII only. A later delivery correction to `not-sent` or `failed` stops counting that send. `round confirm` and auto-attach require an authoritative outreach snapshot; they do not mutate from a stale offline cache. `round confirm --stdin` accepts `{ "roundId": "round-...", "outreachId": "opportunity-..." }` (or `outreachIds`) to attach already sent-verified outreach without creating a second ledger application. `round complete --stdin` accepts `{ "roundId": "round-..." }`, plus concentration reason and evidence when required, and appends a completion event only after the target count, source coverage, attribution, and concentration requirements are satisfied. Discovery attribution and lead linkage still apply only to ledger applications.
|
|
159
159
|
|
|
160
160
|
## Attention input
|
|
161
161
|
|
|
@@ -338,10 +338,17 @@ export class CloudStateClient {
|
|
|
338
338
|
return (await this.request(`/v2/intents/${encodeURIComponent(intentId)}/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ application, leaseId, idempotencyKey }) })).json();
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
-
async reconcile({ dryRun = true, provenance = 'local-reconcile' } = {}) {
|
|
341
|
+
async reconcile({ dryRun = true, provenance = 'local-reconcile', streams = null } = {}) {
|
|
342
342
|
await this.requireAccounting();
|
|
343
343
|
const report = { dryRun, streams: {}, imported: 0, downloaded: 0 };
|
|
344
|
-
|
|
344
|
+
const selected = streams == null
|
|
345
|
+
? Object.entries(CLOUD_STREAM_FILES)
|
|
346
|
+
: streams.map((stream) => {
|
|
347
|
+
const filename = CLOUD_STREAM_FILES[stream];
|
|
348
|
+
if (!filename) throw new Error(`Unknown cloud stream: ${stream}.`);
|
|
349
|
+
return [stream, filename];
|
|
350
|
+
});
|
|
351
|
+
for (const [stream, filename] of selected) {
|
|
345
352
|
if (!dryRun) await this.flushAccountingWrites(stream);
|
|
346
353
|
const normalize = value => stream === 'delivery' ? validateDelivery(value) : stream === 'discovery' && value.version === 1 && value.type === 'lead-reviewed' ? validateLead(value) : value;
|
|
347
354
|
let local = (await readNdjson(join(this.stateDir, filename))).map(normalize);
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
extractNarrativeQuestionsFromText,
|
|
27
27
|
normalizeAttentionQuestions,
|
|
28
28
|
} from './attention-questions.mjs';
|
|
29
|
+
import { confirmOutreachTowardRound, loadOutreachRoundView, loadSentVerifiedOutreach, outreachConfirmationEvents, projectOutreachRoundCounts, resolveOutreachRoundId } from './outreach-round.mjs';
|
|
29
30
|
|
|
30
31
|
const SOURCES = new Set(['linkedin', 'greenhouse', 'lever', 'ashby', 'workable', 'comeet', 'workday', 'rippling', 'smartrecruiters', 'google-form', 'company', 'email', 'other']);
|
|
31
32
|
const DISCOVERY_SOURCES = new Set(['direct-company', 'linkedin', 'x', 'yc', 'hacker-news', 'job-board', 'email', 'user-supplied', 'web-search', 'other']);
|
|
@@ -1501,7 +1502,16 @@ async function roundStatus(roundId = null) {
|
|
|
1501
1502
|
const delivery = deliveryProjection(matching, await jsonLines(join(dir, 'delivery.ndjson')));
|
|
1502
1503
|
const effectiveKeys = new Set(matching.filter((entry,i) => delivery.applications[i].counted).map(canonicalApplicationKey));
|
|
1503
1504
|
const effectiveApplications = applications.filter(entry => effectiveKeys.has(canonicalApplicationKey(entry)));
|
|
1504
|
-
const
|
|
1505
|
+
const outreachView = await loadOutreachRoundView(dir, cloudState);
|
|
1506
|
+
const outreachCounts = projectOutreachRoundCounts({
|
|
1507
|
+
applications: matching,
|
|
1508
|
+
delivery,
|
|
1509
|
+
roundEvents: events,
|
|
1510
|
+
sentVerified: outreachView.sentVerified,
|
|
1511
|
+
invalidatedIds: outreachView.invalidatedIds,
|
|
1512
|
+
roundId: id,
|
|
1513
|
+
});
|
|
1514
|
+
const confirmedCount = outreachCounts.confirmedCount;
|
|
1505
1515
|
const audit = started.discoveryPolicyVersion === 2 ? discoveryProjection(await jsonLines(join(dir, 'discovery.ndjson')), { roundId: id }) : null;
|
|
1506
1516
|
const attention = await attentionList(id);
|
|
1507
1517
|
const completion = events.find((event) => event.type === 'completed' && event.roundId === id);
|
|
@@ -1514,7 +1524,9 @@ async function roundStatus(roundId = null) {
|
|
|
1514
1524
|
completed: Boolean(completion),
|
|
1515
1525
|
discoveryPolicyVersion: started.discoveryPolicyVersion ?? 1,
|
|
1516
1526
|
recordedSubmissionCount: delivery.recordedSubmissionCount,
|
|
1517
|
-
|
|
1527
|
+
applyConfirmationCount: outreachCounts.applyConfirmationCount,
|
|
1528
|
+
outreachConfirmationCount: outreachCounts.outreachConfirmationCount,
|
|
1529
|
+
effectiveSubmissionCount: delivery.effectiveSubmissionCount,
|
|
1518
1530
|
failedDeliveryCount: delivery.failedDeliveryCount,
|
|
1519
1531
|
receiptUnknownEmailCount: delivery.receiptUnknownEmailCount,
|
|
1520
1532
|
shortfallCount: Math.max(0, started.requestedCount - confirmedCount),
|
|
@@ -1554,6 +1566,44 @@ async function roundComplete(input) {
|
|
|
1554
1566
|
return result;
|
|
1555
1567
|
}
|
|
1556
1568
|
|
|
1569
|
+
async function roundConfirm(input) {
|
|
1570
|
+
const value = object(input, 'round confirmation');
|
|
1571
|
+
const allowed = new Set(['roundId', 'outreachId', 'outreachIds']);
|
|
1572
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown round confirmation property: ${key}.`);
|
|
1573
|
+
const roundId = string(value.roundId, 'round.roundId', 180);
|
|
1574
|
+
const ids = [];
|
|
1575
|
+
if (value.outreachId != null) ids.push(string(value.outreachId, 'round.outreachId', 100));
|
|
1576
|
+
if (value.outreachIds != null) {
|
|
1577
|
+
if (!Array.isArray(value.outreachIds) || value.outreachIds.length === 0 || value.outreachIds.length > 50) throw new Error('round.outreachIds must be a bounded array of outreach IDs.');
|
|
1578
|
+
for (const [index, id] of value.outreachIds.entries()) ids.push(string(id, `round.outreachIds[${index}]`, 100));
|
|
1579
|
+
}
|
|
1580
|
+
if (!ids.length) throw new Error('round confirm requires outreachId or outreachIds.');
|
|
1581
|
+
const dir = await ensureStateDir();
|
|
1582
|
+
const uniqueIds = [...new Set(ids)];
|
|
1583
|
+
const roundEvents = await jsonLines(join(dir, 'rounds.ndjson'));
|
|
1584
|
+
const sentVerified = new Set((await loadSentVerifiedOutreach(dir, cloudState, { allowCache: false })).map((item) => item.id));
|
|
1585
|
+
for (const outreachId of uniqueIds) {
|
|
1586
|
+
const targetRoundId = resolveOutreachRoundId(roundEvents, { outreachId, roundId });
|
|
1587
|
+
if (!targetRoundId) throw new Error('Application round was not found.');
|
|
1588
|
+
const alreadyAttached = outreachConfirmationEvents(roundEvents, targetRoundId).some((event) => event.outreachId === outreachId);
|
|
1589
|
+
if (!alreadyAttached && !sentVerified.has(outreachId)) throw new Error(`Outreach ${outreachId} is not sent-verified.`);
|
|
1590
|
+
}
|
|
1591
|
+
const confirmations = [];
|
|
1592
|
+
for (const outreachId of uniqueIds) {
|
|
1593
|
+
const result = await confirmOutreachTowardRound({
|
|
1594
|
+
directory: dir,
|
|
1595
|
+
outreachId,
|
|
1596
|
+
roundId,
|
|
1597
|
+
cloudClient: cloudState,
|
|
1598
|
+
requireRoundId: true,
|
|
1599
|
+
});
|
|
1600
|
+
if (result.reason === 'round-not-found') throw new Error('Application round was not found.');
|
|
1601
|
+
if (result.reason === 'not-sent-verified') throw new Error(`Outreach ${outreachId} is not sent-verified.`);
|
|
1602
|
+
confirmations.push(result);
|
|
1603
|
+
}
|
|
1604
|
+
return { ...await roundStatus(roundId), confirmations };
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1557
1607
|
async function frictionRecord(input) {
|
|
1558
1608
|
const value = object(input, 'friction event');
|
|
1559
1609
|
const allowed = new Set(['stage', 'ats', 'errorCode', 'reproducible', 'general', 'observedAt']);
|
|
@@ -1751,12 +1801,13 @@ function reviewTelemetry(review) {
|
|
|
1751
1801
|
}
|
|
1752
1802
|
|
|
1753
1803
|
function roundCompletedTelemetry(round) {
|
|
1804
|
+
const applicationCount = round.applyConfirmationCount ?? round.effectiveSubmissionCount ?? 0;
|
|
1754
1805
|
return {
|
|
1755
1806
|
event: 'round_completed',
|
|
1756
1807
|
properties: {
|
|
1757
1808
|
requestedCount: round.requestedCount,
|
|
1758
|
-
submittedCount:
|
|
1759
|
-
assessedCount:
|
|
1809
|
+
submittedCount: applicationCount,
|
|
1810
|
+
assessedCount: applicationCount,
|
|
1760
1811
|
skippedCount: 0,
|
|
1761
1812
|
pausedCount: round.blockedCount,
|
|
1762
1813
|
errorCount: 0,
|
|
@@ -1828,6 +1879,7 @@ async function executeCommand([area, action, value], telemetry, session, communi
|
|
|
1828
1879
|
} });
|
|
1829
1880
|
}
|
|
1830
1881
|
else if (area === 'round' && action === 'status') result = await roundStatus(value ?? null);
|
|
1882
|
+
else if (area === 'round' && action === 'confirm' && value === '--stdin') result = await roundConfirm(await jsonStdin());
|
|
1831
1883
|
else if (area === 'round' && action === 'complete' && value === '--stdin') {
|
|
1832
1884
|
result = await roundComplete(await jsonStdin());
|
|
1833
1885
|
if (result.completionRecorded) domainEvents.push(roundCompletedTelemetry(result));
|
|
@@ -1853,7 +1905,7 @@ async function executeCommand([area, action, value], telemetry, session, communi
|
|
|
1853
1905
|
else if (area === 'attention' && action === 'resolve' && value === '--stdin') result = await attentionResolve(await jsonStdin());
|
|
1854
1906
|
else if (area === 'friction' && action === 'record' && value === '--stdin') result = await frictionRecord(await jsonStdin());
|
|
1855
1907
|
else if (area === 'friction' && action === 'list' && value == null) result = await frictionList();
|
|
1856
|
-
else throw new Error('Usage: cloud status|configure --stdin|reconcile [--dry-run]|export [path]|lease-acquire|lease-renew|lease-release|intent-prepare --stdin|intent-sent --stdin|intent-confirm --stdin; profile set|migrate --stdin; profile check|field <name>; resume import <url-or-pdf>|path; score --stdin; ledger check|add|outcome|review-ack --stdin; ledger review; autonomy grant --stdin|status|preview|revoke; round start|source|complete --stdin|status [round-id]; sources list [--stdin]|jobs [--stdin]|suggest --stdin|pending|sync|sharing status|enable|disable|reset; attention add|resolve --stdin|list; friction record --stdin|list; telemetry status|enable|disable|reset|preview --stdin|record --stdin');
|
|
1908
|
+
else throw new Error('Usage: cloud status|configure --stdin|reconcile [--dry-run]|export [path]|lease-acquire|lease-renew|lease-release|intent-prepare --stdin|intent-sent --stdin|intent-confirm --stdin; profile set|migrate --stdin; profile check|field <name>; resume import <url-or-pdf>|path; score --stdin; ledger check|add|outcome|review-ack --stdin; ledger review; autonomy grant --stdin|status|preview|revoke; round start|source|confirm|complete --stdin|status [round-id]; sources list [--stdin]|jobs [--stdin]|suggest --stdin|pending|sync|sharing status|enable|disable|reset; attention add|resolve --stdin|list; friction record --stdin|list; telemetry status|enable|disable|reset|preview --stdin|record --stdin');
|
|
1857
1909
|
for (const event of domainEvents) await telemetry.record(event, session);
|
|
1858
1910
|
return result;
|
|
1859
1911
|
}
|
|
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { CloudStateClient, defaultCloudConfigPath } from './cloud-state-client.mjs';
|
|
6
6
|
import { migrateLegacyStateDir, resolveStateDir } from './secret-store.mjs';
|
|
7
7
|
import { mutateOutreach, readOutreach, OUTREACH_CAPABILITY } from './outreach-domain.mjs';
|
|
8
|
+
import { confirmOutreachTowardRound } from './outreach-round.mjs';
|
|
8
9
|
import { privateOutreachWrite, withLocalOutreach, withOutreachLock } from './outreach-store.mjs';
|
|
9
10
|
|
|
10
11
|
const READS = new Set(['policy-status', 'list', 'show', 'review']);
|
|
@@ -91,5 +92,21 @@ export async function runOutreach(args, { input: suppliedInput, stateDirectory =
|
|
|
91
92
|
result = await withLocalOutreach(stateDirectory, state => read ? { result: readOutreach(state, action, input) } : mutateOutreach(state, action, input, { ...context, actor: 'local' }));
|
|
92
93
|
}
|
|
93
94
|
if (action === 'policy-enable') await guard();
|
|
95
|
+
if (action === 'record' && input?.type === 'sent-verified' && result?.delivery === 'sent-verified' && input?.id) {
|
|
96
|
+
try {
|
|
97
|
+
result = {
|
|
98
|
+
...result,
|
|
99
|
+
roundConfirmation: await confirmOutreachTowardRound({
|
|
100
|
+
directory: stateDirectory,
|
|
101
|
+
outreachId: input.id,
|
|
102
|
+
cloudClient: cloud,
|
|
103
|
+
occurredAt: input.occurredAt,
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
} catch {
|
|
107
|
+
// The outreach observation already committed; attachment is a secondary result.
|
|
108
|
+
result = { ...result, roundConfirmation: { counted: false, reason: 'attachment-failed', outreachId: input.id } };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
94
111
|
return result;
|
|
95
112
|
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { appendFile, chmod, mkdir, open, readFile, unlink } from 'node:fs/promises';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { deliveryProjection } from './application-accounting.mjs';
|
|
4
|
+
import { CloudStateClient, defaultCloudConfigPath } from './cloud-state-client.mjs';
|
|
5
|
+
import { OUTREACH_CAPABILITY, readOutreach } from './outreach-domain.mjs';
|
|
6
|
+
import { withLocalOutreach } from './outreach-store.mjs';
|
|
7
|
+
|
|
8
|
+
export const OUTREACH_ROUND_CHANNEL = 'outreach';
|
|
9
|
+
|
|
10
|
+
export function companyCountKey(value) {
|
|
11
|
+
return String(value ?? '').normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function hasActiveSentVerified(item) {
|
|
15
|
+
if (!item || item.cleared) return false;
|
|
16
|
+
const attempts = Array.isArray(item.attempts) ? item.attempts : [];
|
|
17
|
+
if (attempts.length > 0) return attempts.some((attempt) => attempt.delivery === 'sent-verified');
|
|
18
|
+
return item.delivery === 'sent-verified';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function invalidatedOutreachIds(items = []) {
|
|
22
|
+
return items.filter((item) => item?.id && !item.cleared && !hasActiveSentVerified(item)).map((item) => item.id);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function outreachConfirmationEvents(roundEvents, roundId) {
|
|
26
|
+
return roundEvents.filter((event) => event.type === 'submission-confirmed' && event.roundId === roundId && typeof event.outreachId === 'string' && event.outreachId && event.channel === OUTREACH_ROUND_CHANNEL);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function countedApplyCompanies(applications, delivery) {
|
|
30
|
+
const companies = new Set();
|
|
31
|
+
applications.forEach((entry, index) => {
|
|
32
|
+
if (delivery.applications[index]?.counted) companies.add(companyCountKey(entry.company));
|
|
33
|
+
});
|
|
34
|
+
return companies;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function sentVerifiedFromOutreachItems(items = []) {
|
|
38
|
+
return items.flatMap((item) => {
|
|
39
|
+
if (!hasActiveSentVerified(item)) return [];
|
|
40
|
+
const company = item.content?.assessment?.company?.name;
|
|
41
|
+
if (!company) return [];
|
|
42
|
+
return [{
|
|
43
|
+
id: item.id,
|
|
44
|
+
company,
|
|
45
|
+
role: item.content?.assessment?.role ?? null,
|
|
46
|
+
companyKey: companyCountKey(company),
|
|
47
|
+
}];
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function sentVerifiedFromOutreachState(state, now = new Date().toISOString()) {
|
|
52
|
+
if (!state?.opportunities) return [];
|
|
53
|
+
return sentVerifiedFromOutreachItems(Object.keys(state.opportunities).map((id) => readOutreach(state, 'show', { id }, now)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolveOutreachRoundId(roundEvents, { outreachId, roundId = null } = {}) {
|
|
57
|
+
const attached = roundEvents.find((event) => event.type === 'submission-confirmed' && event.outreachId === outreachId && event.channel === OUTREACH_ROUND_CHANNEL);
|
|
58
|
+
if (attached) {
|
|
59
|
+
if (roundId && attached.roundId !== roundId) {
|
|
60
|
+
throw new Error(`Outreach ${outreachId} is already attached to another round.`);
|
|
61
|
+
}
|
|
62
|
+
return attached.roundId;
|
|
63
|
+
}
|
|
64
|
+
if (roundId) return roundId;
|
|
65
|
+
const completed = new Set(roundEvents.filter((event) => event.type === 'completed').map((event) => event.roundId));
|
|
66
|
+
const open = roundEvents.filter((event) => event.type === 'started' && !completed.has(event.roundId));
|
|
67
|
+
return open.at(-1)?.roundId ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function projectOutreachRoundCounts({ applications, delivery, roundEvents, sentVerified = [], roundId, invalidatedIds = [] }) {
|
|
71
|
+
const applyConfirmationCount = delivery.effectiveSubmissionCount;
|
|
72
|
+
const applyCompanies = countedApplyCompanies(applications, delivery);
|
|
73
|
+
const confirmations = outreachConfirmationEvents(roundEvents, roundId);
|
|
74
|
+
const invalidated = new Set(invalidatedIds);
|
|
75
|
+
const countedCompanies = new Set(applyCompanies);
|
|
76
|
+
const countedOutreachIds = [];
|
|
77
|
+
for (const event of confirmations) {
|
|
78
|
+
if (invalidated.has(event.outreachId)) continue;
|
|
79
|
+
const companyKey = companyCountKey(event.company);
|
|
80
|
+
if (countedCompanies.has(companyKey)) continue;
|
|
81
|
+
countedCompanies.add(companyKey);
|
|
82
|
+
countedOutreachIds.push(event.outreachId);
|
|
83
|
+
}
|
|
84
|
+
const outreachConfirmationCount = countedOutreachIds.length;
|
|
85
|
+
return {
|
|
86
|
+
applyConfirmationCount,
|
|
87
|
+
outreachConfirmationCount,
|
|
88
|
+
confirmedCount: applyConfirmationCount + outreachConfirmationCount,
|
|
89
|
+
countedOutreachIds,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function alreadyCountedOutreachCompany(confirmations, companyKey) {
|
|
94
|
+
return confirmations.some((event) => companyCountKey(event.company) === companyKey);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function confirmationItem(event) {
|
|
98
|
+
if (!event?.company) return undefined;
|
|
99
|
+
return { id: event.outreachId, company: event.company, companyKey: companyCountKey(event.company) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function shouldCountOutreach({ sentVerified, applications, delivery, confirmations, outreachId }) {
|
|
103
|
+
const recorded = confirmations.find((event) => event.outreachId === outreachId);
|
|
104
|
+
if (recorded) {
|
|
105
|
+
return { count: false, reason: 'already-recorded', item: sentVerified.find((entry) => entry.id === outreachId) ?? confirmationItem(recorded) };
|
|
106
|
+
}
|
|
107
|
+
const item = sentVerified.find((entry) => entry.id === outreachId);
|
|
108
|
+
if (!item) return { count: false, reason: 'not-sent-verified' };
|
|
109
|
+
if (countedApplyCompanies(applications, delivery).has(item.companyKey)) return { count: false, reason: 'already-applied', item };
|
|
110
|
+
if (alreadyCountedOutreachCompany(confirmations, item.companyKey)) return { count: false, reason: 'already-counted-outreach', item };
|
|
111
|
+
return { count: true, item };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function jsonLines(file) {
|
|
115
|
+
try {
|
|
116
|
+
return (await readFile(file, 'utf8')).split('\n').filter(Boolean).map((line, index) => {
|
|
117
|
+
try { return JSON.parse(line); } catch { throw new Error(`Invalid JSON on line ${index + 1} of ${basename(file)}.`); }
|
|
118
|
+
});
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (error.code === 'ENOENT') return [];
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function withFileLock(directory, name, action) {
|
|
126
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
127
|
+
await chmod(directory, 0o700);
|
|
128
|
+
const lockPath = join(directory, `.${name}.lock`);
|
|
129
|
+
let handle;
|
|
130
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
131
|
+
try {
|
|
132
|
+
handle = await open(lockPath, 'wx', 0o600);
|
|
133
|
+
break;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (error.code !== 'EEXIST') throw error;
|
|
136
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!handle) throw new Error(`Could not acquire ${name} ledger lock.`);
|
|
140
|
+
try { return await action(); }
|
|
141
|
+
finally {
|
|
142
|
+
await handle.close();
|
|
143
|
+
await unlink(lockPath).catch((error) => { if (error.code !== 'ENOENT') throw error; });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function snapshotItemsFromCache(directory) {
|
|
148
|
+
try {
|
|
149
|
+
const cache = JSON.parse(await readFile(join(directory, 'outreach-cloud-cache.json'), 'utf8'));
|
|
150
|
+
return Array.isArray(cache?.snapshot?.items) ? cache.snapshot.items : [];
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error.code === 'ENOENT') return [];
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isOutreachMigrationRequired(error) {
|
|
158
|
+
return /\(409\): Outreach backend migration required/i.test(error.message) || /Outreach backend migration required/i.test(error.message);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function authoritativeSnapshotError(cause) {
|
|
162
|
+
const error = new Error('Outreach snapshot is unavailable; attachment requires authoritative state.');
|
|
163
|
+
if (cause) error.cause = cause;
|
|
164
|
+
return error;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function loadOutreachSnapshotItems(directory, cloudClient, { allowCache = true } = {}) {
|
|
168
|
+
const cloud = cloudClient ?? new CloudStateClient({ stateDir: directory, configPath: process.env.JOB_APPLICATION_AGENT_CLOUD_CONFIG ?? defaultCloudConfigPath() });
|
|
169
|
+
if (await cloud.configured()) {
|
|
170
|
+
try {
|
|
171
|
+
if (typeof cloud.status === 'function') {
|
|
172
|
+
const status = await cloud.status();
|
|
173
|
+
if (!status.capabilities?.includes(OUTREACH_CAPABILITY)) return [];
|
|
174
|
+
}
|
|
175
|
+
const snapshot = await (await cloud.request('/v2/outreach/snapshot')).json();
|
|
176
|
+
return Array.isArray(snapshot.items) ? snapshot.items : [];
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (isOutreachMigrationRequired(error)) return [];
|
|
179
|
+
if (!/^Cloud state unavailable:/.test(error.message)) throw error;
|
|
180
|
+
if (!allowCache) throw authoritativeSnapshotError(error);
|
|
181
|
+
}
|
|
182
|
+
if (!allowCache) throw authoritativeSnapshotError();
|
|
183
|
+
return snapshotItemsFromCache(directory);
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
return await withLocalOutreach(directory, (state) => ({
|
|
187
|
+
result: Object.keys(state.opportunities).map((id) => readOutreach(state, 'show', { id })),
|
|
188
|
+
}));
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error.code === 'ENOENT') return [];
|
|
191
|
+
const wrapped = new Error(`Outreach store is unavailable: ${error.message}`);
|
|
192
|
+
wrapped.cause = error;
|
|
193
|
+
throw wrapped;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function loadSentVerifiedOutreach(directory, cloudClient, options) {
|
|
198
|
+
return sentVerifiedFromOutreachItems(await loadOutreachSnapshotItems(directory, cloudClient, options));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function loadOutreachRoundView(directory, cloudClient, options) {
|
|
202
|
+
const items = await loadOutreachSnapshotItems(directory, cloudClient, options);
|
|
203
|
+
return {
|
|
204
|
+
sentVerified: sentVerifiedFromOutreachItems(items),
|
|
205
|
+
invalidatedIds: invalidatedOutreachIds(items),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function confirmOutreachTowardRound({
|
|
210
|
+
directory,
|
|
211
|
+
outreachId,
|
|
212
|
+
roundId = null,
|
|
213
|
+
cloudClient = null,
|
|
214
|
+
requireRoundId = false,
|
|
215
|
+
occurredAt = new Date().toISOString(),
|
|
216
|
+
} = {}) {
|
|
217
|
+
const id = String(outreachId ?? '').trim();
|
|
218
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,99}$/.test(id)) throw new Error('Invalid opaque outreach ID.');
|
|
219
|
+
const cloud = cloudClient ?? new CloudStateClient({ stateDir: directory, configPath: process.env.JOB_APPLICATION_AGENT_CLOUD_CONFIG ?? defaultCloudConfigPath() });
|
|
220
|
+
return withFileLock(directory, 'rounds', async () => {
|
|
221
|
+
if (await cloud.configured()) {
|
|
222
|
+
try {
|
|
223
|
+
await cloud.reconcile({ dryRun: false, provenance: 'outreach-round-attach', streams: ['rounds'] });
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (!/^Cloud state unavailable:/.test(error.message)) throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const roundEvents = await jsonLines(join(directory, 'rounds.ndjson'));
|
|
229
|
+
const targetRoundId = resolveOutreachRoundId(roundEvents, { outreachId: id, roundId });
|
|
230
|
+
if (!targetRoundId) return { counted: false, reason: requireRoundId ? 'round-not-found' : 'no-active-round', outreachId: id, roundId: null };
|
|
231
|
+
const started = roundEvents.find((event) => event.type === 'started' && event.roundId === targetRoundId);
|
|
232
|
+
if (!started) throw new Error('Application round was not found.');
|
|
233
|
+
if (roundEvents.some((event) => event.type === 'completed' && event.roundId === targetRoundId)) {
|
|
234
|
+
const existing = outreachConfirmationEvents(roundEvents, targetRoundId).some((event) => event.outreachId === id);
|
|
235
|
+
return { counted: existing, reason: existing ? 'already-recorded' : 'round-completed', outreachId: id, roundId: targetRoundId };
|
|
236
|
+
}
|
|
237
|
+
const applications = (await jsonLines(join(directory, 'applications.ndjson'))).filter((entry) => entry.roundId === targetRoundId && entry.status === 'submitted');
|
|
238
|
+
const delivery = deliveryProjection(applications, await jsonLines(join(directory, 'delivery.ndjson')));
|
|
239
|
+
const sentVerified = await loadSentVerifiedOutreach(directory, cloud, { allowCache: false });
|
|
240
|
+
const confirmations = outreachConfirmationEvents(roundEvents, targetRoundId);
|
|
241
|
+
const decision = shouldCountOutreach({ sentVerified, applications, delivery, confirmations, outreachId: id });
|
|
242
|
+
if (!decision.count) {
|
|
243
|
+
return { counted: decision.reason === 'already-recorded', reason: decision.reason, outreachId: id, roundId: targetRoundId, company: decision.item?.company ?? null };
|
|
244
|
+
}
|
|
245
|
+
const event = {
|
|
246
|
+
type: 'submission-confirmed',
|
|
247
|
+
roundId: targetRoundId,
|
|
248
|
+
outreachId: id,
|
|
249
|
+
channel: OUTREACH_ROUND_CHANNEL,
|
|
250
|
+
company: decision.item.company,
|
|
251
|
+
...(decision.item.role ? { role: decision.item.role } : {}),
|
|
252
|
+
occurredAt,
|
|
253
|
+
};
|
|
254
|
+
const file = join(directory, 'rounds.ndjson');
|
|
255
|
+
await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
256
|
+
await chmod(file, 0o600);
|
|
257
|
+
if (await cloud.configured()) {
|
|
258
|
+
await cloud.appendRecord('rounds', event, {
|
|
259
|
+
recordKey: targetRoundId,
|
|
260
|
+
idempotencyKey: `round-confirmation:${targetRoundId}:outreach:${id}`,
|
|
261
|
+
occurredAt,
|
|
262
|
+
queueOnFailure: true,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return { counted: true, reason: 'recorded', outreachId: id, roundId: targetRoundId, company: decision.item.company };
|
|
266
|
+
});
|
|
267
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SKILL_VERSION = '3.7.
|
|
1
|
+
export const SKILL_VERSION = '3.7.2';
|
|
@@ -86,6 +86,18 @@ sqliteTest('reconcile includes the owner-only discovery review ledger', async ()
|
|
|
86
86
|
assert.deepEqual((await ctx.client.listStream('discovery'))[0].value, lead);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
sqliteTest('targeted reconcile only syncs the requested stream', async () => {
|
|
90
|
+
const ctx = await setup();
|
|
91
|
+
await writeFile(join(ctx.stateDir, 'applications.ndjson'), `${JSON.stringify({ id: 'app-1', company: 'A', submittedAt: '2026-01-01T00:00:00.000Z' })}\n`);
|
|
92
|
+
await writeFile(join(ctx.stateDir, 'rounds.ndjson'), `${JSON.stringify({ type: 'started', roundId: 'round-1', requestedCount: 1, occurredAt: '2026-01-01T00:00:00.000Z' })}\n`);
|
|
93
|
+
const report = await ctx.client.reconcile({ dryRun: false, streams: ['rounds'] });
|
|
94
|
+
assert.equal(report.imported, 1);
|
|
95
|
+
assert.ok(report.streams.rounds);
|
|
96
|
+
assert.equal(report.streams.applications, undefined);
|
|
97
|
+
assert.equal((await ctx.bindings.DB.prepare("SELECT COUNT(*) AS count FROM records WHERE stream = 'applications'").first()).count, 0);
|
|
98
|
+
assert.equal((await ctx.bindings.DB.prepare("SELECT COUNT(*) AS count FROM records WHERE stream = 'rounds'").first()).count, 1);
|
|
99
|
+
});
|
|
100
|
+
|
|
89
101
|
sqliteTest('resume download verifies checksum and creates a private path cache', async () => {
|
|
90
102
|
const ctx = await setup();
|
|
91
103
|
const bytes = Buffer.from('%PDF-1.7\ncloud-resume-fixture');
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtemp, 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 { deliveryProjection } from '../scripts/application-accounting.mjs';
|
|
10
|
+
import { assessment, handoff } from './fixtures/outreach.mjs';
|
|
11
|
+
import {
|
|
12
|
+
OUTREACH_ROUND_CHANNEL,
|
|
13
|
+
companyCountKey,
|
|
14
|
+
confirmOutreachTowardRound,
|
|
15
|
+
invalidatedOutreachIds,
|
|
16
|
+
loadSentVerifiedOutreach,
|
|
17
|
+
projectOutreachRoundCounts,
|
|
18
|
+
resolveOutreachRoundId,
|
|
19
|
+
sentVerifiedFromOutreachItems,
|
|
20
|
+
} from '../scripts/outreach-round.mjs';
|
|
21
|
+
|
|
22
|
+
const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
|
|
23
|
+
|
|
24
|
+
function application(roundId, company = 'Example') {
|
|
25
|
+
return {
|
|
26
|
+
id: `app-${company.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
|
|
27
|
+
company,
|
|
28
|
+
role: 'Staff Product Engineer',
|
|
29
|
+
url: `https://jobs.fixture.example/${company.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
|
|
30
|
+
employerJobId: `example:${company}`,
|
|
31
|
+
source: 'ashby',
|
|
32
|
+
discoverySource: 'linkedin',
|
|
33
|
+
applicationChannel: 'ashby',
|
|
34
|
+
roundId,
|
|
35
|
+
score: 86,
|
|
36
|
+
status: 'submitted',
|
|
37
|
+
submittedAt: '2026-09-14T10:00:00.000Z',
|
|
38
|
+
approval: 'STANDING AUTHORIZATION',
|
|
39
|
+
answers: {},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function verifiedItem(id, company) {
|
|
44
|
+
return {
|
|
45
|
+
id,
|
|
46
|
+
delivery: 'sent-verified',
|
|
47
|
+
content: { assessment: { company: { name: company }, role: 'Staff Product Engineer' } },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('sent-verified outreach increments a round when no apply exists', () => {
|
|
52
|
+
const delivery = deliveryProjection([], []);
|
|
53
|
+
const counts = projectOutreachRoundCounts({
|
|
54
|
+
applications: [],
|
|
55
|
+
delivery,
|
|
56
|
+
roundEvents: [{ type: 'submission-confirmed', roundId: 'round-1', outreachId: 'noon', channel: OUTREACH_ROUND_CHANNEL, company: 'Noon' }],
|
|
57
|
+
sentVerified: sentVerifiedFromOutreachItems([verifiedItem('noon', 'Noon')]),
|
|
58
|
+
roundId: 'round-1',
|
|
59
|
+
});
|
|
60
|
+
assert.equal(counts.applyConfirmationCount, 0);
|
|
61
|
+
assert.equal(counts.outreachConfirmationCount, 1);
|
|
62
|
+
assert.equal(counts.confirmedCount, 1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('apply plus outreach at the same company counts once', () => {
|
|
66
|
+
const applications = [application('round-1', 'Tricog')];
|
|
67
|
+
const delivery = deliveryProjection(applications, []);
|
|
68
|
+
const counts = projectOutreachRoundCounts({
|
|
69
|
+
applications,
|
|
70
|
+
delivery,
|
|
71
|
+
roundEvents: [{ type: 'submission-confirmed', roundId: 'round-1', outreachId: 'tricog', channel: OUTREACH_ROUND_CHANNEL, company: 'Tricog' }],
|
|
72
|
+
sentVerified: sentVerifiedFromOutreachItems([verifiedItem('tricog', 'Tricog')]),
|
|
73
|
+
roundId: 'round-1',
|
|
74
|
+
});
|
|
75
|
+
assert.equal(counts.applyConfirmationCount, 1);
|
|
76
|
+
assert.equal(counts.outreachConfirmationCount, 0);
|
|
77
|
+
assert.equal(counts.confirmedCount, 1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('cleared outreach still counts from the durable confirmation company', () => {
|
|
81
|
+
const counts = projectOutreachRoundCounts({
|
|
82
|
+
applications: [],
|
|
83
|
+
delivery: deliveryProjection([], []),
|
|
84
|
+
roundEvents: [{ type: 'submission-confirmed', roundId: 'round-1', outreachId: 'noon', channel: OUTREACH_ROUND_CHANNEL, company: 'Noon' }],
|
|
85
|
+
sentVerified: sentVerifiedFromOutreachItems([{
|
|
86
|
+
id: 'noon',
|
|
87
|
+
delivery: 'sent-verified',
|
|
88
|
+
cleared: true,
|
|
89
|
+
content: { assessment: { company: { name: 'Noon' }, role: 'Staff Product Engineer' } },
|
|
90
|
+
}]),
|
|
91
|
+
roundId: 'round-1',
|
|
92
|
+
});
|
|
93
|
+
assert.equal(counts.applyConfirmationCount, 0);
|
|
94
|
+
assert.equal(counts.outreachConfirmationCount, 1);
|
|
95
|
+
assert.equal(counts.confirmedCount, 1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('linked company names share one count key', () => {
|
|
99
|
+
assert.equal(companyCountKey('Made Card'), companyCountKey('MadeCard'));
|
|
100
|
+
const applications = [application('round-1', 'MadeCard')];
|
|
101
|
+
const delivery = deliveryProjection(applications, []);
|
|
102
|
+
const counts = projectOutreachRoundCounts({
|
|
103
|
+
applications,
|
|
104
|
+
delivery,
|
|
105
|
+
roundEvents: [{ type: 'submission-confirmed', roundId: 'round-1', outreachId: 'made', channel: OUTREACH_ROUND_CHANNEL, company: 'Made Card' }],
|
|
106
|
+
sentVerified: sentVerifiedFromOutreachItems([verifiedItem('made', 'Made Card')]),
|
|
107
|
+
roundId: 'round-1',
|
|
108
|
+
});
|
|
109
|
+
assert.equal(counts.applyConfirmationCount, 1);
|
|
110
|
+
assert.equal(counts.outreachConfirmationCount, 0);
|
|
111
|
+
assert.equal(counts.confirmedCount, 1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('explicit confirm cannot reassign an attached outreach to another round', () => {
|
|
115
|
+
assert.throws(() => resolveOutreachRoundId([
|
|
116
|
+
{ type: 'started', roundId: 'round-a', requestedCount: 30, occurredAt: '2026-09-14T10:00:00.000Z' },
|
|
117
|
+
{ type: 'submission-confirmed', roundId: 'round-a', outreachId: 'noon', channel: OUTREACH_ROUND_CHANNEL, company: 'Noon' },
|
|
118
|
+
{ type: 'started', roundId: 'round-b', requestedCount: 30, occurredAt: '2026-09-14T11:00:00.000Z' },
|
|
119
|
+
], { outreachId: 'noon', roundId: 'round-b' }), /already attached to another round/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('apply-only still increments without outreach', () => {
|
|
123
|
+
const applications = [application('round-1', 'MadeCard')];
|
|
124
|
+
const delivery = deliveryProjection(applications, []);
|
|
125
|
+
const counts = projectOutreachRoundCounts({
|
|
126
|
+
applications,
|
|
127
|
+
delivery,
|
|
128
|
+
roundEvents: [],
|
|
129
|
+
sentVerified: [],
|
|
130
|
+
roundId: 'round-1',
|
|
131
|
+
});
|
|
132
|
+
assert.equal(counts.applyConfirmationCount, 1);
|
|
133
|
+
assert.equal(counts.outreachConfirmationCount, 0);
|
|
134
|
+
assert.equal(counts.confirmedCount, 1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
async function fixture(t, label) {
|
|
138
|
+
const directory = await mkdtemp(join(tmpdir(), `outreach-round-${label}-`));
|
|
139
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
140
|
+
await writeFile(join(directory, 'telemetry.json'), JSON.stringify({
|
|
141
|
+
version: 1,
|
|
142
|
+
enabled: false,
|
|
143
|
+
disclosed: true,
|
|
144
|
+
graceConsumed: true,
|
|
145
|
+
installationEventPending: false,
|
|
146
|
+
}));
|
|
147
|
+
const env = {
|
|
148
|
+
...process.env,
|
|
149
|
+
JOB_APPLICATION_AGENT_STATE_DIR: directory,
|
|
150
|
+
JOB_APPLICATION_AGENT_CLOUD_CONFIG: join(directory, 'cloud-config.json'),
|
|
151
|
+
JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL: 'http://127.0.0.1:9',
|
|
152
|
+
};
|
|
153
|
+
const cli = (args, input) => JSON.parse(execFileSync(process.execPath, [script, ...args], {
|
|
154
|
+
env,
|
|
155
|
+
encoding: 'utf8',
|
|
156
|
+
...(input === undefined ? {} : { input: JSON.stringify(input) }),
|
|
157
|
+
}));
|
|
158
|
+
return { directory, env, cli };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function recordVerifiedOutreach(cli, { id, company, domain }) {
|
|
162
|
+
cli(['outreach', 'policy', 'enable', '--stdin'], { operationId: `enable-${id}`, timezone: 'Asia/Kolkata' });
|
|
163
|
+
cli(['outreach', 'assess', '--stdin'], assessment(id, {
|
|
164
|
+
operationId: `assess-${id}`,
|
|
165
|
+
company: { name: company, domain, aliases: [] },
|
|
166
|
+
recipient: { account: `https://www.linkedin.com/in/${id}-recruiter`, aliases: [] },
|
|
167
|
+
}));
|
|
168
|
+
cli(['outreach', 'draft', '--stdin'], {
|
|
169
|
+
operationId: `draft-${id}`,
|
|
170
|
+
id,
|
|
171
|
+
text: 'Your product engineering role fits my experience. Would a brief conversation be useful?',
|
|
172
|
+
claimRefs: ['experience'],
|
|
173
|
+
purpose: 'initial',
|
|
174
|
+
});
|
|
175
|
+
const recheckedAt = new Date().toISOString();
|
|
176
|
+
cli(['outreach', 'handoff', '--stdin'], handoff({
|
|
177
|
+
operationId: `handoff-${id}`,
|
|
178
|
+
id,
|
|
179
|
+
recheckedAt,
|
|
180
|
+
history: { kind: 'user-reported', noPriorPitch: true, checkedAt: recheckedAt },
|
|
181
|
+
}));
|
|
182
|
+
const occurredAt = new Date().toISOString();
|
|
183
|
+
return cli(['outreach', 'record', '--stdin'], {
|
|
184
|
+
operationId: `sent-${id}`,
|
|
185
|
+
id,
|
|
186
|
+
attemptId: `handoff-${id}`,
|
|
187
|
+
type: 'sent-verified',
|
|
188
|
+
occurredAt,
|
|
189
|
+
evidence: 'Visible sent message in the conversation.',
|
|
190
|
+
messageRef: `visible-message-${id}`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
test('CLI sent-verified outreach increments the active round', async (t) => {
|
|
195
|
+
const { cli } = await fixture(t, 'outreach-only');
|
|
196
|
+
const { roundId } = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
|
|
197
|
+
const recorded = recordVerifiedOutreach(cli, { id: 'noon', company: 'Noon', domain: 'noon.com' });
|
|
198
|
+
assert.equal(recorded.delivery, 'sent-verified');
|
|
199
|
+
assert.equal(recorded.roundConfirmation.counted, true);
|
|
200
|
+
assert.equal(recorded.roundConfirmation.roundId, roundId);
|
|
201
|
+
const status = cli(['round', 'status', roundId]);
|
|
202
|
+
assert.equal(status.applyConfirmationCount, 0);
|
|
203
|
+
assert.equal(status.outreachConfirmationCount, 1);
|
|
204
|
+
assert.equal(status.confirmedCount, 1);
|
|
205
|
+
assert.equal(status.effectiveSubmissionCount, 0);
|
|
206
|
+
const again = cli(['round', 'confirm', '--stdin'], { roundId, outreachId: 'noon' });
|
|
207
|
+
assert.equal(again.confirmations[0].reason, 'already-recorded');
|
|
208
|
+
assert.equal(again.confirmedCount, 1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('CLI apply plus outreach for the same company counts once', async (t) => {
|
|
212
|
+
const { cli } = await fixture(t, 'apply-and-outreach');
|
|
213
|
+
const { roundId } = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
|
|
214
|
+
cli(['ledger', 'add', '--stdin'], application(roundId, 'Tricog'));
|
|
215
|
+
const recorded = recordVerifiedOutreach(cli, { id: 'tricog', company: 'Tricog', domain: 'tricog.com' });
|
|
216
|
+
assert.equal(recorded.roundConfirmation.counted, false);
|
|
217
|
+
assert.equal(recorded.roundConfirmation.reason, 'already-applied');
|
|
218
|
+
const status = cli(['round', 'status', roundId]);
|
|
219
|
+
assert.equal(status.applyConfirmationCount, 1);
|
|
220
|
+
assert.equal(status.outreachConfirmationCount, 0);
|
|
221
|
+
assert.equal(status.confirmedCount, 1);
|
|
222
|
+
assert.equal(status.effectiveSubmissionCount, 1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('CLI apply-only still increments the round', async (t) => {
|
|
226
|
+
const { cli } = await fixture(t, 'apply-only');
|
|
227
|
+
const { roundId } = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
|
|
228
|
+
cli(['ledger', 'add', '--stdin'], application(roundId, 'MadeCard'));
|
|
229
|
+
const status = cli(['round', 'status', roundId]);
|
|
230
|
+
assert.equal(status.applyConfirmationCount, 1);
|
|
231
|
+
assert.equal(status.outreachConfirmationCount, 0);
|
|
232
|
+
assert.equal(status.confirmedCount, 1);
|
|
233
|
+
assert.equal(status.effectiveSubmissionCount, 1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('later progression records do not auto-attach a historical send', async (t) => {
|
|
237
|
+
const { cli } = await fixture(t, 'reply-does-not-count');
|
|
238
|
+
const recorded = recordVerifiedOutreach(cli, { id: 'noon', company: 'Noon', domain: 'noon.com' });
|
|
239
|
+
assert.equal(recorded.roundConfirmation.counted, false);
|
|
240
|
+
assert.equal(recorded.roundConfirmation.reason, 'no-active-round');
|
|
241
|
+
const { roundId } = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
|
|
242
|
+
const reply = cli(['outreach', 'record', '--stdin'], {
|
|
243
|
+
operationId: 'reply-noon',
|
|
244
|
+
id: 'noon',
|
|
245
|
+
type: 'replied',
|
|
246
|
+
occurredAt: new Date().toISOString(),
|
|
247
|
+
evidence: 'Recruiter replied in the conversation.',
|
|
248
|
+
replyTone: 'neutral',
|
|
249
|
+
});
|
|
250
|
+
assert.equal(reply.delivery, 'sent-verified');
|
|
251
|
+
assert.equal(reply.roundConfirmation, undefined);
|
|
252
|
+
const status = cli(['round', 'status', roundId]);
|
|
253
|
+
assert.equal(status.outreachConfirmationCount, 0);
|
|
254
|
+
assert.equal(status.confirmedCount, 0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('batch confirm validates every ID before committing any', async (t) => {
|
|
258
|
+
const { cli } = await fixture(t, 'batch-validate');
|
|
259
|
+
recordVerifiedOutreach(cli, { id: 'noon', company: 'Noon', domain: 'noon.com' });
|
|
260
|
+
const { roundId } = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
|
|
261
|
+
assert.throws(
|
|
262
|
+
() => cli(['round', 'confirm', '--stdin'], { roundId, outreachIds: ['noon', 'missing'] }),
|
|
263
|
+
/not sent-verified/,
|
|
264
|
+
);
|
|
265
|
+
const status = cli(['round', 'status', roundId]);
|
|
266
|
+
assert.equal(status.outreachConfirmationCount, 0);
|
|
267
|
+
assert.equal(status.confirmedCount, 0);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('CLI delivery correction drops confirmedCount after a counted send', async (t) => {
|
|
271
|
+
const { cli } = await fixture(t, 'delivery-correction');
|
|
272
|
+
const { roundId } = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
|
|
273
|
+
recordVerifiedOutreach(cli, { id: 'noon', company: 'Noon', domain: 'noon.com' });
|
|
274
|
+
assert.equal(cli(['round', 'status', roundId]).confirmedCount, 1);
|
|
275
|
+
cli(['outreach', 'record', '--stdin'], {
|
|
276
|
+
operationId: 'correct-noon',
|
|
277
|
+
id: 'noon',
|
|
278
|
+
attemptId: 'handoff-noon',
|
|
279
|
+
type: 'not-sent',
|
|
280
|
+
occurredAt: new Date().toISOString(),
|
|
281
|
+
evidence: 'Visible conversation shows the message was not sent.',
|
|
282
|
+
supersedes: ['sent-noon'],
|
|
283
|
+
});
|
|
284
|
+
const after = cli(['round', 'status', roundId]);
|
|
285
|
+
assert.equal(after.confirmedCount, 0);
|
|
286
|
+
assert.equal(after.outreachConfirmationCount, 0);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('sent-verified confirm then clear leaves confirmedCount unchanged', async (t) => {
|
|
290
|
+
const { cli } = await fixture(t, 'clear-keeps-count');
|
|
291
|
+
const { roundId } = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
|
|
292
|
+
const recorded = recordVerifiedOutreach(cli, { id: 'noon', company: 'Noon', domain: 'noon.com' });
|
|
293
|
+
assert.equal(recorded.roundConfirmation.counted, true);
|
|
294
|
+
const before = cli(['round', 'status', roundId]);
|
|
295
|
+
assert.equal(before.confirmedCount, 1);
|
|
296
|
+
assert.equal(before.outreachConfirmationCount, 1);
|
|
297
|
+
cli(['outreach', 'clear', '--stdin'], { operationId: 'clear-noon', ids: ['noon'] });
|
|
298
|
+
const after = cli(['round', 'status', roundId]);
|
|
299
|
+
assert.equal(after.confirmedCount, before.confirmedCount);
|
|
300
|
+
assert.equal(after.outreachConfirmationCount, 1);
|
|
301
|
+
assert.equal(after.applyConfirmationCount, 0);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('explicit confirm rejects moving an attached outreach to another round', async (t) => {
|
|
305
|
+
const { cli } = await fixture(t, 'no-reassign');
|
|
306
|
+
const first = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
|
|
307
|
+
recordVerifiedOutreach(cli, { id: 'noon', company: 'Noon', domain: 'noon.com' });
|
|
308
|
+
const second = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
|
|
309
|
+
assert.throws(
|
|
310
|
+
() => cli(['round', 'confirm', '--stdin'], { roundId: second.roundId, outreachId: 'noon' }),
|
|
311
|
+
/already attached to another round/,
|
|
312
|
+
);
|
|
313
|
+
assert.equal(cli(['round', 'status', first.roundId]).confirmedCount, 1);
|
|
314
|
+
assert.equal(cli(['round', 'status', second.roundId]).confirmedCount, 0);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('auto-attach reconciles authenticated cloud rounds before choosing a destination', async (t) => {
|
|
318
|
+
const directory = await mkdtemp(join(tmpdir(), 'outreach-round-reconcile-'));
|
|
319
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
320
|
+
const started = { type: 'started', roundId: 'cloud-round', requestedCount: 30, occurredAt: '2026-09-14T10:00:00.000Z' };
|
|
321
|
+
const reconcileCalls = [];
|
|
322
|
+
const cloudClient = {
|
|
323
|
+
configured: async () => true,
|
|
324
|
+
status: async () => ({ configured: true, capabilities: ['outreach-tracking-v1'] }),
|
|
325
|
+
request: async () => ({ json: async () => ({ items: [verifiedItem('noon', 'Noon')] }) }),
|
|
326
|
+
reconcile: async (options) => {
|
|
327
|
+
reconcileCalls.push(options);
|
|
328
|
+
await writeFile(join(directory, 'rounds.ndjson'), `${JSON.stringify(started)}\n`);
|
|
329
|
+
},
|
|
330
|
+
appendRecord: async () => {},
|
|
331
|
+
};
|
|
332
|
+
const result = await confirmOutreachTowardRound({
|
|
333
|
+
directory,
|
|
334
|
+
outreachId: 'noon',
|
|
335
|
+
cloudClient,
|
|
336
|
+
occurredAt: '2026-09-14T11:00:00.000Z',
|
|
337
|
+
});
|
|
338
|
+
assert.deepEqual(reconcileCalls, [{ dryRun: false, provenance: 'outreach-round-attach', streams: ['rounds'] }]);
|
|
339
|
+
assert.equal(result.counted, true);
|
|
340
|
+
assert.equal(result.roundId, 'cloud-round');
|
|
341
|
+
assert.equal(result.company, 'Noon');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('unmigrated outreach backend is an empty verified set', async (t) => {
|
|
345
|
+
const directory = await mkdtemp(join(tmpdir(), 'outreach-round-unmigrated-'));
|
|
346
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
347
|
+
const missingCapability = await loadSentVerifiedOutreach(directory, {
|
|
348
|
+
configured: async () => true,
|
|
349
|
+
status: async () => ({ configured: true, capabilities: ['application-accounting-v1'] }),
|
|
350
|
+
request: async () => {
|
|
351
|
+
throw new Error('Cloud state request failed (409): Outreach backend migration required');
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
assert.deepEqual(missingCapability, []);
|
|
355
|
+
const migrationConflict = await loadSentVerifiedOutreach(directory, {
|
|
356
|
+
configured: async () => true,
|
|
357
|
+
status: async () => ({ configured: true, capabilities: ['application-accounting-v1', 'outreach-tracking-v1'] }),
|
|
358
|
+
request: async () => {
|
|
359
|
+
throw new Error('Cloud state request failed (409): Outreach backend migration required');
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
assert.deepEqual(migrationConflict, []);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('local outreach read failures stay visible to round status', async (t) => {
|
|
366
|
+
const directory = await mkdtemp(join(tmpdir(), 'outreach-round-locked-'));
|
|
367
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
368
|
+
await writeFile(join(directory, 'outreach.lock'), 'stale-lock');
|
|
369
|
+
await assert.rejects(
|
|
370
|
+
() => loadSentVerifiedOutreach(directory, { configured: async () => false }),
|
|
371
|
+
/Outreach store is unavailable/,
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('delivery correction invalidates a counted confirmation; clear does not', () => {
|
|
376
|
+
const confirmation = { type: 'submission-confirmed', roundId: 'round-1', outreachId: 'noon', channel: OUTREACH_ROUND_CHANNEL, company: 'Noon' };
|
|
377
|
+
const failed = {
|
|
378
|
+
id: 'noon',
|
|
379
|
+
delivery: 'failed',
|
|
380
|
+
cleared: false,
|
|
381
|
+
attempts: [{ id: 'handoff-noon', purpose: 'initial', delivery: 'failed' }],
|
|
382
|
+
content: { assessment: { company: { name: 'Noon' }, role: 'Staff Product Engineer' } },
|
|
383
|
+
};
|
|
384
|
+
const failedCounts = projectOutreachRoundCounts({
|
|
385
|
+
applications: [],
|
|
386
|
+
delivery: deliveryProjection([], []),
|
|
387
|
+
roundEvents: [confirmation],
|
|
388
|
+
sentVerified: sentVerifiedFromOutreachItems([failed]),
|
|
389
|
+
invalidatedIds: invalidatedOutreachIds([failed]),
|
|
390
|
+
roundId: 'round-1',
|
|
391
|
+
});
|
|
392
|
+
assert.equal(failedCounts.outreachConfirmationCount, 0);
|
|
393
|
+
assert.equal(failedCounts.confirmedCount, 0);
|
|
394
|
+
|
|
395
|
+
const cleared = {
|
|
396
|
+
id: 'noon',
|
|
397
|
+
delivery: 'sent-verified',
|
|
398
|
+
cleared: true,
|
|
399
|
+
attempts: [{ id: 'handoff-noon', purpose: 'initial', delivery: 'sent-verified' }],
|
|
400
|
+
content: { assessment: { company: { name: 'Noon' }, role: 'Staff Product Engineer' } },
|
|
401
|
+
};
|
|
402
|
+
const clearedCounts = projectOutreachRoundCounts({
|
|
403
|
+
applications: [],
|
|
404
|
+
delivery: deliveryProjection([], []),
|
|
405
|
+
roundEvents: [confirmation],
|
|
406
|
+
sentVerified: sentVerifiedFromOutreachItems([cleared]),
|
|
407
|
+
invalidatedIds: invalidatedOutreachIds([cleared]),
|
|
408
|
+
roundId: 'round-1',
|
|
409
|
+
});
|
|
410
|
+
assert.equal(clearedCounts.outreachConfirmationCount, 1);
|
|
411
|
+
assert.equal(clearedCounts.confirmedCount, 1);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('attached company identity stays on the confirmation event', () => {
|
|
415
|
+
const applications = [application('round-1', 'Acme')];
|
|
416
|
+
const delivery = deliveryProjection(applications, []);
|
|
417
|
+
const renamed = verifiedItem('acme', 'Acme Holdings');
|
|
418
|
+
const counts = projectOutreachRoundCounts({
|
|
419
|
+
applications,
|
|
420
|
+
delivery,
|
|
421
|
+
roundEvents: [{ type: 'submission-confirmed', roundId: 'round-1', outreachId: 'acme', channel: OUTREACH_ROUND_CHANNEL, company: 'Acme' }],
|
|
422
|
+
sentVerified: sentVerifiedFromOutreachItems([renamed]),
|
|
423
|
+
invalidatedIds: invalidatedOutreachIds([renamed]),
|
|
424
|
+
roundId: 'round-1',
|
|
425
|
+
});
|
|
426
|
+
assert.equal(counts.applyConfirmationCount, 1);
|
|
427
|
+
assert.equal(counts.outreachConfirmationCount, 0);
|
|
428
|
+
assert.equal(counts.confirmedCount, 1);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('non-ASCII company names keep distinct count keys', () => {
|
|
432
|
+
assert.equal(companyCountKey('Made Card'), companyCountKey('MadeCard'));
|
|
433
|
+
assert.notEqual(companyCountKey('ソニー'), '');
|
|
434
|
+
assert.notEqual(companyCountKey('トヨタ'), '');
|
|
435
|
+
assert.notEqual(companyCountKey('ソニー'), companyCountKey('トヨタ'));
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test('an earlier sent-verified attempt still qualifies after a later pending follow-up', () => {
|
|
439
|
+
const item = {
|
|
440
|
+
id: 'noon',
|
|
441
|
+
delivery: 'pending-handoff',
|
|
442
|
+
attempts: [
|
|
443
|
+
{ id: 'handoff-noon', purpose: 'initial', delivery: 'sent-verified' },
|
|
444
|
+
{ id: 'follow-noon', purpose: 'follow-up', delivery: 'pending-handoff' },
|
|
445
|
+
],
|
|
446
|
+
content: { assessment: { company: { name: 'Noon' }, role: 'Staff Product Engineer' } },
|
|
447
|
+
};
|
|
448
|
+
const verified = sentVerifiedFromOutreachItems([item]);
|
|
449
|
+
assert.equal(verified.length, 1);
|
|
450
|
+
assert.equal(verified[0].id, 'noon');
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test('confirm refuses a cached outreach snapshot when cloud is unavailable', async (t) => {
|
|
454
|
+
const directory = await mkdtemp(join(tmpdir(), 'outreach-round-no-cache-confirm-'));
|
|
455
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
456
|
+
await writeFile(join(directory, 'rounds.ndjson'), `${JSON.stringify({ type: 'started', roundId: 'round-1', requestedCount: 30, occurredAt: '2026-09-14T10:00:00.000Z' })}\n`);
|
|
457
|
+
await writeFile(join(directory, 'outreach-cloud-cache.json'), JSON.stringify({
|
|
458
|
+
snapshot: { items: [verifiedItem('noon', 'Noon')] },
|
|
459
|
+
}));
|
|
460
|
+
const cloudClient = {
|
|
461
|
+
configured: async () => true,
|
|
462
|
+
status: async () => ({ configured: true, capabilities: ['outreach-tracking-v1'] }),
|
|
463
|
+
request: async () => {
|
|
464
|
+
throw new Error('Cloud state unavailable: network down');
|
|
465
|
+
},
|
|
466
|
+
reconcile: async () => {
|
|
467
|
+
throw new Error('Cloud state unavailable: network down');
|
|
468
|
+
},
|
|
469
|
+
appendRecord: async () => {
|
|
470
|
+
throw new Error('should not append from cache');
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
await assert.rejects(
|
|
474
|
+
() => confirmOutreachTowardRound({ directory, outreachId: 'noon', cloudClient }),
|
|
475
|
+
/authoritative state/,
|
|
476
|
+
);
|
|
477
|
+
const cachedStatus = await loadSentVerifiedOutreach(directory, cloudClient, { allowCache: true });
|
|
478
|
+
assert.equal(cachedStatus.length, 1);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test('round attachment failure does not disguise a committed send', async (t) => {
|
|
482
|
+
const { directory, cli } = await fixture(t, 'attachment-failed');
|
|
483
|
+
const { roundId } = cli(['round', 'start', '--stdin'], { requestedCount: 30 });
|
|
484
|
+
await writeFile(join(directory, 'rounds.ndjson'), `${JSON.stringify({ type: 'started', roundId, requestedCount: 30, occurredAt: '2026-09-14T10:00:00.000Z' })}\nnot-json\n`);
|
|
485
|
+
const recorded = recordVerifiedOutreach(cli, { id: 'noon', company: 'Noon', domain: 'noon.com' });
|
|
486
|
+
assert.equal(recorded.delivery, 'sent-verified');
|
|
487
|
+
assert.equal(recorded.roundConfirmation.counted, false);
|
|
488
|
+
assert.equal(recorded.roundConfirmation.reason, 'attachment-failed');
|
|
489
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "job-application-agent",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.2",
|
|
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",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"check:native-artifacts": "node scripts/ci/validate-native-artifacts.mjs",
|
|
45
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/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",
|
|
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 job-application-agent/scripts/outreach-round.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": {
|