trackly-cli 0.13.4 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -5
- package/bin/trackly +10 -6
- package/contracts/trackly-apply-contract-history.json +8 -1
- package/contracts/trackly-apply-tools.json +15 -4
- package/docs/trackly-tools.md +12 -0
- package/lib/agent.js +2 -2
- package/lib/apply-tab-set.js +124 -0
- package/lib/apply-upload.js +66 -0
- package/lib/client.js +194 -8
- package/mcp/analytics.js +903 -0
- package/mcp/apply-tools.js +322 -8
- package/mcp/server.js +123 -8
- package/npm-shrinkwrap.json +56 -3
- package/package.json +4 -2
- package/server.json +2 -2
- package/skills/trackly-apply/SKILL.md +12 -4
- package/skills/trackly-apply/references/batch-orchestration.md +34 -0
- package/skills/trackly-apply/references/browser-lifecycle.md +14 -0
- package/skills/trackly-apply/references/browser-upload.md +40 -8
- package/skills/trackly-apply/references/review-handoff.md +87 -0
package/mcp/apply-tools.js
CHANGED
|
@@ -5,6 +5,12 @@ const { apiRequest } = require('../lib/client');
|
|
|
5
5
|
const { prepareResume, verifyPreparedResume } = require('../lib/agent');
|
|
6
6
|
const { lintApplicationText } = require('../lib/application-text');
|
|
7
7
|
const { diagnoseLocalPath, ERRNO_PATTERN } = require('../lib/path-diagnostics');
|
|
8
|
+
const { validateApplyTabKeepSet } = require('../lib/apply-tab-set');
|
|
9
|
+
const {
|
|
10
|
+
APPLY_UPLOAD_FAILURE_CODES,
|
|
11
|
+
APPLY_UPLOAD_STAGES,
|
|
12
|
+
validateApplyResumeUpload,
|
|
13
|
+
} = require('../lib/apply-upload');
|
|
8
14
|
const { isIso3166Alpha2 } = require('../lib/iso-country-codes');
|
|
9
15
|
const APPLY_CONTRACT = require('../contracts/trackly-apply-tools.json');
|
|
10
16
|
|
|
@@ -22,6 +28,8 @@ const APPLY_SURFACE_EVIDENCE_TYPES = APPLY_CONTRACT.constants.applySurfaceEviden
|
|
|
22
28
|
const APPLY_SURFACE_OWNERSHIP_STATES = APPLY_CONTRACT.constants.applySurfaceOwnershipStates;
|
|
23
29
|
const APPLY_SUBMISSION_EVIDENCE_TYPES = APPLY_CONTRACT.constants.applySubmissionEvidenceTypes;
|
|
24
30
|
const APPLY_SUBMISSION_EVIDENCE_SOURCES = APPLY_CONTRACT.constants.applySubmissionEvidenceSources;
|
|
31
|
+
const APPLY_EXECUTION_RECOVERY_ELIGIBILITY_CODES = APPLY_CONTRACT.constants.applyExecutionRecoveryEligibilityCodes;
|
|
32
|
+
const APPLY_HANDOFF_RECONCILIATION_CLASSIFICATION_CODES = APPLY_CONTRACT.constants.applyHandoffReconciliationClassifications;
|
|
25
33
|
const APPLY_BATCH_MAX_MEMBERS = 100;
|
|
26
34
|
const APPLY_BATCH_MAX_CHECKPOINTS_PER_REQUEST = 20;
|
|
27
35
|
const APPLY_BATCH_MAX_ACTIONS_PER_CHECKPOINT = 25;
|
|
@@ -32,6 +40,12 @@ const SAFE_IDEMPOTENCY_KEY = /^[\x20-\x7e]+$/;
|
|
|
32
40
|
const iso3166Alpha2Schema = z.string().regex(/^[A-Za-z]{2}$/).refine(isIso3166Alpha2, {
|
|
33
41
|
message: 'Expected an ISO 3166-1 alpha-2 country code',
|
|
34
42
|
});
|
|
43
|
+
const opaqueTabIdSchema = z.union([
|
|
44
|
+
z.number().int().safe(),
|
|
45
|
+
z.string().min(1).max(512).refine((value) => value.trim().length > 0, {
|
|
46
|
+
message: 'Expected a nonblank opaque tab ID',
|
|
47
|
+
}),
|
|
48
|
+
]);
|
|
35
49
|
const applyExecutionDispositionSchema = z.object({
|
|
36
50
|
jobId: z.number().int().min(1),
|
|
37
51
|
classification: z.enum(APPLY_EXECUTION_ACCESS_CLASSIFICATIONS),
|
|
@@ -44,6 +58,133 @@ const applyExecutionDispositionSchema = z.object({
|
|
|
44
58
|
probeOnlyNoDraft: z.boolean().optional(),
|
|
45
59
|
browserSurface: z.enum(APPLY_BROWSER_SURFACES),
|
|
46
60
|
}).strict();
|
|
61
|
+
const recoverableCandidateSchema = z.object({
|
|
62
|
+
candidateId: z.number().int().min(1),
|
|
63
|
+
jobId: z.number().int().min(1),
|
|
64
|
+
queuePosition: z.number().int().min(0),
|
|
65
|
+
eligibilityCode: z.enum(APPLY_EXECUTION_RECOVERY_ELIGIBILITY_CODES),
|
|
66
|
+
}).strict();
|
|
67
|
+
const recoverableExecutionSourceSchema = z.object({
|
|
68
|
+
sourceExecutionId: z.number().int().min(1),
|
|
69
|
+
sourceSnapshotHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
70
|
+
recoverableUntil: z.string().datetime(),
|
|
71
|
+
candidates: z.array(recoverableCandidateSchema).max(APPLY_EXECUTION_MAX_TARGET),
|
|
72
|
+
}).strict().refine(({ candidates }) => (
|
|
73
|
+
new Set(candidates.map(({ candidateId }) => candidateId)).size === candidates.length
|
|
74
|
+
), { message: 'recoverable source candidate IDs must be unique' });
|
|
75
|
+
const recoverableExecutionsResponseSchema = z.object({
|
|
76
|
+
success: z.literal(true),
|
|
77
|
+
sources: z.array(recoverableExecutionSourceSchema).max(APPLY_EXECUTION_MAX_TARGET),
|
|
78
|
+
}).strict().refine(({ sources }) => (
|
|
79
|
+
new Set(sources.map(({ sourceExecutionId }) => sourceExecutionId)).size === sources.length
|
|
80
|
+
), { message: 'recoverable source execution IDs must be unique' });
|
|
81
|
+
// Discovery responses are strict and value-free so unexpected fields never enter agent context.
|
|
82
|
+
// Mutation responses permit additive top-level metadata, but keep safety-critical nested rows strict.
|
|
83
|
+
const exactRecoveryResponseSchema = z.object({
|
|
84
|
+
success: z.literal(true),
|
|
85
|
+
replay: z.boolean(),
|
|
86
|
+
execution: z.object({
|
|
87
|
+
id: z.number().int().min(1),
|
|
88
|
+
mode: z.literal('recover_exact_members'),
|
|
89
|
+
}).passthrough(),
|
|
90
|
+
assertedCandidateIds: z.array(z.number().int().min(1)).max(APPLY_EXECUTION_MAX_TARGET),
|
|
91
|
+
eligibleCandidateIds: z.array(z.number().int().min(1)).max(APPLY_EXECUTION_MAX_TARGET),
|
|
92
|
+
eligibility: z.array(recoverableCandidateSchema).max(APPLY_EXECUTION_MAX_TARGET),
|
|
93
|
+
}).passthrough();
|
|
94
|
+
|
|
95
|
+
function validateExactRecoveryResponse(input, response) {
|
|
96
|
+
const requested = [...input.candidateIds].sort((a, b) => a - b);
|
|
97
|
+
const asserted = [...response.assertedCandidateIds].sort((a, b) => a - b);
|
|
98
|
+
const eligible = [...response.eligibleCandidateIds].sort((a, b) => a - b);
|
|
99
|
+
const eligibilityIds = response.eligibility
|
|
100
|
+
.map(({ candidateId }) => candidateId)
|
|
101
|
+
.sort((a, b) => a - b);
|
|
102
|
+
const matchesRequested = (values) => (
|
|
103
|
+
values.length === requested.length
|
|
104
|
+
&& values.every((value, index) => value === requested[index])
|
|
105
|
+
);
|
|
106
|
+
if (
|
|
107
|
+
new Set(response.assertedCandidateIds).size !== response.assertedCandidateIds.length
|
|
108
|
+
|| new Set(response.eligibleCandidateIds).size !== response.eligibleCandidateIds.length
|
|
109
|
+
|| new Set(eligibilityIds).size !== eligibilityIds.length
|
|
110
|
+
|| !matchesRequested(asserted)
|
|
111
|
+
|| !matchesRequested(eligible)
|
|
112
|
+
|| !matchesRequested(eligibilityIds)
|
|
113
|
+
|| response.eligibility.some(({ eligibilityCode }) => eligibilityCode !== 'recoverable')
|
|
114
|
+
) {
|
|
115
|
+
throw new Error('Exact recovery response does not match the requested candidate set.');
|
|
116
|
+
}
|
|
117
|
+
return response;
|
|
118
|
+
}
|
|
119
|
+
const handoffClaimMemberSchema = z.object({
|
|
120
|
+
memberId: z.number().int().min(1),
|
|
121
|
+
classification: z.enum(APPLY_HANDOFF_RECONCILIATION_CLASSIFICATION_CODES),
|
|
122
|
+
}).strict();
|
|
123
|
+
const handoffMemberSchema = z.object({
|
|
124
|
+
handoffId: z.number().int().min(1),
|
|
125
|
+
ordinal: z.number().int().min(0),
|
|
126
|
+
batchId: z.number().int().min(1),
|
|
127
|
+
memberId: z.number().int().min(1),
|
|
128
|
+
runId: z.number().int().min(1),
|
|
129
|
+
memberVersion: z.number().int().min(1),
|
|
130
|
+
inspectionEpoch: z.number().int().min(0),
|
|
131
|
+
reconciliationClassification: z.enum(APPLY_HANDOFF_RECONCILIATION_CLASSIFICATION_CODES).nullable(),
|
|
132
|
+
reconciliationResultStatus: z.string().max(100).nullable(),
|
|
133
|
+
}).strict();
|
|
134
|
+
const handoffListResponseSchema = z.object({
|
|
135
|
+
success: z.literal(true),
|
|
136
|
+
executionId: z.number().int().min(1),
|
|
137
|
+
handoffs: z.array(z.object({
|
|
138
|
+
id: z.number().int().min(1),
|
|
139
|
+
executionId: z.number().int().min(1),
|
|
140
|
+
orderedMemberSetHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
141
|
+
generation: z.number().int().min(1),
|
|
142
|
+
status: z.enum(['active', 'partially_reconciled']),
|
|
143
|
+
claimedAt: z.string().datetime().nullable(),
|
|
144
|
+
expiresAt: z.string().datetime(),
|
|
145
|
+
members: z.array(handoffMemberSchema).min(1).max(APPLY_EXECUTION_MAX_TARGET),
|
|
146
|
+
}).strict()).max(APPLY_EXECUTION_MAX_TARGET),
|
|
147
|
+
}).strict();
|
|
148
|
+
|
|
149
|
+
function validateHandoffListResponse(requestedExecutionId, response) {
|
|
150
|
+
const handoffIds = response.handoffs.map(({ id }) => id);
|
|
151
|
+
if (
|
|
152
|
+
response.executionId !== requestedExecutionId
|
|
153
|
+
|| new Set(handoffIds).size !== handoffIds.length
|
|
154
|
+
|| response.handoffs.some((handoff) => (
|
|
155
|
+
handoff.executionId !== requestedExecutionId
|
|
156
|
+
|| new Set(handoff.members.map(({ memberId }) => memberId)).size !== handoff.members.length
|
|
157
|
+
|| handoff.members.some((member) => member.handoffId !== handoff.id)
|
|
158
|
+
))
|
|
159
|
+
) {
|
|
160
|
+
throw new Error('Review handoff response does not match the requested execution and receipt bindings.');
|
|
161
|
+
}
|
|
162
|
+
return response;
|
|
163
|
+
}
|
|
164
|
+
const handoffClaimResponseSchema = z.object({
|
|
165
|
+
success: z.literal(true),
|
|
166
|
+
handoffId: z.number().int().min(1),
|
|
167
|
+
executionId: z.number().int().min(1),
|
|
168
|
+
orderedMemberSetHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
169
|
+
members: z.array(handoffClaimMemberSchema).min(1).max(APPLY_EXECUTION_MAX_TARGET),
|
|
170
|
+
transition: z.literal('claimed'),
|
|
171
|
+
}).passthrough();
|
|
172
|
+
|
|
173
|
+
function validateHandoffClaimResponse(request, response) {
|
|
174
|
+
const requestedMembers = request.members.map(({ memberId, classification }) => `${memberId}:${classification}`);
|
|
175
|
+
const returnedMembers = response.members.map(({ memberId, classification }) => `${memberId}:${classification}`);
|
|
176
|
+
if (
|
|
177
|
+
response.handoffId !== request.handoffId
|
|
178
|
+
|| response.executionId !== request.executionId
|
|
179
|
+
|| response.orderedMemberSetHash !== request.orderedMemberSetHash
|
|
180
|
+
|| new Set(returnedMembers).size !== returnedMembers.length
|
|
181
|
+
|| requestedMembers.length !== returnedMembers.length
|
|
182
|
+
|| !requestedMembers.every((member) => returnedMembers.includes(member))
|
|
183
|
+
) {
|
|
184
|
+
throw new Error('Handoff claim response does not match the requested handoff and member classifications.');
|
|
185
|
+
}
|
|
186
|
+
return response;
|
|
187
|
+
}
|
|
47
188
|
|
|
48
189
|
const truthCertificationCommon = {
|
|
49
190
|
batchId: z.number().int().min(1),
|
|
@@ -121,7 +262,7 @@ const startApplyRunSchema = z.object({
|
|
|
121
262
|
}
|
|
122
263
|
});
|
|
123
264
|
|
|
124
|
-
const APPLY_RELIABILITY_PROMPT = 'Protocol 3.
|
|
265
|
+
const APPLY_RELIABILITY_PROMPT = 'Protocol 3.6 / skill 4.5.0 reliability gate: recover active work first. After full local context loss, list recoverable executions, show only stable job identity, obtain explicit confirmation of the exact candidate set, and call exact-member recovery without substitutions. Treat recovered tab presence, form state, and mutation authority as three separate facts; reacquire a fresh browser binding and inspection epoch before mutation. Before resolving broad submission statements, list active review handoffs for the execution; use only an explicit receipt or the sole returned active receipt, classify every member as detected, user_confirmed, unresolved, or contradictory, and claim that exact handoff before writing outcomes. Use provider-specific positive success evidence; an unchanged URL or title is never negative evidence. Validate an exact expected browser keep set locally before finalization. For resume uploads, negotiate the browser surface capabilities, identify the semantic control, arm the chooser before clicking, attach the immediately verified file, prove the user-facing filename committed, and recheck parser-modified fields. Use compact snapshots and server-provided mutability. Preserve user-edited and unknown non-empty fields. Never reopen parked work without explicit user resumption. Never click Submit.';
|
|
125
266
|
|
|
126
267
|
function registerApplyTools(
|
|
127
268
|
server,
|
|
@@ -143,6 +284,9 @@ function registerApplyTools(
|
|
|
143
284
|
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined,
|
|
144
285
|
)
|
|
145
286
|
);
|
|
287
|
+
const discoveredRecoverableSources = new Map();
|
|
288
|
+
const discoveredHandoffBindings = new Map();
|
|
289
|
+
const discoveredHandoffIdsByExecution = new Map();
|
|
146
290
|
server.tool(
|
|
147
291
|
'trackly_get_apply_queue',
|
|
148
292
|
'Get the deterministic queue of jobs the user already approved by saving as check later. Do not rescore or veto these jobs.',
|
|
@@ -271,6 +415,121 @@ function registerApplyTools(
|
|
|
271
415
|
), 'Failed to fetch apply execution')
|
|
272
416
|
);
|
|
273
417
|
|
|
418
|
+
server.tool(
|
|
419
|
+
'trackly_list_recoverable_apply_executions',
|
|
420
|
+
'List bounded, value-free exact-member recovery candidates after local context loss. Show the stable job identities to the user and obtain explicit confirmation before recovery; never infer or substitute candidates.',
|
|
421
|
+
{},
|
|
422
|
+
wrapTool(async () => {
|
|
423
|
+
const response = recoverableExecutionsResponseSchema.parse(await applyControlRequest(
|
|
424
|
+
'GET', '/api/jobscout/apply/executions/recoverable',
|
|
425
|
+
));
|
|
426
|
+
discoveredRecoverableSources.clear();
|
|
427
|
+
for (const source of response.sources) {
|
|
428
|
+
discoveredRecoverableSources.set(source.sourceExecutionId, {
|
|
429
|
+
sourceSnapshotHash: source.sourceSnapshotHash,
|
|
430
|
+
candidateIds: new Set(source.candidates.map(({ candidateId }) => candidateId)),
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
return response;
|
|
434
|
+
}, 'Failed to list recoverable apply executions')
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
server.tool(
|
|
438
|
+
'trackly_recover_exact_apply_members',
|
|
439
|
+
'Create an exact-member recovery execution from one confirmed source snapshot. The asserted candidate set is immutable and replacements are forbidden.',
|
|
440
|
+
{
|
|
441
|
+
sourceExecutionId: z.number().int().min(1),
|
|
442
|
+
sourceSnapshotHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
443
|
+
candidateIds: z.array(z.number().int().min(1)).min(1).max(APPLY_EXECUTION_MAX_TARGET)
|
|
444
|
+
.refine((values) => new Set(values).size === values.length, {
|
|
445
|
+
message: 'candidateIds must be unique',
|
|
446
|
+
}),
|
|
447
|
+
explicitExactSetConfirmation: z.literal(true),
|
|
448
|
+
idempotencyKey: z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY),
|
|
449
|
+
},
|
|
450
|
+
wrapTool(async ({ idempotencyKey, ...input }) => {
|
|
451
|
+
const source = discoveredRecoverableSources.get(input.sourceExecutionId);
|
|
452
|
+
if (
|
|
453
|
+
!source
|
|
454
|
+
|| source.sourceSnapshotHash !== input.sourceSnapshotHash
|
|
455
|
+
|| input.candidateIds.some((candidateId) => !source.candidateIds.has(candidateId))
|
|
456
|
+
) {
|
|
457
|
+
throw new Error('Exact recovery must use one source and candidate set from the latest discovery response.');
|
|
458
|
+
}
|
|
459
|
+
return validateExactRecoveryResponse(
|
|
460
|
+
input,
|
|
461
|
+
exactRecoveryResponseSchema.parse(await applyControlRequest('POST', '/api/jobscout/apply/executions/recover', {
|
|
462
|
+
mode: 'recover_exact_members',
|
|
463
|
+
...input,
|
|
464
|
+
}, idempotencyKey)),
|
|
465
|
+
);
|
|
466
|
+
}, 'Failed to recover exact apply members')
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
server.tool(
|
|
470
|
+
'trackly_list_apply_review_handoffs',
|
|
471
|
+
'List active, nonexpired review-handoff receipts for one execution after context loss. Returns only stable IDs, lifecycle metadata, and member bindings; never browser values, URLs, or local paths.',
|
|
472
|
+
{ executionId: z.number().int().min(1) },
|
|
473
|
+
wrapTool(async ({ executionId }) => {
|
|
474
|
+
const response = validateHandoffListResponse(
|
|
475
|
+
executionId,
|
|
476
|
+
handoffListResponseSchema.parse(await applyControlRequest(
|
|
477
|
+
'GET', `/api/jobscout/apply/executions/${executionId}/review-handoffs`,
|
|
478
|
+
)),
|
|
479
|
+
);
|
|
480
|
+
for (const handoffId of discoveredHandoffIdsByExecution.get(executionId) || []) {
|
|
481
|
+
discoveredHandoffBindings.delete(handoffId);
|
|
482
|
+
}
|
|
483
|
+
const discoveredHandoffIds = new Set();
|
|
484
|
+
for (const handoff of response.handoffs) {
|
|
485
|
+
discoveredHandoffBindings.set(handoff.id, {
|
|
486
|
+
executionId: handoff.executionId,
|
|
487
|
+
orderedMemberSetHash: handoff.orderedMemberSetHash,
|
|
488
|
+
memberIds: handoff.members.map(({ memberId }) => memberId),
|
|
489
|
+
});
|
|
490
|
+
discoveredHandoffIds.add(handoff.id);
|
|
491
|
+
}
|
|
492
|
+
discoveredHandoffIdsByExecution.set(executionId, discoveredHandoffIds);
|
|
493
|
+
return response;
|
|
494
|
+
}, 'Failed to list apply review handoffs')
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
server.tool(
|
|
498
|
+
'trackly_claim_apply_review_handoff',
|
|
499
|
+
'Claim one exact, unambiguous review-handoff group and classify every member before grouped submission reconciliation. Never use this tool to infer submission or replace success-page evidence or explicit user confirmation.',
|
|
500
|
+
{
|
|
501
|
+
handoffId: z.number().int().min(1),
|
|
502
|
+
members: z.array(z.object({
|
|
503
|
+
memberId: z.number().int().min(1),
|
|
504
|
+
classification: z.enum(APPLY_HANDOFF_RECONCILIATION_CLASSIFICATION_CODES),
|
|
505
|
+
}).strict()).min(1).max(APPLY_EXECUTION_MAX_TARGET)
|
|
506
|
+
.refine((values) => new Set(values.map(({ memberId }) => memberId)).size === values.length, {
|
|
507
|
+
message: 'members must contain unique memberId values',
|
|
508
|
+
}),
|
|
509
|
+
idempotencyKey: z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY),
|
|
510
|
+
},
|
|
511
|
+
wrapTool(async ({ handoffId, idempotencyKey, members }) => {
|
|
512
|
+
const binding = discoveredHandoffBindings.get(handoffId);
|
|
513
|
+
if (!binding) {
|
|
514
|
+
throw new Error('Review handoff must be selected from the latest discovery response.');
|
|
515
|
+
}
|
|
516
|
+
const { memberIds, ...receiptBinding } = binding;
|
|
517
|
+
const requestedMemberIds = new Set(members.map(({ memberId }) => memberId));
|
|
518
|
+
if (
|
|
519
|
+
requestedMemberIds.size !== memberIds.length
|
|
520
|
+
|| memberIds.some((memberId) => !requestedMemberIds.has(memberId))
|
|
521
|
+
) {
|
|
522
|
+
throw new Error('Handoff claim must classify every discovered member exactly once.');
|
|
523
|
+
}
|
|
524
|
+
return validateHandoffClaimResponse(
|
|
525
|
+
{ handoffId, members, ...receiptBinding },
|
|
526
|
+
handoffClaimResponseSchema.parse(await applyControlRequest(
|
|
527
|
+
'POST', `/api/jobscout/apply/review-handoffs/${handoffId}/claim`, { members }, idempotencyKey,
|
|
528
|
+
)),
|
|
529
|
+
);
|
|
530
|
+
}, 'Failed to claim apply review handoff')
|
|
531
|
+
);
|
|
532
|
+
|
|
274
533
|
server.tool(
|
|
275
534
|
'trackly_get_apply_execution_snapshot',
|
|
276
535
|
'Fetch a compact, bounded projection for one Apply execution. Request only the current members and profile keys needed for the visible form. The response owns mutability, allowed operations, milestones, lease timing, and progress.',
|
|
@@ -757,6 +1016,7 @@ function registerApplyTools(
|
|
|
757
1016
|
'Record review readiness or a user-confirmed outcome. Before handoff use literal outcome=review_ready and verify awaiting_manual_submit. Mark submitted with literal outcome=submitted only after a success page or explicit user confirmation.',
|
|
758
1017
|
{
|
|
759
1018
|
runId: z.number().int().min(1),
|
|
1019
|
+
idempotencyKey: z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY),
|
|
760
1020
|
batchId: z.number().int().min(1).optional(),
|
|
761
1021
|
memberId: z.number().int().min(1).optional(),
|
|
762
1022
|
inspectionEpoch: z.number().int().min(0).optional(),
|
|
@@ -764,13 +1024,19 @@ function registerApplyTools(
|
|
|
764
1024
|
outcome: z.enum(['review_ready', 'submitted', 'failed', 'blocked']),
|
|
765
1025
|
confirmation: z.enum(['user_confirmation', 'success_page']).optional(),
|
|
766
1026
|
},
|
|
767
|
-
wrapTool(async ({ runId, ...body }) =>
|
|
1027
|
+
wrapTool(async ({ runId, idempotencyKey, ...body }) => applyControlRequest(
|
|
1028
|
+
'POST',
|
|
1029
|
+
`/api/jobscout/apply/runs/${runId}/outcome`,
|
|
1030
|
+
body,
|
|
1031
|
+
idempotencyKey
|
|
1032
|
+
), 'Failed to record application outcome')
|
|
768
1033
|
);
|
|
769
1034
|
|
|
770
1035
|
server.tool(
|
|
771
1036
|
'trackly_record_application_outcomes',
|
|
772
1037
|
'Bulk-record up to 20 leased, batch-bound review or user-confirmed outcomes. Before handoff every item uses literal outcome=review_ready and every recorded run must return awaiting_manual_submit. After manual confirmation use literal outcome=submitted. Each member returns recorded or a stable conflict without hiding sibling results.',
|
|
773
1038
|
{
|
|
1039
|
+
idempotencyKey: z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY),
|
|
774
1040
|
outcomes: z.array(z.object({
|
|
775
1041
|
runId: z.number().int().min(1),
|
|
776
1042
|
batchId: z.number().int().min(1),
|
|
@@ -779,16 +1045,17 @@ function registerApplyTools(
|
|
|
779
1045
|
leaseToken: z.string().min(1).max(1024),
|
|
780
1046
|
outcome: z.enum(['review_ready', 'submitted', 'failed', 'blocked']),
|
|
781
1047
|
confirmation: z.enum(['user_confirmation', 'success_page']).optional(),
|
|
782
|
-
})).min(1).max(APPLY_BATCH_MAX_BULK_MUTATIONS)
|
|
1048
|
+
})).min(1).max(APPLY_BATCH_MAX_BULK_MUTATIONS).refine(
|
|
1049
|
+
(values) => new Set(values.map(({ runId }) => runId)).size === values.length,
|
|
1050
|
+
{ message: 'outcomes must contain unique runId values' }
|
|
1051
|
+
),
|
|
783
1052
|
},
|
|
784
1053
|
wrapTool(
|
|
785
|
-
async (params) =>
|
|
1054
|
+
async ({ idempotencyKey, ...params }) => applyControlRequest(
|
|
786
1055
|
'POST',
|
|
787
1056
|
'/api/jobscout/apply/outcomes/bulk',
|
|
788
1057
|
params,
|
|
789
|
-
|
|
790
|
-
false,
|
|
791
|
-
MCP_USER_AGENT
|
|
1058
|
+
idempotencyKey
|
|
792
1059
|
),
|
|
793
1060
|
'Failed to record bulk application outcomes'
|
|
794
1061
|
)
|
|
@@ -835,6 +1102,44 @@ function registerApplyTools(
|
|
|
835
1102
|
wrapTool(async ({ exactPath, originalErrno }) => diagnoseLocalPath(exactPath, { originalErrno }), 'Local path diagnosis failed')
|
|
836
1103
|
);
|
|
837
1104
|
|
|
1105
|
+
server.tool(
|
|
1106
|
+
'trackly_validate_apply_tab_keep_set',
|
|
1107
|
+
'Locally validate a caller-supplied session-finalizer keep set against complete controller and user tab inventories. This pure helper never enumerates, focuses, closes, hands off, or finalizes browser tabs and never sends tab IDs to Trackly.',
|
|
1108
|
+
{
|
|
1109
|
+
expectedTabIds: z.array(opaqueTabIdSchema).min(1).max(100),
|
|
1110
|
+
keepTabIds: z.array(opaqueTabIdSchema).max(100),
|
|
1111
|
+
controllerInventory: z.object({
|
|
1112
|
+
complete: z.boolean(),
|
|
1113
|
+
tabIds: z.array(opaqueTabIdSchema).max(1000),
|
|
1114
|
+
}).strict(),
|
|
1115
|
+
userInventory: z.object({
|
|
1116
|
+
complete: z.boolean(),
|
|
1117
|
+
tabIds: z.array(opaqueTabIdSchema).max(1000),
|
|
1118
|
+
}).strict(),
|
|
1119
|
+
},
|
|
1120
|
+
wrapTool(async (params) => validateApplyTabKeepSet(params), 'Apply tab keep-set validation failed')
|
|
1121
|
+
);
|
|
1122
|
+
|
|
1123
|
+
server.tool(
|
|
1124
|
+
'trackly_validate_apply_resume_upload',
|
|
1125
|
+
'Locally validate the browser adapter capabilities and ordered, value-free proof stages required to claim one resume attachment. This helper does not open a chooser, read a local path, control the browser, or send values to Trackly.',
|
|
1126
|
+
{
|
|
1127
|
+
capabilities: z.object({
|
|
1128
|
+
semanticControlDiscovery: z.boolean(),
|
|
1129
|
+
chooserArming: z.boolean(),
|
|
1130
|
+
fileAttachment: z.boolean(),
|
|
1131
|
+
committedFilenameInspection: z.boolean(),
|
|
1132
|
+
parserFieldRecheck: z.boolean(),
|
|
1133
|
+
}).strict(),
|
|
1134
|
+
events: z.array(z.object({
|
|
1135
|
+
stage: z.enum(APPLY_UPLOAD_STAGES),
|
|
1136
|
+
outcome: z.enum(['passed', 'failed']),
|
|
1137
|
+
failureCode: z.enum(APPLY_UPLOAD_FAILURE_CODES).optional(),
|
|
1138
|
+
}).strict()).max(APPLY_UPLOAD_STAGES.length),
|
|
1139
|
+
},
|
|
1140
|
+
wrapTool(async (params) => validateApplyResumeUpload(params), 'Apply resume upload validation failed')
|
|
1141
|
+
);
|
|
1142
|
+
|
|
838
1143
|
server.tool(
|
|
839
1144
|
'trackly_verify_prepared_resume',
|
|
840
1145
|
'Immediately before attachment, recompute the prepared resume fingerprint, validate its run and expiration, and lock the confirmed file read-only.',
|
|
@@ -857,11 +1162,20 @@ function registerApplyTools(
|
|
|
857
1162
|
messages: [{
|
|
858
1163
|
role: 'user',
|
|
859
1164
|
content: { type: 'text', text: APPLY_RELIABILITY_PROMPT },
|
|
1165
|
+
}, {
|
|
1166
|
+
role: 'user',
|
|
1167
|
+
content: { type: 'text', text: 'Only active=true identifies resumable execution work. Active=false and preserved=true is terminal read-only reconciliation evidence.' },
|
|
1168
|
+
}, {
|
|
1169
|
+
role: 'user',
|
|
1170
|
+
content: {
|
|
1171
|
+
type: 'text',
|
|
1172
|
+
text: 'Protocol 3.6.0 reliability gate for new work: require MCP contract 3.7.1 and skill 4.5.0. After complete local context loss, list bounded recovery candidates, obtain explicit confirmation of the exact set, and recover only that set. Treat tab recovery, form-state recovery, and mutation authority as independent. List active handoff receipts for the execution before resolving grouped submission statements; use the named receipt or the sole returned active receipt, classify every member, and claim that receipt before recording outcomes. Validate tab keep sets and resume upload stages locally. Never send raw browser values or click Submit.',
|
|
1173
|
+
},
|
|
860
1174
|
}, {
|
|
861
1175
|
role: 'user',
|
|
862
1176
|
content: {
|
|
863
1177
|
type: 'text',
|
|
864
|
-
text: '
|
|
1178
|
+
text: 'Legacy protocol compatibility gate: require the fetched compatibleSkillMinimumVersion or newer for all new work and use protocol 3.6.0 as specified above. Read the Apply protocol first. Only protocol 3.5 or newer with the compact-snapshot capability may call trackly_get_apply_execution_snapshot or the parked-member resume and execution-resume approval tools. An already-active protocol 3.4 execution is read-only legacy recovery: use only its published get or stop tools and never mutate its browser forms. Only when the fetched protocol is 3.4 or newer call trackly_get_active_apply_execution before legacy batch recovery, including when accessible execution is disabled. For protocol 3.3, skip the execution endpoint and recover the already-active immutable fixed batch directly; protocol 3.2 remains valid only for an already-active explicit legacy single run. A disabled rollout may preserve an active execution: recover it read-only and use only get or stop tools until the capability is enabled; never start, advance, or record dispositions while disabled. If disabled and no execution is active, use the legacy fixed-batch path. Recover every entry in execution.unresolvedWaves in ascending waveOrder; an older unresolved wave remains part of recovery after a replacement wave exists, and execution.currentWave is only the latest scheduling identity, never the complete recovery set. For “fill/apply to the next N,” recover or start one complete_next_n_accessible execution with target 1–20 and follow only the server nextAction and authoritative funnel. If the requested N differs from the active target, explain the mismatch, obtain explicit confirmation, stop the old execution with reason target_changed, refetch its terminal state, then start the new target. If an immutable fixed batch is active when the user requests complete_next_n_accessible, explain the incompatible mode and summarize any review-ready, submitted, or unresolved work before browser mutation. Resume that exact fixed batch when the user chooses to finish it. If the user instead says to start fresh, leave, replace, discard, or otherwise abandon the old batch, treat that statement as explicit cancellation confirmation: refetch the latest batch revision, call trackly_cancel_apply_batch with reason user_requested_restart and a fresh idempotency key, refetch until no active fixed batch remains, preserve every existing browser tab without mutation, and start the requested accessible execution in the same turn. Never wait for batch expiry and never create a scheduled continuation merely to escape an obsolete batch. If cancellation reports submission_in_progress, preserve everything and stop for the user; do not cancel or start replacement work. If the user asks to stop, call trackly_stop_apply_execution with reason user_requested and refetch its terminal state. Continue immutable child waves from the original recent-first snapshot until the authoritative backend funnel says targetReached, the queue is exhausted, or the user stops. Treat achievementCount and target-capped completed as the cumulative target authority. durablyReviewReady and submitted are current operator projections only; never add them together locally to reconstruct completion. Accessible drafts awaiting answers and forms currently being filled occupy target slots; authentication, account creation, OTP, pre-form CAPTCHA, exclusions, manual-only, conflicts, and revocations do not. Record only typed value-free live-probe dispositions. Advance only when no current-wave member remains unclassified queued or inspecting. Never calculate replacements or progress locally. For an explicit “inspect the next N records” request, use the existing fixed immutable batch and never replenish it; if a different accessible execution is active, confirm the intent change with the user, stop that execution with reason target_changed, refetch its terminal state, then recover or create the fixed batch. A cache hint may prioritize a live minimal non-mutating probe but never authorizes private-data entry or replaces that probe. After a redirect or contradictory observation, report only the fresh live disposition with its exact binding and let the backend invalidate its own hint. Preserve every user-edited or unknown non-empty field through the local provenance ledger. Never submit.',
|
|
865
1179
|
},
|
|
866
1180
|
}, {
|
|
867
1181
|
role: 'user',
|
package/mcp/server.js
CHANGED
|
@@ -7,6 +7,7 @@ const { z } = require('zod');
|
|
|
7
7
|
const { apiRequest, createTracklyAccessError, hasAuth, maintenanceOutput } = require('../lib/client');
|
|
8
8
|
const { version: PACKAGE_VERSION } = require('../package.json');
|
|
9
9
|
const { registerApplyTools } = require('./apply-tools');
|
|
10
|
+
const { configureMcpAnalytics, shutdownMcpAnalytics } = require('./analytics');
|
|
10
11
|
|
|
11
12
|
const MCP_USER_AGENT = `trackly-mcp/${PACKAGE_VERSION}`;
|
|
12
13
|
const MCP_MAINTENANCE_ERROR_CODE = -32002;
|
|
@@ -15,6 +16,8 @@ const MCP_AUTH_ERROR_CODE = -32004;
|
|
|
15
16
|
const AUTH_HINT =
|
|
16
17
|
'Existing members: run `trackly login` or set TRACKLY_API_KEY. ' +
|
|
17
18
|
'New members need a private invite during the limited rollout; request access at https://usetrackly.app/early-access.';
|
|
19
|
+
const MCP_ANALYTICS_CONTEXT_DESCRIPTION =
|
|
20
|
+
'Briefly explain why this tool helps the current job-search goal. Never include resume text, profile answers, demographic or work-authorization answers, application notes, credentials, or secrets.';
|
|
18
21
|
|
|
19
22
|
// Mirrors `granola-followup-app/src/services/region-classifier.ts:8` REGION_TAGS.
|
|
20
23
|
// Keep in sync when the backend enum changes.
|
|
@@ -48,6 +51,22 @@ const JOB_MODALITIES = ['full_time', 'internship', 'all'];
|
|
|
48
51
|
// workArrangements query contract and job_postings constraint.
|
|
49
52
|
const WORK_ARRANGEMENTS = ['remote', 'hybrid', 'in_person', 'unspecified'];
|
|
50
53
|
|
|
54
|
+
const MCP_MISSING_CAPABILITY_VALUES = [
|
|
55
|
+
'search', 'inspect', 'compare', 'rank', 'recommend', 'track', 'update', 'apply',
|
|
56
|
+
'submit', 'export', 'notify', 'schedule', 'integrate', 'authenticate', 'debug', 'other',
|
|
57
|
+
];
|
|
58
|
+
const MCP_MISSING_ACTION_VALUES = [
|
|
59
|
+
'find', 'read', 'compare', 'rank', 'create', 'update', 'delete', 'submit',
|
|
60
|
+
'export', 'notify', 'schedule', 'connect', 'debug', 'other',
|
|
61
|
+
];
|
|
62
|
+
const MCP_MISSING_RESOURCE_VALUES = [
|
|
63
|
+
'job', 'company', 'application', 'contact', 'profile', 'resume', 'preference',
|
|
64
|
+
'analytics', 'account', 'tool', 'other',
|
|
65
|
+
];
|
|
66
|
+
const MCP_MISSING_DESTINATION_VALUES = [
|
|
67
|
+
'trackly', 'external_site', 'file', 'email', 'calendar', 'crm', 'browser', 'other',
|
|
68
|
+
];
|
|
69
|
+
|
|
51
70
|
// `sort` enum matches backend handler at `jobscout.ts:3053` — NOT the pre-fix
|
|
52
71
|
// `newest|oldest|company` (backend rejects oldest/company with HTTP 400).
|
|
53
72
|
const SORT_VALUES = ['newest', 'match'];
|
|
@@ -169,7 +188,7 @@ function throwMcpResourceError(error) {
|
|
|
169
188
|
}
|
|
170
189
|
|
|
171
190
|
function wrapTool(handler, fallbackMessage) {
|
|
172
|
-
return async (params) => {
|
|
191
|
+
return async (params, extra) => {
|
|
173
192
|
try {
|
|
174
193
|
if (!hasAuth()) {
|
|
175
194
|
return createAuthErrorResult();
|
|
@@ -180,6 +199,11 @@ function wrapTool(handler, fallbackMessage) {
|
|
|
180
199
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
181
200
|
};
|
|
182
201
|
} catch (error) {
|
|
202
|
+
if (extra && typeof extra === 'object') {
|
|
203
|
+
extra.__mcp_analytics_error = error instanceof Error
|
|
204
|
+
? error
|
|
205
|
+
: new Error(error?.message || fallbackMessage, { cause: error });
|
|
206
|
+
}
|
|
183
207
|
return createErrorResult(
|
|
184
208
|
error,
|
|
185
209
|
fallbackMessage,
|
|
@@ -485,28 +509,119 @@ function createServer() {
|
|
|
485
509
|
mcpUserAgent: MCP_USER_AGENT,
|
|
486
510
|
throwMcpResourceError,
|
|
487
511
|
});
|
|
512
|
+
|
|
513
|
+
server.tool(
|
|
514
|
+
'get_more_tools',
|
|
515
|
+
'Report a capability Trackly does not currently provide. Use structured categories; context is optional and is classified but never stored as raw text.',
|
|
516
|
+
{
|
|
517
|
+
requestedCapability: z.enum(MCP_MISSING_CAPABILITY_VALUES).optional(),
|
|
518
|
+
requestedAction: z.enum(MCP_MISSING_ACTION_VALUES).optional(),
|
|
519
|
+
requestedResource: z.enum(MCP_MISSING_RESOURCE_VALUES).optional(),
|
|
520
|
+
requestedDestination: z.enum(MCP_MISSING_DESTINATION_VALUES).optional(),
|
|
521
|
+
context: z.string().optional().describe(MCP_ANALYTICS_CONTEXT_DESCRIPTION),
|
|
522
|
+
},
|
|
523
|
+
async () => ({
|
|
524
|
+
content: [{
|
|
525
|
+
type: 'text',
|
|
526
|
+
text: JSON.stringify({ accepted: true, delivery: 'best_effort' }),
|
|
527
|
+
}],
|
|
528
|
+
}),
|
|
529
|
+
);
|
|
530
|
+
|
|
488
531
|
return server;
|
|
489
532
|
}
|
|
490
533
|
|
|
491
|
-
|
|
534
|
+
// Keep `createServer` a deterministic catalog factory for embedders and
|
|
535
|
+
// contract verification. Analytics belongs at the executable startup boundary,
|
|
536
|
+
// after every source tool has been registered.
|
|
537
|
+
function configureServerAnalytics(server, options = {}) {
|
|
538
|
+
if (typeof options.configureAnalytics === 'function') {
|
|
539
|
+
return options.configureAnalytics(server, options.analytics);
|
|
540
|
+
}
|
|
541
|
+
return configureMcpAnalytics(server, options.analytics);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function startMcpServer(options = {}) {
|
|
492
545
|
const server = createServer();
|
|
493
|
-
|
|
494
|
-
|
|
546
|
+
configureServerAnalytics(server, options);
|
|
547
|
+
const transport = options.transport || new StdioServerTransport();
|
|
548
|
+
const shutdownAnalytics = options.shutdownAnalytics || shutdownMcpAnalytics;
|
|
549
|
+
const previousOnClose = server.server.onclose;
|
|
550
|
+
const beginAnalyticsShutdown = () => {
|
|
551
|
+
try {
|
|
552
|
+
Promise.resolve(shutdownAnalytics(server)).catch(() => {});
|
|
553
|
+
} catch {
|
|
554
|
+
// Analytics teardown must not affect transport teardown.
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
server.server.onclose = () => {
|
|
558
|
+
try {
|
|
559
|
+
previousOnClose?.();
|
|
560
|
+
} catch {
|
|
561
|
+
// Preserve fail-open behavior if an instrumentation callback misbehaves.
|
|
562
|
+
}
|
|
563
|
+
beginAnalyticsShutdown();
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
try {
|
|
567
|
+
await server.connect(transport);
|
|
568
|
+
} catch (error) {
|
|
569
|
+
try {
|
|
570
|
+
await shutdownAnalytics(server);
|
|
571
|
+
} catch {
|
|
572
|
+
// Preserve the original transport failure.
|
|
573
|
+
}
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
495
576
|
return server;
|
|
496
577
|
}
|
|
497
578
|
|
|
579
|
+
function installMcpSignalHandlers(server, options = {}) {
|
|
580
|
+
const signalTarget = options.signalTarget || process;
|
|
581
|
+
const exit = options.exit || ((code) => process.exit(code));
|
|
582
|
+
const shutdownAnalytics = options.shutdownAnalytics || shutdownMcpAnalytics;
|
|
583
|
+
let terminating = false;
|
|
584
|
+
const handlers = new Map();
|
|
585
|
+
const cleanup = () => {
|
|
586
|
+
for (const [signal, handler] of handlers) {
|
|
587
|
+
signalTarget.removeListener(signal, handler);
|
|
588
|
+
}
|
|
589
|
+
handlers.clear();
|
|
590
|
+
};
|
|
591
|
+
for (const [signal, exitCode] of [['SIGINT', 130], ['SIGTERM', 143]]) {
|
|
592
|
+
const handler = () => {
|
|
593
|
+
if (terminating) return;
|
|
594
|
+
terminating = true;
|
|
595
|
+
void Promise.resolve()
|
|
596
|
+
.then(() => shutdownAnalytics(server))
|
|
597
|
+
.catch(() => {})
|
|
598
|
+
.finally(() => {
|
|
599
|
+
cleanup();
|
|
600
|
+
exit(exitCode);
|
|
601
|
+
});
|
|
602
|
+
};
|
|
603
|
+
handlers.set(signal, handler);
|
|
604
|
+
signalTarget.on(signal, handler);
|
|
605
|
+
}
|
|
606
|
+
return cleanup;
|
|
607
|
+
}
|
|
608
|
+
|
|
498
609
|
if (require.main === module) {
|
|
499
|
-
startMcpServer()
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
610
|
+
startMcpServer()
|
|
611
|
+
.then((server) => installMcpSignalHandlers(server))
|
|
612
|
+
.catch((error) => {
|
|
613
|
+
console.error('MCP server error:', error);
|
|
614
|
+
process.exit(1);
|
|
615
|
+
});
|
|
503
616
|
}
|
|
504
617
|
|
|
505
618
|
module.exports = {
|
|
506
619
|
AUTH_HINT,
|
|
507
620
|
createAuthErrorResult,
|
|
508
621
|
createErrorResult,
|
|
622
|
+
configureServerAnalytics,
|
|
509
623
|
createServer,
|
|
624
|
+
installMcpSignalHandlers,
|
|
510
625
|
startMcpServer,
|
|
511
626
|
throwMcpResourceError,
|
|
512
627
|
};
|