trackly-cli 0.7.1 → 0.7.3
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 +16 -3
- package/bin/trackly +222 -21
- package/contracts/trackly-apply-tools.json +2 -2
- package/docs/trackly-tools.md +13 -1
- package/lib/agent.js +1 -1
- package/lib/client.js +46 -0
- package/mcp/server.js +120 -7
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/trackly-apply/SKILL.md +5 -3
- package/skills/trackly-apply/references/ats-playbook.md +1 -1
- package/skills/trackly-apply/references/form-integrity.md +2 -1
- package/skills/trackly-apply/references/scenario-coverage.md +2 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ trackly jobs --function product
|
|
|
35
35
|
|
|
36
36
|
## At a Glance
|
|
37
37
|
|
|
38
|
-
1,900+ companies | 128K+ jobs | 40+ ATS types | CLI + MCP |
|
|
38
|
+
1,900+ companies | 128K+ jobs | 40+ ATS types | CLI + MCP | 24 local MCP tools
|
|
39
39
|
|
|
40
40
|
## CLI Commands
|
|
41
41
|
|
|
@@ -54,6 +54,9 @@ trackly companies search "fintech" # Semantic company search
|
|
|
54
54
|
trackly search "fintech" # Alias for semantic company search
|
|
55
55
|
trackly stats # Show metrics
|
|
56
56
|
trackly status # Alias for stats
|
|
57
|
+
trackly preferences # Show discovery preferences and experience limits
|
|
58
|
+
trackly preferences experience product=2 strategy=5 # Replace role-specific limits
|
|
59
|
+
trackly preferences experience clear # Turn role-specific experience filtering off
|
|
57
60
|
trackly apply 1234 # Mark as applied
|
|
58
61
|
trackly save 1234 # Save a job
|
|
59
62
|
trackly dismiss 1234 # Dismiss a job
|
|
@@ -78,6 +81,8 @@ trackly agent doctor # Verify setup, profile, resume, and compat
|
|
|
78
81
|
|
|
79
82
|
Add `--json` to any command for JSON output. Use `--api-key <key>` or `--base-url <url>` as one-off global flags when needed.
|
|
80
83
|
|
|
84
|
+
`trackly preferences --json` returns only `success`, `experienceFilterV2Available`, and `preferences`. The availability flag controls whether this client may edit role-specific limits; it does not claim that feed or alert enforcement is active. Every update checks that flag and the latest preference revision before writing.
|
|
85
|
+
|
|
81
86
|
## MCP Server Setup
|
|
82
87
|
|
|
83
88
|
### Hosted (Claude co-work, Claude Desktop, ChatGPT)
|
|
@@ -160,6 +165,8 @@ Then use natural language in any of these clients:
|
|
|
160
165
|
| trackly_search_companies | Semantic company search |
|
|
161
166
|
| trackly_list_companies | List all tracked companies |
|
|
162
167
|
| trackly_get_stats | Job tracker metrics and status counts |
|
|
168
|
+
| trackly_get_preferences | Read bounded discovery preferences, edit availability, and save revision |
|
|
169
|
+
| trackly_update_experience_limits | Atomically replace role-specific limits on a job's stated minimum years |
|
|
163
170
|
| trackly_update_status | Mark jobs as applied, saved, or dismissed |
|
|
164
171
|
| trackly_ask | Natural language job search (20/day) |
|
|
165
172
|
| trackly_get_job_brief | Get network brief for a job (company signal, top contact, actions) |
|
|
@@ -188,9 +195,15 @@ trackly login
|
|
|
188
195
|
|
|
189
196
|
Opens your browser for Google sign-in. Tokens are stored locally at `~/.trackly/config.json`.
|
|
190
197
|
|
|
198
|
+
Trackly is currently opening new memberships through a limited, invite-based
|
|
199
|
+
rollout. Existing members can keep using OAuth and API keys normally. If a new
|
|
200
|
+
account is not yet eligible, the CLI returns an invitation-specific message
|
|
201
|
+
instead of suggesting repeated login attempts; request a future seat at
|
|
202
|
+
[usetrackly.app/early-access](https://usetrackly.app/early-access).
|
|
203
|
+
|
|
191
204
|
### Option 2: API Key
|
|
192
205
|
|
|
193
|
-
|
|
206
|
+
Existing members can use an API key for firewalls, headless servers, or CI:
|
|
194
207
|
|
|
195
208
|
1. Sign in at [usetrackly.app](https://usetrackly.app)
|
|
196
209
|
2. Go to **Settings → API Keys → Create**
|
|
@@ -250,7 +263,7 @@ Install trackly-cli (`npm install -g trackly-cli`), authenticate with `trackly l
|
|
|
250
263
|
|
|
251
264
|
**What MCP servers exist for job searching?**
|
|
252
265
|
|
|
253
|
-
trackly-cli includes a built-in MCP server with
|
|
266
|
+
trackly-cli includes a built-in MCP server with 24 tools for job search, company lookup, discovery preferences, application tracking, profile onboarding, beta evidence, and manual-submit form preparation. Run `trackly mcp` or use `trackly agent setup --client claude`.
|
|
254
267
|
|
|
255
268
|
**How do I use Claude Code for job hunting?**
|
|
256
269
|
|
package/bin/trackly
CHANGED
|
@@ -5,6 +5,7 @@ const {
|
|
|
5
5
|
DEFAULT_BASE_URL,
|
|
6
6
|
apiRequest,
|
|
7
7
|
clearConfig,
|
|
8
|
+
createTracklyAccessError,
|
|
8
9
|
getApiKey,
|
|
9
10
|
getAuthMethod,
|
|
10
11
|
getBaseUrl,
|
|
@@ -65,6 +66,7 @@ const COMMAND_FLAGS = {
|
|
|
65
66
|
search: [],
|
|
66
67
|
stats: [],
|
|
67
68
|
status: [],
|
|
69
|
+
preferences: [],
|
|
68
70
|
apply: [],
|
|
69
71
|
save: [],
|
|
70
72
|
dismiss: [],
|
|
@@ -80,15 +82,32 @@ const COMMAND_FLAGS = {
|
|
|
80
82
|
agent: ['client', 'window-days', 'target-reviewed-runs'],
|
|
81
83
|
};
|
|
82
84
|
const AUTH_HELP_TEXT = `Not authenticated.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
Existing member: trackly login (Google OAuth)
|
|
86
|
+
Existing API key: trackly config --api-key trk_xxx
|
|
87
|
+
New member: Trackly is in a limited rollout. Request access at
|
|
88
|
+
https://usetrackly.app/early-access`;
|
|
86
89
|
const STATUS_LABELS = {
|
|
87
90
|
applied: 'applied',
|
|
88
91
|
saved: 'saved',
|
|
89
92
|
dismissed: 'dismissed',
|
|
90
93
|
};
|
|
91
94
|
const WORK_ARRANGEMENT_VALUES = ['remote', 'hybrid', 'in_person', 'unspecified'];
|
|
95
|
+
const JOB_FUNCTIONS = [
|
|
96
|
+
'product', 'engineering', 'design', 'data', 'marketing', 'sales', 'partnerships',
|
|
97
|
+
'finance', 'strategy', 'operations', 'people', 'legal', 'support', 'other',
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
function loginAccessError(code) {
|
|
101
|
+
return createTracklyAccessError({ code });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function writeLoginProgress(message) {
|
|
105
|
+
if (isJSON()) {
|
|
106
|
+
console.error(message);
|
|
107
|
+
} else {
|
|
108
|
+
console.log(message);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
92
111
|
|
|
93
112
|
function parseArgs(args) {
|
|
94
113
|
const result = { _: [], command: null };
|
|
@@ -225,15 +244,20 @@ function formatMaintenanceForHuman(maintenance) {
|
|
|
225
244
|
|
|
226
245
|
function setApiErrorExit(error, fallbackMessage, exitCode = 1) {
|
|
227
246
|
const maintenance = maintenanceOutput(error);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
247
|
+
const accessError = createTracklyAccessError(error, error?.status);
|
|
248
|
+
if (isJSON() && accessError) {
|
|
249
|
+
printJson({
|
|
250
|
+
status: accessError.status,
|
|
251
|
+
code: accessError.code,
|
|
252
|
+
message: accessError.message,
|
|
253
|
+
retryable: accessError.retryable,
|
|
254
|
+
});
|
|
255
|
+
} else if (maintenance && isJSON()) {
|
|
234
256
|
printJson(maintenance);
|
|
235
|
-
} else {
|
|
257
|
+
} else if (maintenance) {
|
|
236
258
|
outputError(formatMaintenanceForHuman(maintenance));
|
|
259
|
+
} else {
|
|
260
|
+
outputError(error?.error || error?.message || fallbackMessage);
|
|
237
261
|
}
|
|
238
262
|
process.exitCode = exitCode;
|
|
239
263
|
}
|
|
@@ -252,6 +276,39 @@ function parseJobId(value) {
|
|
|
252
276
|
return id;
|
|
253
277
|
}
|
|
254
278
|
|
|
279
|
+
function parseExperienceLimits(values) {
|
|
280
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
281
|
+
throw new Error('Provide at least one role limit or `clear` (for example, `product=2 strategy=5`).');
|
|
282
|
+
}
|
|
283
|
+
if (values.includes('clear')) {
|
|
284
|
+
if (values.length !== 1) throw new Error('`clear` cannot be combined with role limits.');
|
|
285
|
+
return {};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const limits = {};
|
|
289
|
+
for (const value of values) {
|
|
290
|
+
const match = /^([^=]+)=([^=]+)$/.exec(String(value));
|
|
291
|
+
if (!match) throw new Error(`Invalid limit "${value}". Use role=years (for example, product=2).`);
|
|
292
|
+
const jobFunction = match[1].trim().toLowerCase();
|
|
293
|
+
const rawYears = match[2].trim();
|
|
294
|
+
if (!JOB_FUNCTIONS.includes(jobFunction)) {
|
|
295
|
+
throw new Error(`Unknown job function "${jobFunction}". Use: ${JOB_FUNCTIONS.join(', ')}.`);
|
|
296
|
+
}
|
|
297
|
+
if (Object.prototype.hasOwnProperty.call(limits, jobFunction)) {
|
|
298
|
+
throw new Error(`Duplicate limit for ${jobFunction}. Provide each job function once.`);
|
|
299
|
+
}
|
|
300
|
+
if (!/^\d+$/.test(rawYears)) {
|
|
301
|
+
throw new Error(`Limit for ${jobFunction} must be an integer from 0 to 60.`);
|
|
302
|
+
}
|
|
303
|
+
const years = Number(rawYears);
|
|
304
|
+
if (!Number.isInteger(years) || years < 0 || years > 60) {
|
|
305
|
+
throw new Error(`Limit for ${jobFunction} must be an integer from 0 to 60.`);
|
|
306
|
+
}
|
|
307
|
+
limits[jobFunction] = years;
|
|
308
|
+
}
|
|
309
|
+
return limits;
|
|
310
|
+
}
|
|
311
|
+
|
|
255
312
|
// Flags deprecated in the API-docs-plan fixup. Still accepted by parseArgs so old
|
|
256
313
|
// scripts don't ERR_NOT_FOUND, but cmdJobs hard-rejects them with a migration hint
|
|
257
314
|
// — silent no-op would be worse than noise (per PR #13 Codex P1 finding).
|
|
@@ -321,11 +378,11 @@ function getPublicConfig() {
|
|
|
321
378
|
|
|
322
379
|
// -- Commands --
|
|
323
380
|
|
|
324
|
-
async function cmdLogin() {
|
|
381
|
+
async function cmdLogin({ openUrl = openExternalUrl } = {}) {
|
|
325
382
|
const baseUrl = getBaseUrl();
|
|
326
383
|
const state = crypto.randomBytes(16).toString('hex');
|
|
327
384
|
|
|
328
|
-
|
|
385
|
+
writeLoginProgress(color('cyan', 'Opening browser for Google login...'));
|
|
329
386
|
|
|
330
387
|
let activeServer = null;
|
|
331
388
|
|
|
@@ -348,6 +405,21 @@ async function cmdLogin() {
|
|
|
348
405
|
|
|
349
406
|
const token = url.searchParams.get('token');
|
|
350
407
|
const refreshToken = url.searchParams.get('refreshToken');
|
|
408
|
+
const accessError = loginAccessError(url.searchParams.get('error'));
|
|
409
|
+
if (accessError) {
|
|
410
|
+
res.writeHead(403, {
|
|
411
|
+
'Content-Type': 'text/html',
|
|
412
|
+
'Referrer-Policy': 'no-referrer',
|
|
413
|
+
'Cache-Control': 'no-store',
|
|
414
|
+
});
|
|
415
|
+
res.end('<html><body><h2>Trackly access is limited right now.</h2><p>Return to your terminal for next steps.</p></body></html>');
|
|
416
|
+
const loginError = new Error(accessError.message);
|
|
417
|
+
loginError.code = accessError.code;
|
|
418
|
+
loginError.status = accessError.status;
|
|
419
|
+
loginError.retryable = accessError.retryable;
|
|
420
|
+
settle(loginError);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
351
423
|
if (token) {
|
|
352
424
|
const config = loadConfig();
|
|
353
425
|
const hadApiKey = Boolean(config.apiKey);
|
|
@@ -382,9 +454,9 @@ p{color:#a1a1aa;font-size:15px;line-height:1.5}
|
|
|
382
454
|
</div>
|
|
383
455
|
</body>
|
|
384
456
|
</html>`);
|
|
385
|
-
|
|
457
|
+
writeLoginProgress(color('green', 'Login successful!'));
|
|
386
458
|
if (hadApiKey) {
|
|
387
|
-
|
|
459
|
+
writeLoginProgress('Switched to OAuth. Stored API key cleared.');
|
|
388
460
|
}
|
|
389
461
|
settle();
|
|
390
462
|
} else {
|
|
@@ -405,10 +477,12 @@ p{color:#a1a1aa;font-size:15px;line-height:1.5}
|
|
|
405
477
|
|
|
406
478
|
const finish = () => {
|
|
407
479
|
if (error) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
480
|
+
if (!createTracklyAccessError(error, 403)) {
|
|
481
|
+
writeLoginProgress('\nLogin failed. Existing members can try API key auth:');
|
|
482
|
+
writeLoginProgress(' 1. Sign in at https://usetrackly.app');
|
|
483
|
+
writeLoginProgress(' 2. Go to Settings → API Keys → Create');
|
|
484
|
+
writeLoginProgress(' 3. Run: trackly config --api-key <your-key>');
|
|
485
|
+
}
|
|
412
486
|
reject(error);
|
|
413
487
|
} else {
|
|
414
488
|
resolve();
|
|
@@ -436,10 +510,10 @@ p{color:#a1a1aa;font-size:15px;line-height:1.5}
|
|
|
436
510
|
settle(new Error(`OS assigned an out-of-range callback port (${port}); expected 1024-65535.`));
|
|
437
511
|
return;
|
|
438
512
|
}
|
|
439
|
-
|
|
513
|
+
writeLoginProgress(color('dim', `Callback server on port ${port}`));
|
|
440
514
|
const loginUrl = `${baseUrl}/auth/google/cli?port=${port}&state=${state}`;
|
|
441
|
-
if (!
|
|
442
|
-
|
|
515
|
+
if (!openUrl(loginUrl)) {
|
|
516
|
+
writeLoginProgress(`Open this URL in your browser:\n${loginUrl}`);
|
|
443
517
|
}
|
|
444
518
|
});
|
|
445
519
|
|
|
@@ -461,6 +535,7 @@ p{color:#a1a1aa;font-size:15px;line-height:1.5}
|
|
|
461
535
|
process.on('SIGINT', onSigint);
|
|
462
536
|
try {
|
|
463
537
|
await tryLogin();
|
|
538
|
+
if (isJSON()) printJson({ success: true, authMethod: 'oauth' });
|
|
464
539
|
} finally {
|
|
465
540
|
process.removeListener('SIGINT', onSigint);
|
|
466
541
|
}
|
|
@@ -612,6 +687,119 @@ async function cmdStats() {
|
|
|
612
687
|
}
|
|
613
688
|
}
|
|
614
689
|
|
|
690
|
+
function renderPreferences(result) {
|
|
691
|
+
const user = result?.user || {};
|
|
692
|
+
const preferences = user.preferences || result?.preferences || {};
|
|
693
|
+
const limits = preferences.experienceLimitsByJobFunction || {};
|
|
694
|
+
|
|
695
|
+
console.log(color('bold', 'Discovery preferences'));
|
|
696
|
+
const selected = Array.isArray(preferences.wantedJobFunctions)
|
|
697
|
+
? preferences.wantedJobFunctions
|
|
698
|
+
: [];
|
|
699
|
+
console.log(` Target roles: ${selected.length > 0 ? selected.join(', ') : 'All roles'}`);
|
|
700
|
+
console.log(` Experience limits: ${Object.keys(limits).length > 0 ? 'Configured' : 'Not configured'}`);
|
|
701
|
+
for (const [jobFunction, years] of Object.entries(limits)) {
|
|
702
|
+
console.log(` ${jobFunction}: Up to ${years} year${years === 1 ? '' : 's'} required`);
|
|
703
|
+
}
|
|
704
|
+
console.log(color('dim', ' Jobs with no stated minimum remain visible.'));
|
|
705
|
+
console.log(color('dim', ' This confirms what you saved; it does not show whether Trackly is currently applying the limits.'));
|
|
706
|
+
if (Number.isSafeInteger(preferences.discoveryPreferenceRevision)) {
|
|
707
|
+
console.log(color('dim', ` Revision: ${preferences.discoveryPreferenceRevision}`));
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function projectPreferenceResponse(result, experienceFilterV2Available = null) {
|
|
712
|
+
const user = result?.user || {};
|
|
713
|
+
return {
|
|
714
|
+
success: result?.success === true,
|
|
715
|
+
experienceFilterV2Available: experienceFilterV2Available === null
|
|
716
|
+
? user.experienceFilterV2Available === true
|
|
717
|
+
: experienceFilterV2Available === true,
|
|
718
|
+
preferences: user.preferences || result?.preferences || {},
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function handlePreferenceConflict(error) {
|
|
723
|
+
const message = 'Preferences changed on another device. Review `trackly preferences`, then run the update again.';
|
|
724
|
+
if (isJSON()) {
|
|
725
|
+
printJson({
|
|
726
|
+
status: error.status,
|
|
727
|
+
code: error.error,
|
|
728
|
+
message,
|
|
729
|
+
preferences: error.preferences,
|
|
730
|
+
discoveryPreferenceRevision: error.discoveryPreferenceRevision,
|
|
731
|
+
});
|
|
732
|
+
} else {
|
|
733
|
+
outputError(message);
|
|
734
|
+
}
|
|
735
|
+
process.exitCode = 1;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async function cmdPreferences(args) {
|
|
739
|
+
if (!hasAuth()) failNow(AUTH_HELP_TEXT);
|
|
740
|
+
|
|
741
|
+
const subcommand = args._[0];
|
|
742
|
+
if (subcommand == null || subcommand === 'show') {
|
|
743
|
+
if (args._.length > (subcommand === 'show' ? 1 : 0)) {
|
|
744
|
+
failNow('Usage: trackly preferences [show]');
|
|
745
|
+
}
|
|
746
|
+
try {
|
|
747
|
+
const result = await apiRequest('GET', '/api/jobscout/me');
|
|
748
|
+
if (isJSON()) return printJson(projectPreferenceResponse(result));
|
|
749
|
+
renderPreferences(result);
|
|
750
|
+
return;
|
|
751
|
+
} catch (error) {
|
|
752
|
+
setApiErrorExit(error, 'Failed to fetch preferences');
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (subcommand !== 'experience') {
|
|
758
|
+
failNow('Unknown preferences subcommand. Use: trackly preferences [show] | trackly preferences experience <role=years...|clear>');
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
let experienceLimitsByJobFunction;
|
|
762
|
+
try {
|
|
763
|
+
experienceLimitsByJobFunction = parseExperienceLimits(args._.slice(1));
|
|
764
|
+
} catch (error) {
|
|
765
|
+
failNow(error.message);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
try {
|
|
769
|
+
// Read immediately before the write and send the authoritative revision.
|
|
770
|
+
// The backend rejects a stale value with 409 instead of letting this
|
|
771
|
+
// process overwrite a newer iOS, web, macOS, CLI, or MCP save.
|
|
772
|
+
const current = await apiRequest('GET', '/api/jobscout/me');
|
|
773
|
+
if (current?.user?.experienceFilterV2Available !== true) {
|
|
774
|
+
failNow('Role-specific experience preferences are not available for this account yet. No changes were saved.');
|
|
775
|
+
}
|
|
776
|
+
const expectedPreferenceRevision = current?.user?.preferences?.discoveryPreferenceRevision;
|
|
777
|
+
if (!Number.isSafeInteger(expectedPreferenceRevision) || expectedPreferenceRevision < 0) {
|
|
778
|
+
failNow('Trackly did not return a valid preference revision. No changes were saved.');
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const result = await apiRequest('PUT', '/api/jobscout/preferences', {
|
|
782
|
+
experienceLimitsByJobFunction,
|
|
783
|
+
experienceFilterVersion: 2,
|
|
784
|
+
expectedPreferenceRevision,
|
|
785
|
+
});
|
|
786
|
+
if (isJSON()) return printJson(projectPreferenceResponse(result, true));
|
|
787
|
+
console.log(color(
|
|
788
|
+
'green',
|
|
789
|
+
Object.keys(experienceLimitsByJobFunction).length > 0
|
|
790
|
+
? 'Role-specific experience limits updated.'
|
|
791
|
+
: 'Role-specific experience filtering turned off.',
|
|
792
|
+
));
|
|
793
|
+
renderPreferences(result);
|
|
794
|
+
} catch (error) {
|
|
795
|
+
if (error?.status === 409 && error?.error === 'preference_revision_conflict') {
|
|
796
|
+
handlePreferenceConflict(error);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
setApiErrorExit(error, 'Failed to update experience limits');
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
615
803
|
const ACTION_TO_COMMAND = { applied: 'apply', saved: 'save', dismissed: 'dismiss' };
|
|
616
804
|
|
|
617
805
|
// CLI surface accepts applied|saved|dismissed (legacy human-friendly terms).
|
|
@@ -1064,6 +1252,8 @@ ${color('bold', 'Commands:')}
|
|
|
1064
1252
|
${color('cyan', 'search <query>')} Semantic company search
|
|
1065
1253
|
${color('cyan', 'stats')} Show metrics dashboard
|
|
1066
1254
|
${color('cyan', 'status')} Alias for stats
|
|
1255
|
+
${color('cyan', 'preferences')} Show discovery preferences
|
|
1256
|
+
${color('cyan', 'preferences experience')} Replace role limits (role=years; clear turns off)
|
|
1067
1257
|
${color('cyan', 'apply <id>')} Mark job as applied
|
|
1068
1258
|
${color('cyan', 'save <id>')} Save a job
|
|
1069
1259
|
${color('cyan', 'dismiss <id>')} Dismiss a job
|
|
@@ -1095,6 +1285,10 @@ ${color('bold', 'Flags:')}
|
|
|
1095
1285
|
${color('bold', 'Filter values:')}
|
|
1096
1286
|
--function product, engineering, design, data, marketing, sales, partnerships,
|
|
1097
1287
|
finance, strategy, operations, people, legal, support, other (comma-separated)
|
|
1288
|
+
experience limits use the same role values, for example:
|
|
1289
|
+
trackly preferences experience product=2 strategy=5
|
|
1290
|
+
When enforcement is active, includes jobs whose stated minimum is up to the limit.
|
|
1291
|
+
Jobs with no stated minimum remain visible.
|
|
1098
1292
|
--region us, non_us, all, or a region tag (europe, canada, remote, ...)
|
|
1099
1293
|
Use a comma-separated list of region tags for multiple regions;
|
|
1100
1294
|
us/non_us/all cannot be combined.
|
|
@@ -1208,6 +1402,9 @@ async function main() {
|
|
|
1208
1402
|
case 'status':
|
|
1209
1403
|
await cmdStats();
|
|
1210
1404
|
break;
|
|
1405
|
+
case 'preferences':
|
|
1406
|
+
await cmdPreferences(args);
|
|
1407
|
+
break;
|
|
1211
1408
|
case 'apply':
|
|
1212
1409
|
await cmdUpdateStatus(args._[0], 'applied');
|
|
1213
1410
|
break;
|
|
@@ -1276,14 +1473,18 @@ if (require.main === module) {
|
|
|
1276
1473
|
module.exports = {
|
|
1277
1474
|
COMMAND_FLAGS,
|
|
1278
1475
|
UNIVERSAL_FLAGS,
|
|
1476
|
+
cmdLogin,
|
|
1279
1477
|
formatMaintenanceForHuman,
|
|
1280
1478
|
getPublicConfig,
|
|
1281
1479
|
hasJobListFilters,
|
|
1282
1480
|
levenshtein,
|
|
1481
|
+
loginAccessError,
|
|
1283
1482
|
main,
|
|
1284
1483
|
nearestFlag,
|
|
1285
1484
|
normalizeBaseUrlValue,
|
|
1286
1485
|
openExternalUrl,
|
|
1287
1486
|
parseArgs,
|
|
1487
|
+
parseExperienceLimits,
|
|
1288
1488
|
parseJobId,
|
|
1489
|
+
writeLoginProgress,
|
|
1289
1490
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"contractVersion": "3.
|
|
2
|
+
"contractVersion": "3.2.0",
|
|
3
3
|
"constants": {
|
|
4
|
-
"applyScenarioCodes": ["browser_reclaim", "resume_upload", "resume_parser_recheck", "semantic_boolean_commit", "custom_select_commit", "multi_step_navigation", "free_text_voice", "required_error_sweep", "final_consent", "handoff_reclaim", "critical_contact_integrity", "manual_submit_boundary"],
|
|
4
|
+
"applyScenarioCodes": ["browser_reclaim", "resume_upload", "resume_parser_recheck", "semantic_boolean_commit", "custom_select_commit", "multi_step_navigation", "free_text_voice", "required_error_sweep", "final_consent", "handoff_reclaim", "critical_contact_integrity", "manual_submit_boundary", "job_identity_match"],
|
|
5
5
|
"applyBrowserSurfaces": ["codex_in_app", "chrome_extension", "claude_in_chrome"]
|
|
6
6
|
},
|
|
7
7
|
"tools": {
|
package/docs/trackly-tools.md
CHANGED
|
@@ -65,6 +65,11 @@ trackly config --api-key trk_xxxxxxxxxxxxxxxxxxxx
|
|
|
65
65
|
|
|
66
66
|
You can also pass `TRACKLY_API_KEY` as an environment variable for one-off runs.
|
|
67
67
|
|
|
68
|
+
Existing memberships work across the CLI and local MCP server. New memberships
|
|
69
|
+
are invite-only during the limited rollout; access errors link to
|
|
70
|
+
https://usetrackly.app/early-access instead of recommending repeated OAuth or
|
|
71
|
+
API-key creation attempts.
|
|
72
|
+
|
|
68
73
|
### Available Tools
|
|
69
74
|
|
|
70
75
|
- **trackly_search_jobs** — Search/filter jobs.
|
|
@@ -82,6 +87,12 @@ You can also pass `TRACKLY_API_KEY` as an environment variable for one-off runs.
|
|
|
82
87
|
- **trackly_search_companies** — Semantic company search
|
|
83
88
|
- **trackly_list_companies** — List all tracked companies with job counts
|
|
84
89
|
- **trackly_get_stats** — Job tracker metrics dashboard
|
|
90
|
+
- **trackly_get_preferences** — Read a bounded preference response containing only `success`, `experienceFilterV2Available`, and the authenticated user's discovery `preferences`, including selected roles, saved role-specific limits, and the revision required for a safe update. Availability authorizes editing; it does not report feed or alert enforcement.
|
|
91
|
+
- **trackly_update_experience_limits** — Atomically replace the complete role-specific experience-limit map.
|
|
92
|
+
- `experienceLimitsByJobFunction`: object whose keys are the 14 canonical job-function values and whose values are integer years from `0` through `60`. An empty object turns this filter off.
|
|
93
|
+
- `expectedPreferenceRevision`: non-negative safe integer from the latest `trackly_get_preferences` result. A stale revision is rejected; refetch and reconcile with the user rather than retrying blindly.
|
|
94
|
+
- The tool checks `experienceFilterV2Available` immediately before writing and refuses without a PUT unless it is exactly `true`.
|
|
95
|
+
- When server-side enforcement is active, a job remains visible when its stated minimum is less than or equal to that role's limit. Jobs with no stated minimum remain visible.
|
|
85
96
|
- **trackly_update_status** — Mark a job as applied, saved, or dismissed
|
|
86
97
|
- **trackly_ask** — Natural language job search (20/day limit)
|
|
87
98
|
- **trackly_get_job_brief** — Get network brief for a job (company signal, top contact, actions)
|
|
@@ -100,7 +111,7 @@ You can also pass `TRACKLY_API_KEY` as an environment variable for one-off runs.
|
|
|
100
111
|
- **trackly_prepare_resume** — Local MCP only: materialize the default resume in a private, expiring mode-0600 cache and return filename, size, SHA-256, exact local path, and visual-confirmation metadata. Hosted MCP returns a manual/local-agent requirement.
|
|
101
112
|
- **trackly_verify_prepared_resume** — Local MCP only: immediately before attachment, recompute the user-confirmed resume hash and size, validate the exact path/run/expiration, and lock the file read-only. Any mismatch requires a fresh preview and confirmation.
|
|
102
113
|
|
|
103
|
-
Apply contract v3 intentionally gives this verifier different local and hosted schemas: local MCP receives the full proof needed to inspect the private file, while hosted MCP accepts only run and confirmation identifiers and returns the manual/local-agent requirement. Local paths and fingerprints are never sent to the hosted verifier. Version 3.1 also records universal value-free evidence for critical-contact integrity and the manual-submit boundary.
|
|
114
|
+
Apply contract v3 intentionally gives this verifier different local and hosted schemas: local MCP receives the full proof needed to inspect the private file, while hosted MCP accepts only run and confirmation identifiers and returns the manual/local-agent requirement. Local paths and fingerprints are never sent to the hosted verifier. Version 3.1 also records universal value-free evidence for critical-contact integrity and the manual-submit boundary. Version 3.2 authorizes the exact stored HTTPS origin for jobs Trackly ingested from employer careers sources, without granting redirect, iframe, or hostname-suffix privileges.
|
|
104
115
|
|
|
105
116
|
### Maintenance behavior
|
|
106
117
|
|
|
@@ -114,4 +125,5 @@ REST, refresh, download, CLI, and local/hosted MCP surfaces use canonical `code:
|
|
|
114
125
|
- "What remote engineering roles are available?"
|
|
115
126
|
- "Show me jobs at Stripe"
|
|
116
127
|
- "Mark job 1234 as applied"
|
|
128
|
+
- "Show my Trackly preferences, then set Product to up to 2 years and Strategy to up to 5 years"
|
|
117
129
|
- "Use Trackly Apply to fill my next saved application and stop before Submit"
|
package/lib/agent.js
CHANGED
|
@@ -7,7 +7,7 @@ const path = require('node:path');
|
|
|
7
7
|
const { spawnSync } = require('node:child_process');
|
|
8
8
|
const { apiRequest, downloadFile, getConfigPaths, hasAuth, maintenanceOutput } = require('./client');
|
|
9
9
|
const { version: PACKAGE_VERSION } = require('../package.json');
|
|
10
|
-
const SKILL_VERSION = '4.1.
|
|
10
|
+
const SKILL_VERSION = '4.1.1';
|
|
11
11
|
const CLI_USER_AGENT = `trackly-cli/${PACKAGE_VERSION}`;
|
|
12
12
|
const MCP_USER_AGENT = `trackly-mcp/${PACKAGE_VERSION}`;
|
|
13
13
|
|
package/lib/client.js
CHANGED
|
@@ -18,6 +18,22 @@ const MAINTENANCE_APPLY_GUIDANCE =
|
|
|
18
18
|
'Do not blindly retry an Apply mutation. Wait for the advertised maintenance window, ' +
|
|
19
19
|
'refetch the Apply protocol and application profile, then resume the existing agent_browser run. ' +
|
|
20
20
|
'Never create a duplicate run or click Submit.';
|
|
21
|
+
const TRACKLY_ACCESS_GUIDANCE =
|
|
22
|
+
'Trackly is in a limited rollout. Existing members can sign in; new members need a private invite. ' +
|
|
23
|
+
'Request access at https://usetrackly.app/early-access.';
|
|
24
|
+
const TRACKLY_ACCESS_PENDING_GUIDANCE =
|
|
25
|
+
'Your Trackly access request is already pending. No new request is needed.';
|
|
26
|
+
const TRACKLY_ACCESS_UNAVAILABLE_GUIDANCE =
|
|
27
|
+
'Trackly access could not be verified right now. Please try again.';
|
|
28
|
+
const TRACKLY_ACCESS_CODES = new Set([
|
|
29
|
+
'INVITATION_REQUIRED',
|
|
30
|
+
'INVITATION_INVALID',
|
|
31
|
+
'INVITATION_REDEEMED',
|
|
32
|
+
'SIGNUP_INTENT_EXPIRED',
|
|
33
|
+
'ACCESS_BATCH_FULL',
|
|
34
|
+
'ACCESS_CHECK_UNAVAILABLE',
|
|
35
|
+
'ALREADY_REQUESTED',
|
|
36
|
+
]);
|
|
21
37
|
|
|
22
38
|
class TracklyConfigError extends Error {
|
|
23
39
|
constructor(message) {
|
|
@@ -243,6 +259,27 @@ function nestedRecord(value) {
|
|
|
243
259
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
244
260
|
}
|
|
245
261
|
|
|
262
|
+
function createTracklyAccessError(json, status) {
|
|
263
|
+
const record = nestedRecord(json);
|
|
264
|
+
const code = cleanText(record.code);
|
|
265
|
+
if (!code || !TRACKLY_ACCESS_CODES.has(code.toUpperCase())) return null;
|
|
266
|
+
const normalizedCode = code.toUpperCase();
|
|
267
|
+
const retryable = normalizedCode === 'ACCESS_CHECK_UNAVAILABLE';
|
|
268
|
+
const message = retryable
|
|
269
|
+
? TRACKLY_ACCESS_UNAVAILABLE_GUIDANCE
|
|
270
|
+
: normalizedCode === 'ALREADY_REQUESTED'
|
|
271
|
+
? TRACKLY_ACCESS_PENDING_GUIDANCE
|
|
272
|
+
: TRACKLY_ACCESS_GUIDANCE;
|
|
273
|
+
return {
|
|
274
|
+
...record,
|
|
275
|
+
status: status ?? (retryable ? 503 : 403),
|
|
276
|
+
code: normalizedCode,
|
|
277
|
+
error: message,
|
|
278
|
+
message,
|
|
279
|
+
retryable,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
246
283
|
// REST maintenance responses and hosted MCP JSON-RPC errors intentionally use
|
|
247
284
|
// different envelopes. Collapse both into the same source view so every CLI
|
|
248
285
|
// surface preserves equivalent code/retry/ETA/request context.
|
|
@@ -579,6 +616,8 @@ function apiRequest(method, endpoint, data = null, skipAuth = false, _isRetry =
|
|
|
579
616
|
headers: res.headers,
|
|
580
617
|
});
|
|
581
618
|
if (maintenanceError) return reject(maintenanceError);
|
|
619
|
+
const accessError = createTracklyAccessError(json, res.statusCode);
|
|
620
|
+
if (accessError) return reject(accessError);
|
|
582
621
|
// Spread `json` FIRST so the HTTP statusCode always wins. A backend that returns
|
|
583
622
|
// `{"status": "error"}` in the body would otherwise clobber the numeric status with
|
|
584
623
|
// a string, breaking downstream branches like `typeof e.status === 'number'` in
|
|
@@ -676,6 +715,11 @@ function downloadFile(endpoint, userAgent = null, _isRetry = false) {
|
|
|
676
715
|
reject(maintenanceError);
|
|
677
716
|
return;
|
|
678
717
|
}
|
|
718
|
+
const accessError = createTracklyAccessError(body, res.statusCode);
|
|
719
|
+
if (accessError) {
|
|
720
|
+
reject(accessError);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
679
723
|
reject({ ...body, status: res.statusCode, message: body.error || `HTTP ${res.statusCode}` });
|
|
680
724
|
return;
|
|
681
725
|
}
|
|
@@ -720,6 +764,7 @@ module.exports = {
|
|
|
720
764
|
TracklyConfigError,
|
|
721
765
|
MAINTENANCE_APPLY_GUIDANCE,
|
|
722
766
|
MAINTENANCE_CODE,
|
|
767
|
+
TRACKLY_ACCESS_GUIDANCE,
|
|
723
768
|
apiRequest,
|
|
724
769
|
downloadFile,
|
|
725
770
|
clearConfig,
|
|
@@ -738,6 +783,7 @@ module.exports = {
|
|
|
738
783
|
hasAuth,
|
|
739
784
|
loadConfig,
|
|
740
785
|
createMaintenanceError,
|
|
786
|
+
createTracklyAccessError,
|
|
741
787
|
isMaintenanceError,
|
|
742
788
|
maintenanceOutput,
|
|
743
789
|
normalizeMaintenance,
|
package/mcp/server.js
CHANGED
|
@@ -4,14 +4,18 @@ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
|
4
4
|
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
5
5
|
const { McpError } = require('@modelcontextprotocol/sdk/types.js');
|
|
6
6
|
const { z } = require('zod');
|
|
7
|
-
const { apiRequest, hasAuth, maintenanceOutput } = require('../lib/client');
|
|
7
|
+
const { apiRequest, createTracklyAccessError, hasAuth, maintenanceOutput } = require('../lib/client');
|
|
8
8
|
const { prepareResume, verifyPreparedResume } = require('../lib/agent');
|
|
9
9
|
const { version: PACKAGE_VERSION } = require('../package.json');
|
|
10
10
|
const APPLY_CONTRACT = require('../contracts/trackly-apply-tools.json');
|
|
11
11
|
|
|
12
12
|
const MCP_USER_AGENT = `trackly-mcp/${PACKAGE_VERSION}`;
|
|
13
13
|
const MCP_MAINTENANCE_ERROR_CODE = -32002;
|
|
14
|
-
const
|
|
14
|
+
const MCP_ACCESS_ERROR_CODE = -32003;
|
|
15
|
+
const MCP_AUTH_ERROR_CODE = -32004;
|
|
16
|
+
const AUTH_HINT =
|
|
17
|
+
'Existing members: run `trackly login` or set TRACKLY_API_KEY. ' +
|
|
18
|
+
'New members need a private invite during the limited rollout; request access at https://usetrackly.app/early-access.';
|
|
15
19
|
const APPLY_BROWSER_SURFACES = APPLY_CONTRACT.constants.applyBrowserSurfaces;
|
|
16
20
|
const APPLY_SCENARIO_CODES = APPLY_CONTRACT.constants.applyScenarioCodes;
|
|
17
21
|
const SAFE_OBSERVATION_CODE = /^[a-z0-9][a-z0-9_:-]{0,99}$/;
|
|
@@ -60,21 +64,44 @@ const ACTION_TO_STAGE = { applied: 'applied', saved: 'backlog', dismissed: 'disc
|
|
|
60
64
|
|
|
61
65
|
function createErrorResult(error, fallbackMessage, extra = {}) {
|
|
62
66
|
const normalizedMaintenance = maintenanceOutput(error);
|
|
67
|
+
const normalizedAccess = createTracklyAccessError(error, error?.status);
|
|
63
68
|
const payload = normalizedMaintenance
|
|
64
69
|
? {
|
|
65
70
|
...normalizedMaintenance,
|
|
66
71
|
error: error?.error || error?.message || fallbackMessage,
|
|
67
72
|
...extra,
|
|
68
73
|
}
|
|
69
|
-
:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
: normalizedAccess
|
|
75
|
+
? {
|
|
76
|
+
error: normalizedAccess.error,
|
|
77
|
+
message: normalizedAccess.message,
|
|
78
|
+
status: normalizedAccess.status,
|
|
79
|
+
code: normalizedAccess.code,
|
|
80
|
+
retryable: normalizedAccess.retryable,
|
|
81
|
+
...extra,
|
|
82
|
+
}
|
|
83
|
+
: {
|
|
84
|
+
error: error?.error || error?.message || fallbackMessage,
|
|
85
|
+
...extra,
|
|
86
|
+
};
|
|
73
87
|
|
|
74
88
|
if (error?.status) {
|
|
75
89
|
payload.status = error.status;
|
|
76
90
|
}
|
|
77
91
|
|
|
92
|
+
if (
|
|
93
|
+
error?.code === 'experience_filter_v2_unavailable'
|
|
94
|
+
|| error?.code === 'invalid_preference_revision'
|
|
95
|
+
) {
|
|
96
|
+
payload.code = error.code;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (error?.status === 409 && error?.error === 'preference_revision_conflict') {
|
|
100
|
+
payload.preferences = error.preferences;
|
|
101
|
+
payload.discoveryPreferenceRevision = error.discoveryPreferenceRevision;
|
|
102
|
+
payload.hint = 'Preferences changed elsewhere. Refetch with trackly_get_preferences, reconcile with the user, and never retry blindly with the newer revision.';
|
|
103
|
+
}
|
|
104
|
+
|
|
78
105
|
if (error?.status === 429 && !payload.hint) {
|
|
79
106
|
payload.hint = 'Daily limit reached (20 natural language queries per day).';
|
|
80
107
|
}
|
|
@@ -102,6 +129,25 @@ function throwMcpResourceError(error) {
|
|
|
102
129
|
normalizedMaintenance,
|
|
103
130
|
);
|
|
104
131
|
}
|
|
132
|
+
const normalizedAccess = createTracklyAccessError(error, error?.status);
|
|
133
|
+
if (normalizedAccess) {
|
|
134
|
+
throw new McpError(
|
|
135
|
+
MCP_ACCESS_ERROR_CODE,
|
|
136
|
+
normalizedAccess.message,
|
|
137
|
+
{
|
|
138
|
+
status: normalizedAccess.status,
|
|
139
|
+
code: normalizedAccess.code,
|
|
140
|
+
retryable: normalizedAccess.retryable,
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (error?.status === 401) {
|
|
145
|
+
throw new McpError(
|
|
146
|
+
MCP_AUTH_ERROR_CODE,
|
|
147
|
+
error?.message || 'Not authenticated',
|
|
148
|
+
{ status: 401, hint: AUTH_HINT },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
105
151
|
throw error;
|
|
106
152
|
}
|
|
107
153
|
|
|
@@ -126,6 +172,17 @@ function wrapTool(handler, fallbackMessage) {
|
|
|
126
172
|
};
|
|
127
173
|
}
|
|
128
174
|
|
|
175
|
+
function projectPreferenceResponse(result, experienceFilterV2Available = null) {
|
|
176
|
+
const user = result?.user || {};
|
|
177
|
+
return {
|
|
178
|
+
success: result?.success === true,
|
|
179
|
+
experienceFilterV2Available: experienceFilterV2Available === null
|
|
180
|
+
? user.experienceFilterV2Available === true
|
|
181
|
+
: experienceFilterV2Available === true,
|
|
182
|
+
preferences: user.preferences || result?.preferences || {},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
129
186
|
function createServer() {
|
|
130
187
|
const server = new McpServer({
|
|
131
188
|
name: 'trackly',
|
|
@@ -239,6 +296,62 @@ function createServer() {
|
|
|
239
296
|
}, 'Failed to fetch stats')
|
|
240
297
|
);
|
|
241
298
|
|
|
299
|
+
server.tool(
|
|
300
|
+
'trackly_get_preferences',
|
|
301
|
+
'Read the authenticated user\'s discovery preferences, including selected and effective job functions, role-specific experience limits and active state, and the discoveryPreferenceRevision required for a safe update.',
|
|
302
|
+
{},
|
|
303
|
+
wrapTool(async () => {
|
|
304
|
+
const result = await apiRequest('GET', '/api/jobscout/me', null, false, false, MCP_USER_AGENT);
|
|
305
|
+
return projectPreferenceResponse(result);
|
|
306
|
+
}, 'Failed to fetch preferences')
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
server.tool(
|
|
310
|
+
'trackly_update_experience_limits',
|
|
311
|
+
'Atomically replace the complete role-specific experience-limit map. Each value is the highest stated minimum years requirement the user wants for that job function. When Trackly enforcement is active, a job is included when its stated minimum is less than or equal to the limit. Jobs with no stated minimum remain visible. An empty object clears the saved limits. experienceFilterV2Available reports edit capability, not enforcement state. Read discoveryPreferenceRevision with trackly_get_preferences first. If the backend returns a revision conflict, refetch and reconcile with the user; never retry blindly with the newer revision.',
|
|
312
|
+
{
|
|
313
|
+
experienceLimitsByJobFunction: z.record(z.enum(JOB_FUNCTIONS), z.number().int().min(0).max(60)).describe(
|
|
314
|
+
'Complete replacement map from canonical job-function slug to maximum acceptable stated minimum years. Use {} to turn role-specific experience filtering off.'
|
|
315
|
+
),
|
|
316
|
+
expectedPreferenceRevision: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).describe(
|
|
317
|
+
'The non-negative safe-integer discoveryPreferenceRevision returned by the latest trackly_get_preferences call. A stale revision is rejected instead of overwriting newer choices.'
|
|
318
|
+
),
|
|
319
|
+
},
|
|
320
|
+
wrapTool(async ({ experienceLimitsByJobFunction, expectedPreferenceRevision }) => {
|
|
321
|
+
const current = await apiRequest('GET', '/api/jobscout/me', null, false, false, MCP_USER_AGENT);
|
|
322
|
+
if (current?.user?.experienceFilterV2Available !== true) {
|
|
323
|
+
throw {
|
|
324
|
+
status: 403,
|
|
325
|
+
code: 'experience_filter_v2_unavailable',
|
|
326
|
+
error: 'Role-specific experience preferences are not available for this account yet. No changes were saved.',
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const currentPreferences = current?.user?.preferences || {};
|
|
330
|
+
const currentRevision = currentPreferences.discoveryPreferenceRevision;
|
|
331
|
+
if (!Number.isSafeInteger(currentRevision) || currentRevision < 0) {
|
|
332
|
+
throw {
|
|
333
|
+
status: 502,
|
|
334
|
+
code: 'invalid_preference_revision',
|
|
335
|
+
error: 'Trackly did not return a valid preference revision. No changes were saved.',
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
if (currentRevision !== expectedPreferenceRevision) {
|
|
339
|
+
throw {
|
|
340
|
+
status: 409,
|
|
341
|
+
error: 'preference_revision_conflict',
|
|
342
|
+
preferences: currentPreferences,
|
|
343
|
+
discoveryPreferenceRevision: currentRevision,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
const result = await apiRequest('PUT', '/api/jobscout/preferences', {
|
|
347
|
+
experienceLimitsByJobFunction,
|
|
348
|
+
experienceFilterVersion: 2,
|
|
349
|
+
expectedPreferenceRevision,
|
|
350
|
+
}, false, false, MCP_USER_AGENT);
|
|
351
|
+
return projectPreferenceResponse(result, true);
|
|
352
|
+
}, 'Failed to update experience limits')
|
|
353
|
+
);
|
|
354
|
+
|
|
242
355
|
server.tool(
|
|
243
356
|
'trackly_update_status',
|
|
244
357
|
'Update a job application status (apply, save, or dismiss).',
|
|
@@ -532,7 +645,7 @@ function createServer() {
|
|
|
532
645
|
role: 'user',
|
|
533
646
|
content: {
|
|
534
647
|
type: 'text',
|
|
535
|
-
text: 'Fetch the Trackly Apply protocol, profile onboarding, profile, and approved queue. Resolve missing answers with me. Treat required completeness separately from optional reusable coverage and employer-specific contextual questions. Before starting anything, stop on every non-null executionBlocker and every manual_only item. Start only the selected approved queue item, require major(run.protocolVersion) === major(protocol.version), require protocol.compatibleSkillMajor === 4, preserve the stored version for a resumed run, and require its provider, atsCapability, required scenarios, and originPolicy to match the queue preflight. Reclaim semantic browser control, verify the exact job/run/tab binding, hash that value-free binding, and report the same-run browser_ready attestation with committed=true. Before entering private data,
|
|
648
|
+
text: 'Fetch the Trackly Apply protocol, profile onboarding, profile, and approved queue. Resolve missing answers with me. Treat required completeness separately from optional reusable coverage and employer-specific contextual questions. Before starting anything, stop on every non-null executionBlocker and every manual_only item. Start only the selected approved queue item, require major(run.protocolVersion) === major(protocol.version), require protocol.compatibleSkillMajor === 4, preserve the stored version for a resumed run, and require its provider, atsCapability, required scenarios, and originPolicy to match the queue preflight. Reclaim semantic browser control, verify the exact job/run/tab binding, hash that value-free binding, and report the same-run browser_ready attestation with committed=true. Before entering private data, require the visible company and role to match the run binding and, when available, the requisition identifier to match the stored job URL. When job_identity_match is required, report a value-free committed scenario_coverage attestation only after that visible identity check passes; never include the company, role, URL, requisition identifier, page text, or any profile value in that observation. On exact-origin fallback, revalidate the frozen company, role, and available requisition identity after every navigation or redirect and before entering any additional private data. Normalize every page, redirect, and data-receiving iframe URL; accept an exact authorized origin or hostname only when host === allowedDomain or host.endsWith("." + allowedDomain), never by substring or page text. When originPolicy.verification is trackly_employer_source_exact_origin, authorize only the exact origin in authorizedOrigins: never promote it to a host suffix and never carry it across a redirect or iframe origin change. For every other vendor-hosted ATS policy, require both originPolicy.tenantRule and originPolicy.verifiedAtsTenant to be non-null or stop before private data entry. Execute the backend-owned originPolicy.tenantRule exactly after every redirect or data-receiving iframe change, including its extraction, exact-host-depth, locale, percent-decoding, normalization, and fail-closed semantics, then require the normalized result to equal originPolicy.verifiedAtsTenant; never invent or reinterpret a strategy token. Obey every capability stop condition. Determine whether the form has a semantically identified Resume or CV attachment control. Only when that specific control exists, prepare the run-bound resume locally with that browser surface and binding hash, show me its exact path, filename, size, SHA-256, run, and expiration, and obtain my explicit confirmation. Treat cover-letter, portfolio, transcript, and other supporting-document controls separately according to the profile and protocol; never upload a resume to them. Immediately before attaching the resume, use the local verifier to validate the signed proof, recompute hash and size, check expiration, and lock the file read-only. Fill the form through semantic controls, verify committed values and every required error, and report a same-run passed or corrected scenario_coverage observation with committed=true for every backend-required scenario except browser_reclaim, which is satisfied only by browser_ready with the binding hash. Before every review_ready outcome, also report value-free committed critical_contact_integrity and manual_submit_boundary evidence; never include contact values, answers, page text, or local paths. If a required or universal review scenario cannot pass, record blocked rather than review_ready. Stop before Submit. If maintenance interrupts the run, retain the run and browser context, wait for the advertised window, refetch protocol, queue, and profile state, and resume the existing agent_browser run. Never start a duplicate run, blindly retry a mutation, enter credentials or verification codes, evade human verification, or click Submit.',
|
|
536
649
|
},
|
|
537
650
|
}],
|
|
538
651
|
}));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackly-cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"mcpName": "io.github.trackly-app/trackly",
|
|
5
5
|
"description": "AI job search CLI + hosted MCP server with OAuth. 128K+ jobs, 1,900+ companies, 40+ ATS. Works with Claude, ChatGPT, Cursor, Windsurf, Codex via hosted streamable-http or local stdio.",
|
|
6
6
|
"bin": {
|
package/server.json
CHANGED
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
]
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
|
-
"version": "0.7.
|
|
20
|
+
"version": "0.7.3",
|
|
21
21
|
"packages": [
|
|
22
22
|
{
|
|
23
23
|
"registryType": "npm",
|
|
24
24
|
"identifier": "trackly-cli",
|
|
25
|
-
"version": "0.7.
|
|
25
|
+
"version": "0.7.3",
|
|
26
26
|
"runtimeHint": "npx",
|
|
27
27
|
"runtimeArguments": [
|
|
28
28
|
{
|
|
@@ -19,7 +19,7 @@ Use Trackly as the source of truth for profile answers, documents, queue decisio
|
|
|
19
19
|
|
|
20
20
|
## Start every run
|
|
21
21
|
|
|
22
|
-
1. Call `trackly_get_apply_protocol`. Skill 4.1 requires protocol major 3 (version 3.1.0 or newer) and `compatibleSkillMajor: 4`. Reject an older or incompatible version and report that the backend must finish updating or `trackly agent setup` must update the skill. This gate prevents a pre-evidence skill or run from being reused under the stronger cross-ATS contract.
|
|
22
|
+
1. Call `trackly_get_apply_protocol`. Skill 4.1 requires protocol major 3 (version 3.1.0 or newer) and `compatibleSkillMajor: 4`. Reject an older or incompatible version and report that the backend must finish updating or `trackly agent setup` must update the skill. This gate prevents a pre-evidence skill or run from being reused under the stronger cross-ATS contract. Protocol 3.2 adds exact-origin trust for jobs Trackly ingested directly from employer careers sources; do not recreate the retired ownership-timestamp gate in the client.
|
|
23
23
|
2. Call `trackly_get_profile_onboarding` or fetch both the profile schema and application profile. Treat `completeness.percent` as required onboarding readiness only. Use `coverage.missingReusableKeys` to explain reusable optional gaps, while `coverage.contextualKeys` are intentionally asked only on the relevant employer form. Do not claim that 100% required completeness answers every possible application question.
|
|
24
24
|
3. Save answers with `trackly_update_application_profile`:
|
|
25
25
|
- Use `answered`, `intentionally_blank`, `declined`, or `unknown` exactly.
|
|
@@ -37,6 +37,7 @@ Use Trackly as the source of truth for profile answers, documents, queue decisio
|
|
|
37
37
|
5. Call `trackly_get_apply_queue`. Select deterministically unless the user names a job. Do not replace the queue call with a fresh job search.
|
|
38
38
|
- Use the queue item's backend-owned `atsCapability`, `originPolicy`, and `executionBlocker`. Never infer a stronger support claim from the page design or the LLM's familiarity with a provider. Required scenarios are backend-enforced gates; possible scenarios are reported only when actually exercised.
|
|
39
39
|
- Stop before `trackly_start_apply_run` whenever `executionBlocker` is non-null. `standard` runs use the provider playbook. `guided` runs are allowed only while semantic browser state remains observable, `originPolicy.authorized` is true, and every published stop condition is obeyed. `manual_only` items stop without starting or mutating a run.
|
|
40
|
+
- When `originPolicy.verification` is `trackly_employer_source_exact_origin`, Trackly has authorized the exact stored HTTPS origin from its direct employer-careers ingestion. Do not demand a separate ownership timestamp, ATS tenant, or company-domain suffix. This authorization never extends to another origin, hostname suffix, redirect, or iframe. The queue/run will require `job_identity_match`; pass it only after the browser visibly confirms the frozen company, role, and available requisition identity. Revalidate that identity after every navigation or redirect and before entering any additional private data.
|
|
40
41
|
- If the user requests the next `N` jobs, freeze the deterministic ordered set of exactly `N` job IDs before starting any run. Do not replace, rescore, or expand that approved batch.
|
|
41
42
|
- For each fixed batch member, preserve an explicit job ID -> application run ID -> browser tab mapping. Complete the full start -> conditional resume preparation/confirmation/verification when an upload control exists -> form completion -> `review_ready` lifecycle for every member. A review-ready run does not block the next member. Never submit any of them.
|
|
42
43
|
6. Call `trackly_start_apply_run` for the selected job or current fixed batch member. Reuse an active run returned by Trackly; never create a replacement run because browser control was interrupted.
|
|
@@ -78,7 +79,7 @@ Read [references/ats-playbook.md](references/ats-playbook.md) for the detected A
|
|
|
78
79
|
|
|
79
80
|
Follow this order:
|
|
80
81
|
|
|
81
|
-
1. Open the application in the controlled browser context and confirm the employer, role, ATS host, and HTTPS URL. Before entering private data,
|
|
82
|
+
1. Open the application in the controlled browser context and confirm the employer, role, ATS host, and HTTPS URL. Before entering private data, require the visible company and role to match the run binding and, when the stored URL exposes one, require the requisition identifier to match. When `job_identity_match` is required, immediately report its value-free committed `scenario_coverage` proof after this check passes; never include company, role, URL, requisition, or page text in the observation. On exact-origin fallback, revalidate the frozen company, role, and available requisition identity after every navigation or redirect and before entering any additional private data. Parse and normalize the URL, then require the exact origin to equal an `originPolicy.authorizedOrigins` entry or the normalized hostname to satisfy `host === allowedDomain` or `host.endsWith("." + allowedDomain)` for an allowed ATS suffix or verified company domain. Never use substring, display-text, logo, or suffix-without-a-dot matching. When `verification` is `trackly_employer_source_exact_origin`, accept only the exact listed origin; never convert it into a hostname suffix or carry it across a redirect or iframe origin change. For every other vendor-hosted ATS policy, require both `originPolicy.tenantRule` and `originPolicy.verifiedAtsTenant` to be non-null; stop before private data entry if either is missing. Execute the backend-owned declarative tenant rule exactly, including its extraction, exact-host-depth, locale, percent-decoding, normalization, and fail-closed semantics, then require the normalized result to equal `originPolicy.verifiedAtsTenant`; never invent or reinterpret a strategy token. Revalidate both origin and tenant after every redirect and apply the same policy to every iframe that receives private data. Stop on any unmatched origin or tenant, malformed percent encoding, missing required rule, or rule shape the client cannot execute exactly.
|
|
82
83
|
2. Inspect the whole form and identify required fields, semantic controls, consent controls, document inputs, and multi-step sections.
|
|
83
84
|
3. When an attachment control exists, only after the exact-hash visual confirmation and a successful immediate pre-attach `trackly_verify_prepared_resume` check, upload the prepared resume before autofill when parsing may overwrite contact fields. Do not change the file between verification and attachment. Verify that the filename chip exactly matches the prepared resume’s user-facing filename and contains no internal cache identifier. Stop and replace the attachment if it does not. When no attachment control exists, skip resume preparation and upload without treating that absence as an error.
|
|
84
85
|
4. Fill typed fields from the resolved Trackly profile. Clear parser-filled data when the canonical state is intentionally blank.
|
|
@@ -98,6 +99,7 @@ Always report both universal evidence scenarios before every `review_ready` outc
|
|
|
98
99
|
|
|
99
100
|
- `critical_contact_integrity`: inventory all email, phone, country-code, and other required contact controls; verify every present canonical field exactly after parsing/autofill; confirm no required contact control is omitted, duplicated, concatenated, placeholder-only, or visibly errored. If the form truly has no such control, pass only after the whole-form inventory proves none is required. Use `corrected` when any contact field needed repair.
|
|
100
101
|
- `manual_submit_boundary`: prove the live form is at its final review state, the Submit control is present or the ATS has an equivalent clearly identified boundary, and the agent did not activate it. A submit-only transition that cannot be inspected does not pass.
|
|
102
|
+
- `job_identity_match` (conditional): for exact-origin fallback runs, prove the visible company, role, and available requisition identity match the frozen Trackly job before entering private data. Report only value-free metadata.
|
|
101
103
|
|
|
102
104
|
Every required scenario and both universal evidence scenarios must have corresponding same-run committed evidence. If any is missing, uncommitted, or blocked, record the run as `blocked` instead of `review_ready`. Never include email, phone, applicant name, answer values, page text, or local paths in observations. Include the actual scenario coverage in the final handoff; do not claim unobserved coverage. Use `trackly_get_apply_evidence` or `trackly agent evidence` when the user asks for aggregate beta proof; never reconstruct a report from private chat content.
|
|
103
105
|
|
|
@@ -119,5 +121,5 @@ After the user submits manually:
|
|
|
119
121
|
- `best_effort` means use the named playbook and stop if browser and accessibility state disagree.
|
|
120
122
|
- `guided` means the provider or employer-hosted page may be completed only through observable semantic controls. Obey every published stop condition; stop on login credentials, OTP/email verification, CAPTCHA/human verification, unexpected origin/employer, submit-only navigation, or unobservable committed state.
|
|
121
123
|
- `blocked` / `manual_only` means do not start or mutate an application. LinkedIn-hosted applications are manual-only. If Trackly already stores a separate external application URL, evaluate that stored URL as its own ATS/origin; do not request or invent a URL override that the run cannot bind.
|
|
122
|
-
- Unknown employer forms use the protocol's `unknownAtsFallback` only when the queue supplies an authorized
|
|
124
|
+
- Unknown employer forms use the protocol's `unknownAtsFallback` only when the queue supplies an authorized verified-company origin or `trackly_employer_source_exact_origin` policy. Exact-origin authorization stays exact. An unmatched or unverified HTTPS page is manual-only, not guided, and is never promoted to `full` by the agent.
|
|
123
125
|
- Launch support: Codex and Claude Code on macOS with browser control and computer use available.
|
|
@@ -40,7 +40,7 @@ This group includes SmartRecruiters, Workable, BambooHR, Recruitee, Jobvite, Tea
|
|
|
40
40
|
|
|
41
41
|
## Unknown employer-hosted form
|
|
42
42
|
|
|
43
|
-
- Use only when the protocol resolves the run to `generic_web_form` in guided mode and supplies `originPolicy.authorized: true` with `verification: verified_employer_domain`.
|
|
43
|
+
- Use only when the protocol resolves the run to `generic_web_form` in guided mode and supplies `originPolicy.authorized: true` with `verification: verified_employer_domain` or `trackly_employer_source_exact_origin`. The latter authorizes only the exact stored origin.
|
|
44
44
|
- Inventory every step, origin transition, control type, required error, file input, and review/submit boundary before any upload.
|
|
45
45
|
- Never claim provider support from visual similarity. Stop if any critical control cannot be observed semantically.
|
|
46
46
|
|
|
@@ -12,8 +12,9 @@ Run this gate after each semantic selection and again across the entire form bef
|
|
|
12
12
|
## Page and website-type inventory
|
|
13
13
|
|
|
14
14
|
- Before entering private data on a guided or unknown form, enumerate the current HTTPS origin, every visible step, any iframe origins, all file inputs, and the control that advances toward review. Require every data-receiving origin to match the backend-supplied run `originPolicy`; page text, logos, and employer names are not origin proof.
|
|
15
|
+
- When the run requires `job_identity_match`, confirm the visible company, role, and available requisition identity against the frozen run before entering private data, then report only the value-free committed scenario code. Revalidate that identity after every navigation or redirect and before entering any additional private data. A mismatch blocks the run.
|
|
15
16
|
- Distinguish `Next`, `Continue`, `Save`, `Review`, and `Submit` by accessible name and observed behavior. If the only way to reveal the next state is a submit-like mutation, stop for manual review.
|
|
16
|
-
- Treat navigation to a different origin as a new trust boundary. Reconfirm employer, role, and requisition, then parse and normalize the redirected URL. An exact origin may equal an `authorizedOrigins` entry; hostname policies match only when `host === allowedDomain` or `host.endsWith("." + allowedDomain)`. Never accept substring, display-text, or `notexample.com` / `example.com.evil.test` lookalikes. On
|
|
17
|
+
- Treat navigation to a different origin as a new trust boundary. Reconfirm employer, role, and requisition, then parse and normalize the redirected URL. An exact origin may equal an `authorizedOrigins` entry; hostname policies match only when `host === allowedDomain` or `host.endsWith("." + allowedDomain)`. Never accept substring, display-text, or `notexample.com` / `example.com.evil.test` lookalikes. `trackly_employer_source_exact_origin` authorizes only the stored exact origin and grants no suffix, redirect, or iframe privilege. On every other vendor-hosted ATS policy, the shared origin is insufficient: require both `originPolicy.tenantRule` and `originPolicy.verifiedAtsTenant`, then execute the backend-owned declarative rule exactly after every redirect or data-receiving iframe change, including extraction, exact host depth, optional locale, percent decoding, normalization, and every fail-closed condition. Compare the normalized result to `originPolicy.verifiedAtsTenant`. Never guess the meaning of an opaque strategy token. Unmatched redirects, tenants, malformed encoding, missing required or unexecutable rules, and iframe origins are manual-only.
|
|
17
18
|
- Treat an authentication wall as a user handoff. Never enter, retrieve, store, or infer credentials.
|
|
18
19
|
|
|
19
20
|
## Semantic controls
|
|
@@ -32,8 +32,9 @@ For each exercised scenario, call `trackly_report_apply_observation` with:
|
|
|
32
32
|
- `handoff_reclaim`: the browser tab was reclaimed after a context or control handoff.
|
|
33
33
|
- `critical_contact_integrity`: every present canonical contact control was exact after parsing/autofill and the form inventory proved that no required contact field was omitted.
|
|
34
34
|
- `manual_submit_boundary`: the agent reached an observable final review boundary and stopped without activating Submit or its equivalent.
|
|
35
|
+
- `job_identity_match`: on an exact-origin fallback run, the visible company, role, and available requisition identity matched the frozen Trackly job before private data entry.
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
`critical_contact_integrity` and `manual_submit_boundary` are universal review proofs and must be reported for every review-ready run. Their observations are value-free: never include email, phone, applicant name, answer values, page text, or local paths. `job_identity_match` is required only when the queue/run includes it; its value-free observation must also omit company, role, URL, and requisition.
|
|
37
38
|
|
|
38
39
|
## Final reporting
|
|
39
40
|
|