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.
@@ -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.5 / skill 4.4.2 reliability gate: only active=true identifies resumable execution work. A response with active=false and preserved=true is terminal read-only reconciliation evidence. Require nextAction=none and never fetch its compact snapshot, continue an unresolved wave, mutate a member, or interpret historical funnel counts as current work. For an active execution recovery or start, fetch one compact execution snapshot with only the current member IDs and profile keys required by the visible forms. Treat mutable and allowedOperations as authoritative. Never reopen or mutate authentication, account-creation, OTP, pre-form-CAPTCHA, or manual-only members. Only an explicit user request may call trackly_resume_parked_apply_member, and the returned member still requires a fresh non-mutating access probe. Use execution-scoped exact-resume content approval across unchanged replacement waves, but immediately verify the exact local path, hash, size, run binding, and expiration before every upload. Run trackly_lint_application_text before entering free text and fail closed on every violation or unsupported claim. Reconcile every newly supplied reusable answer to the canonical profile before review: save it, confirm it already matches, or report a schema gap. Never create app-shell tabs to reveal an already-bound browser tab without an exact focus receipt. Diagnose I/O errors only against the exact implicated path. Report the server funnel and durable milestone after every state change and at least once every 60 seconds during active work. Never click Submit.';
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 }) => apiRequest('POST', `/api/jobscout/apply/runs/${runId}/outcome`, body, false, false, MCP_USER_AGENT), 'Failed to record application outcome')
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) => apiRequest(
1054
+ async ({ idempotencyKey, ...params }) => applyControlRequest(
786
1055
  'POST',
787
1056
  '/api/jobscout/apply/outcomes/bulk',
788
1057
  params,
789
- false,
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: 'Protocol 3.5.0 reliability gate: require the fetched compatibleSkillMinimumVersion or newer for new execution work. 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 durablyReviewReady plus submitted reaches target, the queue is exhausted, or the user stops. 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.',
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
- async function startMcpServer() {
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
- const transport = new StdioServerTransport();
494
- await server.connect(transport);
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().catch((error) => {
500
- console.error('MCP server error:', error);
501
- process.exit(1);
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
  };