job-application-agent 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -3
- package/job-application-agent/SKILL.md +19 -6
- package/job-application-agent/references/ANALYTICS.md +3 -1
- package/job-application-agent/references/AUTONOMY.md +28 -0
- package/job-application-agent/references/BROWSER_UPLOADS.md +19 -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 +299 -6
- package/job-application-agent/scripts/telemetry-schema.mjs +1 -1
- package/job-application-agent/tests/job-application.test.mjs +14 -0
- package/job-application-agent/tests/skill-contract.test.mjs +32 -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
|
@@ -62,12 +62,12 @@ Updates are staged and validated before replacement. The immediately previous sk
|
|
|
62
62
|
| 🔎 Discover | 🎯 Qualify | 📝 Apply |
|
|
63
63
|
|---|---|---|
|
|
64
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é and drafts truthful short answers. |
|
|
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. |
|
|
66
66
|
|
|
67
67
|
| 🔐 Protect | 📚 Track | 📈 Improve |
|
|
68
68
|
|---|---|---|
|
|
69
69
|
| Keeps profile data in OS-backed storage and browser auth in the browser. | Records only visibly confirmed submissions in a private local ledger. | Reviews results every ten applications and proposes targeting changes. |
|
|
70
|
-
| Stops at sensitive or judgment-heavy steps. | Captures
|
|
70
|
+
| Stops at sensitive or judgment-heavy steps. | Captures explicit round IDs and a resumable attention queue. | Opens tested, sanitized improvement PRs without merging or publishing them. |
|
|
71
71
|
|
|
72
72
|
## 🛡️ Safety by design
|
|
73
73
|
|
|
@@ -104,6 +104,15 @@ The agent will ask for your canonical résumé and missing application facts. Yo
|
|
|
104
104
|
- `review-each` — inspect every completed application before submission.
|
|
105
105
|
- `routine-auto` — allow routine submissions within a destination or batch you explicitly authorize; safety pauses still apply.
|
|
106
106
|
|
|
107
|
+
For continuing autonomy across resumable and scheduled runs, explicitly grant the fixed routine scope once:
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
echo '{"mode":"routine-auto"}' | node ~/.agents/skills/job-application-agent/scripts/job-application.mjs autonomy grant --stdin
|
|
111
|
+
node ~/.agents/skills/job-application-agent/scripts/job-application.mjs autonomy status
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The grant covers routine discovery, filling, canonical résumé upload, submission, verified recruiting email, ledger/outcome updates, completed-tab cleanup, and sanitized improvement-PR creation. It never bypasses login, MFA, CAPTCHA, legal/demographic steps, host permission prompts, or ambiguous facts. `autonomy revoke` stops future routine transmissions without deleting history.
|
|
115
|
+
|
|
107
116
|
### 3. Use natural commands
|
|
108
117
|
|
|
109
118
|
```text
|
|
@@ -111,6 +120,7 @@ search jobs
|
|
|
111
120
|
apply https://company.example/jobs/123
|
|
112
121
|
apply all relevant jobs from this thread: <URL>
|
|
113
122
|
run a round of 10
|
|
123
|
+
show attention queue
|
|
114
124
|
record outcome Company — Senior Engineer — interview
|
|
115
125
|
```
|
|
116
126
|
|
|
@@ -129,7 +139,7 @@ flowchart LR
|
|
|
129
139
|
H --> I["Confirm success and update ledger"]
|
|
130
140
|
```
|
|
131
141
|
|
|
132
|
-
The bundled script provides deterministic profile validation, résumé import, scoring, deduplication, ledger updates, and ten-application reviews. The current coding agent handles discovery and browser interaction while following the guardrails in [`SKILL.md`](job-application-agent/SKILL.md).
|
|
142
|
+
The bundled script provides deterministic profile validation, résumé import, scoring, company/requisition deduplication, explicit round accounting, attention/friction queues, ledger updates, and ten-application reviews. The current coding agent handles discovery and browser interaction while following the guardrails in [`SKILL.md`](job-application-agent/SKILL.md).
|
|
133
143
|
|
|
134
144
|
## 🔐 Privacy model
|
|
135
145
|
|
|
@@ -138,6 +148,7 @@ The bundled script provides deterministic profile validation, résumé import, s
|
|
|
138
148
|
| Candidate profile | OS-backed secret store (macOS Keychain, or Windows Credential Manager + DPAPI) | Never committed |
|
|
139
149
|
| Canonical résumé | Owner-only local state directory | Never committed |
|
|
140
150
|
| Application ledger | Owner-only local state directory | Never committed |
|
|
151
|
+
| Autonomy, round, attention, and friction state | Owner-only local state directory | Never committed |
|
|
141
152
|
| Browser authentication | Existing browser session | Never exported |
|
|
142
153
|
| Skill instructions and scripts | Local skill directory | Version controlled |
|
|
143
154
|
|
|
@@ -22,8 +22,9 @@ Use `scripts/job-application.mjs` for private state and deterministic checks. Re
|
|
|
22
22
|
3. Preserve identity fields during migration. Map legacy `salaryPreference` to `targetCompensation`. Add `compensationFloor` only when the candidate provides an amount, currency, and annual comparison basis.
|
|
23
23
|
4. Store the profile in OS-backed profile storage (macOS Keychain, or Windows Credential Manager with a DPAPI-protected local file). Store the canonical resume and append-only ledgers in the owner-only state directory.
|
|
24
24
|
5. Use `review-each` for per-application approval. Use `routine-auto` only when the current request authorizes the destination or batch and every automatic-eligibility condition passes.
|
|
25
|
-
6.
|
|
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
|
-
7. Upload only the canonical resume unless the candidate explicitly provides another attachment.
|
|
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
|
|
|
@@ -78,12 +82,21 @@ node scripts/job-application.mjs profile migrate --stdin
|
|
|
78
82
|
node scripts/job-application.mjs profile check
|
|
79
83
|
node scripts/job-application.mjs profile field <allowed-field>
|
|
80
84
|
node scripts/job-application.mjs resume import <google-doc-url-or-local-pdf>
|
|
85
|
+
node scripts/job-application.mjs resume path
|
|
81
86
|
node scripts/job-application.mjs score --stdin
|
|
82
87
|
node scripts/job-application.mjs ledger check --stdin
|
|
83
88
|
node scripts/job-application.mjs ledger add --stdin
|
|
84
89
|
node scripts/job-application.mjs ledger outcome --stdin
|
|
85
90
|
node scripts/job-application.mjs ledger review
|
|
86
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
|
|
87
100
|
node scripts/job-application.mjs telemetry status|enable|disable|reset
|
|
88
101
|
node scripts/job-application.mjs telemetry preview --stdin
|
|
89
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,19 @@
|
|
|
1
|
+
# Browser uploads
|
|
2
|
+
|
|
3
|
+
Use this procedure whenever an application requires the canonical resume or another candidate-authorized attachment.
|
|
4
|
+
|
|
5
|
+
1. Run `node scripts/job-application.mjs resume path` and use the returned absolute path. Stop if the command reports that no canonical resume is imported.
|
|
6
|
+
2. Read the selected browser tool's upload documentation. Prefer its privileged path-based upload capability over native UI automation.
|
|
7
|
+
3. When the browser exposes a file chooser, start waiting for the chooser before clicking the actual `input[type="file"]` or its associated upload control, then set the absolute path directly. For example:
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
const chooserPromise = tab.playwright.waitForEvent("filechooser", { timeoutMs: 10000 });
|
|
11
|
+
await tab.playwright.locator('input[type="file"]').click();
|
|
12
|
+
const chooser = await chooserPromise;
|
|
13
|
+
await chooser.setFiles([resumePath]);
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
4. Use an equivalent documented primitive such as `setInputFiles` when that is what the selected browser provides. Do not assign `input.value`, synthesize a `DataTransfer`, inject file bytes through page JavaScript, or inspect browser session storage.
|
|
17
|
+
5. Use a native file picker only as a fallback after the privileged upload path is unavailable or fails and the browser-specific troubleshooting has been exhausted. Never make Finder, Explorer, or another visible picker the default flow.
|
|
18
|
+
6. Wait for the ATS to finish uploading or parsing. Verify the displayed filename, attachment success state, and any fields repopulated from the resume. Restore verified fields that parsing cleared or changed before continuing.
|
|
19
|
+
7. Keep the local path, filename, file bytes, and resume metadata out of telemetry, application answers, logs, and third-party messages.
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { createHash } from 'node:crypto';
|
|
4
|
-
import { appendFile, chmod, mkdir, open, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
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';
|
|
7
7
|
|
|
@@ -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();
|
|
@@ -649,6 +724,18 @@ async function importResume(source) {
|
|
|
649
724
|
return { path: target, sha256: metadata.sha256, bytes: metadata.bytes };
|
|
650
725
|
}
|
|
651
726
|
|
|
727
|
+
async function canonicalResumePath() {
|
|
728
|
+
const target = join(await ensureStateDir(), 'resume.pdf');
|
|
729
|
+
try {
|
|
730
|
+
const details = await stat(target);
|
|
731
|
+
if (!details.isFile()) throw new Error('Canonical resume path is not a file. Import the resume again.');
|
|
732
|
+
} catch (error) {
|
|
733
|
+
if (error.code === 'ENOENT') throw new Error('Canonical resume is not imported. Run resume import first.');
|
|
734
|
+
throw error;
|
|
735
|
+
}
|
|
736
|
+
return { path: target };
|
|
737
|
+
}
|
|
738
|
+
|
|
652
739
|
function duplicateResult(entries, candidate) {
|
|
653
740
|
const candidateCompany = normalizedText(candidate.company);
|
|
654
741
|
const candidateRole = normalizedText(candidate.role);
|
|
@@ -659,11 +746,22 @@ function duplicateResult(entries, candidate) {
|
|
|
659
746
|
|| normalizeUrl(entry.url) === candidateUrl);
|
|
660
747
|
const possible = hard ? null : entries.find(sameCompanyRole);
|
|
661
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
|
+
: [];
|
|
662
758
|
return {
|
|
663
759
|
duplicate: Boolean(hard),
|
|
664
760
|
possibleDuplicate: Boolean(possible),
|
|
665
761
|
reason: hard ? (hard.id === candidate.id ? 'id' : candidate.employerJobId && hard.employerJobId ? 'employer-job-id' : 'url') : possible ? 'company-role' : null,
|
|
666
762
|
match: match ? { id: match.id, company: match.company, role: match.role, submittedAt: match.submittedAt } : null,
|
|
763
|
+
sameCompany: companyApplications.length > 0,
|
|
764
|
+
companyApplications,
|
|
667
765
|
};
|
|
668
766
|
}
|
|
669
767
|
|
|
@@ -679,15 +777,180 @@ async function ledgerAdd(entryInput, duplicateOverride) {
|
|
|
679
777
|
return withStateLock('applications', async (dir) => {
|
|
680
778
|
const file = join(dir, 'applications.ndjson');
|
|
681
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
|
+
}
|
|
682
785
|
const duplicate = duplicateResult(entries, entry);
|
|
683
786
|
if (duplicate.duplicate) throw new Error('This application is already recorded.');
|
|
684
787
|
if (duplicate.possibleDuplicate && duplicateOverride !== 'NEW REQUISITION CONFIRMED') throw new Error('A possible same-company role duplicate requires NEW REQUISITION CONFIRMED.');
|
|
685
788
|
await appendFile(file, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
686
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
|
+
}
|
|
687
795
|
return { recorded: entry.id, review: buildReview([...entries, entry]) };
|
|
688
796
|
});
|
|
689
797
|
}
|
|
690
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
|
+
|
|
691
954
|
async function ledgerOutcome(outcomeInput) {
|
|
692
955
|
const input = object(outcomeInput, 'outcome');
|
|
693
956
|
const allowed = new Set(['id', 'status', 'occurredAt', 'note', 'reasons', 'interviewQuality', 'failurePoint']);
|
|
@@ -769,7 +1032,7 @@ async function outcomeTelemetry(application, outcome) {
|
|
|
769
1032
|
...identity,
|
|
770
1033
|
company: application.company,
|
|
771
1034
|
title: application.role,
|
|
772
|
-
ats: sourceToAts(application.source),
|
|
1035
|
+
ats: sourceToAts(application.applicationChannel ?? application.source),
|
|
773
1036
|
outcome: outcome.status,
|
|
774
1037
|
daysSinceSubmission: Math.max(0, Math.min(3650, Math.floor((Date.parse(outcome.occurredAt) - Date.parse(application.submittedAt)) / 86_400_000))),
|
|
775
1038
|
...(outcome.interviewQuality ? { interviewQuality: outcome.interviewQuality } : {}),
|
|
@@ -792,6 +1055,21 @@ function reviewTelemetry(review) {
|
|
|
792
1055
|
};
|
|
793
1056
|
}
|
|
794
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
|
+
|
|
795
1073
|
async function executeCommand([area, action, value], telemetry, session) {
|
|
796
1074
|
const domainEvents = [];
|
|
797
1075
|
let result;
|
|
@@ -806,6 +1084,7 @@ async function executeCommand([area, action, value], telemetry, session) {
|
|
|
806
1084
|
if (![...STRING_PROFILE_FIELDS, ...ARRAY_PROFILE_FIELDS, ...NUMBER_PROFILE_FIELDS, ...OBJECT_PROFILE_FIELDS].includes(value)) throw new Error('Profile field is not allowed.');
|
|
807
1085
|
result = { [value]: storedProfile()[value] ?? null };
|
|
808
1086
|
} else if (area === 'resume' && action === 'import' && value) result = await importResume(value);
|
|
1087
|
+
else if (area === 'resume' && action === 'path' && value == null) result = await canonicalResumePath();
|
|
809
1088
|
else if (area === 'score' && action === '--stdin') {
|
|
810
1089
|
const job = await jsonStdin();
|
|
811
1090
|
result = scoreJob(job, job.target ?? storedProfile());
|
|
@@ -828,7 +1107,21 @@ async function executeCommand([area, action, value], telemetry, session) {
|
|
|
828
1107
|
domainEvents.push(reviewTelemetry(result));
|
|
829
1108
|
} else if (area === 'ledger' && action === 'review-ack' && value === '--stdin') {
|
|
830
1109
|
result = await ledgerReviewAcknowledge(await jsonStdin());
|
|
831
|
-
} 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');
|
|
832
1125
|
for (const event of domainEvents) await telemetry.record(event, session);
|
|
833
1126
|
return result;
|
|
834
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');
|
|
@@ -65,6 +65,20 @@ test('validates a candidate-defined target profile', () => {
|
|
|
65
65
|
assert.throws(() => validateProfile({ ...target, submissionMode: 'always' }), /review-each/);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
test('returns the canonical resume path for direct browser uploads', async (t) => {
|
|
69
|
+
const directory = await mkdtemp(join(tmpdir(), 'public-job-agent-resume-path-'));
|
|
70
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
71
|
+
const script = new URL('../scripts/job-application.mjs', import.meta.url).pathname;
|
|
72
|
+
const resume = join(directory, 'resume.pdf');
|
|
73
|
+
await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
|
|
74
|
+
await writeFile(resume, '%PDF-1.7\ncanonical resume fixture');
|
|
75
|
+
const env = { ...process.env, JOB_APPLICATION_AGENT_STATE_DIR: directory };
|
|
76
|
+
|
|
77
|
+
const result = JSON.parse(execFileSync(process.execPath, [script, 'resume', 'path'], { env, encoding: 'utf8' }));
|
|
78
|
+
|
|
79
|
+
assert.deepEqual(result, { path: resume });
|
|
80
|
+
});
|
|
81
|
+
|
|
68
82
|
test('migrates a legacy profile without discarding identity or salary preference', () => {
|
|
69
83
|
const legacy = {
|
|
70
84
|
name: 'Test Candidate', email: 'candidate@example.com', phone: '+1 555 0100',
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
|
|
5
|
+
test('requires direct path resume upload before native picker fallback', async () => {
|
|
6
|
+
const skill = await readFile(new URL('../SKILL.md', import.meta.url), 'utf8');
|
|
7
|
+
const guidance = await readFile(new URL('../references/BROWSER_UPLOADS.md', import.meta.url), 'utf8');
|
|
8
|
+
|
|
9
|
+
assert.match(skill, /resume path/);
|
|
10
|
+
assert.match(skill, /BROWSER_UPLOADS\.md/);
|
|
11
|
+
assert.match(guidance, /absolute path/i);
|
|
12
|
+
assert.match(guidance, /file chooser/i);
|
|
13
|
+
assert.match(guidance, /setFiles/);
|
|
14
|
+
assert.match(guidance, /native (file )?picker.*fallback/i);
|
|
15
|
+
assert.match(guidance, /verify.*filename/i);
|
|
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