job-application-agent 3.3.0 → 3.4.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 +5 -1
- package/job-application-agent/SKILL.md +5 -2
- package/job-application-agent/references/ANALYTICS.md +24 -7
- package/job-application-agent/references/RUNS.md +38 -0
- package/job-application-agent/references/SCHEMAS.md +4 -2
- package/job-application-agent/references/SOURCES.md +1 -1
- package/job-application-agent/scripts/job-application.mjs +124 -13
- package/job-application-agent/scripts/telemetry-client.mjs +35 -8
- package/job-application-agent/scripts/telemetry-schema.mjs +30 -3
- package/job-application-agent/scripts/version.mjs +1 -1
- package/job-application-agent/tests/job-application.test.mjs +77 -0
- package/job-application-agent/tests/telemetry-client.test.mjs +91 -2
- package/job-application-agent/tests/telemetry-schema.test.mjs +22 -0
- package/job-application-agent/tests/workflow-state.test.mjs +74 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -108,11 +108,14 @@ The bundled CLI handles private profile storage, résumé import, scoring, dupli
|
|
|
108
108
|
|
|
109
109
|
Discovery combines the reviewed [`SOURCES.json`](job-application-agent/references/SOURCES.json) catalog with an anonymous community registry. Every confirmed application automatically contributes its canonical public job URL, company, role, application channel, and provider; prior confirmed ledger entries backfill during later commands after a one-command disclosure grace period. Jobs, repeatable boards, and feeds are logged pending and become visible in the public dashboard or CLI only after maintainer review. Disable both forms of community sharing independently from analytics with `sources sharing disable`.
|
|
110
110
|
|
|
111
|
+
Rounds require recorded attempts across at least three distinct discovery sources, including a successful search, and source attribution for confirmed submissions. If more than 60% of submissions come from one source, the agent must explain the concentration. Blockers and empty results are reported; fit requirements never change to meet a source quota. See [round coverage](job-application-agent/references/RUNS.md#discovery-coverage).
|
|
112
|
+
|
|
111
113
|
## 🔐 Privacy
|
|
112
114
|
|
|
113
115
|
| Data | Where it stays |
|
|
114
116
|
|---|---|
|
|
115
117
|
| Profile | macOS Keychain, Windows Credential Manager, or Linux Secret Service (libsecret) |
|
|
118
|
+
| Analytics name and email (unless opted out) | Private PostHog events after disclosure; local sharing preference in owner-only state |
|
|
116
119
|
| Résumé and ledgers | Owner-only local state directory |
|
|
117
120
|
| Browser login | Existing browser session |
|
|
118
121
|
| Community-sharing preference and delivery receipts | Owner-only local state directory |
|
|
@@ -120,12 +123,13 @@ Discovery combines the reviewed [`SOURCES.json`](job-application-agent/reference
|
|
|
120
123
|
|
|
121
124
|
Candidate data, résumés, application history, credentials, and browser sessions are never committed to this repository.
|
|
122
125
|
|
|
123
|
-
|
|
126
|
+
Structured usage analytics and name/email sharing are enabled by default for support and product improvement. After a disclosure command with no identity transmission, subsequent commands include the name and email explicitly saved in the candidate profile in the maintainer's private PostHog analytics. No résumé content, other profile fields, prompts, answers, browser data, IP addresses, or raw errors are sent. `telemetry identity disable` stops identity sharing and rotates the analytics UUID so future usage is anonymous; `telemetry disable` stops all analytics. Previously collected events remain subject to the retention policy.
|
|
124
127
|
|
|
125
128
|
Anonymous community sharing is also enabled by default, separately from analytics. Confirmed applications share only a canonical public job URL, company, role, application channel, optional coarse discovery source, and derived provider URL—never candidate identity, answers, résumé, score, referral parameters, or submission timestamp. Repeatable discovery surfaces share their bounded catalog metadata through a maintainer-review queue. The registry stores no raw installation IDs; record-scoped contributor hashes are used only for deduplication and counts, never as identity or publication authority.
|
|
126
129
|
|
|
127
130
|
```bash
|
|
128
131
|
node ~/.agents/skills/job-application-agent/scripts/job-application.mjs telemetry status
|
|
132
|
+
node ~/.agents/skills/job-application-agent/scripts/job-application.mjs telemetry identity disable
|
|
129
133
|
node ~/.agents/skills/job-application-agent/scripts/job-application.mjs telemetry disable
|
|
130
134
|
node ~/.agents/skills/job-application-agent/scripts/job-application.mjs sources sharing status
|
|
131
135
|
node ~/.agents/skills/job-application-agent/scripts/job-application.mjs sources sharing disable
|
|
@@ -24,7 +24,7 @@ Use `scripts/job-application.mjs` for private state and deterministic checks. Re
|
|
|
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
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
26
|
7. Obey browser and tool confirmation requirements regardless of the stored mode or autonomy grant.
|
|
27
|
-
8. Disclose default-enabled structured
|
|
27
|
+
8. Disclose default-enabled structured usage analytics and separate default-enabled name/email sharing with the maintainer through private PostHog analytics for support and product improvement. Explain `telemetry identity disable` to keep future analytics anonymous and `telemetry disable` to stop all analytics. Relay the CLI disclosure to the user before running another command; the disclosure command never sends identity. Use only the explicit saved candidate profile name/email, never names or emails scraped from conversation, résumés, job pages, or recruiter contacts. Honor an opt-out immediately. Disclose default-enabled anonymous community sharing of confirmed public job links and repeatable discovery sources, plus the independent `sources sharing disable` control. The CLI also displays these disclosures before the first eligible transmission.
|
|
28
28
|
|
|
29
29
|
Never store passwords, MFA codes, government IDs, demographic data, CAPTCHA answers, browser session data, or inferred candidate facts.
|
|
30
30
|
|
|
@@ -33,6 +33,7 @@ Never store passwords, MFA codes, government IDs, demographic data, CAPTCHA answ
|
|
|
33
33
|
Read [references/SOURCES.md](references/SOURCES.md) before the first discovery pass in a workflow.
|
|
34
34
|
|
|
35
35
|
1. Run `sources jobs` for recently confirmed direct job links and `sources list` (optionally filtered) for the highest-signal packaged and maintainer-reviewed discovery sources. Resolve every lead to the direct employer or ATS page.
|
|
36
|
+
For each round, select at least three distinct relevant discovery sources before applying. Search across them before working deeply through one feed; include alternatives to the previous round's dominant source. Record each actual search, including zero suitable results, or an observed access blocker with `round source --stdin`. Two YC views count as one network; recruiter inboxes and user-supplied links supplement discovery but do not satisfy the three-source minimum. Do not claim that listing the catalog means a board was searched. Keep a blocked source in the report and continue to accessible alternatives.
|
|
36
37
|
2. Attribute the lead with coarse `discoverySource`, stable packaged or community `discoverySourceId` when known, and independent `applicationChannel`. Treat a one-off user link as `user-supplied`. Whenever a user or agent discovers a repeatable public board, feed, directory, or careers index that is not already listed, run `sources suggest --stdin`; the CLI contributes its sanitized metadata by default unless community sharing has been disabled.
|
|
37
38
|
3. Verify the application channel immediately before assessment. Mark it `active`, `closed`, or `unclear`.
|
|
38
39
|
4. Classify eligibility only after checking residence, location, work authorization, sponsorship, schedule, and employment type.
|
|
@@ -51,6 +52,7 @@ Do not lower seniority, compensation, location, work mode, or evidence threshold
|
|
|
51
52
|
## Apply
|
|
52
53
|
|
|
53
54
|
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.
|
|
55
|
+
Check `round status` after the initial discovery pass and before submitting. Preserve source attribution independently of the ATS. A round cannot complete without recorded coverage and attribution; if one discovery source supplies more than 60% of confirmed submissions, explain why using the reviewed alternatives and their fit or access results. Do not submit weaker matches to balance source percentages. Report searched sources, blockers, source mix, and any concentration explanation when handing off or completing a round.
|
|
54
56
|
|
|
55
57
|
1. Recheck employer, title, direct domain, posting status, eligibility, and `autoEligible` immediately before submission.
|
|
56
58
|
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.
|
|
@@ -95,7 +97,7 @@ node scripts/job-application.mjs ledger review
|
|
|
95
97
|
node scripts/job-application.mjs ledger review-ack --stdin
|
|
96
98
|
node scripts/job-application.mjs autonomy grant --stdin
|
|
97
99
|
node scripts/job-application.mjs autonomy status|preview|revoke
|
|
98
|
-
node scripts/job-application.mjs round start|complete --stdin
|
|
100
|
+
node scripts/job-application.mjs round start|source|complete --stdin
|
|
99
101
|
node scripts/job-application.mjs round status [round-id]
|
|
100
102
|
node scripts/job-application.mjs sources list [--stdin]
|
|
101
103
|
node scripts/job-application.mjs sources jobs [--stdin]
|
|
@@ -108,6 +110,7 @@ node scripts/job-application.mjs attention list
|
|
|
108
110
|
node scripts/job-application.mjs friction record --stdin
|
|
109
111
|
node scripts/job-application.mjs friction list
|
|
110
112
|
node scripts/job-application.mjs telemetry status|enable|disable|reset
|
|
113
|
+
node scripts/job-application.mjs telemetry identity status|enable|disable
|
|
111
114
|
node scripts/job-application.mjs telemetry preview --stdin
|
|
112
115
|
node scripts/job-application.mjs telemetry record --stdin
|
|
113
116
|
```
|
|
@@ -4,10 +4,15 @@ Job Application Agent includes default-enabled, opt-out usage analytics. The pur
|
|
|
4
4
|
|
|
5
5
|
The first eligible command displays a disclosure. New installations may send that command's events after the disclosure. Existing installations receive a one-command grace period before events begin.
|
|
6
6
|
|
|
7
|
+
Name and email sharing is also enabled by default, separately from usage analytics. New and upgraded installations receive an identity disclosure; that entire command sends no identity. Starting with the following command, when analytics and identity sharing are enabled, only the explicit `name` and `email` in the saved candidate profile accompany new usage events. The maintainer can use these private PostHog fields for support and product improvement. Agents must show the disclosure to the user before proceeding to another command. Missing, unreadable, or invalid identity falls back to anonymous events. No conversation or résumé extraction is performed by telemetry.
|
|
8
|
+
|
|
7
9
|
## Controls
|
|
8
10
|
|
|
9
11
|
```text
|
|
10
12
|
node scripts/job-application.mjs telemetry status
|
|
13
|
+
node scripts/job-application.mjs telemetry identity status
|
|
14
|
+
node scripts/job-application.mjs telemetry identity disable
|
|
15
|
+
node scripts/job-application.mjs telemetry identity enable
|
|
11
16
|
node scripts/job-application.mjs telemetry disable
|
|
12
17
|
node scripts/job-application.mjs telemetry enable
|
|
13
18
|
node scripts/job-application.mjs telemetry reset
|
|
@@ -15,10 +20,13 @@ node scripts/job-application.mjs telemetry preview --stdin
|
|
|
15
20
|
node scripts/job-application.mjs telemetry record --stdin
|
|
16
21
|
```
|
|
17
22
|
|
|
18
|
-
- `disable` stops
|
|
19
|
-
- `enable` resumes
|
|
23
|
+
- `identity disable` stops name/email sharing without disabling usage analytics. It clears the analytics UUID and relay credentials; the next eligible event gets a new UUID so future anonymous usage does not share the identified UUID. The command itself sends nothing.
|
|
24
|
+
- `identity enable` resumes default-on identity sharing after another disclosure grace command when previously opted out. It rotates the UUID again to avoid attaching identity to the intervening anonymous period. It does not re-enable disabled usage analytics.
|
|
25
|
+
- `status` and `identity status` show the UUID and sharing/disclosure flags, never the candidate name/email or relay token. They send nothing and do not acknowledge disclosure.
|
|
26
|
+
- `disable` stops all future analytics collection while preserving the installation ID and identity-sharing preference.
|
|
27
|
+
- `enable` resumes collection with the same installation ID and preserves the identity-sharing preference.
|
|
20
28
|
- `reset` disables collection and removes the anonymous ID and relay token. Enabling later creates a new identity.
|
|
21
|
-
- `preview` validates and shows
|
|
29
|
+
- `preview` validates and shows structured event properties without transmitting them. It does not read or display profile identity; identity attachment is controlled separately.
|
|
22
30
|
- `record` rejects undocumented events and properties; browser workflows use it for started, step, pause, skip, and round events. Confirmed submissions are emitted by `ledger add` and must not be recorded twice.
|
|
23
31
|
- `status`, `disable`, `reset`, and `preview` never transmit an event. After `enable`, collection resumes on the next eligible workflow command.
|
|
24
32
|
- Previously collected events remain until the analytics retention period expires. Disabling or resetting does not issue a historical-deletion request.
|
|
@@ -29,11 +37,15 @@ Community sharing is a separate default-enabled feature with independent `source
|
|
|
29
37
|
|
|
30
38
|
## Identity boundary
|
|
31
39
|
|
|
32
|
-
|
|
40
|
+
The only candidate identity fields allowed are the explicitly saved name and email, under the separately disclosed opt-out control above. They appear only in the validated optional `identity` envelope object (`name`: 1–160 characters; `email`: 1–254 characters with email syntax). Unknown fields and control characters are rejected by both client and relay. They are forwarded as private event properties `candidateName` and `candidateEmail`, keeping the UUID as `distinct_id`. They are never sent to the public aggregate store or community registry, and are never duplicated in local telemetry configuration. Workflow event properties still reject name/email and all other identity fields; `telemetry record` cannot inject an identity object.
|
|
41
|
+
|
|
42
|
+
Analytics never includes 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.
|
|
43
|
+
|
|
44
|
+
Opt-out prevents future identity collection; it does not delete or anonymize previously collected events. Before opt-out, events sharing an identified UUID can be linked, including earlier anonymous events for that UUID. UUID rotation is not a historical deletion request and may increase installation-based counts.
|
|
33
45
|
|
|
34
46
|
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.
|
|
35
47
|
|
|
36
|
-
|
|
48
|
+
Per-application `discoverySourceId` attribution is not transmitted. Round coverage reports send only the allowlisted packaged source IDs from `SOURCES.json`; custom/community IDs are collapsed to `community`. Search evidence, concentration explanations, application IDs, and round IDs stay local.
|
|
37
49
|
|
|
38
50
|
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.
|
|
39
51
|
|
|
@@ -45,6 +57,7 @@ Company and title values are bounded and rejected when they resemble an email, p
|
|
|
45
57
|
|---|---|
|
|
46
58
|
| `installation_started` | OS family, Node major version, submission mode |
|
|
47
59
|
| `command_completed` | Command category, result, duration bucket |
|
|
60
|
+
| `source_checked` | Allowlisted packaged source ID or `community`, searched/blocked status, reviewed/qualified counts, optional bounded blocker |
|
|
48
61
|
| `job_discovered` | Company, title, job hash/domain, ATS/source, job country, work mode, seniority, employment type, role family, published salary band |
|
|
49
62
|
| `job_assessed` | Company, title, job hash/domain, ATS, fit score, eligibility, decision, match/gap tags |
|
|
50
63
|
| `application_started` | Job hash, ATS, approval mode, required-field count, resume/cover-letter/referral requirements |
|
|
@@ -52,7 +65,7 @@ Company and title values are bounded and rejected when they resemble an email, p
|
|
|
52
65
|
| `application_paused` | Job hash, ATS, stage, bounded reason |
|
|
53
66
|
| `application_skipped` | Job hash, bounded reason, fit score, eligibility |
|
|
54
67
|
| `application_submitted` | Company, title, job hash/domain, ATS, duration, fields filled, short-answer count, resume-upload Boolean, approval mode |
|
|
55
|
-
| `round_completed` | Requested/submitted/assessed/skipped/paused/error counts, duration bucket |
|
|
68
|
+
| `round_completed` | Requested/submitted/assessed/skipped/paused/error counts, duration bucket; optional attempted/searched/blocked source counts, maximum source share percentage, bounded concentration reason |
|
|
56
69
|
| `outcome_recorded` | Company, title, job hash/domain, ATS, outcome, days since submission, optional bounded interview quality/failure point |
|
|
57
70
|
| `review_generated` | Canonical unique-submission and outcome counts, review-due Boolean |
|
|
58
71
|
| `skill_error` | Stable error code, workflow stage, ATS/job hash when available, recoverable Boolean |
|
|
@@ -65,9 +78,13 @@ Only documented enums, bounded numbers, Booleans, bounded company/title/country
|
|
|
65
78
|
- PostHog US Cloud stores personless events with `$process_person_profile: false`.
|
|
66
79
|
- Every event disables GeoIP enrichment with `$geoip_disable: true`, and the PostHog project discards incoming IP data.
|
|
67
80
|
- The project does not call PostHog identify, alias, group, person-property, autocapture, or session-replay features.
|
|
68
|
-
-
|
|
81
|
+
- Installation IDs remain stable until reset or an identity-sharing boundary change. An identified UUID is pseudonymous, not anonymous.
|
|
69
82
|
- The product retention policy is 24 months and dashboards are private. Dashboard queries exclude data older than 24 months.
|
|
70
83
|
- A public usage dashboard exposes only fixed aggregate metrics. It never exposes raw events or anonymous installation IDs, rolls segment counts below three into `other`, and caches results at the edge for 15 minutes.
|
|
71
84
|
- After PostHog accepts an event, the relay best-effort increments a separate Cloudflare D1 store containing daily counters and HMAC-derived installation hashes. The public endpoint reads only this aggregate store; it has no PostHog read credential.
|
|
72
85
|
- PostHog US Cloud must be configured with a 24-month raw-event TTL before production telemetry is considered fully retention-compliant. The current free project does not expose a self-service raw-event TTL, so the owner must enable that control through an eligible PostHog plan or arrange time-bounded deletion with PostHog. This limitation does not weaken any collection-time identity boundary.
|
|
73
86
|
- The Worker does not forward client IPs or request headers, and Worker observability is disabled.
|
|
87
|
+
|
|
88
|
+
## Release ordering
|
|
89
|
+
|
|
90
|
+
Deploy the relay with optional identity-envelope support before releasing the CLI. Existing schema-v1 envelopes without identity remain valid; older relays reject the new optional identity field. Then release the CLI and updated disclosures together. This change does not backfill candidate identity from historical ledgers or transmit existing local candidate data during development.
|
|
@@ -6,12 +6,50 @@ Start each batch with an explicit ID:
|
|
|
6
6
|
|
|
7
7
|
```text
|
|
8
8
|
node scripts/job-application.mjs round start --stdin
|
|
9
|
+
node scripts/job-application.mjs round source --stdin
|
|
9
10
|
node scripts/job-application.mjs round status [round-id]
|
|
10
11
|
node scripts/job-application.mjs round complete --stdin
|
|
11
12
|
```
|
|
12
13
|
|
|
13
14
|
Start input: `{ "requestedCount": 30 }`. Complete input: `{ "roundId": "round-..." }`.
|
|
14
15
|
|
|
16
|
+
## Discovery coverage
|
|
17
|
+
|
|
18
|
+
Before submitting, search at least three relevant independent sources from `sources list`, including alternatives to the last round's dominant source. Listing the catalog or browsing multiple jobs on one board is not source coverage. Record a report after each actual search or observed access blocker:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"roundId": "round-...",
|
|
23
|
+
"sourceId": "linkedin-jobs-feed",
|
|
24
|
+
"status": "searched",
|
|
25
|
+
"reviewedCount": 12,
|
|
26
|
+
"qualifiedCount": 3,
|
|
27
|
+
"evidence": "Reviewed current target-matching listings; three met the unchanged fit criteria."
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`status` is `searched` or `blocked`. Empty search results count as a search with zero counts, with evidence explaining the query/filter and absence of suitable postings. `reviewedCount` and `qualifiedCount` are bounded at 10,000; qualified cannot exceed reviewed. A blocked report must have zero counts and a `blocker` of `login`, `mfa`, `captcha`, `site-error`, or `access-unavailable`. Evidence is required, at most 2,000 characters, and stays private. Record concrete observations, never invented evidence or raw page content, credentials, recruiter identities, or answers. Reports describe agent-observed work; the CLI cannot independently verify browser activity.
|
|
32
|
+
|
|
33
|
+
The minimum is three distinct sources attempted and at least one successfully searched. Blockers count toward attempts, not searches; try accessible alternatives whenever available. Repeated checks do not increase diversity. The two YC catalog entries count as one network. Inbound recruiting messages and user-provided links may supply good leads but do not count toward the three discovery sources. `round status` returns source reports, search/blocker counts, submission distribution, and missing attribution. A later blocker does not erase a prior search.
|
|
34
|
+
|
|
35
|
+
Every confirmed submission needs a `discoverySourceId` referring to a searched source. For older ledger entries without it, pass `applicationIds` in the matching `round source` report to append attribution without rewriting the ledger. Attribution must reference confirmed applications in that round and cannot conflict with their saved source. Coverage updates cannot modify a completed round. Always record real confirmed submissions even if discovery coverage is incomplete; the completion gate does not prevent accurate ledger accounting.
|
|
36
|
+
|
|
37
|
+
If any discovery source supplies more than 60% of confirmed submissions, `round complete` requires `concentrationReason` and a private `concentrationEvidence` explanation (at most 2,000 characters). Reasons are `stronger-fit`, `alternatives-exhausted`, `access-blocked`, or `candidate-directed`. For example:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"roundId": "round-...",
|
|
42
|
+
"concentrationReason": "stronger-fit",
|
|
43
|
+
"concentrationEvidence": "The other searched sources had no eligible Senior/Staff matches; selected LinkedIn leads met all existing requirements."
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This is a discovery requirement, not an application quota. Never lower fit, eligibility, compensation, or evidence requirements to diversify submissions. ATS concentration alone does not imply discovery concentration: jobs from several boards may all use Ashby. Report coverage and blockers on unfinished handoffs too; `round complete` also continues to enforce the confirmed-submission target. Previously completed rounds remain historical; open rounds need coverage before completion.
|
|
48
|
+
|
|
49
|
+
Coverage reports emit bounded `source_checked` analytics automatically. Only allowlisted packaged source IDs (community sources become `community`), counts, status, and blocker codes are sent. Notes, application IDs, exact community IDs, and round IDs stay local. Completion emits aggregate coverage counts, maximum source share, and the reason code, never the explanation. Failed best-effort analytics does not erase local reports.
|
|
50
|
+
|
|
51
|
+
## Submission accounting
|
|
52
|
+
|
|
15
53
|
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
54
|
|
|
17
55
|
Run both company-level and requisition-level duplicate checks before filling and again immediately before transmission. Hard ledger-ID, canonical-URL, employer-job-ID, and requisition duplicates always stop. Same-role aliases require a verified distinct requisition and `NEW REQUISITION CONFIRMED`. A genuinely different role at the same company may proceed automatically only when `companyReapply.decision` is `eligible-after-cooldown`: 15 full days have passed since the latest company application and no outcome has been recorded. `cooldown-active` and `follow-up-present` require explicit candidate approval.
|
|
@@ -131,7 +131,7 @@ Add only after visible success confirmation.
|
|
|
131
131
|
|
|
132
132
|
Use `duplicateOverride` only for a verified distinct requisition after a possible-duplicate warning. Use `companyReapplyOverride` only when the candidate explicitly approves a different-role reapplication during the cooldown or after a recorded outcome. Both override phrases and `telemetry` are transient. An accepted company reapplication override stores only `reapplicationApproval: "candidate-explicit"` in the private ledger. Use approval `APPROVE SUBMIT` for per-application approval or `STANDING AUTHORIZATION` when the current request authorizes routine batch submission.
|
|
133
133
|
|
|
134
|
-
`discoverySource`, `discoverySourceId`, `applicationChannel`, and `roundId` are optional for backward compatibility and should be supplied for new resumable rounds. `discoverySourceId` accepts stable packaged and `community-…` source IDs;
|
|
134
|
+
`discoverySource`, `discoverySourceId`, `applicationChannel`, and `roundId` are optional for backward compatibility and should be supplied for new resumable rounds. `discoverySourceId` accepts stable packaged and `community-…` source IDs; per-application attribution remains local. Separate source-coverage events may include packaged source IDs, while community IDs are collapsed to `community`. After a visibly confirmed submission, `ledger add` automatically contributes only the canonical public URL, company, role, application channel, optional coarse discovery source, and derived provider URL to the pending community job registry. Existing installations receive a one-command disclosure grace period before historical backfill; `sources sharing disable` opts out. The private answers, score, submission time, IDs, and round remain local, and no job appears publicly before maintainer review. `ledger check` returns hard duplicate status, bounded same-company history, and the same `companyReapply` decision enforced by `ledger add` while holding the application lock.
|
|
135
135
|
|
|
136
136
|
## Autonomy grant input
|
|
137
137
|
|
|
@@ -145,11 +145,13 @@ The owner-only `autonomy.json` stores the fixed routine scopes, grant time, and
|
|
|
145
145
|
|
|
146
146
|
## Round input
|
|
147
147
|
|
|
148
|
+
Use `round source --stdin` for per-source search/blocker reports and optional attribution of existing confirmed application IDs. The exact coverage and concentration contracts, completion requirements, and examples are in [RUNS.md](RUNS.md#discovery-coverage). Evidence remains local; only allowlisted packaged source IDs and bounded coverage metrics enter analytics.
|
|
149
|
+
|
|
148
150
|
```json
|
|
149
151
|
{ "requestedCount": 30 }
|
|
150
152
|
```
|
|
151
153
|
|
|
152
|
-
`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-..." }
|
|
154
|
+
`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-..." }`, plus concentration reason and evidence when required, and appends a completion event only after the target count, source coverage, attribution, and concentration requirements are satisfied.
|
|
153
155
|
|
|
154
156
|
## Attention input
|
|
155
157
|
|
|
@@ -30,7 +30,7 @@ Store three independent attribution fields when available:
|
|
|
30
30
|
- `discoverySourceId`: stable packaged or community ID such as `yc-work-at-a-startup` or `community-abcdef1234567890`.
|
|
31
31
|
- `applicationChannel`: actual submission channel such as `greenhouse`, `ashby`, `lever`, `company`, or `email`.
|
|
32
32
|
|
|
33
|
-
`discoverySourceId` remains local
|
|
33
|
+
Per-application `discoverySourceId` remains local. Round coverage analytics may send allowlisted packaged source IDs; all community source IDs are collapsed to `community`. See [RUNS.md](RUNS.md) for the required discovery coverage, blocker reports, and concentration explanations.
|
|
34
34
|
|
|
35
35
|
## Community sharing
|
|
36
36
|
|
|
@@ -37,6 +37,10 @@ const SOURCE_CATALOG_URL = new URL('../references/SOURCES.json', import.meta.url
|
|
|
37
37
|
const SOURCE_CATALOG = JSON.parse(readFileSync(SOURCE_CATALOG_URL, 'utf8'));
|
|
38
38
|
if (!Array.isArray(SOURCE_CATALOG)) throw new Error('The packaged source catalog is invalid.');
|
|
39
39
|
const SOURCE_CATALOG_IDS = new Set(SOURCE_CATALOG.map((source) => sourceId(source.id, 'source catalog id')));
|
|
40
|
+
const MIN_DISCOVERY_SOURCES = 3;
|
|
41
|
+
const CONCENTRATION_THRESHOLD = 60;
|
|
42
|
+
const SOURCE_BLOCKERS = new Set(['login', 'mfa', 'captcha', 'site-error', 'access-unavailable']);
|
|
43
|
+
const CONCENTRATION_REASONS = new Set(['stronger-fit', 'alternatives-exhausted', 'access-blocked', 'candidate-directed']);
|
|
40
44
|
const COMMUNITY_SOURCE_ID = /^community-[0-9a-f]{16}$/;
|
|
41
45
|
const REQUIRED_PROFILE = ['name', 'email', 'phone', 'location', 'workAuthorization', 'roleFamilies', 'seniority', 'targetLocations', 'workModes', 'submissionMode', 'yearsExperience', 'autoSubmitMinScore', 'manualReviewMinScore', 'minMustHaveCoverage'];
|
|
42
46
|
const STRING_PROFILE_FIELDS = new Set(['name', 'email', 'phone', 'location', 'workAuthorization', 'linkedin', 'github', 'portfolio', 'availability', 'currentCompensation', 'targetCompensation', 'submissionMode']);
|
|
@@ -1065,7 +1069,82 @@ async function roundStart(input) {
|
|
|
1065
1069
|
occurredAt: isoDate(value.startedAt, 'round.startedAt'),
|
|
1066
1070
|
};
|
|
1067
1071
|
await appendPrivateEvent('rounds', event);
|
|
1068
|
-
return { roundId: event.roundId, requestedCount: event.requestedCount, startedAt: event.occurredAt, completed: false };
|
|
1072
|
+
return { roundId: event.roundId, requestedCount: event.requestedCount, startedAt: event.occurredAt, completed: false, discoveryPolicy: { minSources: MIN_DISCOVERY_SOURCES, concentrationThresholdPercent: CONCENTRATION_THRESHOLD }, nextStep: 'Search at least 3 distinct relevant discovery sources, record searches or blockers with round source --stdin, and review round status before applying.' };
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function discoveryGroup(id) {
|
|
1076
|
+
// Two views of the same hiring network do not demonstrate independent coverage.
|
|
1077
|
+
if (['yc-work-at-a-startup', 'yc-company-directory'].includes(id)) return 'yc';
|
|
1078
|
+
return id;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function discoverySummary(events, applications, completion) {
|
|
1082
|
+
const latest = new Map();
|
|
1083
|
+
const searchedIds = new Set();
|
|
1084
|
+
const attribution = new Map();
|
|
1085
|
+
for (const event of events.filter((item) => item.type === 'source-checked')) {
|
|
1086
|
+
latest.set(event.sourceId, event);
|
|
1087
|
+
if (event.status === 'searched') searchedIds.add(event.sourceId);
|
|
1088
|
+
for (const id of event.applicationIds ?? []) attribution.set(id, event.sourceId);
|
|
1089
|
+
}
|
|
1090
|
+
const sources = [...latest.values()];
|
|
1091
|
+
const eligible = sources.filter((item) => !['recruiter-inbound', 'user-supplied-leads'].includes(item.sourceId));
|
|
1092
|
+
const attempted = new Set(eligible.map((item) => discoveryGroup(item.sourceId)));
|
|
1093
|
+
const searched = new Set(eligible.filter((item) => searchedIds.has(item.sourceId)).map((item) => discoveryGroup(item.sourceId)));
|
|
1094
|
+
const distribution = new Map();
|
|
1095
|
+
let unattributedCount = 0;
|
|
1096
|
+
for (const entry of applications) {
|
|
1097
|
+
const source = entry.discoverySourceId ?? attribution.get(entry.id);
|
|
1098
|
+
if (!source || !searchedIds.has(source)) { unattributedCount++; continue; }
|
|
1099
|
+
const group = discoveryGroup(source);
|
|
1100
|
+
distribution.set(group, (distribution.get(group) ?? 0) + 1);
|
|
1101
|
+
}
|
|
1102
|
+
const maxCount = Math.max(0, ...distribution.values());
|
|
1103
|
+
const maxSourceSharePercent = applications.length ? Math.round(maxCount / applications.length * 100) : 0;
|
|
1104
|
+
return {
|
|
1105
|
+
minSources: MIN_DISCOVERY_SOURCES,
|
|
1106
|
+
attemptedSourceCount: attempted.size,
|
|
1107
|
+
searchedSourceCount: searched.size,
|
|
1108
|
+
blockedSourceCount: [...attempted].filter((id) => !searched.has(id)).length,
|
|
1109
|
+
coverageSatisfied: attempted.size >= MIN_DISCOVERY_SOURCES && searched.size > 0,
|
|
1110
|
+
sources: sources.map(({ sourceId, status, reviewedCount, qualifiedCount, blocker, evidence }) => ({ sourceId, status, searchedDuringRound: searchedIds.has(sourceId), reviewedCount, qualifiedCount, ...(blocker ? { blocker } : {}), evidence })),
|
|
1111
|
+
submissionDistribution: [...distribution].map(([sourceId, count]) => ({ sourceId, count })),
|
|
1112
|
+
unattributedCount,
|
|
1113
|
+
maxSourceSharePercent,
|
|
1114
|
+
concentrationNeedsExplanation: applications.length > 0 && maxCount / applications.length * 100 > CONCENTRATION_THRESHOLD,
|
|
1115
|
+
...(completion?.concentrationReason ? { concentrationReason: completion.concentrationReason, concentrationEvidence: completion.concentrationEvidence } : {}),
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
async function roundSource(input) {
|
|
1120
|
+
const value = object(input, 'source coverage');
|
|
1121
|
+
const allowed = new Set(['roundId', 'sourceId', 'status', 'reviewedCount', 'qualifiedCount', 'blocker', 'evidence', 'applicationIds']);
|
|
1122
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown source coverage property: ${key}.`);
|
|
1123
|
+
const sourceId = knownDiscoverySourceId(value.sourceId, 'coverage.sourceId');
|
|
1124
|
+
const status = string(value.status, 'coverage.status', 20);
|
|
1125
|
+
if (!['searched', 'blocked'].includes(status)) throw new Error('coverage.status must be searched or blocked.');
|
|
1126
|
+
const reviewedCount = integer(value.reviewedCount, 'coverage.reviewedCount', 0, 10000);
|
|
1127
|
+
const qualifiedCount = integer(value.qualifiedCount, 'coverage.qualifiedCount', 0, reviewedCount);
|
|
1128
|
+
const blocker = value.blocker == null ? null : string(value.blocker, 'coverage.blocker', 40);
|
|
1129
|
+
if (status === 'blocked' && (!SOURCE_BLOCKERS.has(blocker) || reviewedCount !== 0 || qualifiedCount !== 0)) throw new Error('Blocked sources require a documented blocker and zero counts.');
|
|
1130
|
+
if (status === 'searched' && blocker != null) throw new Error('Searched sources cannot have a blocker.');
|
|
1131
|
+
const evidence = string(value.evidence, 'coverage.evidence', 2000);
|
|
1132
|
+
const applicationIds = value.applicationIds == null ? [] : [...new Set(stringArray(value.applicationIds, 'coverage.applicationIds'))];
|
|
1133
|
+
if (applicationIds.length > 1000 || (status === 'blocked' && applicationIds.length)) throw new Error('Invalid coverage.applicationIds.');
|
|
1134
|
+
return withStateLock('rounds', async (dir) => {
|
|
1135
|
+
const round = await roundStatus(string(value.roundId, 'coverage.roundId', 180));
|
|
1136
|
+
if (round.completed) throw new Error('Cannot record coverage for a completed round.');
|
|
1137
|
+
const applications = await jsonLines(join(dir, 'applications.ndjson'));
|
|
1138
|
+
for (const id of applicationIds) {
|
|
1139
|
+
const entry = applications.find((item) => item.id === id && item.roundId === round.roundId && item.status === 'submitted');
|
|
1140
|
+
if (!entry || (entry.discoverySourceId && entry.discoverySourceId !== sourceId)) throw new Error('Coverage attribution must match a confirmed application in this round and its saved source.');
|
|
1141
|
+
}
|
|
1142
|
+
const event = { type: 'source-checked', roundId: round.roundId, sourceId, status, reviewedCount, qualifiedCount, evidence, applicationIds, ...(blocker ? { blocker } : {}), occurredAt: new Date().toISOString() };
|
|
1143
|
+
const file = join(dir, 'rounds.ndjson');
|
|
1144
|
+
await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
1145
|
+
await chmod(file, 0o600);
|
|
1146
|
+
return event;
|
|
1147
|
+
});
|
|
1069
1148
|
}
|
|
1070
1149
|
|
|
1071
1150
|
function replayAttention(events, roundId = null) {
|
|
@@ -1135,7 +1214,8 @@ async function roundStatus(roundId = null) {
|
|
|
1135
1214
|
if (!id) throw new Error('No application round has been started.');
|
|
1136
1215
|
const started = starts.find((event) => event.roundId === id);
|
|
1137
1216
|
if (!started) throw new Error('Application round was not found.');
|
|
1138
|
-
const
|
|
1217
|
+
const matching = (await jsonLines(join(dir, 'applications.ndjson'))).filter((entry) => entry.roundId === id && entry.status === 'submitted');
|
|
1218
|
+
const applications = [...new Map(matching.map((entry, index) => [canonicalApplicationKey(entry, String(index)), entry])).values()];
|
|
1139
1219
|
const confirmedCount = new Set(applications.map((entry, index) => canonicalApplicationKey(entry, String(index)))).size;
|
|
1140
1220
|
const attention = await attentionList(id);
|
|
1141
1221
|
const completion = events.find((event) => event.type === 'completed' && event.roundId === id);
|
|
@@ -1146,6 +1226,7 @@ async function roundStatus(roundId = null) {
|
|
|
1146
1226
|
remainingCount: Math.max(0, started.requestedCount - confirmedCount),
|
|
1147
1227
|
blockedCount: attention.count,
|
|
1148
1228
|
completed: Boolean(completion),
|
|
1229
|
+
discovery: discoverySummary(events.filter((event) => event.roundId === id), applications, completion),
|
|
1149
1230
|
startedAt: started.occurredAt,
|
|
1150
1231
|
...(completion ? { completedAt: completion.occurredAt } : {}),
|
|
1151
1232
|
};
|
|
@@ -1153,14 +1234,26 @@ async function roundStatus(roundId = null) {
|
|
|
1153
1234
|
|
|
1154
1235
|
async function roundComplete(input) {
|
|
1155
1236
|
const value = object(input, 'round completion');
|
|
1156
|
-
const allowed = new Set(['roundId', 'completedAt']);
|
|
1237
|
+
const allowed = new Set(['roundId', 'completedAt', 'concentrationReason', 'concentrationEvidence']);
|
|
1157
1238
|
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown round completion property: ${key}.`);
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1239
|
+
// Use the same lock order as ledger writes so completion cannot race a confirmed submission.
|
|
1240
|
+
return withStateLock('applications', () => withStateLock('rounds', async (dir) => {
|
|
1241
|
+
const status = await roundStatus(string(value.roundId, 'round.roundId', 180));
|
|
1242
|
+
if (status.completed) return { ...status, completionRecorded: false };
|
|
1243
|
+
if (status.confirmedCount < status.requestedCount) throw new Error(`Round requires ${status.requestedCount} confirmed submissions before completion.`);
|
|
1244
|
+
if (!status.discovery.coverageSatisfied) throw new Error('Round requires attempts across at least 3 distinct discovery sources, including at least one searched source. Record coverage and blockers with round source --stdin.');
|
|
1245
|
+
if (status.discovery.unattributedCount) throw new Error('Round requires discovery source attribution for every confirmed submission. Supply missing attribution using round source applicationIds; do not rewrite the ledger.');
|
|
1246
|
+
let explanation = {};
|
|
1247
|
+
if (status.discovery.concentrationNeedsExplanation || value.concentrationReason != null || value.concentrationEvidence != null) {
|
|
1248
|
+
if (!CONCENTRATION_REASONS.has(value.concentrationReason)) throw new Error('Source concentration requires a documented concentrationReason and private concentrationEvidence.');
|
|
1249
|
+
explanation = { concentrationReason: value.concentrationReason, concentrationEvidence: string(value.concentrationEvidence, 'round.concentrationEvidence', 2000) };
|
|
1250
|
+
}
|
|
1251
|
+
const event = { type: 'completed', roundId: status.roundId, occurredAt: isoDate(value.completedAt, 'round.completedAt'), ...explanation };
|
|
1252
|
+
const file = join(dir, 'rounds.ndjson');
|
|
1253
|
+
await appendFile(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
1254
|
+
await chmod(file, 0o600);
|
|
1255
|
+
return { ...status, discovery: { ...status.discovery, ...explanation }, completed: true, completionRecorded: true, completedAt: event.occurredAt };
|
|
1256
|
+
}));
|
|
1164
1257
|
}
|
|
1165
1258
|
|
|
1166
1259
|
async function frictionRecord(input) {
|
|
@@ -1309,6 +1402,11 @@ function roundCompletedTelemetry(round) {
|
|
|
1309
1402
|
pausedCount: round.blockedCount,
|
|
1310
1403
|
errorCount: 0,
|
|
1311
1404
|
durationBucket: durationBucket(Math.max(0, Date.parse(round.completedAt) - Date.parse(round.startedAt))),
|
|
1405
|
+
attemptedSourceCount: round.discovery.attemptedSourceCount,
|
|
1406
|
+
searchedSourceCount: round.discovery.searchedSourceCount,
|
|
1407
|
+
blockedSourceCount: round.discovery.blockedSourceCount,
|
|
1408
|
+
maxSourceSharePercent: round.discovery.maxSourceSharePercent,
|
|
1409
|
+
...(round.discovery.concentrationReason ? { concentrationReason: round.discovery.concentrationReason } : {}),
|
|
1312
1410
|
},
|
|
1313
1411
|
};
|
|
1314
1412
|
}
|
|
@@ -1356,10 +1454,18 @@ async function executeCommand([area, action, value], telemetry, session, communi
|
|
|
1356
1454
|
else if (area === 'autonomy' && action === 'preview' && value == null) result = await autonomyStatus();
|
|
1357
1455
|
else if (area === 'autonomy' && action === 'revoke' && value == null) result = await autonomyRevoke();
|
|
1358
1456
|
else if (area === 'round' && action === 'start' && value === '--stdin') result = await roundStart(await jsonStdin());
|
|
1457
|
+
else if (area === 'round' && action === 'source' && value === '--stdin') {
|
|
1458
|
+
result = await roundSource(await jsonStdin());
|
|
1459
|
+
domainEvents.push({ event: 'source_checked', properties: {
|
|
1460
|
+
sourceId: result.sourceId.startsWith('community-') ? 'community' : result.sourceId,
|
|
1461
|
+
status: result.status, reviewedCount: result.reviewedCount, qualifiedCount: result.qualifiedCount,
|
|
1462
|
+
...(result.blocker ? { blocker: result.blocker } : {}),
|
|
1463
|
+
} });
|
|
1464
|
+
}
|
|
1359
1465
|
else if (area === 'round' && action === 'status') result = await roundStatus(value ?? null);
|
|
1360
1466
|
else if (area === 'round' && action === 'complete' && value === '--stdin') {
|
|
1361
1467
|
result = await roundComplete(await jsonStdin());
|
|
1362
|
-
domainEvents.push(roundCompletedTelemetry(result));
|
|
1468
|
+
if (result.completionRecorded) domainEvents.push(roundCompletedTelemetry(result));
|
|
1363
1469
|
} else if (area === 'sources' && action === 'list' && value == null) {
|
|
1364
1470
|
await syncAllCommunityData(community);
|
|
1365
1471
|
result = await sourcesList({}, await community.list());
|
|
@@ -1382,7 +1488,7 @@ async function executeCommand([area, action, value], telemetry, session, communi
|
|
|
1382
1488
|
else if (area === 'attention' && action === 'resolve' && value === '--stdin') result = await attentionResolve(await jsonStdin());
|
|
1383
1489
|
else if (area === 'friction' && action === 'record' && value === '--stdin') result = await frictionRecord(await jsonStdin());
|
|
1384
1490
|
else if (area === 'friction' && action === 'list' && value == null) result = await frictionList();
|
|
1385
|
-
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]; sources list [--stdin]|jobs [--stdin]|suggest --stdin|pending|sync|sharing status|enable|disable|reset; attention add|resolve --stdin|list; friction record --stdin|list; telemetry status|enable|disable|reset|preview --stdin|record --stdin');
|
|
1491
|
+
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|source|complete --stdin|status [round-id]; sources list [--stdin]|jobs [--stdin]|suggest --stdin|pending|sync|sharing status|enable|disable|reset; attention add|resolve --stdin|list; friction record --stdin|list; telemetry status|enable|disable|reset|preview --stdin|record --stdin');
|
|
1386
1492
|
for (const event of domainEvents) await telemetry.record(event, session);
|
|
1387
1493
|
return result;
|
|
1388
1494
|
}
|
|
@@ -1403,9 +1509,14 @@ async function recordInstallationStart(telemetry, session) {
|
|
|
1403
1509
|
|
|
1404
1510
|
async function main(args) {
|
|
1405
1511
|
const [area, action, value] = args;
|
|
1406
|
-
const telemetry = new TelemetryClient({ stateDir: stateDir()
|
|
1512
|
+
const telemetry = new TelemetryClient({ stateDir: stateDir(), readIdentity: () => {
|
|
1513
|
+
const profile = storedProfileRaw();
|
|
1514
|
+
// Only explicit saved fields; no resume parsing, conversation scraping, or full profile payload.
|
|
1515
|
+
return Object.fromEntries(['name', 'email'].filter((key) => typeof profile[key] === 'string' && profile[key].trim()).map((key) => [key, profile[key]]));
|
|
1516
|
+
} });
|
|
1407
1517
|
const community = new SourceCommunityClient({ stateDir: stateDir() });
|
|
1408
1518
|
if (area === 'telemetry') {
|
|
1519
|
+
if (action === 'identity' && ['status', 'enable', 'disable'].includes(value) && args.length === 3) return print(await telemetry.configureIdentity(value));
|
|
1409
1520
|
if (['status', 'enable', 'disable', 'reset'].includes(action) && value == null) return print(await telemetry.configure(action));
|
|
1410
1521
|
if (action === 'preview' && value === '--stdin') return print(await telemetry.preview(await jsonStdin()));
|
|
1411
1522
|
if (action === 'record' && value === '--stdin') {
|
|
@@ -1413,7 +1524,7 @@ async function main(args) {
|
|
|
1413
1524
|
await recordInstallationStart(telemetry, session);
|
|
1414
1525
|
return print(await telemetry.record(await jsonStdin(), session, { strict: true }));
|
|
1415
1526
|
}
|
|
1416
|
-
throw new Error('Usage: telemetry status|enable|disable|reset|preview --stdin|record --stdin');
|
|
1527
|
+
throw new Error('Usage: telemetry status|enable|disable|reset|preview --stdin|record --stdin; telemetry identity status|enable|disable');
|
|
1417
1528
|
}
|
|
1418
1529
|
|
|
1419
1530
|
const command = commandCategory(args);
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { chmod, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { createTelemetryEnvelope, jobIdentity, validateEvent } from './telemetry-schema.mjs';
|
|
4
|
+
import { createTelemetryEnvelope, jobIdentity, validateEvent, validateTelemetryIdentity } from './telemetry-schema.mjs';
|
|
5
5
|
import { SKILL_VERSION } from './version.mjs';
|
|
6
6
|
|
|
7
7
|
export { SKILL_VERSION };
|
|
8
8
|
|
|
9
9
|
export const DEFAULT_TELEMETRY_ENDPOINT = process.env.JOB_APPLICATION_AGENT_TELEMETRY_URL ?? 'https://job-application-agent-telemetry.varora1406.workers.dev';
|
|
10
|
-
export const TELEMETRY_NOTICE = '
|
|
10
|
+
export const TELEMETRY_NOTICE = 'Usage analytics are enabled by default. They include structured job and workflow metrics. Name and email sharing has a separate disclosure and opt-out. Resume content, other profile fields, prompts, form answers, browser data, and raw errors are never sent. Run `telemetry disable` to stop all analytics or `telemetry preview` to inspect an event.\n';
|
|
11
|
+
export const IDENTITY_NOTICE = 'Name and email sharing is enabled by default. Starting with the next command, JobAgent shares the name and email explicitly saved in your candidate profile with the maintainer through private PostHog usage analytics for support and product improvement. Run `telemetry identity disable` to keep future analytics anonymous, or `telemetry disable` to stop all analytics. Opting out rotates the analytics ID; previously collected data is retained under the analytics retention policy.\n';
|
|
11
12
|
|
|
12
13
|
const CONFIG_VERSION = 1;
|
|
13
14
|
const CONFIG_FILE = 'telemetry.json';
|
|
@@ -38,13 +39,14 @@ async function writePrivate(file, value) {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export class TelemetryClient {
|
|
41
|
-
constructor({ stateDir, endpoint = DEFAULT_TELEMETRY_ENDPOINT, fetch: fetchFn = globalThis.fetch, stderr = (value) => process.stderr.write(value), now = () => new Date(), timeoutMs = Number(process.env.JOB_APPLICATION_AGENT_TELEMETRY_TIMEOUT_MS ?? 3000) }) {
|
|
42
|
+
constructor({ stateDir, endpoint = DEFAULT_TELEMETRY_ENDPOINT, fetch: fetchFn = globalThis.fetch, stderr = (value) => process.stderr.write(value), now = () => new Date(), timeoutMs = Number(process.env.JOB_APPLICATION_AGENT_TELEMETRY_TIMEOUT_MS ?? 3000), readIdentity = () => undefined }) {
|
|
42
43
|
this.stateDir = stateDir;
|
|
43
44
|
this.endpoint = endpoint.replace(/\/$/, '');
|
|
44
45
|
this.fetch = fetchFn;
|
|
45
46
|
this.stderr = stderr;
|
|
46
47
|
this.now = now;
|
|
47
48
|
this.timeoutMs = timeoutMs;
|
|
49
|
+
this.readIdentity = readIdentity;
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
get configPath() { return join(this.stateDir, CONFIG_FILE); }
|
|
@@ -57,7 +59,7 @@ export class TelemetryClient {
|
|
|
57
59
|
async readConfig() {
|
|
58
60
|
try {
|
|
59
61
|
const value = JSON.parse(await readFile(this.configPath, 'utf8'));
|
|
60
|
-
return { version: CONFIG_VERSION, enabled: value.enabled !== false, disclosed: value.disclosed === true, graceConsumed: value.graceConsumed === true, installationEventPending: value.installationEventPending === true, installationId: value.installationId ?? null, token: value.token ?? null, tokenExpiresAt: value.tokenExpiresAt ?? null };
|
|
62
|
+
return { version: CONFIG_VERSION, enabled: value.enabled !== false, disclosed: value.disclosed === true, graceConsumed: value.graceConsumed === true, installationEventPending: value.installationEventPending === true, installationId: value.installationId ?? null, token: value.token ?? null, tokenExpiresAt: value.tokenExpiresAt ?? null, identityEnabled: value.identityEnabled !== false, identityDisclosed: value.identityDisclosed === true };
|
|
61
63
|
} catch (error) {
|
|
62
64
|
if (error.code === 'ENOENT') return null;
|
|
63
65
|
return { version: CONFIG_VERSION, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false, installationId: null, token: null, tokenExpiresAt: null };
|
|
@@ -101,7 +103,13 @@ export class TelemetryClient {
|
|
|
101
103
|
config.graceConsumed = true;
|
|
102
104
|
await this.saveConfig(config);
|
|
103
105
|
}
|
|
104
|
-
|
|
106
|
+
const allowIdentity = config.identityEnabled !== false && config.identityDisclosed === true;
|
|
107
|
+
if (config.enabled && config.identityEnabled !== false && !config.identityDisclosed) {
|
|
108
|
+
this.stderr(IDENTITY_NOTICE);
|
|
109
|
+
config.identityDisclosed = true;
|
|
110
|
+
await this.saveConfig(config);
|
|
111
|
+
}
|
|
112
|
+
return { command, enabled: config.enabled, allowSend: config.enabled && allowSend, allowIdentity, installationEventPending: config.installationEventPending === true };
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
async credentials(config) {
|
|
@@ -128,13 +136,17 @@ export class TelemetryClient {
|
|
|
128
136
|
if (session.unavailable) return { sent: false, reason: 'unavailable' };
|
|
129
137
|
let config = await this.readConfig();
|
|
130
138
|
if (!config?.enabled) return { sent: false, reason: 'disabled' };
|
|
139
|
+
let identity;
|
|
140
|
+
if (session.allowIdentity === true && config.identityEnabled !== false && config.identityDisclosed === true) {
|
|
141
|
+
try { identity = validateTelemetryIdentity(await this.readIdentity()); } catch { /* Missing or invalid identity never blocks anonymous analytics. */ }
|
|
142
|
+
}
|
|
131
143
|
config = await this.credentials(config);
|
|
132
|
-
const payload = createTelemetryEnvelope({ installationId: config.installationId, token: config.token, event, skillVersion: SKILL_VERSION });
|
|
144
|
+
const payload = createTelemetryEnvelope({ installationId: config.installationId, token: config.token, event, skillVersion: SKILL_VERSION, identity });
|
|
133
145
|
let response = await this.fetch(`${this.endpoint}/v1/events`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(this.timeoutMs) });
|
|
134
146
|
if (response.status === 401) {
|
|
135
147
|
config.tokenExpiresAt = null;
|
|
136
148
|
config = await this.credentials(config);
|
|
137
|
-
response = await this.fetch(`${this.endpoint}/v1/events`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(createTelemetryEnvelope({ installationId: config.installationId, token: config.token, event, skillVersion: SKILL_VERSION })), signal: AbortSignal.timeout(this.timeoutMs) });
|
|
149
|
+
response = await this.fetch(`${this.endpoint}/v1/events`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(createTelemetryEnvelope({ installationId: config.installationId, token: config.token, event, skillVersion: SKILL_VERSION, identity })), signal: AbortSignal.timeout(this.timeoutMs) });
|
|
138
150
|
}
|
|
139
151
|
if (!response.ok) {
|
|
140
152
|
session.unavailable = true;
|
|
@@ -154,7 +166,22 @@ export class TelemetryClient {
|
|
|
154
166
|
|
|
155
167
|
async status() {
|
|
156
168
|
const config = await this.readConfig();
|
|
157
|
-
return { enabled: config?.enabled ?? true, disclosed: config?.disclosed ?? false, hasInstallationId: Boolean(config?.installationId), endpoint: this.endpoint, schemaVersion: 1 };
|
|
169
|
+
return { enabled: config?.enabled ?? true, disclosed: config?.disclosed ?? false, hasInstallationId: Boolean(config?.installationId), installationId: config?.installationId ?? null, identityEnabled: config?.identityEnabled ?? true, identityDisclosed: config?.identityDisclosed ?? false, endpoint: this.endpoint, schemaVersion: 1 };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async configureIdentity(action) {
|
|
173
|
+
if (action === 'status') return this.status();
|
|
174
|
+
if (!['enable', 'disable'].includes(action)) throw new Error('Identity action must be status, enable, or disable.');
|
|
175
|
+
const current = await this.readConfig() ?? { version: CONFIG_VERSION, enabled: true, disclosed: false, graceConsumed: true, installationEventPending: true };
|
|
176
|
+
const enabled = action === 'enable';
|
|
177
|
+
// Rotate at both boundaries to avoid identifying an earlier anonymous period.
|
|
178
|
+
if (enabled !== (current.identityEnabled !== false)) {
|
|
179
|
+
Object.assign(current, { installationId: null, token: null, tokenExpiresAt: null });
|
|
180
|
+
}
|
|
181
|
+
if (enabled && current.identityEnabled === false) current.identityDisclosed = false;
|
|
182
|
+
current.identityEnabled = enabled;
|
|
183
|
+
await this.saveConfig(current);
|
|
184
|
+
return this.status();
|
|
158
185
|
}
|
|
159
186
|
|
|
160
187
|
async configure(action) {
|
|
@@ -31,6 +31,9 @@ const FAILURE_POINTS = values('role-scope', 'company-problem', 'constraints', 'i
|
|
|
31
31
|
const ERROR_CODES = values('invalid_input', 'network_failure', 'relay_unavailable', 'authentication_required', 'site_changed', 'upload_failed', 'submission_unconfirmed', 'rate_limited', 'internal_error');
|
|
32
32
|
const MATCH_TAGS = values('role_family', 'seniority', 'skills', 'industry', 'location', 'remote', 'salary', 'ai', 'product', 'leadership', 'authorization');
|
|
33
33
|
const GAP_TAGS = values('role_family', 'seniority', 'skills', 'industry', 'location', 'salary_unknown', 'salary_below', 'authorization_unclear', 'sponsorship', 'experience', 'domain', 'other');
|
|
34
|
+
const DISCOVERY_SOURCE_KEYS = values('direct-company-careers', 'linkedin-jobs-feed', 'x-hiring-feed', 'yc-work-at-a-startup', 'yc-company-directory', 'hacker-news-who-is-hiring', 'we-work-remotely', 'a16z-build-jobs', 'engg-space', 'js-guru-jobs', 'linux-careers', 'indeed', 'recruiter-inbound', 'user-supplied-leads', 'community');
|
|
35
|
+
const DISCOVERY_BLOCKERS = values('login', 'mfa', 'captcha', 'site-error', 'access-unavailable');
|
|
36
|
+
const CONCENTRATION_REASONS = values('stronger-fit', 'alternatives-exhausted', 'access-blocked', 'candidate-directed');
|
|
34
37
|
|
|
35
38
|
const text = (max, identitySafe = false) => ({ kind: 'text', max, identitySafe });
|
|
36
39
|
const integer = (min, max) => ({ kind: 'integer', min, max });
|
|
@@ -43,6 +46,7 @@ const JOB = {
|
|
|
43
46
|
};
|
|
44
47
|
|
|
45
48
|
export const EVENT_SCHEMAS = {
|
|
49
|
+
source_checked: { required: { sourceId: enumValue(DISCOVERY_SOURCE_KEYS), status: enumValue(values('searched', 'blocked')), reviewedCount: integer(0, 10000), qualifiedCount: integer(0, 10000) }, optional: { blocker: enumValue(DISCOVERY_BLOCKERS) } },
|
|
46
50
|
installation_started: { required: { osFamily: enumValue(values('macos', 'linux', 'windows', 'other')), nodeMajor: integer(20, 99), submissionMode: enumValue(SUBMISSION_MODES) } },
|
|
47
51
|
command_completed: { required: { command: enumValue(COMMANDS), result: enumValue(RESULTS), durationBucket: enumValue(DURATIONS) } },
|
|
48
52
|
job_discovered: { required: { ...JOB, source: enumValue(SOURCES), jobCountry: text(80, true), workMode: enumValue(WORK_MODES), seniority: enumValue(SENIORITIES), employmentType: enumValue(EMPLOYMENT), roleFamily: enumValue(ROLE_FAMILIES) }, optional: { salaryCurrency: { kind: 'currency' }, salaryMin: integer(0, 10000000), salaryMax: integer(0, 10000000) } },
|
|
@@ -52,7 +56,7 @@ export const EVENT_SCHEMAS = {
|
|
|
52
56
|
application_paused: { required: { jobHash: { kind: 'hash' }, ats: enumValue(ATS), stage: enumValue(STAGES), reason: enumValue(PAUSE_REASONS) } },
|
|
53
57
|
application_skipped: { required: { jobHash: { kind: 'hash' }, reason: enumValue(SKIP_REASONS), fitScore: integer(0, 100), eligibility: enumValue(ELIGIBILITY) } },
|
|
54
58
|
application_submitted: { required: { ...JOB, durationBucket: enumValue(DURATIONS), fieldsFilled: integer(0, 500), shortAnswerCount: integer(0, 100), resumeUploaded: boolean, approvalMode: enumValue(APPROVAL_MODES) } },
|
|
55
|
-
round_completed: { required: { requestedCount: integer(1, 1000), submittedCount: integer(0, 1000), assessedCount: integer(0, 10000), skippedCount: integer(0, 10000), pausedCount: integer(0, 10000), errorCount: integer(0, 10000), durationBucket: enumValue(DURATIONS) } },
|
|
59
|
+
round_completed: { required: { requestedCount: integer(1, 1000), submittedCount: integer(0, 1000), assessedCount: integer(0, 10000), skippedCount: integer(0, 10000), pausedCount: integer(0, 10000), errorCount: integer(0, 10000), durationBucket: enumValue(DURATIONS) }, optional: { attemptedSourceCount: integer(0, 10000), searchedSourceCount: integer(0, 10000), blockedSourceCount: integer(0, 10000), maxSourceSharePercent: integer(0, 100), concentrationReason: enumValue(CONCENTRATION_REASONS) } },
|
|
56
60
|
outcome_recorded: {
|
|
57
61
|
required: { ...JOB, outcome: enumValue(OUTCOMES), daysSinceSubmission: integer(0, 3650) },
|
|
58
62
|
optional: { interviewQuality: enumValue(INTERVIEW_QUALITIES), failurePoint: enumValue(FAILURE_POINTS) },
|
|
@@ -118,6 +122,11 @@ export function validateEvent(input) {
|
|
|
118
122
|
}
|
|
119
123
|
for (const [name, rule] of Object.entries(schema.optional ?? {})) if (name in input.properties) properties[name] = validateProperty(name, input.properties[name], rule);
|
|
120
124
|
if (input.event === 'outcome_recorded' && properties.failurePoint && !properties.interviewQuality) throw new Error('failurePoint requires interviewQuality.');
|
|
125
|
+
if (input.event === 'source_checked') {
|
|
126
|
+
if (properties.qualifiedCount > properties.reviewedCount) throw new Error('qualifiedCount cannot exceed reviewedCount.');
|
|
127
|
+
if (properties.status === 'blocked' && (!properties.blocker || properties.reviewedCount !== 0 || properties.qualifiedCount !== 0)) throw new Error('Blocked sources require a blocker and zero counts.');
|
|
128
|
+
if (properties.status === 'searched' && properties.blocker) throw new Error('Searched sources cannot have a blocker.');
|
|
129
|
+
}
|
|
121
130
|
const result = { event: input.event, properties };
|
|
122
131
|
if (new TextEncoder().encode(JSON.stringify(result)).length > TELEMETRY_MAX_BYTES) throw new Error('Telemetry event exceeds 4 KB.');
|
|
123
132
|
return result;
|
|
@@ -142,19 +151,37 @@ export async function jobIdentity(value) {
|
|
|
142
151
|
return { jobHash: [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''), domain: new URL(canonical).hostname };
|
|
143
152
|
}
|
|
144
153
|
|
|
145
|
-
|
|
154
|
+
// Identity is a separate allowlisted envelope field, never arbitrary event input.
|
|
155
|
+
export function validateTelemetryIdentity(input) {
|
|
156
|
+
if (!input || Array.isArray(input) || typeof input !== 'object') throw new Error('Telemetry identity must be an object.');
|
|
157
|
+
for (const key of Object.keys(input)) if (!['name', 'email'].includes(key)) throw new Error('Unknown telemetry identity property.');
|
|
158
|
+
const identity = {};
|
|
159
|
+
for (const [key, max] of [['name', 160], ['email', 254]]) {
|
|
160
|
+
if (!(key in input)) continue;
|
|
161
|
+
const value = input[key];
|
|
162
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max || /[\x00-\x1f\x7f]/.test(value)) throw new Error('Invalid telemetry identity field.');
|
|
163
|
+
const normalized = value.trim();
|
|
164
|
+
if (key === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) throw new Error('Invalid telemetry identity email.');
|
|
165
|
+
identity[key] = normalized;
|
|
166
|
+
}
|
|
167
|
+
if (!Object.keys(identity).length) throw new Error('Telemetry identity must include name or email.');
|
|
168
|
+
return identity;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function createTelemetryEnvelope({ installationId, token, event, properties, skillVersion, identity }) {
|
|
146
172
|
if (!UUID.test(installationId)) throw new Error('installationId must be an anonymous UUID.');
|
|
147
173
|
if (typeof token !== 'string' || token.length < 8 || token.length > 2048) throw new Error('token is invalid.');
|
|
148
174
|
if (typeof skillVersion !== 'string' || !VERSION.test(skillVersion)) throw new Error('skillVersion is invalid.');
|
|
149
175
|
const safe = validateEvent(typeof event === 'string' ? { event, properties } : event);
|
|
150
176
|
const envelope = { schemaVersion: TELEMETRY_SCHEMA_VERSION, skillVersion, installationId, token, ...safe };
|
|
177
|
+
if (identity !== undefined) envelope.identity = validateTelemetryIdentity(identity);
|
|
151
178
|
if (new TextEncoder().encode(JSON.stringify(envelope)).length > TELEMETRY_MAX_BYTES) throw new Error('Telemetry payload exceeds 4 KB.');
|
|
152
179
|
return envelope;
|
|
153
180
|
}
|
|
154
181
|
|
|
155
182
|
export function validateTelemetryEnvelope(input) {
|
|
156
183
|
if (!input || Array.isArray(input) || typeof input !== 'object') throw new Error('Telemetry payload must be an object.');
|
|
157
|
-
const allowed = new Set(['schemaVersion', 'skillVersion', 'installationId', 'token', 'event', 'properties']);
|
|
184
|
+
const allowed = new Set(['schemaVersion', 'skillVersion', 'installationId', 'token', 'event', 'properties', 'identity']);
|
|
158
185
|
for (const key of Object.keys(input)) if (!allowed.has(key)) throw new Error(`Unknown telemetry envelope property: ${key}.`);
|
|
159
186
|
if (input.schemaVersion !== TELEMETRY_SCHEMA_VERSION) throw new Error('Unsupported telemetry schema version.');
|
|
160
187
|
return createTelemetryEnvelope(input);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SKILL_VERSION = '3.
|
|
1
|
+
export const SKILL_VERSION = '3.4.0';
|
|
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import test from 'node:test';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { createServer } from 'node:http';
|
|
8
9
|
|
|
9
10
|
import { buildReview, commandCategory, durationBucket, migrateProfile, profileStatus, scoreJob, telemetryErrorCode, telemetryJobAssessed, validateLedgerEntry, validateProfile, validateSubmissionTelemetry } from '../scripts/job-application.mjs';
|
|
10
11
|
|
|
@@ -492,5 +493,81 @@ test('telemetry CLI controls are private and reset removes anonymous credentials
|
|
|
492
493
|
assert.equal(disabled.enabled, false);
|
|
493
494
|
const reset = JSON.parse(execFileSync(process.execPath, [script, 'telemetry', 'reset'], { env, encoding: 'utf8' }));
|
|
494
495
|
assert.equal(reset.hasInstallationId, false);
|
|
496
|
+
const identityDisabled = JSON.parse(execFileSync(process.execPath, [script, 'telemetry', 'identity', 'disable'], { env, encoding: 'utf8' }));
|
|
497
|
+
assert.equal(identityDisabled.identityEnabled, false);
|
|
498
|
+
assert.equal(identityDisabled.enabled, false);
|
|
499
|
+
const identityEnabled = JSON.parse(execFileSync(process.execPath, [script, 'telemetry', 'identity', 'enable'], { env, encoding: 'utf8' }));
|
|
500
|
+
assert.equal(identityEnabled.identityEnabled, true);
|
|
501
|
+
assert.equal(identityEnabled.enabled, false);
|
|
502
|
+
assert.equal(identityEnabled.identityDisclosed, false);
|
|
495
503
|
if (process.platform !== 'win32') assert.equal((await stat(join(directory, 'telemetry.json'))).mode & 0o777, 0o600);
|
|
496
504
|
});
|
|
505
|
+
|
|
506
|
+
test('CLI emits bounded source coverage without private evidence or attribution', async (t) => {
|
|
507
|
+
const directory = await mkdtemp(join(tmpdir(), 'job-agent-coverage-cli-'));
|
|
508
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
509
|
+
const captured = [];
|
|
510
|
+
const server = createServer(async (request, response) => {
|
|
511
|
+
let raw = '';
|
|
512
|
+
for await (const chunk of request) raw += chunk;
|
|
513
|
+
captured.push(JSON.parse(raw));
|
|
514
|
+
response.setHeader('content-type', 'application/json');
|
|
515
|
+
response.end(JSON.stringify({ accepted: true }));
|
|
516
|
+
});
|
|
517
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
518
|
+
t.after(() => new Promise((resolve) => server.close(resolve)));
|
|
519
|
+
await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: true, disclosed: true, graceConsumed: true, installationEventPending: false, identityEnabled: false, installationId: crypto.randomUUID(), token: 'synthetic-token', tokenExpiresAt: '2099-01-01T00:00:00Z' }));
|
|
520
|
+
const env = { ...isolatedCliEnv(directory), JOB_APPLICATION_AGENT_TELEMETRY_URL: `http://127.0.0.1:${server.address().port}` };
|
|
521
|
+
const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
|
|
522
|
+
const started = await runCli(script, ['round', 'start', '--stdin'], { requestedCount: 1 }, env);
|
|
523
|
+
assert.equal(started.code, 0, started.stderr);
|
|
524
|
+
const roundId = JSON.parse(started.stdout).roundId;
|
|
525
|
+
const sourceId = 'community-0123456789abcdef';
|
|
526
|
+
const report = await runCli(script, ['round', 'source', '--stdin'], { roundId, sourceId, status: 'searched', reviewedCount: 8, qualifiedCount: 2, evidence: 'Private search query and candidate context', applicationIds: [] }, env);
|
|
527
|
+
assert.equal(report.code, 0, report.stderr);
|
|
528
|
+
const event = captured.find((body) => body.event === 'source_checked');
|
|
529
|
+
assert.ok(event, JSON.stringify(captured));
|
|
530
|
+
assert.deepEqual(event.properties, { sourceId: 'community', status: 'searched', reviewedCount: 8, qualifiedCount: 2 });
|
|
531
|
+
assert.equal(JSON.stringify(captured).includes('Private search query'), false);
|
|
532
|
+
assert.equal(JSON.stringify(captured).includes(roundId), false);
|
|
533
|
+
assert.equal(JSON.stringify(captured).includes(sourceId), false);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('CLI attaches only explicit saved name/email after disclosure and opt-out continues anonymous usage', async (t) => {
|
|
537
|
+
const directory = await mkdtemp(join(tmpdir(), 'job-agent-identity-cli-'));
|
|
538
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
539
|
+
const captured = [];
|
|
540
|
+
const server = createServer(async (request, response) => {
|
|
541
|
+
let raw = '';
|
|
542
|
+
for await (const chunk of request) raw += chunk;
|
|
543
|
+
captured.push({ path: request.url, body: JSON.parse(raw) });
|
|
544
|
+
response.setHeader('content-type', 'application/json');
|
|
545
|
+
response.end(JSON.stringify(request.url === '/v1/install' ? { installationId: crypto.randomUUID(), token: 'synthetic-relay-token', expiresAt: '2099-01-01T00:00:00Z' } : { accepted: true }));
|
|
546
|
+
});
|
|
547
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
548
|
+
t.after(() => new Promise((resolve) => server.close(resolve)));
|
|
549
|
+
// Synthetic Secret Service implementation: never touch the developer's real profile.
|
|
550
|
+
const preload = `import cp from 'node:child_process'; import { syncBuiltinESMExports } from 'node:module'; Object.defineProperty(process, 'platform', { value: 'linux' }); cp.execFileSync = (file, args) => { if (file === 'secret-tool' && args[0] === 'lookup') return ${JSON.stringify(JSON.stringify(target))}; throw new Error('Unexpected secret operation'); }; syncBuiltinESMExports();`;
|
|
551
|
+
const runtimeArgs = ['--import', `data:text/javascript,${encodeURIComponent(preload)}`];
|
|
552
|
+
const env = { ...isolatedCliEnv(directory), JOB_APPLICATION_AGENT_TELEMETRY_URL: `http://127.0.0.1:${server.address().port}` };
|
|
553
|
+
const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
|
|
554
|
+
const run = (args) => runCli(script, args, undefined, env, runtimeArgs);
|
|
555
|
+
const first = await run(['profile', 'check']);
|
|
556
|
+
assert.equal(first.code, 0, first.stderr);
|
|
557
|
+
assert.match(first.stderr, /name and email sharing is enabled by default/i);
|
|
558
|
+
assert.ok(captured.length > 0);
|
|
559
|
+
assert.ok(captured.every(({ body }) => body.identity === undefined));
|
|
560
|
+
const second = await run(['profile', 'check']);
|
|
561
|
+
assert.equal(second.code, 0, second.stderr);
|
|
562
|
+
const identified = captured.at(-1).body;
|
|
563
|
+
assert.deepEqual(identified.identity, { name: target.name, email: target.email });
|
|
564
|
+
assert.equal(JSON.stringify(identified).includes(target.phone), false);
|
|
565
|
+
assert.equal(JSON.stringify(identified).includes(target.location), false);
|
|
566
|
+
const count = captured.length;
|
|
567
|
+
const optedOut = await run(['telemetry', 'identity', 'disable']);
|
|
568
|
+
assert.equal(optedOut.code, 0, optedOut.stderr);
|
|
569
|
+
assert.equal(captured.length, count);
|
|
570
|
+
assert.equal((await run(['profile', 'check'])).code, 0);
|
|
571
|
+
assert.equal(captured.at(-1).body.identity, undefined);
|
|
572
|
+
assert.notEqual(captured.at(-1).body.installationId, identified.installationId);
|
|
573
|
+
});
|
|
@@ -26,7 +26,7 @@ test('new installations disclose and send the first event immediately', async (t
|
|
|
26
26
|
assert.equal(session.installationEventPending, true);
|
|
27
27
|
await client.record({ event: 'installation_started', properties: { osFamily: 'macos', nodeMajor: 24, submissionMode: 'unconfigured' } }, session);
|
|
28
28
|
const result = await client.record({ event: 'command_completed', properties: { command: 'search', result: 'success', durationBucket: '1-5s' } }, session);
|
|
29
|
-
assert.match(notice, /
|
|
29
|
+
assert.match(notice, /usage analytics/i);
|
|
30
30
|
assert.equal(result.sent, true);
|
|
31
31
|
assert.equal(relay.requests.length, 3);
|
|
32
32
|
assert.equal((await client.beginCommand('search')).installationEventPending, false);
|
|
@@ -68,7 +68,7 @@ test('a pre-disclosure telemetry config cannot send on its disclosure command',
|
|
|
68
68
|
let notice = '';
|
|
69
69
|
const client = new TelemetryClient({ stateDir: directory, endpoint: 'https://relay.example.com', fetch: relay.fetch, stderr: (value) => { notice += value; } });
|
|
70
70
|
const session = await client.beginCommand('search');
|
|
71
|
-
assert.match(notice, /
|
|
71
|
+
assert.match(notice, /usage analytics/i);
|
|
72
72
|
assert.equal(session.allowSend, false);
|
|
73
73
|
assert.equal(relay.requests.length, 0);
|
|
74
74
|
});
|
|
@@ -130,3 +130,92 @@ test('strict record rejects invalid schema while automatic telemetry stays best
|
|
|
130
130
|
await assert.rejects(() => client.record(invalid, session, { strict: true }), /durationBucket/i);
|
|
131
131
|
assert.deepEqual(await client.record(invalid, session), { sent: false, reason: 'invalid' });
|
|
132
132
|
});
|
|
133
|
+
|
|
134
|
+
const usageEvent = { event: 'command_completed', properties: { command: 'profile', result: 'success', durationBucket: 'under-1s' } };
|
|
135
|
+
|
|
136
|
+
test('identity sharing defaults on but waits until the command after disclosure, including upgrades', async (t) => {
|
|
137
|
+
for (const legacy of [false, true]) {
|
|
138
|
+
const directory = await mkdtemp(join(tmpdir(), 'telemetry-identity-'));
|
|
139
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
140
|
+
if (legacy) await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: true, disclosed: true, graceConsumed: true }));
|
|
141
|
+
const relay = fakeRelay();
|
|
142
|
+
let notice = '';
|
|
143
|
+
const client = new TelemetryClient({ stateDir: directory, fetch: relay.fetch, stderr: (value) => { notice += value; }, readIdentity: () => ({ name: 'Test Candidate', email: 'candidate@example.com' }) });
|
|
144
|
+
const first = await client.beginCommand('profile');
|
|
145
|
+
await client.record(usageEvent, first);
|
|
146
|
+
assert.match(notice, /name and email/i);
|
|
147
|
+
assert.match(notice, /telemetry identity disable/);
|
|
148
|
+
assert.equal(relay.requests.at(-1).body.identity, undefined);
|
|
149
|
+
await client.record(usageEvent); // A missing session must not bypass the disclosure gate.
|
|
150
|
+
assert.equal(relay.requests.at(-1).body.identity, undefined);
|
|
151
|
+
await client.record(usageEvent, await client.beginCommand('profile'));
|
|
152
|
+
assert.deepEqual(relay.requests.at(-1).body.identity, { name: 'Test Candidate', email: 'candidate@example.com' });
|
|
153
|
+
assert.equal((await client.status()).identityEnabled, true);
|
|
154
|
+
assert.equal(JSON.stringify(await client.readConfig()).includes('candidate@example.com'), false);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('identity opt-out rotates credentials and stops identity sharing even in an already started command', async (t) => {
|
|
159
|
+
const directory = await mkdtemp(join(tmpdir(), 'telemetry-identity-optout-'));
|
|
160
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
161
|
+
const relay = fakeRelay();
|
|
162
|
+
const client = new TelemetryClient({ stateDir: directory, fetch: relay.fetch, stderr: () => {}, readIdentity: () => ({ name: 'Test Candidate', email: 'candidate@example.com' }) });
|
|
163
|
+
await client.beginCommand('profile');
|
|
164
|
+
const session = await client.beginCommand('profile');
|
|
165
|
+
await client.record(usageEvent, session);
|
|
166
|
+
assert.ok(relay.requests.at(-1).body.identity);
|
|
167
|
+
const before = relay.requests.length;
|
|
168
|
+
const status = await client.configureIdentity('disable');
|
|
169
|
+
assert.equal(status.enabled, true);
|
|
170
|
+
assert.equal(status.identityEnabled, false);
|
|
171
|
+
assert.equal(status.hasInstallationId, false);
|
|
172
|
+
assert.equal(relay.requests.length, before);
|
|
173
|
+
await client.record(usageEvent, session);
|
|
174
|
+
assert.equal(relay.requests.at(-1).body.identity, undefined);
|
|
175
|
+
assert.deepEqual(relay.requests.at(-2).body, {});
|
|
176
|
+
const anonymousId = (await client.status()).installationId;
|
|
177
|
+
await client.configureIdentity('disable');
|
|
178
|
+
assert.equal((await client.status()).installationId, anonymousId);
|
|
179
|
+
await client.configure('disable');
|
|
180
|
+
await client.configure('enable');
|
|
181
|
+
assert.equal((await client.status()).identityEnabled, false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('disabled telemetry and identity opt-out never read identity; malformed identity does not stop usage', async (t) => {
|
|
185
|
+
const directory = await mkdtemp(join(tmpdir(), 'telemetry-identity-failure-'));
|
|
186
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
187
|
+
const relay = fakeRelay();
|
|
188
|
+
let reads = 0;
|
|
189
|
+
const client = new TelemetryClient({ stateDir: directory, fetch: relay.fetch, stderr: () => {}, readIdentity: () => { reads++; return { name: 'Test', email: 'bad', resume: 'private' }; } });
|
|
190
|
+
await client.configure('disable');
|
|
191
|
+
await client.record(usageEvent, await client.beginCommand('profile'));
|
|
192
|
+
assert.equal(reads, 0);
|
|
193
|
+
await client.configure('enable');
|
|
194
|
+
await client.beginCommand('profile');
|
|
195
|
+
assert.equal((await client.record(usageEvent, await client.beginCommand('profile'))).sent, true);
|
|
196
|
+
assert.equal(relay.requests.at(-1).body.identity, undefined);
|
|
197
|
+
await client.configureIdentity('disable');
|
|
198
|
+
const before = reads;
|
|
199
|
+
await client.record(usageEvent, await client.beginCommand('profile'));
|
|
200
|
+
assert.equal(reads, before);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('identity re-enable discloses again and rotates the anonymous interval without enabling telemetry', async (t) => {
|
|
204
|
+
const directory = await mkdtemp(join(tmpdir(), 'telemetry-identity-resume-'));
|
|
205
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
206
|
+
const relay = fakeRelay();
|
|
207
|
+
const client = new TelemetryClient({ stateDir: directory, fetch: relay.fetch, stderr: () => {}, readIdentity: () => ({ email: 'candidate@example.com' }) });
|
|
208
|
+
await client.configureIdentity('disable');
|
|
209
|
+
await client.record(usageEvent, await client.beginCommand('profile'));
|
|
210
|
+
assert.equal(relay.requests.at(-1).body.identity, undefined);
|
|
211
|
+
await client.configure('disable');
|
|
212
|
+
const status = await client.configureIdentity('enable');
|
|
213
|
+
assert.equal(status.enabled, false);
|
|
214
|
+
assert.equal(status.hasInstallationId, false);
|
|
215
|
+
assert.equal(status.identityDisclosed, false);
|
|
216
|
+
await client.configure('enable');
|
|
217
|
+
await client.record(usageEvent, await client.beginCommand('profile'));
|
|
218
|
+
assert.equal(relay.requests.at(-1).body.identity, undefined);
|
|
219
|
+
await client.record(usageEvent, await client.beginCommand('profile'));
|
|
220
|
+
assert.deepEqual(relay.requests.at(-1).body.identity, { email: 'candidate@example.com' });
|
|
221
|
+
});
|
|
@@ -6,8 +6,20 @@ import {
|
|
|
6
6
|
createTelemetryEnvelope,
|
|
7
7
|
jobIdentity,
|
|
8
8
|
validateEvent,
|
|
9
|
+
validateTelemetryEnvelope,
|
|
9
10
|
} from '../scripts/telemetry-schema.mjs';
|
|
10
11
|
|
|
12
|
+
test('identity envelope accepts only bounded name and email and keeps old anonymous envelopes compatible', () => {
|
|
13
|
+
const input = { installationId: '11111111-1111-4111-8111-111111111111', token: 'signed-token', skillVersion: '3.3.0', event: 'command_completed', properties: { command: 'profile', result: 'success', durationBucket: 'under-1s' } };
|
|
14
|
+
const envelope = createTelemetryEnvelope({ ...input, identity: { name: ' Test Candidate ', email: ' candidate@example.com ' } });
|
|
15
|
+
assert.deepEqual(envelope.identity, { name: 'Test Candidate', email: 'candidate@example.com' });
|
|
16
|
+
assert.deepEqual(validateTelemetryEnvelope(envelope), envelope);
|
|
17
|
+
assert.equal(validateTelemetryEnvelope(createTelemetryEnvelope(input)).identity, undefined);
|
|
18
|
+
for (const identity of [{}, { name: 'x', phone: 'private' }, { email: 'invalid' }, { name: 'x'.repeat(161) }, { email: 'a\nb@example.com' }, { name: 'Test\nPrivate' }, { name: 'Test', resume: 'private' }]) {
|
|
19
|
+
assert.throws(() => validateTelemetryEnvelope({ ...envelope, identity }), /identity/i);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
11
23
|
const baseJob = {
|
|
12
24
|
company: 'Example AI',
|
|
13
25
|
title: 'Staff Product Engineer',
|
|
@@ -16,6 +28,16 @@ const baseJob = {
|
|
|
16
28
|
ats: 'greenhouse',
|
|
17
29
|
};
|
|
18
30
|
|
|
31
|
+
test('source coverage telemetry excludes local evidence and community identifiers', () => {
|
|
32
|
+
const event = { event: 'source_checked', properties: { sourceId: 'linkedin-jobs-feed', status: 'searched', reviewedCount: 10, qualifiedCount: 2 } };
|
|
33
|
+
assert.equal(validateEvent(event).properties.sourceId, 'linkedin-jobs-feed');
|
|
34
|
+
assert.throws(() => validateEvent({ ...event, properties: { ...event.properties, evidence: 'private query and notes' } }), /unknown/i);
|
|
35
|
+
assert.throws(() => validateEvent({ ...event, properties: { ...event.properties, sourceId: 'community-abcdef1234567890' } }), /sourceId/i);
|
|
36
|
+
assert.throws(() => validateEvent({ ...event, properties: { ...event.properties, qualifiedCount: 11 } }), /qualifiedCount/i);
|
|
37
|
+
assert.throws(() => validateEvent({ ...event, properties: { ...event.properties, status: 'blocked' } }), /block/i);
|
|
38
|
+
assert.equal(validateEvent({ event: 'source_checked', properties: { sourceId: 'community', status: 'blocked', reviewedCount: 0, qualifiedCount: 0, blocker: 'captcha' } }).properties.blocker, 'captcha');
|
|
39
|
+
});
|
|
40
|
+
|
|
19
41
|
test('canonicalizes job URLs and hashes the destination without query data', async () => {
|
|
20
42
|
assert.equal(canonicalizeJobUrl('https://Jobs.Example.com/role/123/?utm_source=x#apply'), 'https://jobs.example.com/role/123');
|
|
21
43
|
const first = await jobIdentity('https://jobs.example.com/role/123?ref=friend');
|
|
@@ -121,15 +121,87 @@ test('counts only unique confirmed ledger submissions for an explicit round', as
|
|
|
121
121
|
assert.equal(status.blockedCount, 1);
|
|
122
122
|
assert.equal(status.completed, false);
|
|
123
123
|
|
|
124
|
-
const
|
|
124
|
+
for (const sourceId of ['linkedin-jobs-feed', 'indeed', 'hacker-news-who-is-hiring']) {
|
|
125
|
+
cli(env, ['round', 'source', '--stdin'], {
|
|
126
|
+
roundId: started.roundId, sourceId, status: 'searched', reviewedCount: 30, qualifiedCount: sourceId === 'linkedin-jobs-feed' ? 30 : 0,
|
|
127
|
+
evidence: 'Reviewed matching postings against the unchanged target; other leads did not qualify.',
|
|
128
|
+
applicationIds: sourceId === 'linkedin-jobs-feed' ? Array.from({ length: 30 }, (_, i) => `round-role-${i}`) : [],
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const completed = cli(env, ['round', 'complete', '--stdin'], { roundId: started.roundId, concentrationReason: 'stronger-fit', concentrationEvidence: 'The other searched sources had no qualifying roles; all selected roles met the target.' });
|
|
125
132
|
assert.equal(completed.completed, true);
|
|
126
133
|
assert.equal(cli(env, ['round', 'status', started.roundId]).completed, true);
|
|
127
134
|
const roundEvents = (await readFile(join(directory, 'rounds.ndjson'), 'utf8')).trim().split('\n').map(JSON.parse);
|
|
128
135
|
assert.equal(roundEvents.filter((event) => event.type === 'submission-confirmed').length, 30);
|
|
129
|
-
assert.equal(roundEvents.length,
|
|
136
|
+
assert.equal(roundEvents.length, 35);
|
|
130
137
|
if (process.platform !== 'win32') assert.equal((await stat(join(directory, 'rounds.ndjson'))).mode & 0o777, 0o600);
|
|
131
138
|
});
|
|
132
139
|
|
|
140
|
+
test('round completion requires distinct source coverage and explains concentration without imposing submission quotas', async (t) => {
|
|
141
|
+
const { env } = await fixture(t, 'source-coverage');
|
|
142
|
+
const { roundId } = cli(env, ['round', 'start', '--stdin'], { requestedCount: 2 });
|
|
143
|
+
for (const i of [1, 2]) cli(env, ['ledger', 'add', '--stdin'], submission(i, roundId, { discoverySourceId: 'linkedin-jobs-feed' }));
|
|
144
|
+
assert.match(cliFailure(env, ['round', 'complete', '--stdin'], { roundId }).stderr, /3 distinct discovery sources/i);
|
|
145
|
+
const check = { roundId, sourceId: 'linkedin-jobs-feed', status: 'searched', reviewedCount: 10, qualifiedCount: 2, evidence: 'Reviewed ten relevant postings using the target constraints.' };
|
|
146
|
+
cli(env, ['round', 'source', '--stdin'], check);
|
|
147
|
+
cli(env, ['round', 'source', '--stdin'], check);
|
|
148
|
+
assert.equal(cli(env, ['round', 'status', roundId]).discovery.attemptedSourceCount, 1);
|
|
149
|
+
cli(env, ['round', 'source', '--stdin'], { ...check, sourceId: 'indeed', qualifiedCount: 0 });
|
|
150
|
+
cli(env, ['round', 'source', '--stdin'], { ...check, sourceId: 'hacker-news-who-is-hiring', status: 'blocked', reviewedCount: 0, qualifiedCount: 0, blocker: 'site-error' });
|
|
151
|
+
const status = cli(env, ['round', 'status', roundId]);
|
|
152
|
+
assert.equal(status.discovery.coverageSatisfied, true);
|
|
153
|
+
assert.equal(status.discovery.searchedSourceCount, 2);
|
|
154
|
+
assert.equal(status.discovery.blockedSourceCount, 1);
|
|
155
|
+
assert.equal(status.discovery.maxSourceSharePercent, 100);
|
|
156
|
+
assert.match(cliFailure(env, ['round', 'complete', '--stdin'], { roundId }).stderr, /concentration/i);
|
|
157
|
+
const completed = cli(env, ['round', 'complete', '--stdin'], { roundId, concentrationReason: 'stronger-fit', concentrationEvidence: 'Only LinkedIn produced qualifying roles; Indeed had none and the third source was unavailable.' });
|
|
158
|
+
assert.equal(completed.completed, true);
|
|
159
|
+
assert.equal(cli(env, ['round', 'status', roundId]).discovery.concentrationReason, 'stronger-fit');
|
|
160
|
+
assert.match(cliFailure(env, ['round', 'source', '--stdin'], check).stderr, /completed round/i);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('discovery coverage rejects invalid reports and requires attribution without rewriting confirmed ledger rows', async (t) => {
|
|
164
|
+
const { env, directory } = await fixture(t, 'source-validation');
|
|
165
|
+
const { roundId } = cli(env, ['round', 'start', '--stdin'], { requestedCount: 1 });
|
|
166
|
+
cli(env, ['ledger', 'add', '--stdin'], submission(1, roundId));
|
|
167
|
+
const before = await readFile(join(directory, 'applications.ndjson'), 'utf8');
|
|
168
|
+
const check = { roundId, sourceId: 'linkedin-jobs-feed', status: 'searched', reviewedCount: 4, qualifiedCount: 1, evidence: 'Checked relevant listings against the candidate target.' };
|
|
169
|
+
for (const bad of [{ sourceId: 'imaginary-board' }, { qualifiedCount: 5 }, { status: 'blocked' }, { applicationIds: ['not-in-round'] }, { evidence: '' }, { privateProfile: 'forbidden' }]) {
|
|
170
|
+
assert.equal(cliFailure(env, ['round', 'source', '--stdin'], { ...check, ...bad }).status, 1);
|
|
171
|
+
}
|
|
172
|
+
for (const sourceId of ['linkedin-jobs-feed', 'indeed', 'hacker-news-who-is-hiring']) cli(env, ['round', 'source', '--stdin'], { ...check, sourceId });
|
|
173
|
+
assert.match(cliFailure(env, ['round', 'complete', '--stdin'], { roundId }).stderr, /attribution/i);
|
|
174
|
+
cli(env, ['round', 'source', '--stdin'], { ...check, applicationIds: ['round-role-1'] });
|
|
175
|
+
assert.equal(cli(env, ['round', 'status', roundId]).discovery.unattributedCount, 0);
|
|
176
|
+
assert.equal(await readFile(join(directory, 'applications.ndjson'), 'utf8'), before);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('source diversity is independent of ATS and concentration explanations are unnecessary for a balanced mix', async (t) => {
|
|
180
|
+
const { env } = await fixture(t, 'balanced-discovery');
|
|
181
|
+
const { roundId } = cli(env, ['round', 'start', '--stdin'], { requestedCount: 5 });
|
|
182
|
+
const ids = ['linkedin-jobs-feed', 'indeed', 'hacker-news-who-is-hiring'];
|
|
183
|
+
for (const sourceId of ids) cli(env, ['round', 'source', '--stdin'], { roundId, sourceId, status: 'searched', reviewedCount: 3, qualifiedCount: 2, evidence: 'Reviewed relevant postings and verified target fit.' });
|
|
184
|
+
for (let i = 1; i <= 5; i++) cli(env, ['ledger', 'add', '--stdin'], submission(i, roundId, { discoverySourceId: ids[(i - 1) % 3], applicationChannel: 'ashby' }));
|
|
185
|
+
const completed = cli(env, ['round', 'complete', '--stdin'], { roundId });
|
|
186
|
+
assert.equal(completed.discovery.maxSourceSharePercent, 40);
|
|
187
|
+
assert.equal(completed.discovery.concentrationNeedsExplanation, false);
|
|
188
|
+
assert.equal(cli(env, ['round', 'complete', '--stdin'], { roundId }).completionRecorded, false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('blocked-only attempts and two views of the same network do not satisfy discovery', async (t) => {
|
|
192
|
+
const { env } = await fixture(t, 'blocked-discovery');
|
|
193
|
+
const { roundId } = cli(env, ['round', 'start', '--stdin'], { requestedCount: 1 });
|
|
194
|
+
const check = { roundId, status: 'blocked', reviewedCount: 0, qualifiedCount: 0, blocker: 'login', evidence: 'Source requires an authenticated session that is unavailable.' };
|
|
195
|
+
for (const sourceId of ['linkedin-jobs-feed', 'yc-work-at-a-startup', 'yc-company-directory']) cli(env, ['round', 'source', '--stdin'], { ...check, sourceId });
|
|
196
|
+
assert.equal(cli(env, ['round', 'status', roundId]).discovery.attemptedSourceCount, 2);
|
|
197
|
+
cli(env, ['round', 'source', '--stdin'], { ...check, sourceId: 'indeed' });
|
|
198
|
+
assert.equal(cli(env, ['round', 'status', roundId]).discovery.coverageSatisfied, false);
|
|
199
|
+
const { blocker, ...searched } = check;
|
|
200
|
+
cli(env, ['round', 'source', '--stdin'], { ...searched, sourceId: 'indeed', status: 'searched', reviewedCount: 1, qualifiedCount: 1 });
|
|
201
|
+
cli(env, ['round', 'source', '--stdin'], { ...check, sourceId: 'indeed' });
|
|
202
|
+
assert.equal(cli(env, ['round', 'status', roundId]).discovery.coverageSatisfied, true);
|
|
203
|
+
});
|
|
204
|
+
|
|
133
205
|
test('ships a filterable global discovery source catalog and tracks source attribution', async (t) => {
|
|
134
206
|
const { directory, env } = await fixture(t, 'source-catalog');
|
|
135
207
|
const catalog = cli(env, ['sources', 'list']);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "job-application-agent",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "A privacy-first Agent Skill and CLI for evidence-based job discovery, application completion, and outcome tracking.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"check:native-artifacts": "node scripts/ci/validate-native-artifacts.mjs",
|
|
39
39
|
"privacy-audit": "node --test job-application-agent/tests/privacy-audit.test.mjs",
|
|
40
40
|
"smoke:package": "node scripts/smoke-package.mjs",
|
|
41
|
-
"check": "node --check job-application-agent/scripts/job-application.mjs && node --check job-application-agent/scripts/secret-store.mjs && node --check job-application-agent/scripts/source-community-client.mjs && node --check job-application-agent/scripts/source-community-schema.mjs && node --check job-application-agent/scripts/telemetry-client.mjs && node --check job-application-agent/scripts/telemetry-schema.mjs && node --check job-application-agent/scripts/version.mjs && node --check telemetry-worker/src/worker.mjs && node --check telemetry-worker/src/public-stats.mjs && node --check telemetry-worker/public/dashboard.js && node --check installer/src/cli.mjs && node --check installer/src/installer.mjs && node --check installer/src/runner.mjs && node --check installer/src/scheduler.mjs && node --check scripts/ci/classify-paths.mjs && node --check scripts/ci/validate-native-artifacts.mjs && node --check bin/job-application-agent.mjs",
|
|
41
|
+
"check": "node --check job-application-agent/scripts/job-application.mjs && node --check job-application-agent/scripts/secret-store.mjs && node --check job-application-agent/scripts/source-community-client.mjs && node --check job-application-agent/scripts/source-community-schema.mjs && node --check job-application-agent/scripts/telemetry-client.mjs && node --check job-application-agent/scripts/telemetry-schema.mjs && node --check job-application-agent/scripts/version.mjs && node --check telemetry-worker/src/worker.mjs && node --check telemetry-worker/src/public-stats.mjs && node --check telemetry-worker/public/dashboard.js && node --check installer/src/cli.mjs && node --check installer/src/installer.mjs && node --check installer/src/runner.mjs && node --check installer/src/scheduler.mjs && node --check scripts/ci/classify-paths.mjs && node --check scripts/ci/validate-native-artifacts.mjs && node --check bin/job-application-agent.mjs && node --check state-worker/src/worker.mjs && node --check state-worker/src/cli.mjs && node --check state-worker/bin/sync.mjs",
|
|
42
42
|
"prepack": "npm run check && npm test && npm run privacy-audit"
|
|
43
43
|
},
|
|
44
44
|
"engines": {
|