job-application-agent 3.0.1 → 3.1.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.
package/README.md CHANGED
@@ -2,190 +2,153 @@
2
2
 
3
3
  # 💼 Job Application Agent
4
4
 
5
- ### A privacy-first Agent Skill for a more disciplined job search
5
+ ### Find better roles, apply with verified facts, and learn from outcomes.
6
6
 
7
7
  [![Validate](https://github.com/vaibhavarora14/job-application-agent/actions/workflows/validate.yml/badge.svg)](https://github.com/vaibhavarora14/job-application-agent/actions/workflows/validate.yml)
8
- [![MIT License](https://img.shields.io/badge/license-MIT-2563EB.svg)](LICENSE)
9
- [![Agent Skill](https://img.shields.io/badge/Agent_Skills-compatible-111827)](job-application-agent/SKILL.md)
10
- [![Node 20+](https://img.shields.io/badge/Node.js-20%2B-339933?logo=nodedotjs&logoColor=white)](https://nodejs.org/)
11
8
  [![npm](https://img.shields.io/npm/v/job-application-agent?logo=npm&color=CB3837)](https://www.npmjs.com/package/job-application-agent)
9
+ [![MIT License](https://img.shields.io/badge/license-MIT-2563EB.svg)](LICENSE)
10
+ [![Agent Skills](https://img.shields.io/badge/Agent_Skills-compatible-111827)](job-application-agent/SKILL.md)
12
11
 
13
- **Discover better roles · Apply with verified facts · Keep private data local · Learn from outcomes**
14
-
15
- [Quick start](#-quick-start) · [How it works](#-how-it-works) · [Safety](#-safety-by-design) · [Privacy](#-privacy-model) · [Public dashboard](https://job-application-agent-telemetry.varora1406.workers.dev/) · [Analytics](#-usage-analytics) · [Security](SECURITY.md)
12
+ [Get started](#-get-started) · [Safety](#-safety) · [Privacy](#-privacy) · [Dashboard](https://job-application-agent-telemetry.varora1406.workers.dev/) · [Security](SECURITY.md)
16
13
 
17
14
  </div>
18
15
 
19
16
  ---
20
17
 
21
- Job Application Agent helps you discover, qualify, complete, and track your own job applications from any Agent Skills-compatible coding agent—without inventing credentials or hiding consequential decisions. It combines browser-assisted form filling with a verified résumé, candidate-defined targeting, secure local profile storage, duplicate detection, and an application ledger.
18
+ Job Application Agent is an [Agent Skill](https://agentskills.io/specification) that helps a coding agent discover, evaluate, complete, and track your own job applications. It uses one verified résumé, checks eligibility and duplicates, and records only confirmed submissions.
22
19
 
23
- ## Install for humans and agents
20
+ ## 🚀 Get started
24
21
 
25
- Run this one canonical command in a terminal:
22
+ Install it:
26
23
 
27
24
  ```bash
28
25
  npx job-application-agent@latest install
29
26
  ```
30
27
 
31
- If you are asking a coding agent to set it up, paste this:
28
+ Then tell your coding agent:
32
29
 
33
30
  ```text
34
- Install the Job Application Agent by running:
35
- npx job-application-agent@latest install
36
-
37
- Then confirm the installed version with:
38
- npx job-application-agent@latest status
31
+ Use job-application-agent to onboard my résumé and job preferences.
39
32
  ```
40
33
 
41
- Requires Node.js 20 or newer. The installer places the skill at `~/.agents/skills/job-application-agent` and enables automatic updates by default. Compatible agents load that vendor-neutral path; if a vendor skills directory such as `~/.cursor/skills` already exists, the installer also copies the skill there. The updater checks npm at login, every hour on macOS, Linux, and Windows, and at the start of a job-application workflow. Candidate profile data, the canonical résumé, telemetry identity, and application ledgers remain outside the replaceable skill directory.
34
+ After onboarding, use natural commands:
42
35
 
43
- ```bash
44
- # See the installed version and update mode
45
- npx job-application-agent@latest status
46
-
47
- # Update immediately
48
- npx job-application-agent@latest update
49
-
50
- # Explicitly opt out or back in
51
- npx job-application-agent@latest updates disable
52
- npx job-application-agent@latest updates enable
36
+ ```text
37
+ search jobs
38
+ apply https://company.example/jobs/123
39
+ apply all relevant jobs from this thread: <URL>
40
+ run a round of 10
41
+ show attention queue
42
+ record outcome Company — Senior Engineer — interview
53
43
  ```
54
44
 
55
- Updates are staged and validated before replacement. The immediately previous skill version is retained locally so a failed installation leaves the working version intact.
56
-
57
- > [!IMPORTANT]
58
- > You stay in control. The agent pauses for authentication, CAPTCHA, legal attestations, demographic questions, unclear eligibility, sensitive identifiers, and unverifiable claims.
45
+ Requires Node.js 20 or newer and a browser-capable coding agent.
59
46
 
60
47
  ## ✨ What it does
61
48
 
62
- | 🔎 Discover | 🎯 Qualify | 📝 Apply |
63
- |---|---|---|
64
- | Finds active roles on direct career pages and major ATS platforms. | Scores seniority, skills, location, eligibility, work mode, and compensation. | Fills forms using only verified profile and résumé facts. |
65
- | Resolves social and aggregator leads to direct employer pages. | Skips closed, duplicated, ineligible, and weak-fit opportunities. | Uploads one canonical résumé directly by path when the browser supports it, and drafts truthful short answers. |
49
+ | Stage | Behavior |
50
+ |---|---|
51
+ | **Discover** | Searches direct career pages, ATS platforms, and candidate-provided leads. |
52
+ | **Qualify** | Checks seniority, skills, location, authorization, compensation, and posting status. |
53
+ | **Apply** | Fills forms and uploads one canonical résumé using verified facts only. |
54
+ | **Track** | Deduplicates applications and records only visible submission confirmations. |
55
+ | **Improve** | Reviews outcomes and proposes targeting changes without rewriting candidate facts. |
66
56
 
67
- | 🔐 Protect | 📚 Track | 📈 Improve |
68
- |---|---|---|
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. |
57
+ ## 🤖 Choose your autonomy level
71
58
 
72
- ## 🛡️ Safety by design
59
+ - **`review-each`** review every completed application before submission.
60
+ - **`routine-auto`** — allow routine submissions while keeping sensitive and judgment-heavy steps with you.
73
61
 
74
- The skill deliberately pauses for decisions or actions that should stay with you:
62
+ For durable autonomy across resumable or scheduled runs:
75
63
 
76
- - passwords, SSO, MFA, and CAPTCHA;
77
- - demographic and voluntary self-identification questions;
78
- - legal attestations and government identifiers;
79
- - unclear work authorization, sponsorship, location, or compensation;
80
- - claims that cannot be verified from your profile or résumé.
81
-
82
- It never reads browser cookies or session files, and it records an application as submitted only after the destination shows a clear success confirmation.
83
-
84
- ## 🚀 Quick start
85
-
86
- ### 1. Install the skill
87
-
88
- ```sh
89
- npx job-application-agent@latest install
64
+ ```bash
65
+ echo '{"mode":"routine-auto"}' | node ~/.agents/skills/job-application-agent/scripts/job-application.mjs autonomy grant --stdin
90
66
  ```
91
67
 
92
- This is the supported installation path for both people and coding agents. After install, ask the current agent to list available skills if it does not pick up `job-application-agent` immediately.
93
-
94
- ### 2. Onboard your profile
68
+ Check or revoke it at any time:
95
69
 
96
- Start a new agent chat and invoke this skill however your agent names skills (`$job-application-agent`, `/job-application-agent`, or natural language):
97
-
98
- ```text
99
- Use job-application-agent to onboard my resume and job-search preferences.
70
+ ```bash
71
+ node ~/.agents/skills/job-application-agent/scripts/job-application.mjs autonomy status
72
+ node ~/.agents/skills/job-application-agent/scripts/job-application.mjs autonomy revoke
100
73
  ```
101
74
 
102
- The agent will ask for your canonical résumé and missing application facts. You choose one of two submission modes:
75
+ ## 🛡️ Safety
103
76
 
104
- - `review-each` inspect every completed application before submission.
105
- - `routine-auto` — allow routine submissions within a destination or batch you explicitly authorize; safety pauses still apply.
77
+ The agent pauses for:
106
78
 
107
- ### 3. Use natural commands
79
+ - passwords, SSO, MFA, and CAPTCHA;
80
+ - legal attestations and government identifiers;
81
+ - demographic or voluntary self-identification questions;
82
+ - unclear work authorization, sponsorship, location, or compensation;
83
+ - claims that cannot be verified from your profile or résumé;
84
+ - browser or operating-system permission prompts.
108
85
 
109
- ```text
110
- search jobs
111
- apply https://company.example/jobs/123
112
- apply all relevant jobs from this thread: <URL>
113
- run a round of 10
114
- record outcome Company — Senior Engineer — interview
115
- ```
86
+ It never reads browser cookies or session files, bypasses access controls, or counts a filled form as a submission.
116
87
 
117
88
  ## 🧭 How it works
118
89
 
119
90
  ```mermaid
120
91
  flowchart LR
121
- A["Verified résumé + profile"] --> B["Discover direct roles"]
122
- B --> C["Validate activity and eligibility"]
123
- C --> D["Score and deduplicate"]
124
- D --> E["Fill truthful application"]
125
- E --> F{"Manual action needed?"}
126
- F -- Yes --> G["Pause for candidate"]
127
- F -- No --> H["Validate and submit"]
128
- G --> H
129
- H --> I["Confirm success and update ledger"]
92
+ A["Verified résumé + profile"] --> B["Find active roles"]
93
+ B --> C["Check fit + duplicates"]
94
+ C --> D["Fill truthful application"]
95
+ D --> E{"Needs you?"}
96
+ E -- Yes --> F["Attention queue"]
97
+ E -- No --> G["Submit"]
98
+ F --> G
99
+ G --> H["Confirm + record"]
130
100
  ```
131
101
 
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).
133
-
134
- ## 🔐 Privacy model
135
-
136
- | Data | Storage | Repository |
137
- |---|---|---|
138
- | Candidate profile | OS-backed secret store (macOS Keychain, or Windows Credential Manager + DPAPI) | Never committed |
139
- | Canonical résumé | Owner-only local state directory | Never committed |
140
- | Application ledger | Owner-only local state directory | Never committed |
141
- | Browser authentication | Existing browser session | Never exported |
142
- | Skill instructions and scripts | Local skill directory | Version controlled |
102
+ The bundled CLI handles private profile storage, résumé import, scoring, duplicate checks, resumable rounds, attention queues, and application/outcome ledgers. The coding agent handles discovery and browser interaction under the rules in [`SKILL.md`](job-application-agent/SKILL.md).
143
103
 
144
- The package contains no candidate profile, résumé, application history, credentials, or browser data. The included [`.gitignore`](.gitignore) adds a second line of defense against committing common private artifacts.
104
+ ## 🔐 Privacy
145
105
 
146
- ## 📊 Usage analytics
106
+ | Data | Where it stays |
107
+ |---|---|
108
+ | Profile | macOS Keychain or Windows Credential Manager |
109
+ | Résumé and ledgers | Owner-only local state directory |
110
+ | Browser login | Existing browser session |
111
+ | Skill code | Version-controlled installation directory |
147
112
 
148
- Structured anonymous usage analytics are enabled by default so the project can learn which discovery sources, job segments, ATS platforms, and application steps work well. Analytics may include company, role title, job domain/hash, published salary band, fit score, workflow stages, pauses, submissions, outcomes, and bounded interview-quality/failure-point categories. Local reviews also correlate interview quality with source and fit-score bands; private notes never enter analytics.
113
+ Candidate data, résumés, application history, credentials, and browser sessions are never committed to this repository.
149
114
 
150
- It never includes candidate identity, profile fields, résumé content, prompts, form answers, notes, browser data, IP addresses, or raw errors. A Cloudflare relay validates the schema before forwarding personless events to a private PostHog dashboard.
115
+ Anonymous structured analytics are enabled by default to improve the agent. They may include job and workflow categories, but never candidate identity, résumé content, prompts, answers, browser data, IP addresses, or raw errors.
151
116
 
152
- The [public usage dashboard](https://job-application-agent-telemetry.varora1406.workers.dev/) shows aggregate installations, activity, applications, outcomes, ATS mix, and role seniority. The relay increments a separate aggregate-only Cloudflare D1 store after PostHog accepts each event. The public API exposes no raw events or installation identifiers and suppresses small segments.
153
-
154
- ```sh
117
+ ```bash
155
118
  node ~/.agents/skills/job-application-agent/scripts/job-application.mjs telemetry status
156
119
  node ~/.agents/skills/job-application-agent/scripts/job-application.mjs telemetry disable
157
120
  ```
158
121
 
159
- See the complete event contract, retention policy, and controls in [`ANALYTICS.md`](job-application-agent/references/ANALYTICS.md).
160
-
161
- ## 🧰 Requirements
162
-
163
- - Node.js 20 or newer
164
- - A browser-capable coding agent that loads [Agent Skills](https://agentskills.io/specification)
165
- - OS-backed profile storage (macOS Keychain, or Windows Credential Manager + DPAPI)
122
+ See [`ANALYTICS.md`](job-application-agent/references/ANALYTICS.md) for the event contract and retention policy, or view the [public aggregate dashboard](https://job-application-agent-telemetry.varora1406.workers.dev/).
166
123
 
167
- Secure profile storage is supported on macOS and Windows. Linux can keep ledgers and a canonical résumé locally, but profile storage needs macOS or Windows.
124
+ <details>
125
+ <summary><strong>Installation and update details</strong></summary>
168
126
 
169
- ## Validate locally
127
+ The installer places the skill at `~/.agents/skills/job-application-agent` and enables automatic updates by default. Compatible vendor skill directories are also supported when they already exist.
170
128
 
171
- ```sh
172
- npm test
129
+ ```bash
130
+ npx job-application-agent@latest status
131
+ npx job-application-agent@latest update
132
+ npx job-application-agent@latest updates disable
133
+ npx job-application-agent@latest updates enable
173
134
  ```
174
135
 
175
- GitHub Actions validates the skill frontmatter and runs the same test suite on every push and pull request.
136
+ Updates are staged and validated before replacement. Private candidate state lives outside the replaceable skill directory.
176
137
 
177
- ## 💬 Use without installation
138
+ </details>
178
139
 
179
- Paste [`SHARE_PROMPT.md`](SHARE_PROMPT.md) into a new agent chat. The installed skill is recommended for repeat use because it bundles deterministic checks and private local state handling.
140
+ ## 🧰 Develop
180
141
 
181
- ## ⚖️ Responsible use
142
+ ```bash
143
+ npm test
144
+ ```
182
145
 
183
- This project assists a person with their own job search. It does not guarantee interviews, offers, eligibility, or application accuracy. You are responsible for reviewing factual claims, complying with applicable laws and platform terms, and deciding when an application should be submitted. Do not use it to impersonate another person, evade access controls, bypass CAPTCHA, or make deceptive claims.
146
+ GitHub Actions validates the skill and runs the same test suite on every pull request. To try the workflow without installing, paste [`SHARE_PROMPT.md`](SHARE_PROMPT.md) into a new agent chat.
184
147
 
185
- ## 🛡️ Security
148
+ ## ⚖️ Responsible use
186
149
 
187
- Please report suspected privacy or security issues using the private process in [`SECURITY.md`](SECURITY.md). Do not open a public issue containing personal data, credentials, résumé content, or application records.
150
+ Use this project only for your own job search. It does not guarantee interviews, offers, eligibility, or application accuracy. Never use it to impersonate another person, bypass CAPTCHA, evade access controls, or make deceptive claims.
188
151
 
189
- ## 📄 License
152
+ Report privacy or security issues through the process in [`SECURITY.md`](SECURITY.md). Do not open a public issue containing personal data or application records.
190
153
 
191
154
  Released under the [MIT License](LICENSE).
@@ -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.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",