trackly-cli 0.13.3 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ const APPLY_TAB_SET_FAILURE_CODES = Object.freeze({
4
+ CONTROLLER_INVENTORY_INCOMPLETE: 'controller_inventory_incomplete',
5
+ USER_INVENTORY_INCOMPLETE: 'user_inventory_incomplete',
6
+ EXPECTED_SET_EMPTY: 'expected_set_empty',
7
+ KEEP_SET_EMPTY_WITH_EXPECTED_TABS: 'keep_set_empty_with_expected_tabs',
8
+ DUPLICATE_EXPECTED_TAB_ID: 'duplicate_expected_tab_id',
9
+ DUPLICATE_KEEP_TAB_ID: 'duplicate_keep_tab_id',
10
+ DUPLICATE_CONTROLLER_TAB_ID: 'duplicate_controller_tab_id',
11
+ DUPLICATE_USER_TAB_ID: 'duplicate_user_tab_id',
12
+ AMBIGUOUS_CROSS_TYPE_TAB_ID: 'ambiguous_cross_type_tab_id',
13
+ EXPECTED_KEEP_CARDINALITY_MISMATCH: 'expected_keep_cardinality_mismatch',
14
+ EXPECTED_KEEP_SET_MISMATCH: 'expected_keep_set_mismatch',
15
+ EXPECTED_TAB_MISSING_FROM_INVENTORY_UNION: 'expected_tab_missing_from_inventory_union',
16
+ });
17
+
18
+ function canonicalizeTabId(tabId) {
19
+ if (typeof tabId === 'number') {
20
+ if (!Number.isSafeInteger(tabId)) throw new TypeError('Tab IDs supplied as numbers must be safe integers.');
21
+ return String(tabId);
22
+ }
23
+ if (typeof tabId !== 'string' || tabId.trim().length === 0) {
24
+ throw new TypeError('Tab IDs must be safe integers or nonblank opaque strings.');
25
+ }
26
+ if (tabId.length > 512) throw new RangeError('Opaque tab IDs must not exceed 512 characters.');
27
+ return tabId;
28
+ }
29
+
30
+ function canonicalizeTabIds(tabIds, { label, maxItems }) {
31
+ if (!Array.isArray(tabIds)) throw new TypeError('Tab ID collections must be arrays.');
32
+ if (tabIds.length > maxItems) {
33
+ throw new RangeError(`${label} must not contain more than ${maxItems} tab IDs.`);
34
+ }
35
+ return tabIds.map(canonicalizeTabId);
36
+ }
37
+
38
+ function hasDuplicates(tabIds) {
39
+ return new Set(tabIds).size !== tabIds.length;
40
+ }
41
+
42
+ function hasCrossTypeCollision(collections) {
43
+ const typesByCanonicalId = new Map();
44
+ for (const tabId of collections.flat()) {
45
+ const canonical = canonicalizeTabId(tabId);
46
+ const types = typesByCanonicalId.get(canonical) ?? new Set();
47
+ types.add(typeof tabId);
48
+ typesByCanonicalId.set(canonical, types);
49
+ }
50
+ return [...typesByCanonicalId.values()].some((types) => types.size > 1);
51
+ }
52
+
53
+ function sameSet(left, right) {
54
+ if (left.length !== right.length) return false;
55
+ const rightSet = new Set(right);
56
+ return left.every((tabId) => rightSet.has(tabId));
57
+ }
58
+
59
+ function validateApplyTabKeepSet({
60
+ expectedTabIds,
61
+ keepTabIds,
62
+ controllerInventory,
63
+ userInventory,
64
+ }) {
65
+ const expected = canonicalizeTabIds(expectedTabIds, { label: 'expectedTabIds', maxItems: 100 });
66
+ const keep = canonicalizeTabIds(keepTabIds, { label: 'keepTabIds', maxItems: 100 });
67
+ const controller = canonicalizeTabIds(controllerInventory?.tabIds, {
68
+ label: 'controllerInventory.tabIds',
69
+ maxItems: 1000,
70
+ });
71
+ const user = canonicalizeTabIds(userInventory?.tabIds, {
72
+ label: 'userInventory.tabIds',
73
+ maxItems: 1000,
74
+ });
75
+ const failureCodes = [];
76
+ const addFailure = (code) => {
77
+ if (!failureCodes.includes(code)) failureCodes.push(code);
78
+ };
79
+
80
+ if (controllerInventory?.complete !== true) {
81
+ addFailure(APPLY_TAB_SET_FAILURE_CODES.CONTROLLER_INVENTORY_INCOMPLETE);
82
+ }
83
+ if (userInventory?.complete !== true) {
84
+ addFailure(APPLY_TAB_SET_FAILURE_CODES.USER_INVENTORY_INCOMPLETE);
85
+ }
86
+ if (expected.length === 0) addFailure(APPLY_TAB_SET_FAILURE_CODES.EXPECTED_SET_EMPTY);
87
+ if (expected.length > 0 && keep.length === 0) {
88
+ addFailure(APPLY_TAB_SET_FAILURE_CODES.KEEP_SET_EMPTY_WITH_EXPECTED_TABS);
89
+ }
90
+ if (hasDuplicates(expected)) addFailure(APPLY_TAB_SET_FAILURE_CODES.DUPLICATE_EXPECTED_TAB_ID);
91
+ if (hasDuplicates(keep)) addFailure(APPLY_TAB_SET_FAILURE_CODES.DUPLICATE_KEEP_TAB_ID);
92
+ if (hasDuplicates(controller)) addFailure(APPLY_TAB_SET_FAILURE_CODES.DUPLICATE_CONTROLLER_TAB_ID);
93
+ if (hasDuplicates(user)) addFailure(APPLY_TAB_SET_FAILURE_CODES.DUPLICATE_USER_TAB_ID);
94
+ if (hasCrossTypeCollision([
95
+ expectedTabIds, keepTabIds, controllerInventory.tabIds, userInventory.tabIds,
96
+ ])) addFailure(APPLY_TAB_SET_FAILURE_CODES.AMBIGUOUS_CROSS_TYPE_TAB_ID);
97
+ if (expected.length !== keep.length) {
98
+ addFailure(APPLY_TAB_SET_FAILURE_CODES.EXPECTED_KEEP_CARDINALITY_MISMATCH);
99
+ }
100
+ if (!sameSet(expected, keep)) addFailure(APPLY_TAB_SET_FAILURE_CODES.EXPECTED_KEEP_SET_MISMATCH);
101
+
102
+ const inventoryUnion = new Set([...controller, ...user]);
103
+ if (expected.some((tabId) => !inventoryUnion.has(tabId))) {
104
+ addFailure(APPLY_TAB_SET_FAILURE_CODES.EXPECTED_TAB_MISSING_FROM_INVENTORY_UNION);
105
+ }
106
+
107
+ const safeToFinalize = failureCodes.length === 0;
108
+ return {
109
+ safeToFinalize,
110
+ failureCodes,
111
+ expectedCount: expected.length,
112
+ keepCount: keep.length,
113
+ controllerInventoryCount: controller.length,
114
+ userInventoryCount: user.length,
115
+ inventoryUnionCount: inventoryUnion.size,
116
+ canonicalKeepTabIds: safeToFinalize ? keep : [],
117
+ };
118
+ }
119
+
120
+ module.exports = {
121
+ APPLY_TAB_SET_FAILURE_CODES,
122
+ canonicalizeTabId,
123
+ validateApplyTabKeepSet,
124
+ };
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+
3
+ const APPLY_CONTRACT = require('../contracts/trackly-apply-tools.json');
4
+
5
+ const APPLY_UPLOAD_STAGES = Object.freeze([...APPLY_CONTRACT.constants.applyUploadStages]);
6
+ const APPLY_UPLOAD_FAILURE_CODES = Object.freeze([
7
+ ...APPLY_CONTRACT.constants.applyUploadFailureCodes,
8
+ ]);
9
+ const APPLY_UPLOAD_CAPABILITIES = Object.freeze([
10
+ 'semanticControlDiscovery',
11
+ 'chooserArming',
12
+ 'fileAttachment',
13
+ 'committedFilenameInspection',
14
+ 'parserFieldRecheck',
15
+ ]);
16
+
17
+ function validateApplyResumeUpload({ capabilities, events }) {
18
+ const failureCodes = [];
19
+ const seen = new Set();
20
+ let expectedIndex = 0;
21
+ let lastPassedStage = null;
22
+
23
+ for (const capability of APPLY_UPLOAD_CAPABILITIES) {
24
+ if (capabilities[capability] !== true) failureCodes.push('upload_capability_unavailable');
25
+ }
26
+
27
+ for (const event of events) {
28
+ if (seen.has(event.stage)) {
29
+ failureCodes.push('upload_stage_duplicate');
30
+ continue;
31
+ }
32
+ seen.add(event.stage);
33
+ const index = APPLY_UPLOAD_STAGES.indexOf(event.stage);
34
+ const isExpectedStage = index === expectedIndex;
35
+ if (!isExpectedStage) failureCodes.push('upload_stage_out_of_order');
36
+
37
+ const hasFailureCode = Boolean(event.failureCode);
38
+ if (hasFailureCode) {
39
+ failureCodes.push(event.failureCode);
40
+ }
41
+
42
+ if (event.outcome === 'failed' || hasFailureCode) {
43
+ if (!event.failureCode) failureCodes.push('upload_stage_missing');
44
+ } else if (isExpectedStage) {
45
+ expectedIndex += 1;
46
+ lastPassedStage = event.stage;
47
+ }
48
+ }
49
+
50
+ if (expectedIndex !== APPLY_UPLOAD_STAGES.length) failureCodes.push('upload_stage_missing');
51
+ const uniqueFailureCodes = [...new Set(failureCodes)];
52
+ return {
53
+ safeToClaimAttachment: uniqueFailureCodes.length === 0,
54
+ failureCodes: uniqueFailureCodes,
55
+ lastPassedStage,
56
+ completedStageCount: expectedIndex,
57
+ requiredStageCount: APPLY_UPLOAD_STAGES.length,
58
+ };
59
+ }
60
+
61
+ module.exports = {
62
+ APPLY_UPLOAD_CAPABILITIES,
63
+ APPLY_UPLOAD_FAILURE_CODES,
64
+ APPLY_UPLOAD_STAGES,
65
+ validateApplyResumeUpload,
66
+ };
@@ -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,8 @@ function registerApplyTools(
143
284
  idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined,
144
285
  )
145
286
  );
287
+ const discoveredHandoffBindings = new Map();
288
+ const discoveredHandoffIdsByExecution = new Map();
146
289
  server.tool(
147
290
  'trackly_get_apply_queue',
148
291
  'Get the deterministic queue of jobs the user already approved by saving as check later. Do not rescore or veto these jobs.',
@@ -271,6 +414,101 @@ function registerApplyTools(
271
414
  ), 'Failed to fetch apply execution')
272
415
  );
273
416
 
417
+ server.tool(
418
+ 'trackly_list_recoverable_apply_executions',
419
+ '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.',
420
+ {},
421
+ wrapTool(async () => recoverableExecutionsResponseSchema.parse(await applyControlRequest(
422
+ 'GET', '/api/jobscout/apply/executions/recoverable',
423
+ )), 'Failed to list recoverable apply executions')
424
+ );
425
+
426
+ server.tool(
427
+ 'trackly_recover_exact_apply_members',
428
+ 'Create an exact-member recovery execution from one confirmed source snapshot. The asserted candidate set is immutable and replacements are forbidden.',
429
+ {
430
+ sourceExecutionId: z.number().int().min(1),
431
+ sourceSnapshotHash: z.string().regex(/^[a-f0-9]{64}$/),
432
+ candidateIds: z.array(z.number().int().min(1)).min(1).max(APPLY_EXECUTION_MAX_TARGET)
433
+ .refine((values) => new Set(values).size === values.length, {
434
+ message: 'candidateIds must be unique',
435
+ }),
436
+ explicitExactSetConfirmation: z.literal(true),
437
+ idempotencyKey: z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY),
438
+ },
439
+ wrapTool(async ({ idempotencyKey, ...input }) => validateExactRecoveryResponse(
440
+ input,
441
+ exactRecoveryResponseSchema.parse(await applyControlRequest('POST', '/api/jobscout/apply/executions/recover', {
442
+ mode: 'recover_exact_members',
443
+ ...input,
444
+ }, idempotencyKey)),
445
+ ), 'Failed to recover exact apply members')
446
+ );
447
+
448
+ server.tool(
449
+ 'trackly_list_apply_review_handoffs',
450
+ '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.',
451
+ { executionId: z.number().int().min(1) },
452
+ wrapTool(async ({ executionId }) => {
453
+ const response = validateHandoffListResponse(
454
+ executionId,
455
+ handoffListResponseSchema.parse(await applyControlRequest(
456
+ 'GET', `/api/jobscout/apply/executions/${executionId}/review-handoffs`,
457
+ )),
458
+ );
459
+ for (const handoffId of discoveredHandoffIdsByExecution.get(executionId) || []) {
460
+ discoveredHandoffBindings.delete(handoffId);
461
+ }
462
+ const discoveredHandoffIds = new Set();
463
+ for (const handoff of response.handoffs) {
464
+ discoveredHandoffBindings.set(handoff.id, {
465
+ executionId: handoff.executionId,
466
+ orderedMemberSetHash: handoff.orderedMemberSetHash,
467
+ memberIds: handoff.members.map(({ memberId }) => memberId),
468
+ });
469
+ discoveredHandoffIds.add(handoff.id);
470
+ }
471
+ discoveredHandoffIdsByExecution.set(executionId, discoveredHandoffIds);
472
+ return response;
473
+ }, 'Failed to list apply review handoffs')
474
+ );
475
+
476
+ server.tool(
477
+ 'trackly_claim_apply_review_handoff',
478
+ '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.',
479
+ {
480
+ handoffId: z.number().int().min(1),
481
+ members: z.array(z.object({
482
+ memberId: z.number().int().min(1),
483
+ classification: z.enum(APPLY_HANDOFF_RECONCILIATION_CLASSIFICATION_CODES),
484
+ }).strict()).min(1).max(APPLY_EXECUTION_MAX_TARGET)
485
+ .refine((values) => new Set(values.map(({ memberId }) => memberId)).size === values.length, {
486
+ message: 'members must contain unique memberId values',
487
+ }),
488
+ idempotencyKey: z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY),
489
+ },
490
+ wrapTool(async ({ handoffId, idempotencyKey, members }) => {
491
+ const binding = discoveredHandoffBindings.get(handoffId);
492
+ if (!binding) {
493
+ throw new Error('Review handoff must be selected from the latest discovery response.');
494
+ }
495
+ const { memberIds, ...receiptBinding } = binding;
496
+ const requestedMemberIds = new Set(members.map(({ memberId }) => memberId));
497
+ if (
498
+ requestedMemberIds.size !== memberIds.length
499
+ || memberIds.some((memberId) => !requestedMemberIds.has(memberId))
500
+ ) {
501
+ throw new Error('Handoff claim must classify every discovered member exactly once.');
502
+ }
503
+ return validateHandoffClaimResponse(
504
+ { handoffId, members, ...receiptBinding },
505
+ handoffClaimResponseSchema.parse(await applyControlRequest(
506
+ 'POST', `/api/jobscout/apply/review-handoffs/${handoffId}/claim`, { members }, idempotencyKey,
507
+ )),
508
+ );
509
+ }, 'Failed to claim apply review handoff')
510
+ );
511
+
274
512
  server.tool(
275
513
  'trackly_get_apply_execution_snapshot',
276
514
  '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 +995,7 @@ function registerApplyTools(
757
995
  '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
996
  {
759
997
  runId: z.number().int().min(1),
998
+ idempotencyKey: z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY),
760
999
  batchId: z.number().int().min(1).optional(),
761
1000
  memberId: z.number().int().min(1).optional(),
762
1001
  inspectionEpoch: z.number().int().min(0).optional(),
@@ -764,13 +1003,19 @@ function registerApplyTools(
764
1003
  outcome: z.enum(['review_ready', 'submitted', 'failed', 'blocked']),
765
1004
  confirmation: z.enum(['user_confirmation', 'success_page']).optional(),
766
1005
  },
767
- wrapTool(async ({ runId, ...body }) => apiRequest('POST', `/api/jobscout/apply/runs/${runId}/outcome`, body, false, false, MCP_USER_AGENT), 'Failed to record application outcome')
1006
+ wrapTool(async ({ runId, idempotencyKey, ...body }) => applyControlRequest(
1007
+ 'POST',
1008
+ `/api/jobscout/apply/runs/${runId}/outcome`,
1009
+ body,
1010
+ idempotencyKey
1011
+ ), 'Failed to record application outcome')
768
1012
  );
769
1013
 
770
1014
  server.tool(
771
1015
  'trackly_record_application_outcomes',
772
1016
  '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
1017
  {
1018
+ idempotencyKey: z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY),
774
1019
  outcomes: z.array(z.object({
775
1020
  runId: z.number().int().min(1),
776
1021
  batchId: z.number().int().min(1),
@@ -779,16 +1024,17 @@ function registerApplyTools(
779
1024
  leaseToken: z.string().min(1).max(1024),
780
1025
  outcome: z.enum(['review_ready', 'submitted', 'failed', 'blocked']),
781
1026
  confirmation: z.enum(['user_confirmation', 'success_page']).optional(),
782
- })).min(1).max(APPLY_BATCH_MAX_BULK_MUTATIONS),
1027
+ })).min(1).max(APPLY_BATCH_MAX_BULK_MUTATIONS).refine(
1028
+ (values) => new Set(values.map(({ runId }) => runId)).size === values.length,
1029
+ { message: 'outcomes must contain unique runId values' }
1030
+ ),
783
1031
  },
784
1032
  wrapTool(
785
- async (params) => apiRequest(
1033
+ async ({ idempotencyKey, ...params }) => applyControlRequest(
786
1034
  'POST',
787
1035
  '/api/jobscout/apply/outcomes/bulk',
788
1036
  params,
789
- false,
790
- false,
791
- MCP_USER_AGENT
1037
+ idempotencyKey
792
1038
  ),
793
1039
  'Failed to record bulk application outcomes'
794
1040
  )
@@ -835,6 +1081,44 @@ function registerApplyTools(
835
1081
  wrapTool(async ({ exactPath, originalErrno }) => diagnoseLocalPath(exactPath, { originalErrno }), 'Local path diagnosis failed')
836
1082
  );
837
1083
 
1084
+ server.tool(
1085
+ 'trackly_validate_apply_tab_keep_set',
1086
+ '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.',
1087
+ {
1088
+ expectedTabIds: z.array(opaqueTabIdSchema).min(1).max(100),
1089
+ keepTabIds: z.array(opaqueTabIdSchema).max(100),
1090
+ controllerInventory: z.object({
1091
+ complete: z.boolean(),
1092
+ tabIds: z.array(opaqueTabIdSchema).max(1000),
1093
+ }).strict(),
1094
+ userInventory: z.object({
1095
+ complete: z.boolean(),
1096
+ tabIds: z.array(opaqueTabIdSchema).max(1000),
1097
+ }).strict(),
1098
+ },
1099
+ wrapTool(async (params) => validateApplyTabKeepSet(params), 'Apply tab keep-set validation failed')
1100
+ );
1101
+
1102
+ server.tool(
1103
+ 'trackly_validate_apply_resume_upload',
1104
+ '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.',
1105
+ {
1106
+ capabilities: z.object({
1107
+ semanticControlDiscovery: z.boolean(),
1108
+ chooserArming: z.boolean(),
1109
+ fileAttachment: z.boolean(),
1110
+ committedFilenameInspection: z.boolean(),
1111
+ parserFieldRecheck: z.boolean(),
1112
+ }).strict(),
1113
+ events: z.array(z.object({
1114
+ stage: z.enum(APPLY_UPLOAD_STAGES),
1115
+ outcome: z.enum(['passed', 'failed']),
1116
+ failureCode: z.enum(APPLY_UPLOAD_FAILURE_CODES).optional(),
1117
+ }).strict()).max(APPLY_UPLOAD_STAGES.length),
1118
+ },
1119
+ wrapTool(async (params) => validateApplyResumeUpload(params), 'Apply resume upload validation failed')
1120
+ );
1121
+
838
1122
  server.tool(
839
1123
  'trackly_verify_prepared_resume',
840
1124
  'Immediately before attachment, recompute the prepared resume fingerprint, validate its run and expiration, and lock the confirmed file read-only.',
@@ -857,11 +1141,20 @@ function registerApplyTools(
857
1141
  messages: [{
858
1142
  role: 'user',
859
1143
  content: { type: 'text', text: APPLY_RELIABILITY_PROMPT },
1144
+ }, {
1145
+ role: 'user',
1146
+ content: { type: 'text', text: 'Only active=true identifies resumable execution work. Active=false and preserved=true is terminal read-only reconciliation evidence.' },
1147
+ }, {
1148
+ role: 'user',
1149
+ content: {
1150
+ type: 'text',
1151
+ 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.',
1152
+ },
860
1153
  }, {
861
1154
  role: 'user',
862
1155
  content: {
863
1156
  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.',
1157
+ 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
1158
  },
866
1159
  }, {
867
1160
  role: 'user',