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 +82 -119
- package/job-application-agent/SKILL.md +17 -5
- package/job-application-agent/references/ANALYTICS.md +3 -1
- package/job-application-agent/references/AUTONOMY.md +28 -0
- package/job-application-agent/references/RUNS.md +48 -0
- package/job-application-agent/references/SCHEMAS.md +56 -0
- package/job-application-agent/scripts/job-application.mjs +285 -5
- package/job-application-agent/scripts/telemetry-schema.mjs +1 -1
- package/job-application-agent/tests/skill-contract.test.mjs +16 -0
- package/job-application-agent/tests/telemetry-schema.test.mjs +19 -0
- package/job-application-agent/tests/workflow-state.test.mjs +212 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,190 +2,153 @@
|
|
|
2
2
|
|
|
3
3
|
# 💼 Job Application Agent
|
|
4
4
|
|
|
5
|
-
###
|
|
5
|
+
### Find better roles, apply with verified facts, and learn from outcomes.
|
|
6
6
|
|
|
7
7
|
[](https://github.com/vaibhavarora14/job-application-agent/actions/workflows/validate.yml)
|
|
8
|
-
[](LICENSE)
|
|
9
|
-
[](job-application-agent/SKILL.md)
|
|
10
|
-
[](https://nodejs.org/)
|
|
11
8
|
[](https://www.npmjs.com/package/job-application-agent)
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
[](job-application-agent/SKILL.md)
|
|
12
11
|
|
|
13
|
-
|
|
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
|
|
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
|
-
##
|
|
20
|
+
## 🚀 Get started
|
|
24
21
|
|
|
25
|
-
|
|
22
|
+
Install it:
|
|
26
23
|
|
|
27
24
|
```bash
|
|
28
25
|
npx job-application-agent@latest install
|
|
29
26
|
```
|
|
30
27
|
|
|
31
|
-
|
|
28
|
+
Then tell your coding agent:
|
|
32
29
|
|
|
33
30
|
```text
|
|
34
|
-
|
|
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
|
-
|
|
34
|
+
After onboarding, use natural commands:
|
|
42
35
|
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
63
|
-
|
|
64
|
-
|
|
|
65
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
+
For durable autonomy across resumable or scheduled runs:
|
|
75
63
|
|
|
76
|
-
|
|
77
|
-
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
### 2. Onboard your profile
|
|
68
|
+
Check or revoke it at any time:
|
|
95
69
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
75
|
+
## 🛡️ Safety
|
|
103
76
|
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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["
|
|
122
|
-
B --> C["
|
|
123
|
-
C --> D["
|
|
124
|
-
D --> E
|
|
125
|
-
E --> F
|
|
126
|
-
|
|
127
|
-
F
|
|
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
|
|
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
|
-
|
|
104
|
+
## 🔐 Privacy
|
|
145
105
|
|
|
146
|
-
|
|
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
|
-
|
|
113
|
+
Candidate data, résumés, application history, credentials, and browser sessions are never committed to this repository.
|
|
149
114
|
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
124
|
+
<details>
|
|
125
|
+
<summary><strong>Installation and update details</strong></summary>
|
|
168
126
|
|
|
169
|
-
|
|
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
|
-
```
|
|
172
|
-
|
|
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
|
-
|
|
136
|
+
Updates are staged and validated before replacement. Private candidate state lives outside the replaceable skill directory.
|
|
176
137
|
|
|
177
|
-
|
|
138
|
+
</details>
|
|
178
139
|
|
|
179
|
-
|
|
140
|
+
## 🧰 Develop
|
|
180
141
|
|
|
181
|
-
|
|
142
|
+
```bash
|
|
143
|
+
npm test
|
|
144
|
+
```
|
|
182
145
|
|
|
183
|
-
|
|
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
|
-
##
|
|
148
|
+
## ⚖️ Responsible use
|
|
186
149
|
|
|
187
|
-
|
|
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
|
-
|
|
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.
|
|
26
|
-
7.
|
|
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
|
|
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,
|
|
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
|
|
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