job-application-agent 3.7.0 → 3.7.1

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.
@@ -76,7 +76,7 @@ Check `round status` after the initial discovery pass and before submitting. Pre
76
76
 
77
77
  1. When private cloud state is configured, run `cloud status`, acquire the application-run lease with `cloud lease-acquire`, and renew it at least every five minutes. A client without the live lease may research and draft but must not submit.
78
78
  2. Recheck employer, title, direct domain, posting status, eligibility, and `autoEligible` immediately before submission.
79
- 3. Run `ledger check --stdin` with the internal ledger ID, canonical URL, employer job ID, company, and role when available. Review both requisition duplicate status and same-company history.
79
+ 3. Run `ledger check --stdin` with any one identifier set: job URL, internal application id, employer job id plus company, or company+role. Include more identifiers when known. A company+role match is a possible duplicate and returns the stored URL; never treat it as a hard already-applied. Review both requisition duplicate status and same-company history.
80
80
  4. Stop on a hard ledger-ID, canonical-URL, employer-job-ID, or requisition duplicate. Treat a same-company/same-role alias as a possible duplicate. Use `duplicateOverride: "NEW REQUISITION CONFIRMED"` only after verifying it is a distinct requisition.
81
81
  5. For a genuinely different role at a previously applied company, follow `companyReapply`: proceed automatically only when it returns `eligible-after-cooldown` (15 full days since the latest company application and no recorded outcome). `cooldown-active` and `follow-up-present` require the candidate's explicit approval and `companyReapplyOverride: "CANDIDATE APPROVED EARLY REAPPLICATION"`.
82
82
  6. Keep authentication in the existing browser session. Never inspect cookies, local storage, passwords, or session files.
@@ -95,6 +95,7 @@ Check `round status` after the initial discovery pass and before submitting. Pre
95
95
 
96
96
  - Keep `applications.ndjson` and `outcomes.ndjson` append-only. Never delete or rewrite historical rows.
97
97
  - Record outcomes with `ledger outcome --stdin`. Use structured rejection reasons and mark each as `explicit` or `inferred`. Do not treat an inference as a candidate fact.
98
+ - Run `ledger check` and `ledger outcome` as two separate CLI processes. Do not combine them in one invocation. When mail has company and role but no URL or id, look up the row with `ledger check` first; that hit is only a possible duplicate and includes the stored URL and id. Then pass the returned `match.id` to `ledger outcome`. If `match.id` is absent, stop and ask; do not guess among fuzzy company+role hits.
98
99
  - After an interview, optionally record `interviewQuality` (`promising`, `viable`, `weak`, or `dead`) and a bounded `failurePoint`. Keep free-form interview notes private.
99
100
  - Rely on idempotent outcome recording; identical events do not append rows or emit duplicate telemetry.
100
101
  - Audit matched delivery failures with authorized email tools when available; otherwise report delivery not audited and continue. Keep delivery failures separate from hiring rejections.
@@ -81,7 +81,7 @@ Assessment output retains `review`, `ask`, `skip`, and `exclude`, and adds `auto
81
81
 
82
82
  ## Duplicate check input
83
83
 
84
- Include as many identifiers as are known.
84
+ `ledger check` accepts any one identifier set: job URL, internal application id, employer job id plus company, or company+role. Include more identifiers when known. Company-only, role-only, and empty objects are rejected.
85
85
 
86
86
  ```json
87
87
  {
@@ -93,7 +93,7 @@ Include as many identifiers as are known.
93
93
  }
94
94
  ```
95
95
 
96
- The check removes fragments and non-job query parameters while retaining recognized job or requisition identifiers. Matching ledger ID, canonical URL, or same-company employer job ID is a hard duplicate. Same company and role without a shared job ID is a possible duplicate.
96
+ The check removes fragments and non-job query parameters while retaining recognized job or requisition identifiers. Matching ledger ID, canonical URL, or same-company employer job ID is a hard duplicate. Same company and role without a shared job ID is a possible duplicate and includes the stored URL. Never promote a company+role match to a hard already-applied. `ledger add` still requires a real job URL.
97
97
 
98
98
  `ledger check` also returns `companyReapply`. A genuinely different role is `eligible-after-cooldown` only when at least 15 full days have passed since the latest application to that company and no outcome has been recorded for that application. Hard duplicates are never eligible. Same-role matches, `cooldown-active`, and `follow-up-present` remain blocked at `ledger add` unless their exact documented override is present.
99
99
 
@@ -208,6 +208,11 @@ function string(value, label, max = 5000) {
208
208
  return value.trim();
209
209
  }
210
210
 
211
+ function optionalPresentString(candidate, key, max) {
212
+ if (!Object.hasOwn(candidate, key)) return '';
213
+ return string(candidate[key], `candidate.${key}`, max);
214
+ }
215
+
211
216
  function stringArray(value, label, required = false) {
212
217
  if (!Array.isArray(value) || (required && value.length === 0)) throw new Error(`${label} must be ${required ? 'a non-empty' : 'an'} array of strings.`);
213
218
  return value.map((item, index) => string(item, `${label}[${index}]`, 300));
@@ -481,6 +486,26 @@ export function validateLedgerEntry(input) {
481
486
  return normalized;
482
487
  }
483
488
 
489
+ export function validateLedgerCheckCandidate(input) {
490
+ const candidate = object(input, 'candidate');
491
+ const id = optionalPresentString(candidate, 'id', 180);
492
+ const url = optionalPresentString(candidate, 'url', 2048);
493
+ const employerJobId = optionalPresentString(candidate, 'employerJobId', 300);
494
+ const company = optionalPresentString(candidate, 'company', 300);
495
+ const role = optionalPresentString(candidate, 'role', 300);
496
+ if (!url && !id && !(employerJobId && company) && !(company && role)) {
497
+ throw new Error('ledger check requires at least one identifier set: url, id, employerJobId+company, or company+role.');
498
+ }
499
+ if (url) normalizeUrl(url);
500
+ return {
501
+ ...(id ? { id } : {}),
502
+ ...(url ? { url } : {}),
503
+ ...(employerJobId ? { employerJobId } : {}),
504
+ ...(company ? { company } : {}),
505
+ ...(role ? { role } : {}),
506
+ };
507
+ }
508
+
484
509
  export function validateSubmissionTelemetry(input) {
485
510
  if (input == null) return {};
486
511
  const value = object(input, 'entry.telemetry');
@@ -519,6 +544,15 @@ function rolesLikelySame(left, right) {
519
544
  return shared / Math.min(leftTokens.size, rightTokens.size) >= 0.75;
520
545
  }
521
546
 
547
+ function latestMatchingEntry(entries, predicate) {
548
+ const matches = entries.filter(predicate);
549
+ if (matches.length === 0) return null;
550
+ return [...matches]
551
+ .filter((entry) => !Number.isNaN(Date.parse(entry.submittedAt)))
552
+ .sort((left, right) => Date.parse(right.submittedAt) - Date.parse(left.submittedAt))[0]
553
+ ?? matches.at(-1);
554
+ }
555
+
522
556
  const canonicalApplicationKey = accountingApplicationKey;
523
557
 
524
558
  function businessDaysBetween(startValue, endValue) {
@@ -995,21 +1029,28 @@ async function canonicalResumePath() {
995
1029
  function duplicateResult(entries, candidate, outcomes = [], now = new Date()) {
996
1030
  const candidateCompany = normalizedText(candidate.company);
997
1031
  const candidateRole = normalizedText(candidate.role);
998
- const candidateUrl = normalizeUrl(candidate.url);
1032
+ const candidateUrl = candidate.url ? normalizeUrl(candidate.url) : null;
999
1033
  const sameCompanyRole = (entry) => candidateCompany && candidateRole
1000
1034
  && normalizedText(entry.company) === candidateCompany
1001
1035
  && rolesLikelySame(entry.role, candidate.role);
1002
- const hardId = entries.find((entry) => entry.id === candidate.id);
1036
+ const exactCompanyRole = (entry) => candidateCompany && candidateRole
1037
+ && normalizedText(entry.company) === candidateCompany
1038
+ && normalizedText(entry.role) === candidateRole;
1039
+ const hardId = candidate.id ? entries.find((entry) => entry.id === candidate.id) : null;
1003
1040
  const hardEmployerJobId = entries.find((entry) => candidate.employerJobId && entry.employerJobId
1004
1041
  && normalizedText(entry.company) === candidateCompany
1005
1042
  && entry.employerJobId.toLowerCase() === String(candidate.employerJobId).toLowerCase());
1006
- const hardUrl = entries.find((entry) => normalizeUrl(entry.url) === candidateUrl);
1043
+ const hardUrl = candidateUrl ? entries.find((entry) => normalizeUrl(entry.url) === candidateUrl) : null;
1007
1044
  const hard = hardId ?? hardEmployerJobId ?? hardUrl;
1008
1045
  const hardReason = hardId ? 'id' : hardEmployerJobId ? 'employer-job-id' : hardUrl ? 'url' : null;
1009
- const possible = hard ? null : entries.find(sameCompanyRole);
1046
+ const exact = hard ? null : latestMatchingEntry(entries, exactCompanyRole);
1047
+ const fuzzyMatches = hard || exact ? [] : entries.filter(sameCompanyRole);
1048
+ const ambiguousCompanyRole = fuzzyMatches.length > 1;
1049
+ const possible = hard ? null : exact ?? (fuzzyMatches.length === 1 ? fuzzyMatches[0] : null);
1010
1050
  const match = hard ?? possible;
1011
- const sameCompanyEntries = candidateCompany
1012
- ? entries.filter((entry) => normalizedText(entry.company) === candidateCompany)
1051
+ const historyCompany = candidateCompany || normalizedText(hard?.company);
1052
+ const sameCompanyEntries = historyCompany
1053
+ ? entries.filter((entry) => normalizedText(entry.company) === historyCompany)
1013
1054
  : [];
1014
1055
  const companyApplications = sameCompanyEntries.slice(-20).map((entry) => ({
1015
1056
  id: entry.id,
@@ -1029,15 +1070,15 @@ function duplicateResult(entries, candidate, outcomes = [], now = new Date()) {
1029
1070
  : false;
1030
1071
  let companyReapplyDecision = 'fresh-company';
1031
1072
  if (hard) companyReapplyDecision = 'hard-duplicate';
1032
- else if (possible) companyReapplyDecision = 'same-role-review';
1073
+ else if (possible || ambiguousCompanyRole) companyReapplyDecision = 'same-role-review';
1033
1074
  else if (latestCompanyApplication && hasFollowUp) companyReapplyDecision = 'follow-up-present';
1034
1075
  else if (latestCompanyApplication && daysSinceLatest < COMPANY_REAPPLY_COOLDOWN_DAYS) companyReapplyDecision = 'cooldown-active';
1035
1076
  else if (latestCompanyApplication) companyReapplyDecision = 'eligible-after-cooldown';
1036
1077
  return {
1037
1078
  duplicate: Boolean(hard),
1038
- possibleDuplicate: Boolean(possible),
1039
- reason: hardReason ?? (possible ? 'company-role' : null),
1040
- match: match ? { id: match.id, company: match.company, role: match.role, submittedAt: match.submittedAt } : null,
1079
+ possibleDuplicate: Boolean(possible) || ambiguousCompanyRole,
1080
+ reason: hardReason ?? (possible || ambiguousCompanyRole ? 'company-role' : null),
1081
+ match: match ? { id: match.id, company: match.company, role: match.role, submittedAt: match.submittedAt, url: match.url } : null,
1041
1082
  sameCompany: companyApplications.length > 0,
1042
1083
  companyApplications,
1043
1084
  companyReapply: {
@@ -1052,12 +1093,11 @@ function duplicateResult(entries, candidate, outcomes = [], now = new Date()) {
1052
1093
  }
1053
1094
 
1054
1095
  async function ledgerCheck(candidate) {
1055
- object(candidate, 'candidate');
1096
+ const normalized = validateLedgerCheckCandidate(candidate);
1056
1097
  const dir = await ensureStateDir();
1057
1098
  const entries = await jsonLines(join(dir, 'applications.ndjson'));
1058
1099
  const outcomes = await jsonLines(join(dir, 'outcomes.ndjson'));
1059
- string(candidate.url, 'candidate.url', 2048);
1060
- return duplicateResult(entries, candidate, outcomes);
1100
+ return duplicateResult(entries, normalized, outcomes);
1061
1101
  }
1062
1102
 
1063
1103
  async function ledgerAdd(entryInput, duplicateOverride, companyReapplyOverride, cloudIntent = null) {
@@ -1 +1 @@
1
- export const SKILL_VERSION = '3.7.0';
1
+ export const SKILL_VERSION = '3.7.1';
@@ -7,7 +7,7 @@ import test from 'node:test';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { createServer } from 'node:http';
9
9
 
10
- import { buildReview, commandCategory, durationBucket, migrateProfile, profileStatus, scoreJob, telemetryErrorCode, telemetryJobAssessed, validateLedgerEntry, validateProfile, validateSubmissionTelemetry } from '../scripts/job-application.mjs';
10
+ import { buildReview, commandCategory, durationBucket, migrateProfile, profileStatus, scoreJob, telemetryErrorCode, telemetryJobAssessed, validateLedgerCheckCandidate, validateLedgerEntry, validateProfile, validateSubmissionTelemetry } from '../scripts/job-application.mjs';
11
11
 
12
12
  const target = {
13
13
  name: 'Test Candidate',
@@ -347,6 +347,164 @@ test('warns on same-company role matches and serializes concurrent duplicate sub
347
347
  assert.equal(possible.possibleDuplicate, true);
348
348
  });
349
349
 
350
+ test('accepts any one ledger check identifier set and never hard-matches company+role', () => {
351
+ assert.deepEqual(validateLedgerCheckCandidate({ url: 'https://jobs.example.com/123' }), { url: 'https://jobs.example.com/123' });
352
+ assert.deepEqual(validateLedgerCheckCandidate({ id: 'example-role-1' }), { id: 'example-role-1' });
353
+ assert.deepEqual(validateLedgerCheckCandidate({ employerJobId: 'example:123', company: 'Example' }), {
354
+ employerJobId: 'example:123', company: 'Example',
355
+ });
356
+ assert.deepEqual(validateLedgerCheckCandidate({ company: 'Example', role: 'Senior Product Engineer' }), {
357
+ company: 'Example', role: 'Senior Product Engineer',
358
+ });
359
+ const identifierError = /ledger check requires at least one identifier set: url, id, employerJobId\+company, or company\+role/;
360
+ assert.throws(() => validateLedgerCheckCandidate({}), identifierError);
361
+ assert.throws(() => validateLedgerCheckCandidate({ company: 'Example' }), identifierError);
362
+ assert.throws(() => validateLedgerCheckCandidate({ role: 'Senior Product Engineer' }), identifierError);
363
+ assert.throws(() => validateLedgerCheckCandidate({ employerJobId: 'example:123' }), identifierError);
364
+ try {
365
+ validateLedgerCheckCandidate({});
366
+ } catch (error) {
367
+ assert.match(error.message, identifierError);
368
+ assert.doesNotMatch(error.message, /candidate\.url must/);
369
+ }
370
+ assert.throws(() => validateLedgerCheckCandidate({ url: 'not-a-url' }), /Invalid URL|invalid/i);
371
+ assert.throws(() => validateLedgerCheckCandidate({ id: 'example-role-1', url: 123 }), /candidate\.url must/);
372
+ assert.throws(() => validateLedgerCheckCandidate({ id: 'example-role-1', url: null }), /candidate\.url must/);
373
+ });
374
+
375
+ test('looks up ledger rows without requiring a URL on check', async (t) => {
376
+ const directory = await mkdtemp(join(tmpdir(), 'public-job-agent-check-lookup-'));
377
+ t.after(() => rm(directory, { recursive: true, force: true }));
378
+ const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
379
+ await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
380
+ const storedUrl = 'https://jobs.example.com/123';
381
+ await writeFile(join(directory, 'applications.ndjson'), `${JSON.stringify({
382
+ id: 'example-role-1', company: 'Example', role: 'Senior Product Engineer', url: storedUrl, source: 'company', score: 88,
383
+ status: 'submitted', submittedAt: '2026-01-15T10:00:00Z', approval: 'STANDING AUTHORIZATION', answers: {}, employerJobId: 'example:123',
384
+ })}\n`);
385
+ const env = isolatedCliEnv(directory);
386
+
387
+ const byUrl = JSON.parse(execFileSync(process.execPath, [script, 'ledger', 'check', '--stdin'], {
388
+ input: JSON.stringify({ url: 'https://jobs.example.com/123?utm_source=board' }), env, encoding: 'utf8',
389
+ }));
390
+ assert.equal(byUrl.duplicate, true);
391
+ assert.equal(byUrl.reason, 'url');
392
+
393
+ const byId = JSON.parse(execFileSync(process.execPath, [script, 'ledger', 'check', '--stdin'], {
394
+ input: JSON.stringify({ id: 'example-role-1' }), env, encoding: 'utf8',
395
+ }));
396
+ assert.equal(byId.duplicate, true);
397
+ assert.equal(byId.possibleDuplicate, false);
398
+ assert.equal(byId.reason, 'id');
399
+ assert.equal(byId.match.url, storedUrl);
400
+
401
+ const byEmployerJob = JSON.parse(execFileSync(process.execPath, [script, 'ledger', 'check', '--stdin'], {
402
+ input: JSON.stringify({ employerJobId: 'example:123', company: 'Example' }), env, encoding: 'utf8',
403
+ }));
404
+ assert.equal(byEmployerJob.duplicate, true);
405
+ assert.equal(byEmployerJob.possibleDuplicate, false);
406
+ assert.equal(byEmployerJob.reason, 'employer-job-id');
407
+
408
+ const newerUrl = 'https://jobs.example.com/123-new';
409
+ await writeFile(join(directory, 'applications.ndjson'), `${JSON.stringify({
410
+ id: 'example-role-1', company: 'Example', role: 'Senior Product Engineer', url: storedUrl, source: 'company', score: 88,
411
+ status: 'submitted', submittedAt: '2026-01-15T10:00:00Z', approval: 'STANDING AUTHORIZATION', answers: {}, employerJobId: 'example:123',
412
+ })}\n${JSON.stringify({
413
+ id: 'example-role-1b', company: 'Example', role: 'Senior Product Engineer', url: newerUrl, source: 'company', score: 88,
414
+ status: 'submitted', submittedAt: '2026-02-01T10:00:00Z', approval: 'STANDING AUTHORIZATION', answers: {},
415
+ })}\n`);
416
+
417
+ const byCompanyRole = JSON.parse(execFileSync(process.execPath, [script, 'ledger', 'check', '--stdin'], {
418
+ input: JSON.stringify({ company: 'Example', role: 'Senior Product Engineer' }), env, encoding: 'utf8',
419
+ }));
420
+ assert.equal(byCompanyRole.duplicate, false);
421
+ assert.equal(byCompanyRole.possibleDuplicate, true);
422
+ assert.equal(byCompanyRole.reason, 'company-role');
423
+ assert.equal(byCompanyRole.match.url, newerUrl);
424
+ assert.equal(byCompanyRole.match.id, 'example-role-1b');
425
+
426
+ const seniorUrl = 'https://jobs.example.com/senior';
427
+ const staffUrl = 'https://jobs.example.com/staff';
428
+ await writeFile(join(directory, 'applications.ndjson'), `${JSON.stringify({
429
+ id: 'example-senior', company: 'Example', role: 'Senior Software Engineer', url: seniorUrl, source: 'company', score: 88,
430
+ status: 'submitted', submittedAt: '2026-01-10T10:00:00Z', approval: 'STANDING AUTHORIZATION', answers: {},
431
+ })}\n${JSON.stringify({
432
+ id: 'example-staff', company: 'Example', role: 'Staff Software Engineer', url: staffUrl, source: 'company', score: 90,
433
+ status: 'submitted', submittedAt: '2026-02-10T10:00:00Z', approval: 'STANDING AUTHORIZATION', answers: {},
434
+ })}\n`);
435
+ const exactSenior = JSON.parse(execFileSync(process.execPath, [script, 'ledger', 'check', '--stdin'], {
436
+ input: JSON.stringify({ company: 'Example', role: 'Senior Software Engineer' }), env, encoding: 'utf8',
437
+ }));
438
+ assert.equal(exactSenior.possibleDuplicate, true);
439
+ assert.equal(exactSenior.match.id, 'example-senior');
440
+ assert.equal(exactSenior.match.url, seniorUrl);
441
+
442
+ await writeFile(join(directory, 'applications.ndjson'), `${JSON.stringify({
443
+ id: 'example-backend', company: 'Example', role: 'Backend Engineer', url: 'https://jobs.example.com/backend', source: 'company', score: 84,
444
+ status: 'submitted', submittedAt: '2026-01-05T10:00:00Z', approval: 'STANDING AUTHORIZATION', answers: {},
445
+ })}\n${JSON.stringify({
446
+ id: 'example-frontend', company: 'Example', role: 'Frontend Engineer', url: 'https://jobs.example.com/frontend', source: 'company', score: 85,
447
+ status: 'submitted', submittedAt: '2026-03-01T10:00:00Z', approval: 'STANDING AUTHORIZATION', answers: {},
448
+ })}\n`);
449
+ const ambiguousEngineer = JSON.parse(execFileSync(process.execPath, [script, 'ledger', 'check', '--stdin'], {
450
+ input: JSON.stringify({ company: 'Example', role: 'Engineer' }), env, encoding: 'utf8',
451
+ }));
452
+ assert.equal(ambiguousEngineer.duplicate, false);
453
+ assert.equal(ambiguousEngineer.possibleDuplicate, true);
454
+ assert.equal(ambiguousEngineer.reason, 'company-role');
455
+ assert.equal(ambiguousEngineer.match, null);
456
+
457
+ const identifierError = /ledger check requires at least one identifier set: url, id, employerJobId\+company, or company\+role/;
458
+ for (const input of [{}, { company: 'Example' }, { role: 'Senior Product Engineer' }]) {
459
+ const failed = await runCli(script, ['ledger', 'check', '--stdin'], input, env);
460
+ assert.equal(failed.code, 1);
461
+ assert.match(failed.stderr, identifierError);
462
+ assert.doesNotMatch(failed.stderr, /candidate\.url must/);
463
+ }
464
+
465
+ const malformedUrl = await runCli(script, ['ledger', 'check', '--stdin'], { id: 'example-role-1', url: 123 }, env);
466
+ assert.equal(malformedUrl.code, 1);
467
+ assert.match(malformedUrl.stderr, /candidate\.url must/);
468
+
469
+ assert.equal(byId.sameCompany, true);
470
+ assert.equal(byId.companyApplications.length, 1);
471
+ assert.equal(byId.companyApplications[0].id, 'example-role-1');
472
+
473
+ const invalidUrl = await runCli(script, ['ledger', 'check', '--stdin'], { url: 'not-a-url' }, env);
474
+ assert.equal(invalidUrl.code, 1);
475
+ assert.match(invalidUrl.stderr, /Invalid URL|invalid/i);
476
+
477
+ const missingAddUrl = await runCli(script, ['ledger', 'add', '--stdin'], {
478
+ id: 'example-role-2', company: 'Example', role: 'Staff Engineer', source: 'company', score: 80,
479
+ status: 'submitted', submittedAt: '2026-01-16T10:00:00Z', approval: 'STANDING AUTHORIZATION', answers: {},
480
+ }, env);
481
+ assert.equal(missingAddUrl.code, 1);
482
+ assert.match(missingAddUrl.stderr, /entry\.url must/);
483
+ });
484
+
485
+ test('records mail and looks up company+role in separate CLI processes', async (t) => {
486
+ const directory = await mkdtemp(join(tmpdir(), 'public-job-agent-outcome-then-check-'));
487
+ t.after(() => rm(directory, { recursive: true, force: true }));
488
+ const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
489
+ await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
490
+ const storedUrl = 'https://jobs.example.com/apollo-1';
491
+ await writeFile(join(directory, 'applications.ndjson'), `${JSON.stringify({
492
+ id: 'apollo-role-1', company: 'Apollo', role: 'Staff Engineer', url: storedUrl, source: 'company', score: 90,
493
+ status: 'submitted', submittedAt: '2026-01-15T10:00:00Z', approval: 'STANDING AUTHORIZATION', answers: {},
494
+ })}\n`);
495
+ const env = isolatedCliEnv(directory);
496
+ const outcome = JSON.parse(execFileSync(process.execPath, [script, 'ledger', 'outcome', '--stdin'], {
497
+ input: JSON.stringify({ id: 'apollo-role-1', status: 'rejected', occurredAt: '2026-01-20T09:00:00Z' }), env, encoding: 'utf8',
498
+ }));
499
+ assert.equal(outcome.recorded, true);
500
+ const check = JSON.parse(execFileSync(process.execPath, [script, 'ledger', 'check', '--stdin'], {
501
+ input: JSON.stringify({ company: 'Apollo', role: 'Staff Engineer' }), env, encoding: 'utf8',
502
+ }));
503
+ assert.equal(check.duplicate, false);
504
+ assert.equal(check.possibleDuplicate, true);
505
+ assert.equal(check.match.url, storedUrl);
506
+ });
507
+
350
508
  test('records structured outcomes idempotently without duplicate rows', async (t) => {
351
509
  const directory = await mkdtemp(join(tmpdir(), 'public-job-agent-outcomes-'));
352
510
  t.after(() => rm(directory, { recursive: true, force: true }));
@@ -43,6 +43,25 @@ test('documents durable autonomy, resumable rounds, attention and friction contr
43
43
  assert.match(agentBox, /DISPLAY=:99/);
44
44
  });
45
45
 
46
+ test('documents flexible ledger check lookup as a separate process from outcome', async () => {
47
+ const skill = await readFile(new URL('../SKILL.md', import.meta.url), 'utf8');
48
+ const marketplaceSkill = await readFile(new URL('../../skills/job-application-agent/SKILL.md', import.meta.url), 'utf8');
49
+ const schemas = await readFile(new URL('../references/SCHEMAS.md', import.meta.url), 'utf8');
50
+
51
+ for (const copy of [skill, marketplaceSkill]) {
52
+ assert.match(copy, /two separate CLI processes/);
53
+ assert.match(copy, /company\+role/);
54
+ assert.match(copy, /possible duplicate/);
55
+ assert.match(copy, /never treat it as a hard already-applied/);
56
+ assert.match(copy, /look up the row with `ledger check` first/);
57
+ assert.match(copy, /pass the returned `match\.id` to `ledger outcome`/);
58
+ assert.match(copy, /If `match\.id` is absent, stop and ask/);
59
+ }
60
+ assert.match(schemas, /any one identifier set/);
61
+ assert.match(schemas, /Never promote a company\+role match to a hard already-applied/);
62
+ assert.match(schemas, /ledger add` still requires a real job URL/);
63
+ });
64
+
46
65
  test('documents Free.ai as optional LLM assist only', async () => {
47
66
  const skill = await readFile(new URL('../SKILL.md', import.meta.url), 'utf8');
48
67
  const freeAi = await readFile(new URL('../references/FREE_AI.md', import.meta.url), 'utf8');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-application-agent",
3
- "version": "3.7.0",
3
+ "version": "3.7.1",
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",