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.
- package/README.md +23 -0
- package/installer/src/cli.mjs +8 -1
- package/installer/src/installer.mjs +8 -0
- package/job-application-agent/SKILL.md +32 -2
- package/job-application-agent/capabilities.json +3 -1
- package/job-application-agent/references/ACCOUNTING.md +104 -0
- package/job-application-agent/references/AUTONOMY.md +2 -0
- package/job-application-agent/references/CLOUD_STATE.md +2 -0
- package/job-application-agent/references/FREE_AI.md +39 -0
- package/job-application-agent/references/OUTREACH.md +210 -0
- package/job-application-agent/references/RUNS.md +35 -0
- package/job-application-agent/references/SCHEMAS.md +12 -2
- package/job-application-agent/references/agent-box/README.md +77 -0
- package/job-application-agent/references/agent-box/novnc.service.example +16 -0
- package/job-application-agent/scripts/application-accounting.mjs +246 -0
- package/job-application-agent/scripts/ats/answer-inject.mjs +203 -0
- package/job-application-agent/scripts/ats/submit-adapters.mjs +194 -0
- package/job-application-agent/scripts/attention-questions.mjs +111 -0
- package/job-application-agent/scripts/attention-resume-submit.mjs +450 -0
- package/job-application-agent/scripts/attention-runner-poll.mjs +328 -0
- package/job-application-agent/scripts/captcha-vendor.mjs +328 -0
- package/job-application-agent/scripts/cloud-state-client.mjs +62 -8
- package/job-application-agent/scripts/job-application.mjs +243 -27
- package/job-application-agent/scripts/novnc-display-guard.mjs +300 -0
- package/job-application-agent/scripts/outreach-cli.mjs +95 -0
- package/job-application-agent/scripts/outreach-domain.mjs +287 -0
- package/job-application-agent/scripts/outreach-store.mjs +72 -0
- package/job-application-agent/scripts/session-binding.mjs +474 -0
- package/job-application-agent/scripts/version.mjs +1 -1
- package/job-application-agent/tests/accounting-cli.test.mjs +86 -0
- package/job-application-agent/tests/accounting-cloud-client.test.mjs +183 -0
- package/job-application-agent/tests/accounting-retry-cli.test.mjs +96 -0
- package/job-application-agent/tests/accounting-source-race.test.mjs +109 -0
- package/job-application-agent/tests/answer-inject-captcha.test.mjs +169 -0
- package/job-application-agent/tests/application-accounting.test.mjs +315 -0
- package/job-application-agent/tests/attention-resume-submit.test.mjs +119 -0
- package/job-application-agent/tests/attention-runner-poll.test.mjs +135 -0
- package/job-application-agent/tests/fixtures/outreach.mjs +22 -0
- package/job-application-agent/tests/job-application.test.mjs +5 -0
- package/job-application-agent/tests/novnc-display-guard.test.mjs +50 -0
- package/job-application-agent/tests/outreach-cli.test.mjs +73 -0
- package/job-application-agent/tests/outreach.test.mjs +178 -0
- package/job-application-agent/tests/privacy-audit.test.mjs +2 -0
- package/job-application-agent/tests/review-cadence.test.mjs +66 -0
- package/job-application-agent/tests/session-binding.test.mjs +102 -0
- package/job-application-agent/tests/skill-contract.test.mjs +25 -0
- package/job-application-agent/tests/workflow-state.test.mjs +30 -3
- package/package.json +6 -3
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ACCOUNTING_CAPABILITY, stableJson, validateDelivery, validateLead } from './application-accounting.mjs';
|
|
1
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
3
|
import { appendFile, chmod, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { homedir, platform } from 'node:os';
|
|
@@ -8,6 +9,7 @@ export const CLOUD_STREAM_FILES = Object.freeze({
|
|
|
8
9
|
outcomes: 'outcomes.ndjson',
|
|
9
10
|
rounds: 'rounds.ndjson',
|
|
10
11
|
discovery: 'discovery.ndjson',
|
|
12
|
+
delivery: 'delivery.ndjson',
|
|
11
13
|
attention: 'attention.ndjson',
|
|
12
14
|
reviews: 'reviews.ndjson',
|
|
13
15
|
friction: 'friction.ndjson',
|
|
@@ -93,6 +95,7 @@ export async function enableCloudUpdateGuard({ home = homedir(), agentHome = pro
|
|
|
93
95
|
catch (error) { if (error.code === 'ENOENT') return { guarded: false, reason: 'managed-install-not-found' }; throw error; }
|
|
94
96
|
const required = new Set(config.requiredCapabilities ?? []);
|
|
95
97
|
required.add('cloud-state-v2');
|
|
98
|
+
required.add(ACCOUNTING_CAPABILITY);
|
|
96
99
|
await privateWrite(path, `${JSON.stringify({ ...config, requiredCapabilities: [...required].sort() }, null, 2)}\n`);
|
|
97
100
|
return { guarded: true, capability: 'cloud-state-v2' };
|
|
98
101
|
}
|
|
@@ -159,6 +162,23 @@ export class CloudStateClient {
|
|
|
159
162
|
return { configured: true, url: config.url, configuredClient: { id: config.clientId ?? null, name: config.clientName ?? null, token: tokenSuffix(config.token) }, pendingLocalWrites: pending.length, ...remote };
|
|
160
163
|
}
|
|
161
164
|
|
|
165
|
+
async requireAccounting() {
|
|
166
|
+
const config = await this.config(true);
|
|
167
|
+
if (!config) return;
|
|
168
|
+
const backend = hash(`${config.url}:${config.token}`);
|
|
169
|
+
try {
|
|
170
|
+
const status = await this.status();
|
|
171
|
+
if (!status.configured) return;
|
|
172
|
+
if (!status.capabilities?.includes(ACCOUNTING_CAPABILITY)) throw new Error('Private backend upgrade required: application-accounting-v1 is missing.');
|
|
173
|
+
await ensurePrivateDirectory(this.stateDir);
|
|
174
|
+
await privateWrite(join(this.stateDir, 'cloud-accounting-capability.json'), JSON.stringify({ supported: true, backend }));
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (!/^Cloud state unavailable:/.test(error.message)) throw error;
|
|
177
|
+
try { if (JSON.parse(await readFile(join(this.stateDir, 'cloud-accounting-capability.json'), 'utf8')).backend === backend) return; } catch {}
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
162
182
|
async getDocument(name) {
|
|
163
183
|
const response = await this.request(`/v2/documents/${encodeURIComponent(name)}`);
|
|
164
184
|
return response.json();
|
|
@@ -199,7 +219,7 @@ export class CloudStateClient {
|
|
|
199
219
|
if (forbidden) throw new Error(`Cloud record contains forbidden field: ${forbidden}`);
|
|
200
220
|
const payload = {
|
|
201
221
|
recordKey: String(recordKey ?? value?.id ?? value?.roundId ?? randomUUID()),
|
|
202
|
-
idempotencyKey: String(idempotencyKey ?? `${stream}:${hash(
|
|
222
|
+
idempotencyKey: String(idempotencyKey ?? `${stream}:${hash(stableJson(value))}`),
|
|
203
223
|
occurredAt: occurredAt ?? value?.occurredAt ?? value?.submittedAt ?? new Date().toISOString(),
|
|
204
224
|
provenance,
|
|
205
225
|
value,
|
|
@@ -227,8 +247,20 @@ export class CloudStateClient {
|
|
|
227
247
|
}
|
|
228
248
|
|
|
229
249
|
async pendingWrites() {
|
|
230
|
-
|
|
231
|
-
|
|
250
|
+
const events = await readNdjson(join(this.stateDir, 'cloud-pending.ndjson'));
|
|
251
|
+
const receipts = new Set((await readNdjson(join(this.stateDir, 'cloud-pending-receipts.ndjson'))).map(event => event.key));
|
|
252
|
+
return events.filter(event => !receipts.has(hash(stableJson(event))));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async flushAccountingWrites(stream) {
|
|
256
|
+
if (!['delivery','discovery'].includes(stream)) return;
|
|
257
|
+
for (const event of (await this.pendingWrites()).filter(event => event.type === 'append-record' && event.stream === stream)) {
|
|
258
|
+
await this.appendRecord(stream, event.payload.value, { ...event.payload, queueOnFailure: false });
|
|
259
|
+
const receipt = { key: hash(stableJson(event)) };
|
|
260
|
+
const path = join(this.stateDir, 'cloud-pending-receipts.ndjson');
|
|
261
|
+
await appendFile(path, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
|
|
262
|
+
await chmod(path, 0o600);
|
|
263
|
+
}
|
|
232
264
|
}
|
|
233
265
|
|
|
234
266
|
async putFile(name, bytes, revision) {
|
|
@@ -289,9 +321,15 @@ export class CloudStateClient {
|
|
|
289
321
|
}
|
|
290
322
|
|
|
291
323
|
async createIntent(value) {
|
|
324
|
+
await this.requireAccounting();
|
|
292
325
|
return (await this.request('/v2/intents', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) })).json();
|
|
293
326
|
}
|
|
294
327
|
|
|
328
|
+
async confirmRetry(intentId, delivery, leaseId) {
|
|
329
|
+
await this.requireAccounting();
|
|
330
|
+
return (await this.request(`/v2/intents/${encodeURIComponent(intentId)}/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ delivery, leaseId }) })).json();
|
|
331
|
+
}
|
|
332
|
+
|
|
295
333
|
async markIntentSentUnverified(intentId, leaseId) {
|
|
296
334
|
return (await this.request(`/v2/intents/${encodeURIComponent(intentId)}/sent-unverified`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ leaseId }) })).json();
|
|
297
335
|
}
|
|
@@ -301,11 +339,23 @@ export class CloudStateClient {
|
|
|
301
339
|
}
|
|
302
340
|
|
|
303
341
|
async reconcile({ dryRun = true, provenance = 'local-reconcile' } = {}) {
|
|
342
|
+
await this.requireAccounting();
|
|
304
343
|
const report = { dryRun, streams: {}, imported: 0, downloaded: 0 };
|
|
305
344
|
for (const [stream, filename] of Object.entries(CLOUD_STREAM_FILES)) {
|
|
306
|
-
|
|
345
|
+
if (!dryRun) await this.flushAccountingWrites(stream);
|
|
346
|
+
const normalize = value => stream === 'delivery' ? validateDelivery(value) : stream === 'discovery' && value.version === 1 && value.type === 'lead-reviewed' ? validateLead(value) : value;
|
|
347
|
+
let local = (await readNdjson(join(this.stateDir, filename))).map(normalize);
|
|
307
348
|
const cloudRecords = await this.listStream(stream);
|
|
308
|
-
|
|
349
|
+
let cloud = cloudRecords.map((record) => normalize(record.value));
|
|
350
|
+
if (['delivery','discovery'].includes(stream)) {
|
|
351
|
+
const ids = new Map();
|
|
352
|
+
for (const value of [...local, ...cloud].filter(v => v.version === 1 && (stream === 'delivery' || v.type === 'lead-reviewed'))) {
|
|
353
|
+
if (ids.has(value.id) && stableJson(ids.get(value.id)) !== stableJson(value)) throw new Error('Conflicting accounting event ID during cloud reconciliation.');
|
|
354
|
+
ids.set(value.id, value);
|
|
355
|
+
}
|
|
356
|
+
const unique = values => values.filter((value,index) => value.version !== 1 || (stream === 'discovery' && value.type !== 'lead-reviewed') || values.findIndex(other => other.id === value.id && other.version === 1) === index);
|
|
357
|
+
local = unique(local); cloud = unique(cloud);
|
|
358
|
+
}
|
|
309
359
|
const localCounts = multiset(local);
|
|
310
360
|
const cloudCounts = multiset(cloud);
|
|
311
361
|
const localOnly = multisetDifference(local, cloudCounts);
|
|
@@ -314,7 +364,7 @@ export class CloudStateClient {
|
|
|
314
364
|
if (!dryRun) {
|
|
315
365
|
const prepared = localOnly.map(({ value, index }) => ({
|
|
316
366
|
recordKey: String(value?.id ?? value?.roundId ?? value?.applicationId ?? `${stream}-${index}`),
|
|
317
|
-
idempotencyKey: `reconcile:${hash(
|
|
367
|
+
idempotencyKey: value.version === 1 && (stream === 'delivery' || (stream === 'discovery' && value.type === 'lead-reviewed')) ? `accounting:${value.id}` : `reconcile:${hash(stableJson(value))}:${index}`,
|
|
318
368
|
occurredAt: value?.occurredAt ?? value?.submittedAt ?? new Date().toISOString(),
|
|
319
369
|
provenance,
|
|
320
370
|
value,
|
|
@@ -341,8 +391,12 @@ export class CloudStateClient {
|
|
|
341
391
|
for (const item of status.documents ?? []) documents[item.name] = await this.getDocument(item.name);
|
|
342
392
|
const streams = {};
|
|
343
393
|
for (const stream of Object.keys(CLOUD_STREAM_FILES)) streams[stream] = await this.listStream(stream);
|
|
394
|
+
const outreach = status.capabilities?.includes('outreach-tracking-v1') ? {
|
|
395
|
+
snapshot: await (await this.request('/v2/outreach/snapshot')).json(),
|
|
396
|
+
deletionManifest: await (await this.request('/v2/outreach/deletions')).json(),
|
|
397
|
+
} : undefined;
|
|
344
398
|
const output = path ?? join(this.stateDir, `cloud-export-${new Date().toISOString().replaceAll(':', '-')}.json`);
|
|
345
|
-
await privateWrite(output, `${JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), backend: status.backend, documents, streams, files: status.files ?? [] })}\n`);
|
|
399
|
+
await privateWrite(output, `${JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), backend: status.backend, documents, streams, files: status.files ?? [], ...(outreach ? { outreach } : {}) })}\n`);
|
|
346
400
|
return { path: output, documents: Object.keys(documents).length, records: Object.values(streams).reduce((sum, rows) => sum + rows.length, 0) };
|
|
347
401
|
}
|
|
348
402
|
}
|
|
@@ -353,7 +407,7 @@ async function readNdjson(path) {
|
|
|
353
407
|
}
|
|
354
408
|
|
|
355
409
|
function key(value) {
|
|
356
|
-
return
|
|
410
|
+
return stableJson(value);
|
|
357
411
|
}
|
|
358
412
|
|
|
359
413
|
function multiset(values) {
|
|
@@ -7,12 +7,25 @@ import { platform } from 'node:os';
|
|
|
7
7
|
import { basename, join, resolve } from 'node:path';
|
|
8
8
|
import { pathToFileURL } from 'node:url';
|
|
9
9
|
|
|
10
|
+
import { ACCOUNTING_CAPABILITY, accountingApplicationKey, canonicalUrl, deliveryProjection, discoveryProjection, validateDelivery, validateDeliveryReferences, validateLead, validateLeadReferences, stableJson } from './application-accounting.mjs';
|
|
10
11
|
import { createSecretStore, migrateLegacyStateDir, resolveStateDir } from './secret-store.mjs';
|
|
11
12
|
import { SourceCommunityClient } from './source-community-client.mjs';
|
|
12
13
|
import { normalizeCommunityJob, normalizeCommunitySource } from './source-community-schema.mjs';
|
|
13
14
|
import { TelemetryClient } from './telemetry-client.mjs';
|
|
14
15
|
import { jobIdentity } from './telemetry-schema.mjs';
|
|
15
16
|
import { CloudStateClient, defaultCloudConfigPath, enableCloudUpdateGuard, saveCloudConfig } from './cloud-state-client.mjs';
|
|
17
|
+
import {
|
|
18
|
+
SESSION_BINDING_ATTENTION_KEYS,
|
|
19
|
+
createSessionBinding,
|
|
20
|
+
extractSessionBindingFields,
|
|
21
|
+
sessionBindingPath,
|
|
22
|
+
writeSessionBindingFile,
|
|
23
|
+
} from './session-binding.mjs';
|
|
24
|
+
import {
|
|
25
|
+
detectAiAssistanceDiscouraged,
|
|
26
|
+
extractNarrativeQuestionsFromText,
|
|
27
|
+
normalizeAttentionQuestions,
|
|
28
|
+
} from './attention-questions.mjs';
|
|
16
29
|
|
|
17
30
|
const SOURCES = new Set(['linkedin', 'greenhouse', 'lever', 'ashby', 'workable', 'comeet', 'workday', 'rippling', 'smartrecruiters', 'google-form', 'company', 'email', 'other']);
|
|
18
31
|
const DISCOVERY_SOURCES = new Set(['direct-company', 'linkedin', 'x', 'yc', 'hacker-news', 'job-board', 'email', 'user-supplied', 'web-search', 'other']);
|
|
@@ -506,12 +519,7 @@ function rolesLikelySame(left, right) {
|
|
|
506
519
|
return shared / Math.min(leftTokens.size, rightTokens.size) >= 0.75;
|
|
507
520
|
}
|
|
508
521
|
|
|
509
|
-
|
|
510
|
-
if (entry.employerJobId) return `job:${normalizedText(entry.company)}:${String(entry.employerJobId).toLowerCase()}`;
|
|
511
|
-
if (entry.company && entry.role) return `legacy-role:${normalizedText(entry.company)}:${normalizedText(entry.role)}`;
|
|
512
|
-
if (entry.url) return `url:${normalizeUrl(entry.url)}`;
|
|
513
|
-
return `id:${entry.id ?? fallback}`;
|
|
514
|
-
}
|
|
522
|
+
const canonicalApplicationKey = accountingApplicationKey;
|
|
515
523
|
|
|
516
524
|
function businessDaysBetween(startValue, endValue) {
|
|
517
525
|
const start = new Date(startValue);
|
|
@@ -525,8 +533,10 @@ function businessDaysBetween(startValue, endValue) {
|
|
|
525
533
|
return days;
|
|
526
534
|
}
|
|
527
535
|
|
|
528
|
-
export function buildReview(entries, outcomeEntries = [], acknowledgements = [], now = new Date()) {
|
|
536
|
+
export function buildReview(entries, outcomeEntries = [], acknowledgements = [], now = new Date(), deliveryEvents = []) {
|
|
529
537
|
const submissions = entries.filter((entry) => !Number.isNaN(Date.parse(entry.submittedAt)));
|
|
538
|
+
const delivery = deliveryProjection(submissions, deliveryEvents);
|
|
539
|
+
const effectiveKeys = new Set(submissions.filter((entry, i) => delivery.applications[i].counted).map(canonicalApplicationKey));
|
|
530
540
|
const explicitOutcomes = outcomeEntries.length > 0;
|
|
531
541
|
const outcomes = explicitOutcomes ? outcomeEntries : entries.filter((entry) => ['interview', 'rejected', 'offer', 'withdrawn'].includes(entry.status));
|
|
532
542
|
const groups = new Map();
|
|
@@ -535,7 +545,8 @@ export function buildReview(entries, outcomeEntries = [], acknowledgements = [],
|
|
|
535
545
|
if (!groups.has(key)) groups.set(key, []);
|
|
536
546
|
groups.get(key).push(entry);
|
|
537
547
|
});
|
|
538
|
-
const
|
|
548
|
+
const recordedCanonical = [...groups.values()].map(group => [...group].sort((a, b) => Date.parse(a.submittedAt) - Date.parse(b.submittedAt))[0]);
|
|
549
|
+
const canonical = [...groups].filter(([key]) => effectiveKeys.has(key)).map(([,group]) => [...group].sort((a, b) => Date.parse(a.submittedAt) - Date.parse(b.submittedAt))[0]);
|
|
539
550
|
const outcomesById = new Map();
|
|
540
551
|
for (const outcome of outcomes) {
|
|
541
552
|
if (!outcomesById.has(outcome.id)) outcomesById.set(outcome.id, []);
|
|
@@ -544,7 +555,7 @@ export function buildReview(entries, outcomeEntries = [], acknowledgements = [],
|
|
|
544
555
|
const canonicalOutcomes = [];
|
|
545
556
|
const matureCanonicalOutcomes = [];
|
|
546
557
|
const canonicalInterviewDetails = [];
|
|
547
|
-
for (const group of groups
|
|
558
|
+
for (const [groupKey, group] of groups) {
|
|
548
559
|
const candidates = explicitOutcomes
|
|
549
560
|
? group.flatMap((entry) => outcomesById.get(entry.id) ?? [])
|
|
550
561
|
: group.filter((entry) => ['interview', 'rejected', 'offer', 'withdrawn'].includes(entry.status));
|
|
@@ -559,14 +570,15 @@ export function buildReview(entries, outcomeEntries = [], acknowledgements = [],
|
|
|
559
570
|
source: canonicalApplication.source,
|
|
560
571
|
score: canonicalApplication.score,
|
|
561
572
|
});
|
|
562
|
-
if (businessDaysBetween(canonicalApplication.submittedAt, now) >= 10) matureCanonicalOutcomes.push(latest);
|
|
573
|
+
if (effectiveKeys.has(groupKey) && businessDaysBetween(canonicalApplication.submittedAt, now) >= 10) matureCanonicalOutcomes.push(latest);
|
|
563
574
|
}
|
|
564
575
|
}
|
|
565
576
|
const maturedApplications = canonical.filter((entry) => businessDaysBetween(entry.submittedAt, now) >= 10).length;
|
|
566
577
|
const lastAck = acknowledgements.length ? acknowledgements[acknowledgements.length - 1] : {};
|
|
567
|
-
const
|
|
578
|
+
const recordedMaturedApplicationCount = recordedCanonical.filter(entry => businessDaysBetween(entry.submittedAt, now) >= 10).length;
|
|
579
|
+
const submittedSinceLastReview = Math.max(0, recordedCanonical.length - (lastAck.uniqueSubmissionCount ?? 0));
|
|
568
580
|
const hygieneDue = submittedSinceLastReview >= 10;
|
|
569
|
-
const outcomeDue =
|
|
581
|
+
const outcomeDue = recordedMaturedApplicationCount - (lastAck.maturedApplicationCount ?? 0) >= 20;
|
|
570
582
|
const reviewReasons = [...(hygieneDue ? ['submission-hygiene'] : []), ...(outcomeDue ? ['outcome-effectiveness'] : [])];
|
|
571
583
|
const outcomeCounts = Object.fromEntries(['interview', 'rejected', 'offer', 'withdrawn'].map((status) => [status, canonicalOutcomes.filter((entry) => entry.status === status).length]));
|
|
572
584
|
const matureOutcomeCounts = Object.fromEntries(['interview', 'rejected', 'offer', 'withdrawn'].map((status) => [status, matureCanonicalOutcomes.filter((entry) => entry.status === status).length]));
|
|
@@ -598,9 +610,14 @@ export function buildReview(entries, outcomeEntries = [], acknowledgements = [],
|
|
|
598
610
|
submittedTotal: canonical.length,
|
|
599
611
|
uniqueSubmittedTotal: canonical.length,
|
|
600
612
|
rawSubmissionRows: submissions.length,
|
|
601
|
-
duplicateSubmissionRows: submissions.length -
|
|
613
|
+
duplicateSubmissionRows: submissions.length - groups.size,
|
|
614
|
+
recordedSubmissionCount: groups.size,
|
|
615
|
+
effectiveSubmissionCount: canonical.length,
|
|
616
|
+
failedDeliveryCount: delivery.failedDeliveryCount,
|
|
617
|
+
receiptUnknownEmailCount: delivery.receiptUnknownEmailCount,
|
|
602
618
|
submittedSinceLastReview,
|
|
603
619
|
maturedApplications,
|
|
620
|
+
recordedMaturedApplicationCount,
|
|
604
621
|
outcomeCounts,
|
|
605
622
|
matureOutcomeCounts,
|
|
606
623
|
reasonCounts,
|
|
@@ -1081,7 +1098,7 @@ async function ledgerAdd(entryInput, duplicateOverride, companyReapplyOverride,
|
|
|
1081
1098
|
if (entry.roundId) await cloudState.appendRecord('rounds', { type: 'submission-confirmed', roundId: entry.roundId, applicationId: entry.id, occurredAt: entry.submittedAt }, { recordKey: entry.roundId, idempotencyKey: `round-confirmation:${entry.roundId}:${entry.id}`, occurredAt: entry.submittedAt, queueOnFailure: true });
|
|
1082
1099
|
}
|
|
1083
1100
|
}
|
|
1084
|
-
return { recorded: storedEntry.id, review: buildReview([...entries, storedEntry]), ...(cloudIntent ? { cloudIntent: cloudIntent.intentId } : {}) };
|
|
1101
|
+
return { recorded: storedEntry.id, review: buildReview([...entries, storedEntry], outcomes, [], new Date(), await jsonLines(join(dir, 'delivery.ndjson'))), ...(cloudIntent ? { cloudIntent: cloudIntent.intentId } : {}) };
|
|
1085
1102
|
});
|
|
1086
1103
|
}
|
|
1087
1104
|
|
|
@@ -1107,6 +1124,63 @@ function isoDate(value, label) {
|
|
|
1107
1124
|
return result;
|
|
1108
1125
|
}
|
|
1109
1126
|
|
|
1127
|
+
async function appendAccounting(stream, event, { cloudConfirmed = false } = {}) {
|
|
1128
|
+
const file = join(await ensureStateDir(), `${stream}.ndjson`);
|
|
1129
|
+
const previous = await jsonLines(file);
|
|
1130
|
+
const matches = previous.filter(e => e.id === event.id);
|
|
1131
|
+
if (matches.some(e => stableJson(e) !== stableJson(event))) throw new Error('Conflicting accounting event ID.');
|
|
1132
|
+
if (matches.length) return { recorded: false, duplicate: true, event };
|
|
1133
|
+
await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
1134
|
+
await chmod(file, 0o600);
|
|
1135
|
+
if (!cloudConfirmed && await cloudState.configured()) await cloudState.appendRecord(stream, event, { recordKey: event.id, idempotencyKey: `accounting:${event.id}`, occurredAt: event.occurredAt ?? event.observedAt, queueOnFailure: true });
|
|
1136
|
+
return { recorded: true, duplicate: false, event };
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
async function recordDelivery(input, retry = false) {
|
|
1140
|
+
const { cloudIntentId, cloudLeaseId, ...raw } = object(input, 'delivery');
|
|
1141
|
+
const event = validateDelivery({ ...raw, ...(retry ? { type: 'retry-confirmed' } : {}) });
|
|
1142
|
+
if (!retry && event.type === 'retry-confirmed') throw new Error('Use ledger retry for replacement transmissions.');
|
|
1143
|
+
return withStateLock('applications', () => withStateLock('delivery', async dir => {
|
|
1144
|
+
const applications = await jsonLines(join(dir, 'applications.ndjson'));
|
|
1145
|
+
const events = await jsonLines(join(dir, 'delivery.ndjson'));
|
|
1146
|
+
const cloudRetry = retry && await cloudState.configured();
|
|
1147
|
+
if (cloudRetry && !events.some(e => e.id === event.id) && (!cloudIntentId || !cloudLeaseId || event.attemptId !== cloudIntentId)) throw new Error('Cloud retry requires its intent and live lease; attemptId must equal cloudIntentId.');
|
|
1148
|
+
// Cloud transmission eligibility was checked at intent preparation. The
|
|
1149
|
+
// Worker verifies that intent before we persist the observed transmission.
|
|
1150
|
+
validateDeliveryReferences(event, applications, events, { preparedRetry: cloudRetry });
|
|
1151
|
+
let cloudConfirmed = false;
|
|
1152
|
+
if (cloudRetry && !events.some(e => e.id === event.id)) {
|
|
1153
|
+
await cloudState.confirmRetry(cloudIntentId, event, cloudLeaseId);
|
|
1154
|
+
cloudConfirmed = true;
|
|
1155
|
+
}
|
|
1156
|
+
const result = await appendAccounting('delivery', event, { cloudConfirmed });
|
|
1157
|
+
return { ...result, delivery: deliveryProjection(applications, [...events, event]) };
|
|
1158
|
+
}));
|
|
1159
|
+
}
|
|
1160
|
+
async function deliveryHistory(applicationId) {
|
|
1161
|
+
const dir = await ensureStateDir();
|
|
1162
|
+
const apps = (await jsonLines(join(dir, 'applications.ndjson'))).filter(a => !applicationId || a.id === applicationId);
|
|
1163
|
+
const events = (await jsonLines(join(dir, 'delivery.ndjson'))).filter(e => !applicationId || e.applicationId === applicationId);
|
|
1164
|
+
return { ...deliveryProjection(apps, events), events };
|
|
1165
|
+
}
|
|
1166
|
+
async function recordLead(input) {
|
|
1167
|
+
const event = validateLead(input);
|
|
1168
|
+
knownDiscoverySourceId(event.sourceId, 'lead.sourceId');
|
|
1169
|
+
return withStateLock('rounds', async dir => {
|
|
1170
|
+
const round = await roundStatus(event.roundId);
|
|
1171
|
+
const events = await jsonLines(join(dir, 'discovery.ndjson'));
|
|
1172
|
+
if (round.completed && !event.supersedes && !events.some(e => e.id === event.id)) throw new Error('Completed rounds accept only corrections to existing leads.');
|
|
1173
|
+
validateLeadReferences(event, events);
|
|
1174
|
+
return appendAccounting('discovery', event);
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
async function leadHistory(roundId) {
|
|
1178
|
+
const dir = await ensureStateDir();
|
|
1179
|
+
const id = roundId ?? (await roundStatus()).roundId;
|
|
1180
|
+
const history = (await jsonLines(join(dir, 'discovery.ndjson'))).filter(e => e.roundId === id);
|
|
1181
|
+
return { ...discoveryProjection(history, { roundId: id }), history };
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1110
1184
|
async function roundStart(input) {
|
|
1111
1185
|
const value = object(input, 'round');
|
|
1112
1186
|
const allowed = new Set(['requestedCount', 'startedAt']);
|
|
@@ -1114,6 +1188,7 @@ async function roundStart(input) {
|
|
|
1114
1188
|
const event = {
|
|
1115
1189
|
type: 'started',
|
|
1116
1190
|
roundId: `round-${new Date().toISOString().slice(0, 10)}-${randomUUID()}`,
|
|
1191
|
+
discoveryPolicyVersion: 2,
|
|
1117
1192
|
requestedCount: integer(value.requestedCount, 'round.requestedCount', 1, 1000),
|
|
1118
1193
|
occurredAt: isoDate(value.startedAt, 'round.startedAt'),
|
|
1119
1194
|
};
|
|
@@ -1127,7 +1202,7 @@ function discoveryGroup(id) {
|
|
|
1127
1202
|
return id;
|
|
1128
1203
|
}
|
|
1129
1204
|
|
|
1130
|
-
function discoverySummary(events, applications, completion) {
|
|
1205
|
+
function discoverySummary(events, applications, completion, audit = null) {
|
|
1131
1206
|
const latest = new Map();
|
|
1132
1207
|
const searchedIds = new Set();
|
|
1133
1208
|
const attribution = new Map();
|
|
@@ -1136,7 +1211,10 @@ function discoverySummary(events, applications, completion) {
|
|
|
1136
1211
|
if (event.status === 'searched') searchedIds.add(event.sourceId);
|
|
1137
1212
|
for (const id of event.applicationIds ?? []) attribution.set(id, event.sourceId);
|
|
1138
1213
|
}
|
|
1139
|
-
const sources = [...latest.values()]
|
|
1214
|
+
const sources = [...latest.values()].map(report => {
|
|
1215
|
+
const leads = audit?.leads.filter(lead => lead.sourceId === report.sourceId) ?? [];
|
|
1216
|
+
return audit ? { ...report, reviewedCount: leads.length, qualifiedCount: leads.filter(lead => !lead.conflict && lead.disposition === 'qualified').length, accounting: 'per-lead' } : { ...report, accounting: 'legacy-unverified' };
|
|
1217
|
+
});
|
|
1140
1218
|
const eligible = sources.filter((item) => !['recruiter-inbound', 'user-supplied-leads'].includes(item.sourceId));
|
|
1141
1219
|
const attempted = new Set(eligible.map((item) => discoveryGroup(item.sourceId)));
|
|
1142
1220
|
const searched = new Set(eligible.filter((item) => searchedIds.has(item.sourceId)).map((item) => discoveryGroup(item.sourceId)));
|
|
@@ -1172,17 +1250,23 @@ async function roundSource(input) {
|
|
|
1172
1250
|
const sourceId = knownDiscoverySourceId(value.sourceId, 'coverage.sourceId');
|
|
1173
1251
|
const status = string(value.status, 'coverage.status', 20);
|
|
1174
1252
|
if (!['searched', 'blocked'].includes(status)) throw new Error('coverage.status must be searched or blocked.');
|
|
1175
|
-
const
|
|
1176
|
-
const qualifiedCount = integer(value.qualifiedCount, 'coverage.qualifiedCount', 0, reviewedCount);
|
|
1253
|
+
const roundId = string(value.roundId, 'coverage.roundId', 180);
|
|
1177
1254
|
const blocker = value.blocker == null ? null : string(value.blocker, 'coverage.blocker', 40);
|
|
1178
|
-
if (status === 'blocked' && (!SOURCE_BLOCKERS.has(blocker) || reviewedCount !== 0 || qualifiedCount !== 0)) throw new Error('Blocked sources require a documented blocker and zero counts.');
|
|
1179
1255
|
if (status === 'searched' && blocker != null) throw new Error('Searched sources cannot have a blocker.');
|
|
1180
1256
|
const evidence = string(value.evidence, 'coverage.evidence', 2000);
|
|
1181
1257
|
const applicationIds = value.applicationIds == null ? [] : [...new Set(stringArray(value.applicationIds, 'coverage.applicationIds'))];
|
|
1182
1258
|
if (applicationIds.length > 1000 || (status === 'blocked' && applicationIds.length)) throw new Error('Invalid coverage.applicationIds.');
|
|
1183
1259
|
const event = await withStateLock('rounds', async (dir) => {
|
|
1184
|
-
const round = await roundStatus(
|
|
1260
|
+
const round = await roundStatus(roundId);
|
|
1185
1261
|
if (round.completed) throw new Error('Cannot record coverage for a completed round.');
|
|
1262
|
+
const audit = discoveryProjection(await jsonLines(join(dir, 'discovery.ndjson')), { roundId: value.roundId });
|
|
1263
|
+
const leads = audit.leads.filter(lead => lead.sourceId === sourceId);
|
|
1264
|
+
const derivedReviewed = status === 'blocked' ? 0 : leads.length;
|
|
1265
|
+
const derivedQualified = status === 'blocked' ? 0 : leads.filter(lead => !lead.conflict && lead.disposition === 'qualified').length;
|
|
1266
|
+
const reviewedCount = round.discoveryPolicyVersion === 2 ? derivedReviewed : integer(value.reviewedCount, 'coverage.reviewedCount', 0, 10000);
|
|
1267
|
+
const qualifiedCount = round.discoveryPolicyVersion === 2 ? derivedQualified : integer(value.qualifiedCount, 'coverage.qualifiedCount', 0, reviewedCount);
|
|
1268
|
+
if (round.discoveryPolicyVersion === 2 && ((value.reviewedCount != null && value.reviewedCount !== reviewedCount) || (value.qualifiedCount != null && value.qualifiedCount !== qualifiedCount))) throw new Error('Source count assertions do not match recorded leads.');
|
|
1269
|
+
if (status === 'blocked' && (!SOURCE_BLOCKERS.has(blocker) || reviewedCount !== 0 || qualifiedCount !== 0)) throw new Error('Blocked sources require a documented blocker and zero counts.');
|
|
1186
1270
|
const applications = await jsonLines(join(dir, 'applications.ndjson'));
|
|
1187
1271
|
for (const id of applicationIds) {
|
|
1188
1272
|
const entry = applications.find((item) => item.id === id && item.roundId === round.roundId && item.status === 'submitted');
|
|
@@ -1217,12 +1301,75 @@ function replayAttention(events, roundId = null) {
|
|
|
1217
1301
|
async function attentionList(roundId = null) {
|
|
1218
1302
|
const events = await jsonLines(join(await ensureStateDir(), 'attention.ndjson'));
|
|
1219
1303
|
const items = replayAttention(events, roundId);
|
|
1304
|
+
const dir = await ensureStateDir();
|
|
1305
|
+
const apps = await jsonLines(join(dir, 'applications.ndjson'));
|
|
1306
|
+
const delivery = deliveryProjection(apps, await jsonLines(join(dir, 'delivery.ndjson')));
|
|
1307
|
+
for (const item of delivery.applications.filter(a => a.failed || a.conflict)) {
|
|
1308
|
+
const app = apps.find(a => a.id === item.applicationId);
|
|
1309
|
+
if (roundId && app.roundId !== roundId) continue;
|
|
1310
|
+
items.push({ id: `delivery:${app.id}`, applicationId: app.id, roundId: app.roundId ?? null, url: app.url, stage: 'confirmation', blocker: item.conflict ? 'delivery-conflict' : 'delivery-failed', requiredActions: ['review-delivery'], derived: true });
|
|
1311
|
+
}
|
|
1312
|
+
const discovery = discoveryProjection(await jsonLines(join(dir, 'discovery.ndjson')), { roundId });
|
|
1313
|
+
for (const lead of discovery.leads.filter(l => l.conflict)) items.push({ id: `discovery:${lead.key}`, roundId: lead.roundId, url: lead.url, stage: 'discovery', blocker: 'assessment-conflict', requiredActions: ['review-assessment'], derived: true });
|
|
1220
1314
|
return { count: items.length, items };
|
|
1221
1315
|
}
|
|
1222
1316
|
|
|
1317
|
+
async function attentionNotifyHook(event, context = {}) {
|
|
1318
|
+
const notifyUrl = process.env.ATTENTION_NOTIFY_URL?.trim();
|
|
1319
|
+
const notifySecret = process.env.ATTENTION_NOTIFY_SECRET?.trim();
|
|
1320
|
+
if (!notifyUrl || !notifySecret) return { attempted: false, reason: 'notify_unconfigured' };
|
|
1321
|
+
|
|
1322
|
+
let email = '';
|
|
1323
|
+
try { email = String(storedProfileRaw()?.email ?? '').trim().toLowerCase(); } catch { email = ''; }
|
|
1324
|
+
if (!email) {
|
|
1325
|
+
console.error('[attention-notify] profile email missing; skip notify fail-closed');
|
|
1326
|
+
return { attempted: true, ok: false, error: 'profile_email_missing' };
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const company = String(context.company ?? '').trim() || 'Company';
|
|
1330
|
+
const role = String(context.role ?? '').trim() || 'Role';
|
|
1331
|
+
try {
|
|
1332
|
+
const response = await fetch(notifyUrl, {
|
|
1333
|
+
method: 'POST',
|
|
1334
|
+
headers: {
|
|
1335
|
+
authorization: `Bearer ${notifySecret}`,
|
|
1336
|
+
'content-type': 'application/json',
|
|
1337
|
+
},
|
|
1338
|
+
body: JSON.stringify({
|
|
1339
|
+
attentionId: event.id,
|
|
1340
|
+
email,
|
|
1341
|
+
company,
|
|
1342
|
+
role,
|
|
1343
|
+
url: event.url,
|
|
1344
|
+
stage: event.stage,
|
|
1345
|
+
blocker: event.blocker,
|
|
1346
|
+
requiredActions: event.requiredActions,
|
|
1347
|
+
questions: event.questions ?? [],
|
|
1348
|
+
aiAssistanceDiscouraged: Boolean(event.aiAssistanceDiscouraged),
|
|
1349
|
+
postingText: context.postingText ?? '',
|
|
1350
|
+
}),
|
|
1351
|
+
});
|
|
1352
|
+
const body = await response.json().catch(() => ({}));
|
|
1353
|
+
if (!response.ok) {
|
|
1354
|
+
console.error(`[attention-notify] site notify failed (${response.status}): ${body?.error ?? 'unknown'}`);
|
|
1355
|
+
return { attempted: true, ok: false, error: body?.error ?? 'notify_failed', status: response.status };
|
|
1356
|
+
}
|
|
1357
|
+
return { attempted: true, ok: true, magicLinkUrl: body?.magicLinkUrl ?? null, emailId: body?.emailId ?? null };
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
console.error(`[attention-notify] site notify request failed: ${error instanceof Error ? error.message : 'unknown'}`);
|
|
1360
|
+
return { attempted: true, ok: false, error: 'notify_request_failed' };
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1223
1364
|
async function attentionAdd(input) {
|
|
1224
1365
|
const value = object(input, 'attention item');
|
|
1225
|
-
const allowed = new Set([
|
|
1366
|
+
const allowed = new Set([
|
|
1367
|
+
'roundId', 'applicationId', 'url', 'stage', 'blocker', 'requiredActions', 'createdAt', 'company', 'role',
|
|
1368
|
+
// P1.5 judgment packaging (prompts only — never candidate responses).
|
|
1369
|
+
'questions', 'postingText', 'aiAssistanceDiscouraged',
|
|
1370
|
+
// Local-only session binding hooks — never appended to the cloud attention event.
|
|
1371
|
+
...SESSION_BINDING_ATTENTION_KEYS,
|
|
1372
|
+
]);
|
|
1226
1373
|
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown attention property: ${key}.`);
|
|
1227
1374
|
const stage = string(value.stage, 'attention.stage', 40).toLowerCase();
|
|
1228
1375
|
const blocker = string(value.blocker, 'attention.blocker', 60).toLowerCase();
|
|
@@ -1230,19 +1377,62 @@ async function attentionAdd(input) {
|
|
|
1230
1377
|
if (!ATTENTION_BLOCKERS.has(blocker)) throw new Error('attention.blocker is invalid.');
|
|
1231
1378
|
const requiredActions = stringArray(value.requiredActions, 'attention.requiredActions', true).map((item) => item.toLowerCase());
|
|
1232
1379
|
if (requiredActions.length > 8 || requiredActions.some((item) => !REQUIRED_ACTIONS.has(item))) throw new Error('attention.requiredActions must contain only documented actions.');
|
|
1380
|
+
const applicationId = string(value.applicationId, 'attention.applicationId', 180);
|
|
1381
|
+
let company = typeof value.company === 'string' ? value.company.trim() : '';
|
|
1382
|
+
let role = typeof value.role === 'string' ? value.role.trim() : '';
|
|
1383
|
+
if (!company || !role) {
|
|
1384
|
+
try {
|
|
1385
|
+
const apps = await jsonLines(join(await ensureStateDir(), 'applications.ndjson'));
|
|
1386
|
+
const match = apps.find((entry) => entry.id === applicationId);
|
|
1387
|
+
if (match) {
|
|
1388
|
+
company = company || String(match.company ?? '').trim();
|
|
1389
|
+
role = role || String(match.role ?? match.title ?? '').trim();
|
|
1390
|
+
}
|
|
1391
|
+
} catch { /* best-effort context for notify only */ }
|
|
1392
|
+
}
|
|
1393
|
+
const postingText = typeof value.postingText === 'string' ? value.postingText.slice(0, 20_000) : '';
|
|
1394
|
+
let questions = normalizeAttentionQuestions(value.questions);
|
|
1395
|
+
if (!questions.length && postingText && requiredActions.includes('provide-judgment')) {
|
|
1396
|
+
questions = extractNarrativeQuestionsFromText(postingText);
|
|
1397
|
+
}
|
|
1398
|
+
const aiAssistanceDiscouraged = value.aiAssistanceDiscouraged === true
|
|
1399
|
+
|| detectAiAssistanceDiscouraged(postingText);
|
|
1400
|
+
const localBindingFields = extractSessionBindingFields(value);
|
|
1233
1401
|
const event = {
|
|
1234
1402
|
type: 'opened',
|
|
1235
1403
|
id: `attention-${randomUUID()}`,
|
|
1236
1404
|
roundId: string(value.roundId, 'attention.roundId', 180),
|
|
1237
|
-
applicationId
|
|
1405
|
+
applicationId,
|
|
1238
1406
|
url: string(value.url, 'attention.url', 2048),
|
|
1239
1407
|
stage,
|
|
1240
1408
|
blocker,
|
|
1241
1409
|
requiredActions: [...new Set(requiredActions)],
|
|
1410
|
+
// Prompts only — never store candidate responses on the attention queue.
|
|
1411
|
+
...(questions.length ? { questions } : {}),
|
|
1412
|
+
...(aiAssistanceDiscouraged ? { aiAssistanceDiscouraged: true } : {}),
|
|
1242
1413
|
createdAt: isoDate(value.createdAt, 'attention.createdAt'),
|
|
1243
1414
|
};
|
|
1244
1415
|
await appendPrivateEvent('attention', event);
|
|
1245
|
-
|
|
1416
|
+
|
|
1417
|
+
let sessionBinding = null;
|
|
1418
|
+
if (localBindingFields) {
|
|
1419
|
+
const binding = createSessionBinding({
|
|
1420
|
+
attentionId: event.id,
|
|
1421
|
+
jobUrl: event.url,
|
|
1422
|
+
applicationId: event.applicationId,
|
|
1423
|
+
roundId: event.roundId,
|
|
1424
|
+
createdAt: event.createdAt,
|
|
1425
|
+
questions: event.questions,
|
|
1426
|
+
aiAssistanceDiscouraged: event.aiAssistanceDiscouraged,
|
|
1427
|
+
...localBindingFields,
|
|
1428
|
+
});
|
|
1429
|
+
const path = sessionBindingPath(await ensureStateDir(), event.id);
|
|
1430
|
+
sessionBinding = { path, binding: await writeSessionBindingFile(path, binding) };
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
const notify = await attentionNotifyHook(event, { company, role, postingText });
|
|
1434
|
+
const base = sessionBinding ? { ...event, sessionBinding } : event;
|
|
1435
|
+
return notify.attempted ? { ...base, notify } : base;
|
|
1246
1436
|
}
|
|
1247
1437
|
|
|
1248
1438
|
async function attentionResolve(input) {
|
|
@@ -1251,6 +1441,7 @@ async function attentionResolve(input) {
|
|
|
1251
1441
|
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown attention resolution property: ${key}.`);
|
|
1252
1442
|
const id = string(value.id, 'attention.id', 180);
|
|
1253
1443
|
const current = await attentionList();
|
|
1444
|
+
if (current.items.some(item => item.id === id && item.derived)) throw new Error('Resolve accounting attention with a delivery correction, verified recovery, or lead revision.');
|
|
1254
1445
|
if (!current.items.some((item) => item.id === id)) throw new Error('Attention item is not active.');
|
|
1255
1446
|
const event = { type: 'resolved', id, resolvedAt: isoDate(value.resolvedAt, 'attention.resolvedAt') };
|
|
1256
1447
|
await appendPrivateEvent('attention', event);
|
|
@@ -1267,7 +1458,11 @@ async function roundStatus(roundId = null) {
|
|
|
1267
1458
|
if (!started) throw new Error('Application round was not found.');
|
|
1268
1459
|
const matching = (await jsonLines(join(dir, 'applications.ndjson'))).filter((entry) => entry.roundId === id && entry.status === 'submitted');
|
|
1269
1460
|
const applications = [...new Map(matching.map((entry, index) => [canonicalApplicationKey(entry, String(index)), entry])).values()];
|
|
1270
|
-
const
|
|
1461
|
+
const delivery = deliveryProjection(matching, await jsonLines(join(dir, 'delivery.ndjson')));
|
|
1462
|
+
const effectiveKeys = new Set(matching.filter((entry,i) => delivery.applications[i].counted).map(canonicalApplicationKey));
|
|
1463
|
+
const effectiveApplications = applications.filter(entry => effectiveKeys.has(canonicalApplicationKey(entry)));
|
|
1464
|
+
const confirmedCount = delivery.effectiveSubmissionCount;
|
|
1465
|
+
const audit = started.discoveryPolicyVersion === 2 ? discoveryProjection(await jsonLines(join(dir, 'discovery.ndjson')), { roundId: id }) : null;
|
|
1271
1466
|
const attention = await attentionList(id);
|
|
1272
1467
|
const completion = events.find((event) => event.type === 'completed' && event.roundId === id);
|
|
1273
1468
|
return {
|
|
@@ -1277,7 +1472,16 @@ async function roundStatus(roundId = null) {
|
|
|
1277
1472
|
remainingCount: Math.max(0, started.requestedCount - confirmedCount),
|
|
1278
1473
|
blockedCount: attention.count,
|
|
1279
1474
|
completed: Boolean(completion),
|
|
1280
|
-
|
|
1475
|
+
discoveryPolicyVersion: started.discoveryPolicyVersion ?? 1,
|
|
1476
|
+
recordedSubmissionCount: delivery.recordedSubmissionCount,
|
|
1477
|
+
effectiveSubmissionCount: confirmedCount,
|
|
1478
|
+
failedDeliveryCount: delivery.failedDeliveryCount,
|
|
1479
|
+
receiptUnknownEmailCount: delivery.receiptUnknownEmailCount,
|
|
1480
|
+
shortfallCount: Math.max(0, started.requestedCount - confirmedCount),
|
|
1481
|
+
needsRecovery: Boolean(completion) && confirmedCount < started.requestedCount,
|
|
1482
|
+
discovery: { ...discoverySummary(events.filter((event) => event.roundId === id), effectiveApplications, completion, audit),
|
|
1483
|
+
...(audit ? { reviewedCount: audit.reviewedCount, qualifiedCount: audit.qualifiedCount, uniqueLeadCount: audit.uniqueLeadCount, dispositionCounts: audit.dispositionCounts, conflicts: audit.conflicts,
|
|
1484
|
+
missingLeadApplicationIds: effectiveApplications.filter(app => !audit.leads.some(lead => !lead.conflict && lead.disposition === 'qualified' && lead.applicationId === app.id && lead.sourceId === (app.discoverySourceId ?? events.filter(e => e.type === 'source-checked' && e.applicationIds?.includes(app.id)).at(-1)?.sourceId) && (normalizedText(lead.company) === normalizedText(app.company) && (lead.employerJobId && app.employerJobId ? lead.employerJobId.toLowerCase() === app.employerJobId.toLowerCase() : canonicalUrl(lead.url) === canonicalUrl(app.url))))).map(app => app.id) } : { accounting: 'legacy-unverified' }) },
|
|
1281
1485
|
startedAt: started.occurredAt,
|
|
1282
1486
|
...(completion ? { completedAt: completion.occurredAt } : {}),
|
|
1283
1487
|
};
|
|
@@ -1294,6 +1498,7 @@ async function roundComplete(input) {
|
|
|
1294
1498
|
if (status.confirmedCount < status.requestedCount) throw new Error(`Round requires ${status.requestedCount} confirmed submissions before completion.`);
|
|
1295
1499
|
if (!status.discovery.coverageSatisfied) throw new Error('Round requires attempts across at least 3 distinct discovery sources, including at least one searched source. Record coverage and blockers with round source --stdin.');
|
|
1296
1500
|
if (status.discovery.unattributedCount) throw new Error('Round requires discovery source attribution for every confirmed submission. Supply missing attribution using round source applicationIds; do not rewrite the ledger.');
|
|
1501
|
+
if (status.discovery.conflicts?.length || status.discovery.missingLeadApplicationIds?.length) throw new Error('Round requires qualified lead records for every submission and resolution of conflicting assessments.');
|
|
1297
1502
|
let explanation = {};
|
|
1298
1503
|
if (status.discovery.concentrationNeedsExplanation || value.concentrationReason != null || value.concentrationEvidence != null) {
|
|
1299
1504
|
if (!CONCENTRATION_REASONS.has(value.concentrationReason)) throw new Error('Source concentration requires a documented concentrationReason and private concentrationEvidence.');
|
|
@@ -1399,7 +1604,7 @@ async function ledgerReview() {
|
|
|
1399
1604
|
const applications = await jsonLines(join(dir, 'applications.ndjson'));
|
|
1400
1605
|
const outcomes = await jsonLines(join(dir, 'outcomes.ndjson'));
|
|
1401
1606
|
const acknowledgements = await jsonLines(join(dir, 'reviews.ndjson'));
|
|
1402
|
-
return buildReview(applications, outcomes, acknowledgements);
|
|
1607
|
+
return buildReview(applications, outcomes, acknowledgements, new Date(), await jsonLines(join(dir, 'delivery.ndjson')));
|
|
1403
1608
|
}
|
|
1404
1609
|
|
|
1405
1610
|
async function ledgerReviewAcknowledge(input) {
|
|
@@ -1407,7 +1612,7 @@ async function ledgerReviewAcknowledge(input) {
|
|
|
1407
1612
|
const reviewedAt = string(value.reviewedAt ?? new Date().toISOString(), 'reviewedAt', 80);
|
|
1408
1613
|
if (Number.isNaN(Date.parse(reviewedAt))) throw new Error('reviewedAt must be an ISO date.');
|
|
1409
1614
|
const review = await ledgerReview();
|
|
1410
|
-
const event = { reviewedAt, uniqueSubmissionCount: review.
|
|
1615
|
+
const event = { reviewedAt, uniqueSubmissionCount: review.recordedSubmissionCount, maturedApplicationCount: review.recordedMaturedApplicationCount };
|
|
1411
1616
|
await withStateLock('reviews', async (dir) => {
|
|
1412
1617
|
const file = join(dir, 'reviews.ndjson');
|
|
1413
1618
|
await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
@@ -1547,6 +1752,9 @@ async function executeCommand([area, action, value], telemetry, session, communi
|
|
|
1547
1752
|
const event = await telemetryJobAssessed(job, result);
|
|
1548
1753
|
if (event) domainEvents.push(event);
|
|
1549
1754
|
} else if (area === 'ledger' && action === 'check' && value === '--stdin') result = await ledgerCheck(await jsonStdin());
|
|
1755
|
+
else if (area === 'ledger' && action === 'delivery' && value === '--stdin') result = await recordDelivery(await jsonStdin());
|
|
1756
|
+
else if (area === 'ledger' && action === 'retry' && value === '--stdin') result = await recordDelivery(await jsonStdin(), true);
|
|
1757
|
+
else if (area === 'ledger' && action === 'deliveries') result = await deliveryHistory(value);
|
|
1550
1758
|
else if (area === 'ledger' && action === 'add' && value === '--stdin') {
|
|
1551
1759
|
const input = await jsonStdin();
|
|
1552
1760
|
const telemetryDetails = validateSubmissionTelemetry(input.telemetry);
|
|
@@ -1569,6 +1777,8 @@ async function executeCommand([area, action, value], telemetry, session, communi
|
|
|
1569
1777
|
else if (area === 'autonomy' && action === 'preview' && value == null) result = await autonomyStatus();
|
|
1570
1778
|
else if (area === 'autonomy' && action === 'revoke' && value == null) result = await autonomyRevoke();
|
|
1571
1779
|
else if (area === 'round' && action === 'start' && value === '--stdin') result = await roundStart(await jsonStdin());
|
|
1780
|
+
else if (area === 'round' && action === 'lead' && value === '--stdin') result = await recordLead(await jsonStdin());
|
|
1781
|
+
else if (area === 'round' && action === 'leads') result = await leadHistory(value);
|
|
1572
1782
|
else if (area === 'round' && action === 'source' && value === '--stdin') {
|
|
1573
1783
|
result = await roundSource(await jsonStdin());
|
|
1574
1784
|
domainEvents.push({ event: 'source_checked', properties: {
|
|
@@ -1624,6 +1834,12 @@ async function recordInstallationStart(telemetry, session) {
|
|
|
1624
1834
|
|
|
1625
1835
|
async function main(args) {
|
|
1626
1836
|
const [area, action, value] = args;
|
|
1837
|
+
// Outreach is private-only, including errors: never initialize telemetry or
|
|
1838
|
+
// community clients, flush their queues, or run generic reconciliation here.
|
|
1839
|
+
if (area === 'outreach') {
|
|
1840
|
+
const { runOutreach } = await import('./outreach-cli.mjs');
|
|
1841
|
+
return print(await runOutreach(args.slice(1)));
|
|
1842
|
+
}
|
|
1627
1843
|
const telemetry = new TelemetryClient({ stateDir: stateDir(), readIdentity: () => {
|
|
1628
1844
|
const profile = storedProfileRaw();
|
|
1629
1845
|
// Only explicit saved fields; no resume parsing, conversation scraping, or full profile payload.
|