job-application-agent 3.0.1 → 3.1.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 CHANGED
@@ -67,7 +67,7 @@ Updates are staged and validated before replacement. The immediately previous sk
67
67
  | 🔐 Protect | 📚 Track | 📈 Improve |
68
68
  |---|---|---|
69
69
  | Keeps profile data in OS-backed storage and browser auth in the browser. | Records only visibly confirmed submissions in a private local ledger. | Reviews results every ten applications and proposes targeting changes. |
70
- | Stops at sensitive or judgment-heavy steps. | Captures outcomes plus optional interview quality and failure points. | Never changes preferences or instructions without your approval. |
70
+ | Stops at sensitive or judgment-heavy steps. | Captures explicit round IDs and a resumable attention queue. | Opens tested, sanitized improvement PRs without merging or publishing them. |
71
71
 
72
72
  ## 🛡️ Safety by design
73
73
 
@@ -104,6 +104,15 @@ The agent will ask for your canonical résumé and missing application facts. Yo
104
104
  - `review-each` — inspect every completed application before submission.
105
105
  - `routine-auto` — allow routine submissions within a destination or batch you explicitly authorize; safety pauses still apply.
106
106
 
107
+ For continuing autonomy across resumable and scheduled runs, explicitly grant the fixed routine scope once:
108
+
109
+ ```sh
110
+ echo '{"mode":"routine-auto"}' | node ~/.agents/skills/job-application-agent/scripts/job-application.mjs autonomy grant --stdin
111
+ node ~/.agents/skills/job-application-agent/scripts/job-application.mjs autonomy status
112
+ ```
113
+
114
+ The grant covers routine discovery, filling, canonical résumé upload, submission, verified recruiting email, ledger/outcome updates, completed-tab cleanup, and sanitized improvement-PR creation. It never bypasses login, MFA, CAPTCHA, legal/demographic steps, host permission prompts, or ambiguous facts. `autonomy revoke` stops future routine transmissions without deleting history.
115
+
107
116
  ### 3. Use natural commands
108
117
 
109
118
  ```text
@@ -111,6 +120,7 @@ search jobs
111
120
  apply https://company.example/jobs/123
112
121
  apply all relevant jobs from this thread: <URL>
113
122
  run a round of 10
123
+ show attention queue
114
124
  record outcome Company — Senior Engineer — interview
115
125
  ```
116
126
 
@@ -129,7 +139,7 @@ flowchart LR
129
139
  H --> I["Confirm success and update ledger"]
130
140
  ```
131
141
 
132
- The bundled script provides deterministic profile validation, résumé import, scoring, deduplication, ledger updates, and ten-application reviews. The current coding agent handles discovery and browser interaction while following the guardrails in [`SKILL.md`](job-application-agent/SKILL.md).
142
+ The bundled script provides deterministic profile validation, résumé import, scoring, company/requisition deduplication, explicit round accounting, attention/friction queues, ledger updates, and ten-application reviews. The current coding agent handles discovery and browser interaction while following the guardrails in [`SKILL.md`](job-application-agent/SKILL.md).
133
143
 
134
144
  ## 🔐 Privacy model
135
145
 
@@ -138,6 +148,7 @@ The bundled script provides deterministic profile validation, résumé import, s
138
148
  | Candidate profile | OS-backed secret store (macOS Keychain, or Windows Credential Manager + DPAPI) | Never committed |
139
149
  | Canonical résumé | Owner-only local state directory | Never committed |
140
150
  | Application ledger | Owner-only local state directory | Never committed |
151
+ | Autonomy, round, attention, and friction state | Owner-only local state directory | Never committed |
141
152
  | Browser authentication | Existing browser session | Never exported |
142
153
  | Skill instructions and scripts | Local skill directory | Version controlled |
143
154
 
@@ -22,8 +22,9 @@ Use `scripts/job-application.mjs` for private state and deterministic checks. Re
22
22
  3. Preserve identity fields during migration. Map legacy `salaryPreference` to `targetCompensation`. Add `compensationFloor` only when the candidate provides an amount, currency, and annual comparison basis.
23
23
  4. Store the profile in OS-backed profile storage (macOS Keychain, or Windows Credential Manager with a DPAPI-protected local file). Store the canonical resume and append-only ledgers in the owner-only state directory.
24
24
  5. Use `review-each` for per-application approval. Use `routine-auto` only when the current request authorizes the destination or batch and every automatic-eligibility condition passes.
25
- 6. Obey browser and tool confirmation requirements regardless of the stored mode.
26
- 7. Disclose default-enabled structured anonymous analytics and the `telemetry disable` control. The CLI also displays this disclosure.
25
+ 6. When the candidate explicitly grants continuing autonomy, read [references/AUTONOMY.md](references/AUTONOMY.md) and persist it with `autonomy grant --stdin`. Do not repeat skill-level upload or submission approval prompts while the active grant and profile both use `routine-auto`.
26
+ 7. Obey browser and tool confirmation requirements regardless of the stored mode or autonomy grant.
27
+ 8. Disclose default-enabled structured anonymous analytics and the `telemetry disable` control. The CLI also displays this disclosure.
27
28
 
28
29
  Never store passwords, MFA codes, government IDs, demographic data, CAPTCHA answers, browser session data, or inferred candidate facts.
29
30
 
@@ -46,17 +47,20 @@ Do not lower seniority, compensation, location, work mode, or evidence threshold
46
47
 
47
48
  ## Apply
48
49
 
50
+ For batches, scheduled work, or resumable handoffs, read [references/RUNS.md](references/RUNS.md), create a round ID, and use the attention and friction queues.
51
+
49
52
  1. Recheck employer, title, direct domain, posting status, eligibility, and `autoEligible` immediately before submission.
50
- 2. Run `ledger check --stdin` with the internal ledger ID, canonical URL, employer job ID, company, and role when available.
53
+ 2. 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.
51
54
  3. Stop on a hard duplicate. Treat a same-company/same-role match without a shared job ID as a possible duplicate. Use `duplicateOverride: "NEW REQUISITION CONFIRMED"` only after verifying it is a distinct requisition.
52
55
  4. Keep authentication in the existing browser session. Never inspect cookies, local storage, passwords, or session files.
53
56
  5. Fill only explicit profile fields, candidate-provided answers, or facts verified in the canonical resume.
54
57
  6. Follow [references/APPLICATION_GUIDANCE.md](references/APPLICATION_GUIDANCE.md) for narrative answers.
55
58
  7. Upload only the canonical resume unless the candidate explicitly provides another attachment. Resolve its absolute path with `resume path`, then follow [references/BROWSER_UPLOADS.md](references/BROWSER_UPLOADS.md). Use the browser's privileged path-based upload capability first; treat a visible native file picker as a fallback.
56
59
  8. Do not answer demographic questions. Stop for login/SSO/MFA, CAPTCHA, legal attestations, unclear authorization or compensation, sensitive identifiers, and judgment-only questions.
57
- 9. Verify every required field, answer, attachment, and disclosure. Submit only when the current request and confirmation policy authorize it.
58
- 10. Record `submitted` only after visible success confirmation. Record no submission when confirmation is missing or ambiguous.
60
+ 9. Verify every required field, answer, attachment, and disclosure. Submit when the current request or active autonomy grant authorizes it.
61
+ 10. Record `submitted` only after visible success confirmation, using independent `discoverySource`, `applicationChannel`, and `roundId` values. Record no submission when confirmation is missing or ambiguous.
59
62
  11. Record workflow telemetry with `telemetry record --stdin`. Let `ledger add` emit `application_submitted`; do not emit it twice. Pass job URLs and structured metrics only through documented transient fields.
63
+ 12. Queue hard stops with `attention add --stdin` and continue elsewhere. Record reproducible general-purpose failures with `friction record --stdin`; improvement work must never delay application work.
60
64
 
61
65
  ## Outcomes and reviews
62
66
 
@@ -85,6 +89,14 @@ node scripts/job-application.mjs ledger add --stdin
85
89
  node scripts/job-application.mjs ledger outcome --stdin
86
90
  node scripts/job-application.mjs ledger review
87
91
  node scripts/job-application.mjs ledger review-ack --stdin
92
+ node scripts/job-application.mjs autonomy grant --stdin
93
+ node scripts/job-application.mjs autonomy status|preview|revoke
94
+ node scripts/job-application.mjs round start|complete --stdin
95
+ node scripts/job-application.mjs round status [round-id]
96
+ node scripts/job-application.mjs attention add|resolve --stdin
97
+ node scripts/job-application.mjs attention list
98
+ node scripts/job-application.mjs friction record --stdin
99
+ node scripts/job-application.mjs friction list
88
100
  node scripts/job-application.mjs telemetry status|enable|disable|reset
89
101
  node scripts/job-application.mjs telemetry preview --stdin
90
102
  node scripts/job-application.mjs telemetry record --stdin
@@ -29,7 +29,9 @@ Telemetry is best effort. It has no offline queue, uses a short network timeout,
29
29
 
30
30
  Analytics never includes the candidate's name, email, phone, exact address, profile URLs, candidate location, work authorization, personal compensation or compensation floor, target profile or thresholds, resume or attachments, must-have evidence or coverage details, rejection reasons, prompts, responses, job descriptions, form questions, drafted answers, notes, passwords, MFA, CAPTCHA, legal or demographic answers, browser data, IP address, request headers, user agent, or raw error messages.
31
31
 
32
- Structured job context may include company, role title, canonical destination domain, a SHA-256 hash of the job URL after removing query parameters and fragments, ATS, source channel, job country, work mode, employment type, seniority, role family, published salary band, fit score, match/gap categories, workflow stages, field categories, pause reasons, submission result, outcome, bounded interview quality, and bounded interview failure point.
32
+ Structured job context may include company, role title, canonical destination domain, a SHA-256 hash of the job URL after removing query parameters and fragments, bounded discovery source, ATS/application channel, job country, work mode, employment type, seniority, role family, published salary band, fit score, match/gap categories, workflow stages, field categories, pause reasons, submission result, outcome, bounded interview quality, and bounded interview failure point.
33
+
34
+ Local attention details and friction evidence are never transmitted. Analytics may receive only their already-documented bounded stage, ATS, pause reason, result, and aggregate count fields.
33
35
 
34
36
  Company and title values are bounded and rejected when they resemble an email, phone number, URL, LinkedIn profile, or GitHub profile.
35
37
 
@@ -0,0 +1,28 @@
1
+ # Standing autonomy
2
+
3
+ Use a durable autonomy grant only after an explicit candidate instruction such as “apply autonomously.” Store no prompt text in the grant.
4
+
5
+ ```text
6
+ node scripts/job-application.mjs autonomy grant --stdin
7
+ node scripts/job-application.mjs autonomy status
8
+ node scripts/job-application.mjs autonomy preview
9
+ node scripts/job-application.mjs autonomy revoke
10
+ ```
11
+
12
+ Grant input:
13
+
14
+ ```json
15
+ { "mode": "routine-auto" }
16
+ ```
17
+
18
+ An active grant covers discovery, assessment, verified form filling, canonical résumé upload, routine submission, verified recruiting email, ledger/outcome recording, completed-tab cleanup, and opening a sanitized improvement PR. Do not ask for another skill-level upload or submission approval while both the profile and grant use `routine-auto`.
19
+
20
+ Always stop for authentication, MFA, CAPTCHA, legal attestations, demographic responses, government identifiers, unverifiable claims, and ambiguous work authorization or compensation. Obey every browser, host, and tool permission prompt; the grant never bypasses an access control.
21
+
22
+ `autonomy revoke` blocks new routine transmissions without deleting applications, outcomes, rounds, or queued attention items.
23
+
24
+ ## Improvement boundary
25
+
26
+ Record friction while the application round continues. Open a public-agent PR only when the issue is reproducible, general-purpose, sanitized, and covered by a regression test. Validate the full suite, privacy audit, package smoke test, and secret scan before marking the PR ready.
27
+
28
+ Never automatically merge, tag, release, or publish. Never alter telemetry privacy boundaries, candidate facts, résumé claims, targeting thresholds, or answer guidance from an improvement PR. Keep live application work on the latest released package; do not hot-swap unmerged code.
@@ -0,0 +1,48 @@
1
+ # Resumable application runs
2
+
3
+ ## Round lifecycle
4
+
5
+ Start each batch with an explicit ID:
6
+
7
+ ```text
8
+ node scripts/job-application.mjs round start --stdin
9
+ node scripts/job-application.mjs round status [round-id]
10
+ node scripts/job-application.mjs round complete --stdin
11
+ ```
12
+
13
+ Start input: `{ "requestedCount": 30 }`. Complete input: `{ "roundId": "round-..." }`.
14
+
15
+ Count only 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`. Filled forms, blockers, drafts, unsent email, and ambiguous confirmations never count. `round complete` rejects an under-target round.
16
+
17
+ Run both company-level and requisition-level duplicate checks before filling and again immediately before transmission. A prior company application is a review signal, not by itself proof that a distinct requisition is a duplicate.
18
+
19
+ Resolve the canonical résumé with `resume path`, upload its absolute path through the browser’s privileged chooser first, and verify the filename and parsed fields. Use a visible native picker only as a fallback.
20
+
21
+ Store independent attribution on every new ledger row:
22
+
23
+ - `discoverySource`: where the lead was found (`linkedin`, `x`, `yc`, `hacker-news`, `job-board`, `direct-company`, `email`, `user-supplied`, `web-search`, or `other`).
24
+ - `applicationChannel`: where it was submitted (`ashby`, `greenhouse`, `lever`, `workday`, `company`, `email`, and the other documented ATS values).
25
+ - `source`: the legacy-compatible application channel.
26
+
27
+ ## Attention queue
28
+
29
+ Append blocked work instead of interrupting the round:
30
+
31
+ ```text
32
+ node scripts/job-application.mjs attention add --stdin
33
+ node scripts/job-application.mjs attention list
34
+ node scripts/job-application.mjs attention resolve --stdin
35
+ ```
36
+
37
+ Store only application ID, canonical URL, round ID, stage, blocker enum, timestamp, and bounded required-action enums. Never store passwords, MFA codes, CAPTCHA answers, demographic answers, government IDs, or legal responses. Prioritize authentication/MFA/CAPTCHA, then legal/authorization/compensation, then judgment/video/site issues. Preserve the tab when supported; otherwise reopen the canonical URL and refill verified data.
38
+
39
+ ## Friction queue
40
+
41
+ Record bounded general workflow failures without candidate data:
42
+
43
+ ```text
44
+ node scripts/job-application.mjs friction record --stdin
45
+ node scripts/job-application.mjs friction list
46
+ ```
47
+
48
+ Use a stable kebab-case error code, documented stage and ATS, and Boolean `reproducible` and `general` fields. Only entries where both are true qualify for a tested public-agent PR. Never include URLs, form text, answers, résumé metadata, identity, or raw errors.
@@ -48,6 +48,8 @@ Treat `mustHaves[].evidence` as private resume analysis. It is used locally and
48
48
  "company": "Example",
49
49
  "description": "Posting text",
50
50
  "source": "greenhouse",
51
+ "discoverySource": "linkedin",
52
+ "applicationChannel": "greenhouse",
51
53
  "url": "https://job-boards.greenhouse.io/example/jobs/123",
52
54
  "postingStatus": "active",
53
55
  "eligibility": "eligible",
@@ -70,6 +72,8 @@ Treat `mustHaves[].evidence` as private resume analysis. It is used locally and
70
72
 
71
73
  Allowed sources: `linkedin`, `greenhouse`, `lever`, `ashby`, `workable`, `comeet`, `workday`, `rippling`, `smartrecruiters`, `google-form`, `company`, `email`, and `other`.
72
74
 
75
+ `source` remains the backward-compatible application channel. New workflows should also supply `discoverySource` (`direct-company`, `linkedin`, `x`, `yc`, `hacker-news`, `job-board`, `email`, `user-supplied`, `web-search`, or `other`) and `applicationChannel` using the allowed `source` values.
76
+
73
77
  Allowed posting statuses: `active`, `closed`, `unclear`. Allowed eligibility: `eligible`, `unclear`, `ineligible`. Allowed seniority: `junior`, `mid`, `senior`, `staff`, `principal`, `lead`, `manager`, `director`, `founding`, `unspecified`. Allowed work modes: `remote`, `hybrid`, `onsite`, `unspecified`. Must-have statuses: `met`, `partial`, `missing`, `unclear`.
74
78
 
75
79
  Assessment output retains `review`, `ask`, `skip`, and `exclude`, and adds `autoEligible`, `mustHaveCoverage`, and structured `gates`. `review` does not itself authorize submission.
@@ -102,6 +106,9 @@ Add only after visible success confirmation.
102
106
  "url": "https://jobs.example.com/roles/123",
103
107
  "employerJobId": "greenhouse:123",
104
108
  "source": "company",
109
+ "discoverySource": "x",
110
+ "applicationChannel": "company",
111
+ "roundId": "round-2026-01-15-00000000-0000-4000-8000-000000000000",
105
112
  "score": 84,
106
113
  "status": "submitted",
107
114
  "submittedAt": "2026-01-15T10:00:00.000Z",
@@ -119,6 +126,55 @@ Add only after visible success confirmation.
119
126
 
120
127
  Use `duplicateOverride` only for a verified distinct requisition after a possible-duplicate warning. It and `telemetry` are transient and are not written to the application ledger. Use approval `APPROVE SUBMIT` for per-application approval or `STANDING AUTHORIZATION` when the current request authorizes routine batch submission.
121
128
 
129
+ `discoverySource`, `applicationChannel`, and `roundId` are optional for backward compatibility and should be supplied for new resumable rounds. `ledger check` returns hard requisition/URL duplicate status plus bounded same-company history so a distinct role can be reviewed without conflating it with a duplicate.
130
+
131
+ ## Autonomy grant input
132
+
133
+ Create a grant only from an explicit candidate instruction. Do not store the instruction text.
134
+
135
+ ```json
136
+ { "mode": "routine-auto" }
137
+ ```
138
+
139
+ The owner-only `autonomy.json` stores the fixed routine scopes, grant time, and enabled state. Revocation preserves ledgers and stops future routine transmissions. Hard stops and host permission prompts remain mandatory.
140
+
141
+ ## Round input
142
+
143
+ ```json
144
+ { "requestedCount": 30 }
145
+ ```
146
+
147
+ `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-..." }` and appends a completion event only after the target count is present in the ledger.
148
+
149
+ ## Attention input
150
+
151
+ ```json
152
+ {
153
+ "roundId": "round-...",
154
+ "applicationId": "example-role",
155
+ "url": "https://jobs.example.com/role",
156
+ "stage": "submission",
157
+ "blocker": "captcha",
158
+ "requiredActions": ["complete-captcha"]
159
+ }
160
+ ```
161
+
162
+ Allowed blockers: `authentication`, `mfa`, `captcha`, `legal-attestation`, `demographic`, `government-id`, `ambiguous-authorization`, `ambiguous-compensation`, `unverifiable-claim`, `judgment`, `video`, `upload`, `site-error`, and `other`. Resolve with `{ "id": "attention-..." }`. The queue never stores the candidate's response.
163
+
164
+ ## Friction input
165
+
166
+ ```json
167
+ {
168
+ "stage": "upload",
169
+ "ats": "ashby",
170
+ "errorCode": "direct-upload-fallback-opened",
171
+ "reproducible": true,
172
+ "general": true
173
+ }
174
+ ```
175
+
176
+ Use only documented stage/ATS values and a stable kebab-case code. Never include URLs, identity, résumé metadata, answers, form text, or raw errors. Only reproducible general events qualify for a tested public-agent PR.
177
+
122
178
  ## Outcome input
123
179
 
124
180
  ```json
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { createHash } from 'node:crypto';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
4
  import { appendFile, chmod, mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
5
5
  import { platform } from 'node:os';
6
6
  import { basename, join, resolve } from 'node:path';
@@ -10,6 +10,7 @@ import { TelemetryClient } from './telemetry-client.mjs';
10
10
  import { jobIdentity } from './telemetry-schema.mjs';
11
11
 
12
12
  const SOURCES = new Set(['linkedin', 'greenhouse', 'lever', 'ashby', 'workable', 'comeet', 'workday', 'rippling', 'smartrecruiters', 'google-form', 'company', 'email', 'other']);
13
+ const DISCOVERY_SOURCES = new Set(['direct-company', 'linkedin', 'x', 'yc', 'hacker-news', 'job-board', 'email', 'user-supplied', 'web-search', 'other']);
13
14
  const ELIGIBILITY = new Set(['eligible', 'unclear', 'ineligible']);
14
15
  const POSTING_STATUS = new Set(['active', 'closed', 'unclear']);
15
16
  const WORK_MODES = new Set(['remote', 'hybrid', 'onsite', 'unspecified']);
@@ -21,6 +22,11 @@ const FAILURE_POINTS = new Set(['role-scope', 'company-problem', 'constraints',
21
22
  const APPROVALS = new Set(['APPROVE SUBMIT', 'STANDING AUTHORIZATION']);
22
23
  const MODES = new Set(['review-each', 'routine-auto']);
23
24
  const TELEMETRY_DURATIONS = new Set(['under-1s', '1-5s', '5-30s', '30-60s', '1-2m', '2-5m', '5-15m', '15m-plus']);
25
+ const AUTONOMY_SCOPES = Object.freeze(['discover', 'assess', 'fill', 'upload-resume', 'submit', 'send-recruiting-email', 'record-ledger', 'record-outcomes', 'cleanup-tabs', 'open-improvement-pr']);
26
+ const AUTONOMY_HARD_STOPS = Object.freeze(['authentication', 'mfa', 'captcha', 'legal-attestation', 'demographic', 'government-id', 'unverifiable-claim', 'ambiguous-authorization', 'ambiguous-compensation']);
27
+ const ATTENTION_STAGES = new Set(['discovery', 'assessment', 'application', 'contact', 'resume', 'questions', 'legal', 'demographic', 'review', 'submission', 'confirmation', 'outcome', 'answers', 'upload']);
28
+ const ATTENTION_BLOCKERS = new Set(['authentication', 'mfa', 'captcha', 'legal-attestation', 'demographic', 'government-id', 'ambiguous-authorization', 'ambiguous-compensation', 'unverifiable-claim', 'judgment', 'video', 'upload', 'site-error', 'other']);
29
+ const REQUIRED_ACTIONS = new Set(['sign-in', 'complete-mfa', 'complete-captcha', 'review-legal', 'choose-demographic', 'provide-government-id', 'provide-authorization', 'provide-compensation', 'verify-claim', 'provide-judgment', 'record-video', 'enable-upload', 'retry-site']);
24
30
  const REQUIRED_PROFILE = ['name', 'email', 'phone', 'location', 'workAuthorization', 'roleFamilies', 'seniority', 'targetLocations', 'workModes', 'submissionMode', 'yearsExperience', 'autoSubmitMinScore', 'manualReviewMinScore', 'minMustHaveCoverage'];
25
31
  const STRING_PROFILE_FIELDS = new Set(['name', 'email', 'phone', 'location', 'workAuthorization', 'linkedin', 'github', 'portfolio', 'availability', 'currentCompensation', 'targetCompensation', 'submissionMode']);
26
32
  const ARRAY_PROFILE_FIELDS = new Set(['roleFamilies', 'seniority', 'skills', 'targetLocations', 'excludedLocations', 'workModes', 'industries', 'excludedCompanies']);
@@ -70,6 +76,9 @@ export function commandCategory([area, action]) {
70
76
  if (area === 'ledger' && action === 'review-ack') return 'review';
71
77
  if (area === 'ledger') return 'apply';
72
78
  if (area === 'telemetry') return 'telemetry';
79
+ if (area === 'autonomy') return 'profile';
80
+ if (area === 'round') return 'round';
81
+ if (area === 'attention' || area === 'friction') return 'batch';
73
82
  return 'other';
74
83
  }
75
84
 
@@ -120,7 +129,7 @@ export async function telemetryJobAssessed(job, result) {
120
129
  ...identity,
121
130
  company: job.company,
122
131
  title: job.title,
123
- ats: sourceToAts(String(job.source).toLowerCase()),
132
+ ats: sourceToAts(String(job.applicationChannel ?? job.source).toLowerCase()),
124
133
  fitScore: result.score,
125
134
  eligibility: String(job.eligibility).toLowerCase(),
126
135
  decision: result.decision,
@@ -138,7 +147,7 @@ async function telemetryApplicationSubmitted(entry, details = {}) {
138
147
  ...identity,
139
148
  company: entry.company,
140
149
  title: entry.role,
141
- ats: sourceToAts(entry.source),
150
+ ats: sourceToAts(entry.applicationChannel ?? entry.source),
142
151
  durationBucket: details.durationBucket ?? 'under-1s',
143
152
  fieldsFilled: details.fieldsFilled ?? answers.length,
144
153
  shortAnswerCount: details.shortAnswerCount ?? answers.filter((key) => !/resume|attachment/i.test(key)).length,
@@ -240,12 +249,16 @@ export function scoreJob(input, target) {
240
249
  const company = string(job.company, 'job.company', 300);
241
250
  const description = string(job.description, 'job.description', 40000);
242
251
  const source = string(job.source, 'job.source', 40).toLowerCase();
252
+ const discoverySource = job.discoverySource == null ? null : string(job.discoverySource, 'job.discoverySource', 40).toLowerCase();
253
+ const applicationChannel = job.applicationChannel == null ? null : string(job.applicationChannel, 'job.applicationChannel', 40).toLowerCase();
243
254
  const eligibility = string(job.eligibility, 'job.eligibility', 40).toLowerCase();
244
255
  const postingStatus = string(job.postingStatus ?? 'unclear', 'job.postingStatus', 40).toLowerCase();
245
256
  const seniority = string(job.seniority ?? 'unspecified', 'job.seniority', 40).toLowerCase();
246
257
  const roleFamily = string(job.roleFamily ?? 'other', 'job.roleFamily', 80).toLowerCase();
247
258
  const workMode = string(job.workMode ?? (job.remote === true ? 'remote' : 'unspecified'), 'job.workMode', 40).toLowerCase();
248
259
  if (!SOURCES.has(source)) throw new Error('job.source is invalid.');
260
+ if (discoverySource != null && !DISCOVERY_SOURCES.has(discoverySource)) throw new Error('job.discoverySource is invalid.');
261
+ if (applicationChannel != null && !SOURCES.has(applicationChannel)) throw new Error('job.applicationChannel is invalid.');
249
262
  if (!ELIGIBILITY.has(eligibility)) throw new Error('job.eligibility must be eligible, unclear, or ineligible.');
250
263
  if (!POSTING_STATUS.has(postingStatus)) throw new Error('job.postingStatus must be active, closed, or unclear.');
251
264
  if (!JOB_SENIORITIES.has(seniority)) throw new Error('job.seniority is invalid.');
@@ -393,7 +406,12 @@ export function validateLedgerEntry(input) {
393
406
  answers: entry.answers ?? {},
394
407
  };
395
408
  if (entry.employerJobId != null) normalized.employerJobId = string(entry.employerJobId, 'entry.employerJobId', 300);
409
+ if (entry.discoverySource != null) normalized.discoverySource = string(entry.discoverySource, 'entry.discoverySource', 40).toLowerCase();
410
+ if (entry.applicationChannel != null) normalized.applicationChannel = string(entry.applicationChannel, 'entry.applicationChannel', 40).toLowerCase();
411
+ if (entry.roundId != null) normalized.roundId = string(entry.roundId, 'entry.roundId', 180);
396
412
  if (!SOURCES.has(normalized.source)) throw new Error('entry.source is invalid.');
413
+ if (normalized.discoverySource != null && !DISCOVERY_SOURCES.has(normalized.discoverySource)) throw new Error('entry.discoverySource is invalid.');
414
+ if (normalized.applicationChannel != null && !SOURCES.has(normalized.applicationChannel)) throw new Error('entry.applicationChannel is invalid.');
397
415
  if (!Number.isInteger(normalized.score) || normalized.score < 0 || normalized.score > 100) throw new Error('entry.score must be an integer from 0 to 100.');
398
416
  if (normalized.status !== 'submitted') throw new Error('New ledger entries must have status submitted.');
399
417
  if (!APPROVALS.has(normalized.approval)) throw new Error('entry.approval is invalid.');
@@ -596,6 +614,63 @@ async function withStateLock(name, action) {
596
614
  }
597
615
  }
598
616
 
617
+ async function writePrivateJson(file, value) {
618
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
619
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
620
+ await rename(temporary, file);
621
+ await chmod(file, 0o600);
622
+ }
623
+
624
+ async function readPrivateJson(file, fallback) {
625
+ try { return JSON.parse(await readFile(file, 'utf8')); }
626
+ catch (error) {
627
+ if (error.code === 'ENOENT') return fallback;
628
+ throw new Error(`${basename(file)} is missing or unreadable.`);
629
+ }
630
+ }
631
+
632
+ function autonomyView(value) {
633
+ const enabled = value?.enabled === true && value?.mode === 'routine-auto';
634
+ return {
635
+ version: 1,
636
+ enabled,
637
+ mode: enabled ? 'routine-auto' : null,
638
+ scopes: enabled ? [...AUTONOMY_SCOPES] : [],
639
+ hardStops: [...AUTONOMY_HARD_STOPS],
640
+ maySubmitRoutineApplications: enabled,
641
+ maySendVerifiedRecruitingEmail: enabled,
642
+ mayCreateImprovementPr: enabled,
643
+ mayMergeReleaseOrPublish: false,
644
+ mayBypassHostPermissionPrompts: false,
645
+ ...(value?.grantedAt ? { grantedAt: value.grantedAt } : {}),
646
+ ...(value?.revokedAt ? { revokedAt: value.revokedAt } : {}),
647
+ };
648
+ }
649
+
650
+ async function autonomyStatus() {
651
+ const file = join(await ensureStateDir(), 'autonomy.json');
652
+ return autonomyView(await readPrivateJson(file, { version: 1, enabled: false }));
653
+ }
654
+
655
+ async function autonomyGrant(input) {
656
+ const value = object(input, 'autonomy grant');
657
+ const allowed = new Set(['mode']);
658
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown autonomy grant property: ${key}.`);
659
+ if (value.mode !== 'routine-auto') throw new Error('autonomy grant mode must be routine-auto.');
660
+ const grant = { version: 1, enabled: true, mode: 'routine-auto', scopes: [...AUTONOMY_SCOPES], grantedAt: new Date().toISOString() };
661
+ const file = join(await ensureStateDir(), 'autonomy.json');
662
+ await writePrivateJson(file, grant);
663
+ return autonomyView(grant);
664
+ }
665
+
666
+ async function autonomyRevoke() {
667
+ const file = join(await ensureStateDir(), 'autonomy.json');
668
+ const current = await readPrivateJson(file, { version: 1, enabled: false });
669
+ const revoked = { version: 1, enabled: false, revokedAt: new Date().toISOString(), ...(current.grantedAt ? { grantedAt: current.grantedAt } : {}) };
670
+ await writePrivateJson(file, revoked);
671
+ return autonomyView(revoked);
672
+ }
673
+
599
674
  async function storeProfile(profileInput) {
600
675
  const profile = validateProfile(profileInput);
601
676
  if (process.platform === 'win32') await ensureStateDir();
@@ -671,11 +746,22 @@ function duplicateResult(entries, candidate) {
671
746
  || normalizeUrl(entry.url) === candidateUrl);
672
747
  const possible = hard ? null : entries.find(sameCompanyRole);
673
748
  const match = hard ?? possible;
749
+ const companyApplications = candidateCompany
750
+ ? entries.filter((entry) => normalizedText(entry.company) === candidateCompany).slice(-20).map((entry) => ({
751
+ id: entry.id,
752
+ company: entry.company,
753
+ role: entry.role,
754
+ submittedAt: entry.submittedAt,
755
+ ...(entry.employerJobId ? { employerJobId: entry.employerJobId } : {}),
756
+ }))
757
+ : [];
674
758
  return {
675
759
  duplicate: Boolean(hard),
676
760
  possibleDuplicate: Boolean(possible),
677
761
  reason: hard ? (hard.id === candidate.id ? 'id' : candidate.employerJobId && hard.employerJobId ? 'employer-job-id' : 'url') : possible ? 'company-role' : null,
678
762
  match: match ? { id: match.id, company: match.company, role: match.role, submittedAt: match.submittedAt } : null,
763
+ sameCompany: companyApplications.length > 0,
764
+ companyApplications,
679
765
  };
680
766
  }
681
767
 
@@ -691,15 +777,180 @@ async function ledgerAdd(entryInput, duplicateOverride) {
691
777
  return withStateLock('applications', async (dir) => {
692
778
  const file = join(dir, 'applications.ndjson');
693
779
  const entries = await jsonLines(file);
780
+ if (entry.roundId) {
781
+ const roundEvents = await jsonLines(join(dir, 'rounds.ndjson'));
782
+ if (!roundEvents.some((event) => event.type === 'started' && event.roundId === entry.roundId)) throw new Error('entry.roundId does not identify a started round.');
783
+ if (roundEvents.some((event) => event.type === 'completed' && event.roundId === entry.roundId)) throw new Error('entry.roundId identifies a completed round.');
784
+ }
694
785
  const duplicate = duplicateResult(entries, entry);
695
786
  if (duplicate.duplicate) throw new Error('This application is already recorded.');
696
787
  if (duplicate.possibleDuplicate && duplicateOverride !== 'NEW REQUISITION CONFIRMED') throw new Error('A possible same-company role duplicate requires NEW REQUISITION CONFIRMED.');
697
788
  await appendFile(file, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
698
789
  await chmod(file, 0o600);
790
+ if (entry.roundId) {
791
+ const roundsFile = join(dir, 'rounds.ndjson');
792
+ await appendFile(roundsFile, `${JSON.stringify({ type: 'submission-confirmed', roundId: entry.roundId, applicationId: entry.id, occurredAt: entry.submittedAt })}\n`, { mode: 0o600 });
793
+ await chmod(roundsFile, 0o600);
794
+ }
699
795
  return { recorded: entry.id, review: buildReview([...entries, entry]) };
700
796
  });
701
797
  }
702
798
 
799
+ async function appendPrivateEvent(name, event) {
800
+ return withStateLock(name, async (dir) => {
801
+ const file = join(dir, `${name}.ndjson`);
802
+ await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
803
+ await chmod(file, 0o600);
804
+ return event;
805
+ });
806
+ }
807
+
808
+ function isoDate(value, label) {
809
+ const result = string(value ?? new Date().toISOString(), label, 80);
810
+ if (Number.isNaN(Date.parse(result))) throw new Error(`${label} must be an ISO date.`);
811
+ return result;
812
+ }
813
+
814
+ async function roundStart(input) {
815
+ const value = object(input, 'round');
816
+ const allowed = new Set(['requestedCount', 'startedAt']);
817
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown round property: ${key}.`);
818
+ const event = {
819
+ type: 'started',
820
+ roundId: `round-${new Date().toISOString().slice(0, 10)}-${randomUUID()}`,
821
+ requestedCount: integer(value.requestedCount, 'round.requestedCount', 1, 1000),
822
+ occurredAt: isoDate(value.startedAt, 'round.startedAt'),
823
+ };
824
+ await appendPrivateEvent('rounds', event);
825
+ return { roundId: event.roundId, requestedCount: event.requestedCount, startedAt: event.occurredAt, completed: false };
826
+ }
827
+
828
+ function replayAttention(events, roundId = null) {
829
+ const active = new Map();
830
+ for (const event of events) {
831
+ if (event.type === 'opened') active.set(event.id, event);
832
+ else if (event.type === 'resolved') active.delete(event.id);
833
+ }
834
+ const priority = (blocker) => {
835
+ if (['authentication', 'mfa', 'captcha'].includes(blocker)) return 0;
836
+ if (['legal-attestation', 'ambiguous-authorization', 'ambiguous-compensation'].includes(blocker)) return 1;
837
+ return 2;
838
+ };
839
+ return [...active.values()]
840
+ .filter((item) => roundId == null || item.roundId === roundId)
841
+ .sort((a, b) => priority(a.blocker) - priority(b.blocker) || Date.parse(a.createdAt) - Date.parse(b.createdAt));
842
+ }
843
+
844
+ async function attentionList(roundId = null) {
845
+ const events = await jsonLines(join(await ensureStateDir(), 'attention.ndjson'));
846
+ const items = replayAttention(events, roundId);
847
+ return { count: items.length, items };
848
+ }
849
+
850
+ async function attentionAdd(input) {
851
+ const value = object(input, 'attention item');
852
+ const allowed = new Set(['roundId', 'applicationId', 'url', 'stage', 'blocker', 'requiredActions', 'createdAt']);
853
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown attention property: ${key}.`);
854
+ const stage = string(value.stage, 'attention.stage', 40).toLowerCase();
855
+ const blocker = string(value.blocker, 'attention.blocker', 60).toLowerCase();
856
+ if (!ATTENTION_STAGES.has(stage)) throw new Error('attention.stage is invalid.');
857
+ if (!ATTENTION_BLOCKERS.has(blocker)) throw new Error('attention.blocker is invalid.');
858
+ const requiredActions = stringArray(value.requiredActions, 'attention.requiredActions', true).map((item) => item.toLowerCase());
859
+ if (requiredActions.length > 8 || requiredActions.some((item) => !REQUIRED_ACTIONS.has(item))) throw new Error('attention.requiredActions must contain only documented actions.');
860
+ const event = {
861
+ type: 'opened',
862
+ id: `attention-${randomUUID()}`,
863
+ roundId: string(value.roundId, 'attention.roundId', 180),
864
+ applicationId: string(value.applicationId, 'attention.applicationId', 180),
865
+ url: string(value.url, 'attention.url', 2048),
866
+ stage,
867
+ blocker,
868
+ requiredActions: [...new Set(requiredActions)],
869
+ createdAt: isoDate(value.createdAt, 'attention.createdAt'),
870
+ };
871
+ await appendPrivateEvent('attention', event);
872
+ return event;
873
+ }
874
+
875
+ async function attentionResolve(input) {
876
+ const value = object(input, 'attention resolution');
877
+ const allowed = new Set(['id', 'resolvedAt']);
878
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown attention resolution property: ${key}.`);
879
+ const id = string(value.id, 'attention.id', 180);
880
+ const current = await attentionList();
881
+ if (!current.items.some((item) => item.id === id)) throw new Error('Attention item is not active.');
882
+ const event = { type: 'resolved', id, resolvedAt: isoDate(value.resolvedAt, 'attention.resolvedAt') };
883
+ await appendPrivateEvent('attention', event);
884
+ return { resolved: id };
885
+ }
886
+
887
+ async function roundStatus(roundId = null) {
888
+ const dir = await ensureStateDir();
889
+ const events = await jsonLines(join(dir, 'rounds.ndjson'));
890
+ const starts = events.filter((event) => event.type === 'started');
891
+ const id = roundId ?? starts.at(-1)?.roundId;
892
+ if (!id) throw new Error('No application round has been started.');
893
+ const started = starts.find((event) => event.roundId === id);
894
+ if (!started) throw new Error('Application round was not found.');
895
+ const applications = (await jsonLines(join(dir, 'applications.ndjson'))).filter((entry) => entry.roundId === id && entry.status === 'submitted');
896
+ const confirmedCount = new Set(applications.map((entry, index) => canonicalApplicationKey(entry, String(index)))).size;
897
+ const attention = await attentionList(id);
898
+ const completion = events.find((event) => event.type === 'completed' && event.roundId === id);
899
+ return {
900
+ roundId: id,
901
+ requestedCount: started.requestedCount,
902
+ confirmedCount,
903
+ remainingCount: Math.max(0, started.requestedCount - confirmedCount),
904
+ blockedCount: attention.count,
905
+ completed: Boolean(completion),
906
+ startedAt: started.occurredAt,
907
+ ...(completion ? { completedAt: completion.occurredAt } : {}),
908
+ };
909
+ }
910
+
911
+ async function roundComplete(input) {
912
+ const value = object(input, 'round completion');
913
+ const allowed = new Set(['roundId', 'completedAt']);
914
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown round completion property: ${key}.`);
915
+ const status = await roundStatus(string(value.roundId, 'round.roundId', 180));
916
+ if (status.completed) return status;
917
+ if (status.confirmedCount < status.requestedCount) throw new Error(`Round requires ${status.requestedCount} confirmed submissions before completion.`);
918
+ const event = { type: 'completed', roundId: status.roundId, occurredAt: isoDate(value.completedAt, 'round.completedAt') };
919
+ await appendPrivateEvent('rounds', event);
920
+ return { ...status, completed: true, completedAt: event.occurredAt };
921
+ }
922
+
923
+ async function frictionRecord(input) {
924
+ const value = object(input, 'friction event');
925
+ const allowed = new Set(['stage', 'ats', 'errorCode', 'reproducible', 'general', 'observedAt']);
926
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown friction property: ${key}.`);
927
+ const stage = string(value.stage, 'friction.stage', 40).toLowerCase();
928
+ const ats = string(value.ats, 'friction.ats', 40).toLowerCase();
929
+ const errorCode = string(value.errorCode, 'friction.errorCode', 100).toLowerCase();
930
+ if (!ATTENTION_STAGES.has(stage)) throw new Error('friction.stage is invalid.');
931
+ if (!SOURCES.has(ats)) throw new Error('friction.ats is invalid.');
932
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(errorCode)) throw new Error('friction.errorCode must be a stable kebab-case code.');
933
+ if (typeof value.reproducible !== 'boolean' || typeof value.general !== 'boolean') throw new Error('friction.reproducible and friction.general must be Booleans.');
934
+ const event = {
935
+ id: `friction-${randomUUID()}`,
936
+ type: 'observed',
937
+ stage,
938
+ ats,
939
+ errorCode,
940
+ reproducible: value.reproducible,
941
+ general: value.general,
942
+ qualifiesForPr: value.reproducible && value.general,
943
+ observedAt: isoDate(value.observedAt, 'friction.observedAt'),
944
+ };
945
+ await appendPrivateEvent('friction', event);
946
+ return event;
947
+ }
948
+
949
+ async function frictionList() {
950
+ const items = (await jsonLines(join(await ensureStateDir(), 'friction.ndjson'))).filter((event) => event.type === 'observed');
951
+ return { count: items.length, items, prCandidates: items.filter((item) => item.qualifiesForPr) };
952
+ }
953
+
703
954
  async function ledgerOutcome(outcomeInput) {
704
955
  const input = object(outcomeInput, 'outcome');
705
956
  const allowed = new Set(['id', 'status', 'occurredAt', 'note', 'reasons', 'interviewQuality', 'failurePoint']);
@@ -781,7 +1032,7 @@ async function outcomeTelemetry(application, outcome) {
781
1032
  ...identity,
782
1033
  company: application.company,
783
1034
  title: application.role,
784
- ats: sourceToAts(application.source),
1035
+ ats: sourceToAts(application.applicationChannel ?? application.source),
785
1036
  outcome: outcome.status,
786
1037
  daysSinceSubmission: Math.max(0, Math.min(3650, Math.floor((Date.parse(outcome.occurredAt) - Date.parse(application.submittedAt)) / 86_400_000))),
787
1038
  ...(outcome.interviewQuality ? { interviewQuality: outcome.interviewQuality } : {}),
@@ -804,6 +1055,21 @@ function reviewTelemetry(review) {
804
1055
  };
805
1056
  }
806
1057
 
1058
+ function roundCompletedTelemetry(round) {
1059
+ return {
1060
+ event: 'round_completed',
1061
+ properties: {
1062
+ requestedCount: round.requestedCount,
1063
+ submittedCount: round.confirmedCount,
1064
+ assessedCount: round.confirmedCount,
1065
+ skippedCount: 0,
1066
+ pausedCount: round.blockedCount,
1067
+ errorCount: 0,
1068
+ durationBucket: durationBucket(Math.max(0, Date.parse(round.completedAt) - Date.parse(round.startedAt))),
1069
+ },
1070
+ };
1071
+ }
1072
+
807
1073
  async function executeCommand([area, action, value], telemetry, session) {
808
1074
  const domainEvents = [];
809
1075
  let result;
@@ -841,7 +1107,21 @@ async function executeCommand([area, action, value], telemetry, session) {
841
1107
  domainEvents.push(reviewTelemetry(result));
842
1108
  } else if (area === 'ledger' && action === 'review-ack' && value === '--stdin') {
843
1109
  result = await ledgerReviewAcknowledge(await jsonStdin());
844
- } else throw new Error('Usage: 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; telemetry status|enable|disable|reset|preview --stdin|record --stdin');
1110
+ } else if (area === 'autonomy' && action === 'grant' && value === '--stdin') result = await autonomyGrant(await jsonStdin());
1111
+ else if (area === 'autonomy' && action === 'status' && value == null) result = await autonomyStatus();
1112
+ else if (area === 'autonomy' && action === 'preview' && value == null) result = await autonomyStatus();
1113
+ else if (area === 'autonomy' && action === 'revoke' && value == null) result = await autonomyRevoke();
1114
+ else if (area === 'round' && action === 'start' && value === '--stdin') result = await roundStart(await jsonStdin());
1115
+ else if (area === 'round' && action === 'status') result = await roundStatus(value ?? null);
1116
+ else if (area === 'round' && action === 'complete' && value === '--stdin') {
1117
+ result = await roundComplete(await jsonStdin());
1118
+ domainEvents.push(roundCompletedTelemetry(result));
1119
+ } else if (area === 'attention' && action === 'add' && value === '--stdin') result = await attentionAdd(await jsonStdin());
1120
+ else if (area === 'attention' && action === 'list' && value == null) result = await attentionList();
1121
+ else if (area === 'attention' && action === 'resolve' && value === '--stdin') result = await attentionResolve(await jsonStdin());
1122
+ else if (area === 'friction' && action === 'record' && value === '--stdin') result = await frictionRecord(await jsonStdin());
1123
+ else if (area === 'friction' && action === 'list' && value == null) result = await frictionList();
1124
+ else throw new Error('Usage: 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|complete --stdin|status [round-id]; attention add|resolve --stdin|list; friction record --stdin|list; telemetry status|enable|disable|reset|preview --stdin|record --stdin');
845
1125
  for (const event of domainEvents) await telemetry.record(event, session);
846
1126
  return result;
847
1127
  }
@@ -9,7 +9,7 @@ export const TELEMETRY_MAX_BYTES = 4096;
9
9
 
10
10
  const values = (...items) => new Set(items);
11
11
  const ATS = values('linkedin', 'greenhouse', 'lever', 'ashby', 'workable', 'comeet', 'workday', 'rippling', 'smartrecruiters', 'google-form', 'company', 'email', 'other');
12
- const SOURCES = values('linkedin', 'x', 'hacker-news', 'yc', 'aggregator', 'company', 'email', 'referral', 'other');
12
+ const SOURCES = values('direct-company', 'linkedin', 'x', 'hacker-news', 'yc', 'job-board', 'aggregator', 'company', 'email', 'referral', 'user-supplied', 'web-search', 'other');
13
13
  const DURATIONS = values('under-1s', '1-5s', '5-30s', '30-60s', '1-2m', '2-5m', '5-15m', '15m-plus');
14
14
  const COMMANDS = values('onboard', 'search', 'assess', 'apply', 'batch', 'round', 'outcome', 'review', 'resume', 'profile', 'telemetry', 'other');
15
15
  const RESULTS = values('success', 'paused', 'skipped', 'abandoned', 'error');
@@ -14,3 +14,19 @@ test('requires direct path resume upload before native picker fallback', async (
14
14
  assert.match(guidance, /native (file )?picker.*fallback/i);
15
15
  assert.match(guidance, /verify.*filename/i);
16
16
  });
17
+
18
+ test('documents durable autonomy, resumable rounds, attention and friction controls', async () => {
19
+ const skill = await readFile(new URL('../SKILL.md', import.meta.url), 'utf8');
20
+ const autonomy = await readFile(new URL('../references/AUTONOMY.md', import.meta.url), 'utf8');
21
+ const runs = await readFile(new URL('../references/RUNS.md', import.meta.url), 'utf8');
22
+
23
+ assert.match(skill, /autonomy status/);
24
+ assert.match(skill, /round status/);
25
+ assert.match(skill, /attention list/);
26
+ assert.match(skill, /friction record/);
27
+ assert.match(autonomy, /never.*merge.*publish/i);
28
+ assert.match(autonomy, /CAPTCHA/i);
29
+ assert.match(runs, /visible.*confirmation/i);
30
+ assert.match(runs, /discoverySource/);
31
+ assert.match(runs, /applicationChannel/);
32
+ });
@@ -41,6 +41,25 @@ test('accepts rich structured job and workflow events', () => {
41
41
  assert.equal(event.properties.fitScore, 84);
42
42
  });
43
43
 
44
+ test('accepts bounded discovery sources without exposing application URLs or attention details', () => {
45
+ for (const source of ['direct-company', 'job-board', 'user-supplied', 'web-search']) {
46
+ const event = validateEvent({
47
+ event: 'job_discovered',
48
+ properties: {
49
+ ...baseJob,
50
+ source,
51
+ jobCountry: 'India',
52
+ workMode: 'remote',
53
+ seniority: 'staff',
54
+ employmentType: 'full-time',
55
+ roleFamily: 'product-engineering',
56
+ },
57
+ });
58
+ assert.equal(event.properties.source, source);
59
+ assert.equal('url' in event.properties, false);
60
+ }
61
+ });
62
+
44
63
  test('rejects direct identity, free-form content, unknown properties, and oversized payloads', () => {
45
64
  const valid = { event: 'job_assessed', properties: { ...baseJob, fitScore: 80, eligibility: 'eligible', decision: 'review', matchTags: [], gapTags: [] } };
46
65
  assert.throws(() => validateEvent({ ...valid, properties: { ...valid.properties, company: 'candidate@example.com' } }), /identity/i);
@@ -0,0 +1,212 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync, spawnSync } from 'node:child_process';
3
+ import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import test from 'node:test';
7
+
8
+ const script = new URL('../scripts/job-application.mjs', import.meta.url).pathname;
9
+
10
+ async function fixture(t, label) {
11
+ const directory = await mkdtemp(join(tmpdir(), `public-job-agent-${label}-`));
12
+ t.after(() => rm(directory, { recursive: true, force: true }));
13
+ await writeFile(join(directory, 'telemetry.json'), JSON.stringify({
14
+ version: 1,
15
+ enabled: false,
16
+ disclosed: true,
17
+ graceConsumed: true,
18
+ installationEventPending: false,
19
+ }));
20
+ return { directory, env: { ...process.env, JOB_APPLICATION_AGENT_STATE_DIR: directory } };
21
+ }
22
+
23
+ function cli(env, args, input) {
24
+ return JSON.parse(execFileSync(process.execPath, [script, ...args], {
25
+ env,
26
+ encoding: 'utf8',
27
+ ...(input === undefined ? {} : { input: JSON.stringify(input) }),
28
+ }));
29
+ }
30
+
31
+ function cliFailure(env, args, input) {
32
+ return spawnSync(process.execPath, [script, ...args], {
33
+ env,
34
+ encoding: 'utf8',
35
+ ...(input === undefined ? {} : { input: JSON.stringify(input) }),
36
+ });
37
+ }
38
+
39
+ function submission(index, roundId, overrides = {}) {
40
+ return {
41
+ id: `round-role-${index}`,
42
+ company: `Company ${index}`,
43
+ role: 'Senior Product Engineer',
44
+ url: `https://jobs.example.com/${index}`,
45
+ employerJobId: `example:${index}`,
46
+ source: 'ashby',
47
+ discoverySource: 'linkedin',
48
+ applicationChannel: 'ashby',
49
+ roundId,
50
+ score: 86,
51
+ status: 'submitted',
52
+ submittedAt: `2026-08-${String(index + 1).padStart(2, '0')}T10:00:00.000Z`,
53
+ approval: 'STANDING AUTHORIZATION',
54
+ answers: {},
55
+ ...overrides,
56
+ };
57
+ }
58
+
59
+ test('persists a scoped autonomy grant and revokes future routine transmissions', async (t) => {
60
+ const { directory, env } = await fixture(t, 'autonomy');
61
+
62
+ const granted = cli(env, ['autonomy', 'grant', '--stdin'], { mode: 'routine-auto' });
63
+ assert.equal(granted.enabled, true);
64
+ assert.equal(granted.mode, 'routine-auto');
65
+ assert.ok(granted.scopes.includes('submit'));
66
+ assert.ok(granted.scopes.includes('send-recruiting-email'));
67
+ assert.ok(granted.hardStops.includes('captcha'));
68
+
69
+ const status = cli(env, ['autonomy', 'status']);
70
+ assert.equal(status.enabled, true);
71
+ assert.equal(status.mode, 'routine-auto');
72
+ assert.equal((await stat(join(directory, 'autonomy.json'))).mode & 0o777, 0o600);
73
+
74
+ const preview = cli(env, ['autonomy', 'preview']);
75
+ assert.equal(preview.maySubmitRoutineApplications, true);
76
+ assert.equal(preview.mayBypassHostPermissionPrompts, false);
77
+ assert.ok(preview.hardStops.includes('legal-attestation'));
78
+
79
+ const revoked = cli(env, ['autonomy', 'revoke']);
80
+ assert.equal(revoked.enabled, false);
81
+ assert.equal(cli(env, ['autonomy', 'status']).enabled, false);
82
+ });
83
+
84
+ test('counts only unique confirmed ledger submissions for an explicit round', async (t) => {
85
+ const { directory, env } = await fixture(t, 'round');
86
+ const started = cli(env, ['round', 'start', '--stdin'], { requestedCount: 30 });
87
+ assert.match(started.roundId, /^round-/);
88
+
89
+ cli(env, ['attention', 'add', '--stdin'], {
90
+ roundId: started.roundId,
91
+ applicationId: 'blocked-role',
92
+ url: 'https://jobs.example.com/blocked-role',
93
+ stage: 'submission',
94
+ blocker: 'captcha',
95
+ requiredActions: ['complete-captcha'],
96
+ });
97
+
98
+ for (let index = 0; index < 30; index += 1) {
99
+ cli(env, ['ledger', 'add', '--stdin'], submission(index, started.roundId));
100
+ }
101
+
102
+ const status = cli(env, ['round', 'status', started.roundId]);
103
+ assert.equal(status.requestedCount, 30);
104
+ assert.equal(status.confirmedCount, 30);
105
+ assert.equal(status.remainingCount, 0);
106
+ assert.equal(status.blockedCount, 1);
107
+ assert.equal(status.completed, false);
108
+
109
+ const completed = cli(env, ['round', 'complete', '--stdin'], { roundId: started.roundId });
110
+ assert.equal(completed.completed, true);
111
+ assert.equal(cli(env, ['round', 'status', started.roundId]).completed, true);
112
+ const roundEvents = (await readFile(join(directory, 'rounds.ndjson'), 'utf8')).trim().split('\n').map(JSON.parse);
113
+ assert.equal(roundEvents.filter((event) => event.type === 'submission-confirmed').length, 30);
114
+ assert.equal(roundEvents.length, 32);
115
+ assert.equal((await stat(join(directory, 'rounds.ndjson'))).mode & 0o777, 0o600);
116
+ });
117
+
118
+ test('does not complete a round from blocked or partially filled applications', async (t) => {
119
+ const { env } = await fixture(t, 'incomplete-round');
120
+ const { roundId } = cli(env, ['round', 'start', '--stdin'], { requestedCount: 2 });
121
+ cli(env, ['ledger', 'add', '--stdin'], submission(1, roundId));
122
+ cli(env, ['attention', 'add', '--stdin'], {
123
+ roundId,
124
+ applicationId: 'blocked-role',
125
+ url: 'https://jobs.example.com/blocked-role',
126
+ stage: 'submission',
127
+ blocker: 'legal-attestation',
128
+ requiredActions: ['review-legal'],
129
+ });
130
+
131
+ const failed = cliFailure(env, ['round', 'complete', '--stdin'], { roundId });
132
+ assert.equal(failed.status, 1);
133
+ assert.match(failed.stderr, /2 confirmed submissions/i);
134
+ assert.equal(cli(env, ['round', 'status', roundId]).confirmedCount, 1);
135
+ });
136
+
137
+ test('replays and prioritizes an owner-only attention queue', async (t) => {
138
+ const { directory, env } = await fixture(t, 'attention');
139
+ const { roundId } = cli(env, ['round', 'start', '--stdin'], { requestedCount: 30 });
140
+ const judgment = cli(env, ['attention', 'add', '--stdin'], {
141
+ roundId,
142
+ applicationId: 'role-judgment',
143
+ url: 'https://jobs.example.com/judgment',
144
+ stage: 'answers',
145
+ blocker: 'judgment',
146
+ requiredActions: ['provide-judgment'],
147
+ });
148
+ const captcha = cli(env, ['attention', 'add', '--stdin'], {
149
+ roundId,
150
+ applicationId: 'role-captcha',
151
+ url: 'https://jobs.example.com/captcha',
152
+ stage: 'submission',
153
+ blocker: 'captcha',
154
+ requiredActions: ['complete-captcha'],
155
+ });
156
+
157
+ const before = cli(env, ['attention', 'list']);
158
+ assert.deepEqual(before.items.map((item) => item.id), [captcha.id, judgment.id]);
159
+ assert.equal((await stat(join(directory, 'attention.ndjson'))).mode & 0o777, 0o600);
160
+
161
+ cli(env, ['attention', 'resolve', '--stdin'], { id: captcha.id });
162
+ const after = cli(env, ['attention', 'list']);
163
+ assert.deepEqual(after.items.map((item) => item.id), [judgment.id]);
164
+ });
165
+
166
+ test('qualifies only reproducible general-purpose friction for a public PR', async (t) => {
167
+ const { directory, env } = await fixture(t, 'friction');
168
+ const local = cli(env, ['friction', 'record', '--stdin'], {
169
+ stage: 'upload',
170
+ ats: 'ashby',
171
+ errorCode: 'candidate-specific-document',
172
+ reproducible: true,
173
+ general: false,
174
+ });
175
+ const general = cli(env, ['friction', 'record', '--stdin'], {
176
+ stage: 'upload',
177
+ ats: 'ashby',
178
+ errorCode: 'direct-upload-fallback-opened',
179
+ reproducible: true,
180
+ general: true,
181
+ });
182
+
183
+ assert.equal(local.qualifiesForPr, false);
184
+ assert.equal(general.qualifiesForPr, true);
185
+ const listed = cli(env, ['friction', 'list']);
186
+ assert.equal(listed.items.length, 2);
187
+ assert.deepEqual(listed.prCandidates.map((item) => item.id), [general.id]);
188
+ assert.equal((await stat(join(directory, 'friction.ndjson'))).mode & 0o777, 0o600);
189
+ assert.equal((await readFile(join(directory, 'friction.ndjson'), 'utf8')).includes('candidate@example.com'), false);
190
+ });
191
+
192
+ test('preserves independent discovery and application channel attribution', async (t) => {
193
+ const { directory, env } = await fixture(t, 'attribution');
194
+ const { roundId } = cli(env, ['round', 'start', '--stdin'], { requestedCount: 1 });
195
+ cli(env, ['ledger', 'add', '--stdin'], submission(0, roundId));
196
+ const stored = JSON.parse((await readFile(join(directory, 'applications.ndjson'), 'utf8')).trim());
197
+ assert.equal(stored.source, 'ashby');
198
+ assert.equal(stored.discoverySource, 'linkedin');
199
+ assert.equal(stored.applicationChannel, 'ashby');
200
+ assert.equal(stored.roundId, roundId);
201
+
202
+ const companyHistory = cli(env, ['ledger', 'check', '--stdin'], {
203
+ id: 'another-role',
204
+ company: 'Company 0',
205
+ role: 'Staff Backend Engineer',
206
+ url: 'https://jobs.example.com/another-role',
207
+ employerJobId: 'example:another-role',
208
+ });
209
+ assert.equal(companyHistory.duplicate, false);
210
+ assert.equal(companyHistory.sameCompany, true);
211
+ assert.equal(companyHistory.companyApplications.length, 1);
212
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-application-agent",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "A privacy-first Agent Skill and CLI for evidence-based job discovery, application completion, and outcome tracking.",
5
5
  "license": "MIT",
6
6
  "type": "module",