trackly-cli 0.13.4 → 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.
package/README.md CHANGED
@@ -35,7 +35,7 @@ trackly jobs --function product
35
35
 
36
36
  ## At a Glance
37
37
 
38
- 3,800+ companies | 170K+ jobs | 40+ ATS types | CLI + MCP | 48 local MCP tools
38
+ 3,800+ companies | 170K+ jobs | 40+ ATS types | CLI + MCP | 54 local MCP tools
39
39
 
40
40
  ## CLI Commands
41
41
 
@@ -181,6 +181,10 @@ Then use natural language in any of these clients:
181
181
  | trackly_start_apply_execution | Start a target-counted accessible Apply execution |
182
182
  | trackly_get_active_apply_execution | Recover the active execution before legacy batch recovery |
183
183
  | trackly_get_apply_execution | Read the authoritative progress funnel and immutable child waves |
184
+ | trackly_list_recoverable_apply_executions | List bounded exact-member recovery candidates after context loss |
185
+ | trackly_recover_exact_apply_members | Recover only one explicitly confirmed candidate set without substitutes |
186
+ | trackly_list_apply_review_handoffs | Rediscover active, value-free review-handoff receipts for one execution |
187
+ | trackly_claim_apply_review_handoff | Claim and classify one exact review-handoff group before reconciliation |
184
188
  | trackly_get_apply_execution_snapshot | Fetch a compact bounded projection for current execution members and required profile keys |
185
189
  | trackly_resume_parked_apply_member | Explicitly resume one parked member for a fresh non-mutating access probe |
186
190
  | trackly_approve_apply_execution_resume | Approve one exact resume identity for an unchanged execution snapshot |
@@ -204,6 +208,8 @@ Then use natural language in any of these clients:
204
208
  | trackly_report_apply_observation | Send redacted ATS mechanics feedback |
205
209
  | trackly_lint_application_text | Locally lint application writing and return only value-free violations plus a draft hash |
206
210
  | trackly_diagnose_local_path | Locally diagnose the exact implicated filesystem path without deleting files |
211
+ | trackly_validate_apply_tab_keep_set | Locally validate an exact expected/keep tab set against complete caller-supplied inventories without controlling the browser or sending tab IDs remotely |
212
+ | trackly_validate_apply_resume_upload | Locally validate adapter capability and ordered value-free attachment proof stages |
207
213
  | trackly_report_apply_observations | Bulk-send up to 20 leased, batch-bound redacted observations |
208
214
  | trackly_record_application_outcome | Record review or confirmed submission outcome |
209
215
  | trackly_record_application_outcomes | Bulk-record up to 20 leased batch outcomes with per-member conflicts |
@@ -274,7 +280,7 @@ trackly config --base-url http://127.0.0.1:3000 # Point at a different backend
274
280
  | Job search + filters | Yes | Yes | Yes |
275
281
  | Apply/save/dismiss | Yes | Yes | Yes |
276
282
  | AI-powered search | Yes (trackly ask) | Yes | Yes |
277
- | MCP integration | Yes (48 local tools) | -- | -- |
283
+ | MCP integration | Yes (54 local tools) | -- | -- |
278
284
  | Browser required | No | Yes | No |
279
285
  | Best for | Terminal + AI agents | Visual browsing | Custom integrations |
280
286
 
@@ -288,7 +294,7 @@ Install trackly-cli (`npm install -g trackly-cli`), authenticate with `trackly l
288
294
 
289
295
  **What MCP servers exist for job searching?**
290
296
 
291
- trackly-cli includes a built-in MCP server with 48 tools for job search, company lookup, discovery preferences, application tracking, accessible execution and frozen-batch orchestration, profile onboarding, beta evidence, and manual-submit form preparation. Run `trackly mcp` or use `trackly agent setup --client claude`.
297
+ trackly-cli includes a built-in MCP server with 54 tools for job search, company lookup, discovery preferences, application tracking, accessible execution and frozen-batch orchestration, profile onboarding, beta evidence, and manual-submit form preparation. Run `trackly mcp` or use `trackly agent setup --client claude`.
292
298
 
293
299
  **How do I use Claude Code for job hunting?**
294
300
 
@@ -6,9 +6,16 @@
6
6
  "contractSha256": "8c52daa8b62095a6431d5b954505079c3f7c802e5c5ae39efa8ea7e988dfbc7a"
7
7
  },
8
8
  "3.6.2": {
9
- "contractSha256": "83e107ad7ae2abdc42cdacb9848d8e3bf345951ed7b8f42d5cd9b5e203528bf2"
9
+ "contractSha256": "83e107ad7ae2abdc42cdacb9848d8e3bf345951ed7b8f42d5cd9b5e203528bf2",
10
+ "toolsSha256": "d9d24da4b79a4cc65aec589debd2b9072e41b309fd6bf6a65e95dd4b5bef3c08"
10
11
  },
11
12
  "3.6.3": {
12
13
  "contractSha256": "93a8e55196c14f04afb923711997eacdc26dbdeae50b253b906d547abd346e30"
14
+ },
15
+ "3.7.0": {
16
+ "toolsSha256": "8380e2a9fabef4da18ea731f55c8994aef5a4738bb6e7daef9be4ce6ebfec64f"
17
+ },
18
+ "3.7.1": {
19
+ "contractSha256": "0eb582fcac72804347c5da53d214a67908f657f600536db7b42167350bb3f3ab"
13
20
  }
14
21
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "contractVersion": "3.6.3",
2
+ "contractVersion": "3.7.1",
3
3
  "constants": {
4
4
  "applyExecutionMaxTarget": 20,
5
5
  "applyScenarioCodes": ["browser_reclaim", "resume_upload", "resume_parser_recheck", "semantic_boolean_commit", "custom_select_commit", "multi_step_navigation", "free_text_voice", "required_error_sweep", "final_consent", "handoff_reclaim", "critical_contact_integrity", "manual_submit_boundary", "job_identity_match"],
@@ -16,7 +16,12 @@
16
16
  "applySurfaceEvidenceTypes": ["surface_inventory_reconciled", "surface_missing", "surface_close_attempted", "surface_close_receipt", "surface_post_close_absent", "surface_close_failed"],
17
17
  "applySurfaceOwnershipStates": ["controller_owned", "user_owned", "controller_user_union", "unresolved"],
18
18
  "applySubmissionEvidenceTypes": ["request_accepted", "confirmation_detected", "provider_receipt_detected"],
19
- "applySubmissionEvidenceSources": ["submission_request", "success_page", "user_confirmation", "provider_receipt", "post_submit_receipt"]
19
+ "applySubmissionEvidenceSources": ["submission_request", "success_page", "user_confirmation", "provider_receipt", "post_submit_receipt"],
20
+ "applyPositiveSubmissionStrategyCodes": ["greenhouse_confirmation_redirect", "greenhouse_semantic_success", "ashby_semantic_success_banner", "ashby_submitted_state", "lever_confirmation_redirect", "lever_semantic_confirmation"],
21
+ "applyExecutionRecoveryEligibilityCodes": ["recoverable", "already_applied", "inactive", "revoked", "changed_requisition", "concurrently_owned", "lineage_unavailable", "retention_expired"],
22
+ "applyHandoffReconciliationClassifications": ["detected", "user_confirmed", "unresolved", "contradictory"],
23
+ "applyUploadStages": ["semantic_control_discovered", "file_chooser_armed", "file_chooser_opened", "set_files_succeeded", "user_facing_filename_committed", "parser_fields_rechecked"],
24
+ "applyUploadFailureCodes": ["upload_capability_unavailable", "upload_stage_missing", "upload_stage_out_of_order", "upload_stage_duplicate", "control_ambiguous", "chooser_timeout", "chooser_open_without_commit", "file_missing", "file_expired", "set_files_failed", "filename_uncommitted", "unexpected_file_navigation", "parser_unsettled", "parser_field_regression"]
20
25
  },
21
26
  "tools": {
22
27
  "trackly_get_apply_queue": "{limit:z.number().int().min(1).max(100).optional(),cursor:z.string().min(1).max(2048).optional()}",
@@ -26,6 +31,10 @@
26
31
  "trackly_start_apply_execution": "{mode:z.literal('complete_next_n_accessible'),target:z.number().int().min(1).max(APPLY_EXECUTION_MAX_TARGET),idempotencyKey:z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY)}",
27
32
  "trackly_get_active_apply_execution": "{}",
28
33
  "trackly_get_apply_execution": "{executionId:z.number().int().min(1)}",
34
+ "trackly_list_recoverable_apply_executions": "{}",
35
+ "trackly_recover_exact_apply_members": "{sourceExecutionId:z.number().int().min(1),sourceSnapshotHash:z.string().regex(/^[a-f0-9]{64}$/),candidateIds:z.array(z.number().int().min(1)).min(1).max(APPLY_EXECUTION_MAX_TARGET).refine((values)=>newSet(values).size===values.length,{message:'candidateIdsmustbeunique'}),explicitExactSetConfirmation:z.literal(true),idempotencyKey:z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY)}",
36
+ "trackly_list_apply_review_handoffs": "{executionId:z.number().int().min(1)}",
37
+ "trackly_claim_apply_review_handoff": "{handoffId:z.number().int().min(1),members:z.array(z.object({memberId:z.number().int().min(1),classification:z.enum(APPLY_HANDOFF_RECONCILIATION_CLASSIFICATION_CODES)}).strict()).min(1).max(APPLY_EXECUTION_MAX_TARGET).refine((values)=>newSet(values.map(({memberId})=>memberId)).size===values.length,{message:'membersmustcontainuniquememberIdvalues'}),idempotencyKey:z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY)}",
29
38
  "trackly_get_apply_execution_snapshot": "{executionId:z.number().int().min(1),memberIds:z.array(z.number().int().min(1)).min(1).max(APPLY_EXECUTION_MAX_TARGET),profileKeys:z.array(z.string().min(1).max(200)).max(100).optional(),browserSurface:z.enum(APPLY_BROWSER_SURFACES)}",
30
39
  "trackly_resume_parked_apply_member": "{executionId:z.number().int().min(1),memberId:z.number().int().min(1),expectedRevision:z.number().int().min(1),browserSurface:z.enum(APPLY_BROWSER_SURFACES),explicitUserResume:z.literal(true),idempotencyKey:z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY)}",
31
40
  "trackly_approve_apply_execution_resume": "{executionId:z.number().int().min(1),expectedRevision:z.number().int().min(1),originalSnapshotHash:z.string().regex(/^[a-f0-9]{64}$/),profileRevision:z.number().int().min(1),resumeId:z.number().int().min(1),resumeSha256:z.string().regex(/^[a-f0-9]{64}$/),resumeFilename:z.string().min(1).max(255),resumeSizeBytes:z.number().int().min(1),expiresAt:z.string().datetime(),idempotencyKey:z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY)}",
@@ -48,11 +57,13 @@
48
57
  "trackly_get_apply_protocol": "{}",
49
58
  "trackly_report_apply_observation": "{runId:z.number().int().min(1),batchId:z.number().int().min(1).optional(),memberId:z.number().int().min(1).optional(),inspectionEpoch:z.number().int().min(0).optional(),leaseToken:z.string().min(1).max(1024).optional(),provider:z.string().regex(SAFE_OBSERVATION_CODE),fieldLabel:z.string().min(1).max(1000),observationType:z.string().regex(SAFE_OBSERVATION_CODE),resolutionCode:z.string().regex(SAFE_OBSERVATION_CODE).optional(),metadata:z.object({controlType:z.string().regex(SAFE_OBSERVATION_CODE).optional(),required:z.boolean().optional(),errorCode:z.string().regex(SAFE_OBSERVATION_CODE).optional(),committed:z.boolean(),scenarioCode:z.enum(APPLY_SCENARIO_CODES),browserSurface:z.enum(APPLY_BROWSER_SURFACES),browserBindingHash:z.string().regex(/^[a-f0-9]{64}$/).optional(),resumedAfterHandoff:z.boolean().optional()})}",
50
59
  "trackly_report_apply_observations": "{observations:z.array(z.object({runId:z.number().int().min(1),batchId:z.number().int().min(1),memberId:z.number().int().min(1),inspectionEpoch:z.number().int().min(0),leaseToken:z.string().min(1).max(1024),provider:z.string().regex(SAFE_OBSERVATION_CODE),fieldLabel:z.string().min(1).max(1000),observationType:z.string().regex(SAFE_OBSERVATION_CODE),resolutionCode:z.string().regex(SAFE_OBSERVATION_CODE).optional(),metadata:z.object({controlType:z.string().regex(SAFE_OBSERVATION_CODE).optional(),required:z.boolean().optional(),errorCode:z.string().regex(SAFE_OBSERVATION_CODE).optional(),committed:z.boolean(),scenarioCode:z.enum(APPLY_SCENARIO_CODES),browserSurface:z.enum(APPLY_BROWSER_SURFACES),browserBindingHash:z.string().regex(/^[a-f0-9]{64}$/).optional(),resumedAfterHandoff:z.boolean().optional()})})).min(1).max(APPLY_BATCH_MAX_BULK_MUTATIONS)}",
51
- "trackly_record_application_outcome": "{runId:z.number().int().min(1),batchId:z.number().int().min(1).optional(),memberId:z.number().int().min(1).optional(),inspectionEpoch:z.number().int().min(0).optional(),leaseToken:z.string().min(1).max(1024).optional(),outcome:z.enum(['review_ready','submitted','failed','blocked']),confirmation:z.enum(['user_confirmation','success_page']).optional()}",
52
- "trackly_record_application_outcomes": "{outcomes:z.array(z.object({runId:z.number().int().min(1),batchId:z.number().int().min(1),memberId:z.number().int().min(1),inspectionEpoch:z.number().int().min(0),leaseToken:z.string().min(1).max(1024),outcome:z.enum(['review_ready','submitted','failed','blocked']),confirmation:z.enum(['user_confirmation','success_page']).optional()})).min(1).max(APPLY_BATCH_MAX_BULK_MUTATIONS)}",
60
+ "trackly_record_application_outcome": "{runId:z.number().int().min(1),idempotencyKey:z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY),batchId:z.number().int().min(1).optional(),memberId:z.number().int().min(1).optional(),inspectionEpoch:z.number().int().min(0).optional(),leaseToken:z.string().min(1).max(1024).optional(),outcome:z.enum(['review_ready','submitted','failed','blocked']),confirmation:z.enum(['user_confirmation','success_page']).optional()}",
61
+ "trackly_record_application_outcomes": "{idempotencyKey:z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY),outcomes:z.array(z.object({runId:z.number().int().min(1),batchId:z.number().int().min(1),memberId:z.number().int().min(1),inspectionEpoch:z.number().int().min(0),leaseToken:z.string().min(1).max(1024),outcome:z.enum(['review_ready','submitted','failed','blocked']),confirmation:z.enum(['user_confirmation','success_page']).optional()})).min(1).max(APPLY_BATCH_MAX_BULK_MUTATIONS).refine((values)=>newSet(values.map(({runId})=>runId)).size===values.length,{message:'outcomesmustcontainuniquerunIdvalues'})}",
53
62
  "trackly_prepare_resume": "{runId:z.number().int().min(1),browserSurface:z.enum(APPLY_BROWSER_SURFACES),browserBindingHash:z.string().regex(/^[a-f0-9]{64}$/)}",
54
63
  "trackly_lint_application_text": "{text:z.string().max(20000),emDashPolicy:z.enum(['forbid','allow_if_voice_sample','allow']).optional(),voiceSampleAllowsEmDash:z.boolean().optional(),prohibitedPhrases:z.array(z.string().min(1).max(200)).max(50).optional(),minLength:z.number().int().min(0).max(20000).optional(),maxLength:z.number().int().min(0).max(20000).optional(),claims:z.array(z.object({claimFingerprint:z.string().regex(/^[a-f0-9]{64}$/),evidenceRefs:z.array(z.string().regex(SAFE_OBSERVATION_CODE)).max(20)}).strict()).max(100),claimsComplete:z.literal(true)}",
55
64
  "trackly_diagnose_local_path": "{exactPath:z.string().min(1).max(4096),originalErrno:z.string().regex(ERRNO_PATTERN).optional()}",
65
+ "trackly_validate_apply_tab_keep_set": "{expectedTabIds:z.array(opaqueTabIdSchema).min(1).max(100),keepTabIds:z.array(opaqueTabIdSchema).max(100),controllerInventory:z.object({complete:z.boolean(),tabIds:z.array(opaqueTabIdSchema).max(1000)}).strict(),userInventory:z.object({complete:z.boolean(),tabIds:z.array(opaqueTabIdSchema).max(1000)}).strict()}",
66
+ "trackly_validate_apply_resume_upload": "{capabilities:z.object({semanticControlDiscovery:z.boolean(),chooserArming:z.boolean(),fileAttachment:z.boolean(),committedFilenameInspection:z.boolean(),parserFieldRecheck:z.boolean()}).strict(),events:z.array(z.object({stage:z.enum(APPLY_UPLOAD_STAGES),outcome:z.enum(['passed','failed']),failureCode:z.enum(APPLY_UPLOAD_FAILURE_CODES).optional()}).strict()).max(APPLY_UPLOAD_STAGES.length)}",
56
67
  "trackly_verify_prepared_resume": {
57
68
  "local": "{runId:z.number().int().min(1),resumeId:z.number().int().min(1),confirmationId:z.string().min(1).max(200),exactLocalPath:z.string().min(1).max(4096),sha256:z.string().regex(/^[a-f0-9]{64}$/i),sizeBytes:z.number().int().min(1),expiresAt:z.string().datetime()}",
58
69
  "hosted": "{runId:z.number().int().min(1),confirmationId:z.string().min(1).max(200)}"
@@ -106,6 +106,10 @@ API-key creation attempts.
106
106
  - **trackly_start_apply_execution** — Start a server-owned `complete_next_n_accessible` execution with a target from 1–20, then consume the returned authoritative `progress` and `nextAction` before doing browser work.
107
107
  - **trackly_get_active_apply_execution** — Recover the active execution before legacy batch recovery.
108
108
  - **trackly_get_apply_execution** — Read an execution's latest current-wave identity and authoritative aggregate progress funnel.
109
+ - **trackly_list_recoverable_apply_executions** — List a bounded, value-free menu of exact recovery candidates after local context loss.
110
+ - **trackly_recover_exact_apply_members** — Recover only the explicitly confirmed candidates from one retained source snapshot, with no substitutions.
111
+ - **trackly_list_apply_review_handoffs** — Rediscover active, nonexpired review-handoff receipts for one execution without browser or profile values.
112
+ - **trackly_claim_apply_review_handoff** — Claim one exact handoff receipt and classify every member before grouped submission reconciliation.
109
113
  - **trackly_get_apply_execution_snapshot**: Fetch a compact bounded projection of current members, requested profile keys, mutability, allowed operations, milestones, lease timing, and the authoritative funnel.
110
114
  - **trackly_resume_parked_apply_member**: Resume one parked member only after explicit user instruction; a fresh non-mutating access probe remains required before form mutation.
111
115
  - **trackly_approve_apply_execution_resume**: Approve one exact resume identity for an execution's unchanged original snapshot while preserving immediate per-run local verification before upload.
@@ -134,9 +138,16 @@ API-key creation attempts.
134
138
  - **trackly_verify_prepared_resume** — Local MCP only: immediately before attachment, recompute the user-confirmed resume hash and size, validate the exact path/run/expiration, and lock the file read-only. Any mismatch requires a fresh preview and confirmation.
135
139
  - **trackly_lint_application_text**: Local MCP only: require an explicitly complete claim-reference packet, then return a draft hash, length, and stable value-free writing violations without sending or echoing application text to Trackly.
136
140
  - **trackly_diagnose_local_path**: Local MCP only: measure the exact implicated path's filesystem, inode, quota observability, and write-probe result without deleting user files or claiming a global disk cause.
141
+ - **trackly_validate_apply_tab_keep_set**: Local MCP only: fail closed unless a caller-supplied nonempty expected tab set exactly matches the keep set and is covered by complete controller and user inventories. This pure helper allows unrelated tabs, performs no browser action, and never sends tab IDs to Trackly.
142
+ - **trackly_validate_apply_resume_upload**: Local MCP only: validate the browser adapter's capabilities and ordered, value-free attachment proof stages without opening a chooser, reading a path, controlling a browser, or sending form data to Trackly.
137
143
 
138
144
  Apply contract v3 intentionally gives this verifier different local and hosted schemas: local MCP receives the full proof needed to inspect the private file, while hosted MCP accepts only run and confirmation identifiers and returns the manual/local-agent requirement. Local paths are never sent remotely. Resume fingerprints are sent only to authenticated Trackly resume approval and truth-certification endpoints, never observations or employer forms. Version 3.1 also records universal value-free evidence for critical-contact integrity and the manual-submit boundary. Version 3.2 authorizes the exact stored HTTPS origin for jobs Trackly ingested from employer careers sources, without granting redirect, iframe, or hostname-suffix privileges. Version 3.3.1 adds active-batch recovery, epoch-bound observations/outcomes, and truth certification for forms with no resume control. Version 3.4 adds server-owned accessible executions above immutable child batches, typed access dispositions, and an authoritative target-completion funnel.
139
145
 
146
+ Version 3.7 adds public exact-member recovery and scoped review-handoff claims,
147
+ plus local tab-finalizer and resume-upload proof validators. Recovery candidates
148
+ are bounded and value-free. The local helpers perform no browser actions and
149
+ send no tab IDs, filenames, paths, page text, or form values to Trackly.
150
+
140
151
  ### Maintenance behavior
141
152
 
142
153
  REST, refresh, download, CLI, and local/hosted MCP surfaces use canonical `code: "maintenance_mode"`; older `planned_maintenance` responses are accepted only as a compatibility alias. Structured errors retain HTTP and service status, retry time, estimated return, and request ID without clearing valid credentials. Maintenance is resumable rather than retryable: wait for the advertised window, refetch the Apply protocol and profile, then resume the existing `agent_browser` run. Never create a duplicate run or click Submit.
package/lib/agent.js CHANGED
@@ -8,13 +8,13 @@ const { spawnSync } = require('node:child_process');
8
8
  const { apiRequest, downloadFile, getConfigPaths, hasAuth, maintenanceOutput } = require('./client');
9
9
  const { version: PACKAGE_VERSION } = require('../package.json');
10
10
  const { contractVersion: MCP_CONTRACT_VERSION } = require('../contracts/trackly-apply-tools.json');
11
- const SKILL_VERSION = '4.4.2';
11
+ const SKILL_VERSION = '4.5.0';
12
12
  const CLI_USER_AGENT = `trackly-cli/${PACKAGE_VERSION}`;
13
13
  const MCP_USER_AGENT = `trackly-mcp/${PACKAGE_VERSION}`;
14
14
 
15
15
  const SKILL_NAME = 'trackly-apply';
16
16
  const SKILL_MAJOR = Number(SKILL_VERSION.split('.')[0]);
17
- const MIN_APPLY_PROTOCOL_VERSION = '3.5.0';
17
+ const MIN_APPLY_PROTOCOL_VERSION = '3.6.0';
18
18
  const CACHE_TTL_MS = 2 * 60 * 60 * 1000;
19
19
  const MANAGED_FILE = '.trackly-managed.json';
20
20
  const SKILL_MANIFEST_VERSION = 1;
@@ -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',
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "trackly-cli",
3
- "version": "0.13.4",
3
+ "version": "0.14.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "trackly-cli",
9
- "version": "0.13.4",
9
+ "version": "0.14.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@modelcontextprotocol/sdk": "1.30.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackly-cli",
3
- "version": "0.13.4",
3
+ "version": "0.14.0",
4
4
  "mcpName": "io.github.trackly-app/trackly",
5
5
  "description": "AI job search CLI + hosted MCP server with OAuth. 170K+ jobs, 3,800+ companies, 40+ ATS. Works with Claude, ChatGPT, Cursor, Windsurf, Codex via hosted streamable-http or local stdio.",
6
6
  "bin": {
package/server.json CHANGED
@@ -17,12 +17,12 @@
17
17
  ]
18
18
  }
19
19
  ],
20
- "version": "0.13.4",
20
+ "version": "0.14.0",
21
21
  "packages": [
22
22
  {
23
23
  "registryType": "npm",
24
24
  "identifier": "trackly-cli",
25
- "version": "0.13.4",
25
+ "version": "0.14.0",
26
26
  "runtimeHint": "npx",
27
27
  "runtimeArguments": [
28
28
  {
@@ -27,7 +27,7 @@ references define the server-frozen batch, grouped-question, tab-reconciliation,
27
27
  and missing-surface recovery contract; they are mandatory even for a one-job batch.
28
28
  Read [references/legal-clarifications.md](references/legal-clarifications.md) when a form asks for a legal, regulatory, consent, or employer-relationship decision.
29
29
 
30
- 1. Call `trackly_get_apply_protocol`. Skill 4.4.2 requires protocol 3.5.0 or newer for new reliability work. Protocol 3.2 remains valid only for an already-active explicit legacy single run; an already-active explicit 3.2 single run may finish through its legacy path. Preserve the protocol's documented recovery paths for already-active older work. Require `compatibleSkillMajor: 4` and `compatibleSkillMinimumVersion` no newer than this installed skill. Reject an older or incompatible version for new work and report that the backend must finish updating or `trackly agent setup` must update the skill. Protocol 3.2 added exact-origin trust for jobs Trackly ingested directly from employer careers sources; do not recreate the retired ownership-timestamp gate in the client.
30
+ 1. Call `trackly_get_apply_protocol`. Skill 4.5.0 requires protocol 3.6.0 or newer for new reliability work. Protocol 3.2 remains valid only for an already-active explicit legacy single run; an already-active explicit 3.2 single run may finish through its legacy path. Preserve the protocol's documented recovery paths for already-active older work. Require `compatibleSkillMajor: 4` and `compatibleSkillMinimumVersion` no newer than this installed skill. Reject an older or incompatible version for new work and report that the backend must finish updating or `trackly agent setup` must update the skill. Protocol 3.2 added exact-origin trust for jobs Trackly ingested directly from employer careers sources; do not recreate the retired ownership-timestamp gate in the client.
31
31
  2. Call `trackly_get_profile_onboarding` or fetch both the profile schema and application profile. When present, render `schema.screens` in ascending `order` as one grouped question packet per screen. Within a screen, preserve category `order` and then field `order`; use each field's user-facing `rationale` when the user asks why it is needed. Ask only unknown or confirmation-needed fields and honor `consistencyRules` before submitting profile changes, resolving contradictions with the user instead of guessing. When `schema.screens` is absent on a legacy backend, fall back to the existing category-based onboarding behavior. Treat `completeness.percent` as required onboarding readiness only. Use `coverage.missingReusableKeys` to explain reusable optional gaps, while `coverage.contextualKeys` are intentionally asked only on the relevant employer form. Do not claim that 100% required completeness answers every possible application question.
32
32
  3. Save answers with `trackly_update_application_profile`:
33
33
  - Use `answered`, `intentionally_blank`, `declined`, or `unknown` exactly.
@@ -44,7 +44,9 @@ Read [references/legal-clarifications.md](references/legal-clarifications.md) wh
44
44
  - Use `writing.em_dash_policy` and `writing.optional_question_policy` when the fetched schema exposes them. An unanswered em-dash policy fails closed to `forbid` for form writing.
45
45
  - Infer a direct prior-employer answer of No only when the profile explicitly marks history complete, the target and backend-confirmed aliases are absent, and the question is limited to direct employment. Ask for subsidiary, affiliate, acquisition, contractor, or otherwise ambiguous relationships.
46
46
  4. Require the one-time profile confirmation, complete education entries, and default-resume metadata before browser work. Do not prepare or upload the resume when the form has no attachment control.
47
- 5. For every protocol 3.4 or newer request, call `trackly_get_active_apply_execution` before legacy recovery. Resume execution work only when the response says `active: true`. A response with `preserved: true` and `active: false` exposes a stopped, closed, or otherwise terminal execution only for read-only reconciliation. Never fetch its compact snapshot, continue its wave, mutate its members, or interpret historical `currentlyFilling` or reservation counts as current work; require `progress.nextAction: none`. Only with protocol 3.5 or newer and an enabled compact-snapshot capability may an active recovery or new execution call `trackly_get_apply_execution_snapshot`, using only the current member IDs, profile keys needed by the visible forms, and actual browser surface. An already-active protocol 3.4 execution is read-only legacy recovery: use only its published get or stop operations, never call the 3.5-only snapshot, resume, approval, advance, or disposition tools, and never mutate its browser forms. For protocol 3.5 or newer, treat snapshot `mutable` and `allowedOperations` values as authoritative. Never mutate or reopen authentication, account-creation, OTP, pre-form-CAPTCHA, or manual-only members. Only an explicit user request may call `trackly_resume_parked_apply_member`; the returned member still requires a fresh non-mutating probe before private data or form mutation. Resume unresolved waves in ascending order only for `active: true` and obey the server `nextAction` and funnel. Never reconstruct progress or choose replacements locally. Count only `durablyReviewReady` and `submitted`; let the backend own reservations, capacity, attempted-job deduplication, immutable waves, and advancement. Continue until `targetReached`, `queueExhausted`, a blocking `nextAction`, or the user stops.
47
+ 5. For every protocol 3.4 or newer request, call `trackly_get_active_apply_execution` before legacy recovery. Resume execution work only when the response says `active: true`. A response with `preserved: true` and `active: false` exposes a stopped, closed, or otherwise terminal execution only for read-only reconciliation. Never fetch its compact snapshot, continue its wave, mutate its members, or interpret historical `currentlyFilling` or reservation counts as current work; require `progress.nextAction: none`. Only with protocol 3.5 or newer and an enabled compact-snapshot capability may an active recovery or new execution call `trackly_get_apply_execution_snapshot`, using only the current member IDs, profile keys needed by the visible forms, and actual browser surface. An already-active protocol 3.4 execution is read-only legacy recovery: use only its published get or stop operations, never call the 3.5-only snapshot, resume, approval, advance, or disposition tools, and never mutate its browser forms. For protocol 3.5 or newer, treat snapshot `mutable` and `allowedOperations` values as authoritative. Never mutate or reopen authentication, account-creation, OTP, pre-form-CAPTCHA, or manual-only members. Only an explicit user request may call `trackly_resume_parked_apply_member`; the returned member still requires a fresh non-mutating probe before private data or form mutation. Resume unresolved waves in ascending order only for `active: true` and obey the server `nextAction` and funnel. Never reconstruct progress or choose replacements locally. Treat backend `achievementCount`, target-capped `completed`, `targetReached`, and `nextAction` as authoritative. `durablyReviewReady` and `submitted` remain current operator projections and must never be added together by the client to reconstruct cumulative completion. Let the backend own reservations, capacity, attempted-job deduplication, immutable waves, and advancement. Continue until `targetReached`, `queueExhausted`, a blocking `nextAction`, or the user stops.
48
+ - After full local context loss with no active execution, call `trackly_list_recoverable_apply_executions`. For every distinct returned `jobId`, call `trackly_get_job`, bind that Trackly-owned job record to the returned `candidateId`, and show the user its company, role, requisition identity when available, and source-execution identity; do not identify candidates from chat, tab order, search results, or page copy. A missing or mismatched Trackly job record blocks confirmation. Require explicit confirmation of one exact candidate set, then call `trackly_recover_exact_apply_members` with that source execution, snapshot hash, and only those candidate IDs. Verify `assertedCandidateIds`, `eligibleCandidateIds`, and the eligibility candidate IDs each contain exactly the confirmed set with no duplicates. Never add substitutes, newly saved jobs, or merely similar roles.
49
+ - Exact recovery restores server lineage only. Treat tab restored, form state restored, and mutation authority restored as three independent receipts. Bind every recovered surface with a fresh recovery binding and inspection epoch, revalidate the full form, and preserve every user-edited or unknown non-empty value before any write. Earlier-epoch browser, field, upload, integrity, and submission evidence cannot authorize the recovered form.
48
50
  - Every compact snapshot request must contain a non-empty list of only the current member IDs. Never request an empty or inferred all-members projection.
49
51
  - Recover every entry in `execution.unresolvedWaves` in ascending `waveOrder`; `currentWave` identifies only the latest scheduling wave, not the complete recovery set.
50
52
  - Immediately consume the start response's authoritative `progress` and `nextAction`; do not issue a speculative advance first.
@@ -69,7 +71,7 @@ Read [references/legal-clarifications.md](references/legal-clarifications.md) wh
69
71
  - For each fixed batch member, preserve an explicit job ID -> application run ID -> browser tab mapping. Complete the full start -> conditional resume preparation/confirmation/verification when an upload control exists -> form completion -> `review_ready` lifecycle for every member. A review-ready run does not block the next member. Never submit any of them.
70
72
  6. When the selected job or current fixed batch member has no `runId`, call `trackly_start_apply_run` with the complete `batchId`, `memberId`, `expectedMemberVersion`, `expectedInspectionEpoch`, and `leaseToken` returned by Trackly. This is the sanctioned idempotent lookup for a recovered member whose `runId` is absent; call it with the exact recovered binding and Trackly will reuse the bound active run. When the member already has a `runId`, do not call `trackly_start_apply_run` in this or any subsequent start step: reuse that exact run and bind or recover its browser surface through `trackly_bind_apply_surface` as required. Never create a replacement run because browser control was interrupted.
71
73
  - If a bound start returns a transport failure, a non-access HTTP 5xx response, or an error explicitly marked `retryable: true`, preserve the frozen member and browser state. Refetch the active batch, renew its lease, and retry the same complete binding once. Classify the retry response independently with these same rules: route canonical `maintenance_mode` or legacy `planned_maintenance` directly through **Resume after maintenance**; surface controlled-access/request errors marked `retryable: false` and every other HTTP 4xx response unchanged; only a second transport failure, non-access HTTP 5xx response, or explicitly retryable error becomes `backend_run_start_unavailable`. Route maintenance on either attempt without consuming or relabeling the retry, and never relabel a permanent retry response as an outage. For `backend_run_start_unavailable`, continue other members and report a Trackly control-plane failure. Do not call `trackly_checkpoint_apply_batch` for this condition because start failure has not produced the required run ID. The unchanged frozen member is the durable resume point. Never switch that frozen member to an unbound legacy run or blame the employer form.
72
- - Require `run.protocolVersion` to be 3.1.0 or newer and to share protocol major 3 with the fetched protocol. A new reliability execution member requires 3.5.0 or newer; already-active older work may use only the recovery path published by its fetched protocol. Never continue a pre-evidence 3.0.x run under skill 4.4.2. Preserve that run instead of starting a replacement, record it `blocked` with a value-free protocol-upgrade reason when possible, and tell the user the saved job can be retried only after the stale run is cleared through Trackly's supported lifecycle. Stop and refetch the protocol and active execution or batch if support level, execution mode, provider, required scenarios, authorized origin policy, member version, or inspection epoch changes after run creation.
74
+ - Require `run.protocolVersion` to be 3.1.0 or newer and to share protocol major 3 with the fetched protocol. A new reliability execution member requires 3.6.0 or newer; already-active older work may use only the recovery path published by its fetched protocol. Never continue a pre-evidence 3.0.x run under skill 4.5.0. Preserve that run instead of starting a replacement, record it `blocked` with a value-free protocol-upgrade reason when possible, and tell the user the saved job can be retried only after the stale run is cleared through Trackly's supported lifecycle. Stop and refetch the protocol and active execution or batch if support level, execution mode, provider, required scenarios, authorized origin policy, member version, or inspection epoch changes after run creation.
73
75
  - In guided mode, inspect the page before preparing any resume bytes. Confirm the employer, role, HTTPS origin, reachable review path, semantic controls, whether an attachment control exists, and absence of a credential, verification, CAPTCHA, or submit-only wall. A missing file input is not itself a blocker; skip the resume path when the application has no attachment control. Any other failed precondition is an execution blocker, not permission to improvise.
74
76
  7. Pass the browser readiness gate before preparing resume bytes:
75
77
  - Use semantic browser control through Codex in-app browser controls, Chrome MCP/extension browser control, or Claude in Chrome.
@@ -79,7 +81,7 @@ Read [references/legal-clarifications.md](references/legal-clarifications.md) wh
79
81
  - After a handoff, context resume, or browser-control interruption, reclaim and re-verify every mapped tab before continuing.
80
82
  - Report `observationType: browser_ready` for the current run with the exact current `batchId`, `memberId`, and `inspectionEpoch`, plus `scenarioCode: browser_reclaim`, the allowed `browserSurface`, `committed: true`, and that `browserBindingHash`. Do not call `trackly_prepare_resume` until this same-run attestation succeeds. When preparation is permitted, bind it to the exact run ID, browser surface, and browser binding hash. Accessibility may provide an independent verification signal, but coordinate-only clicking is forbidden for form completion.
81
83
  - If the semantic browser bridge is unavailable, preserve every existing run and tab mapping, record the blocker when possible, and stop before any upload or form mutation.
82
- 8. If and only if the application offers or requires a resume attachment, read [references/browser-upload.md](references/browser-upload.md). For an accessible execution, call `trackly_approve_apply_execution_resume` once after the user approves the exact unchanged resume and original snapshot. Reuse only that content approval within the unchanged execution. For every run, still call `trackly_prepare_resume` with the exact run ID, browser surface, and binding hash and immediately verify the exact path, hash, size, and expiration before upload. If hosted MCP reports local preparation unavailable, use local Trackly MCP or manual upload. If no attachment control exists, skip steps 8–11 and do not report `resume_upload` as exercised.
84
+ 8. If and only if the application offers or requires a resume attachment, read [references/browser-upload.md](references/browser-upload.md). For an accessible execution, call `trackly_approve_apply_execution_resume` once after the user approves the exact unchanged resume and original snapshot. Reuse only that content approval within the unchanged execution. For every run, still call `trackly_prepare_resume` with the exact run ID, browser surface, and binding hash and immediately verify the exact path, hash, size, and expiration before upload. Require the browser upload capability gate and the local `trackly_validate_apply_resume_upload` stage proof before claiming attachment success. If hosted MCP reports local preparation unavailable, use local Trackly MCP or manual upload. If no attachment control exists, skip steps 8–11 and do not report `resume_upload` as exercised.
83
85
  9. Preserve the user’s filename returned by `trackly_prepare_resume`. Internal cache identifiers belong only in private parent directories and must never appear in the employer-facing upload filename.
84
86
  10. Before any upload, let the user inspect the exact prepared file returned by `trackly_prepare_resume`:
85
87
  - Prefer an inline visual preview. Otherwise open the exact local file in Quick Look or Preview.app.
@@ -138,6 +140,12 @@ Every required scenario and both universal evidence scenarios must have correspo
138
140
 
139
141
  ## Review handoff
140
142
 
143
+ After context loss, call `trackly_list_apply_review_handoffs` for the execution
144
+ before interpreting any grouped submission statement. Use the named receipt or
145
+ the sole active receipt returned by Trackly; if multiple receipts remain, ask
146
+ the user to identify the intended group. Never guess a receipt from tabs or
147
+ chat history.
148
+
141
149
  After every conditional field is settled and all other review gates pass, bulk checkpoint every ready member with `review/manual_submit`, the current unchanged inspection epoch in both epoch fields, and any `resolvedActionIds`. Use the returned member versions and epochs; this checkpoint resolves the listed human actions and moves the durable member lifecycle to `review_ready` without invalidating current-epoch browser evidence or an unchanged resume-content approval. Do not wait for `needs_input` members: whenever at least one member is durably `review_ready`, ask for one explicit final truthfulness confirmation for the exact complete subset that is currently `review_ready`. If any form in that certified subset used a resume attachment, certify with `resumeDependency: approved` and the exact approved resume identity. If no form in the subset exposes a resume control, certify with `resumeDependency: not_applicable` and omit the resume ID and hash; do not invent an upload requirement. Then call `trackly_record_application_outcomes` once with every member in that certified subset using its exact current `runId`, `batchId`, `memberId`, `inspectionEpoch`, and `leaseToken`; every item must use the literal `outcome: review_ready`. Verify every recorded run returns `awaiting_manual_submit` before showing the review handoff. A member conflict does not invalidate successful siblings: preserve its tab, refresh that member once, and surface the returned stable conflict code instead of blindly replaying a different transition. A member that becomes ready later requires a fresh certification for the then-current complete `review_ready` subset; never reuse an older certification for it. Use singular `trackly_record_application_outcome` only for a legacy single run or an isolated post-review transition. Use the normal review block in [references/review-handoff.md](references/review-handoff.md) only after documented visibility proof for the exact review tab succeeds. If visibility is unverified, preserve the tab, use the separate visibility-unverified block, and do not tell the user to submit until the tab is reclaimed and visibly proven. For a single run, keep the browser on the final review state and stop. For a frozen batch, preserve every review-ready tab, hand off each certified review-ready subset without waiting on unrelated human actions, and continue the same lifecycle for the remaining mapped members. Members with unresolved actions stay frozen and resumable. Never submit any member. End with job ID -> run ID -> browser tab -> ATS -> status plus the actual scenario coverage for each run.
142
150
 
143
151
  After the user submits manually:
@@ -1,6 +1,11 @@
1
1
  # Batch orchestration
2
2
 
3
3
  Protocol 3.4 adds a server-owned Apply execution above immutable child batches.
4
+ Protocol 3.6 adds exact-member recovery after local context loss and scoped
5
+ review-handoff reconciliation.
6
+ After context loss, rediscover active handoff receipts with
7
+ `trackly_list_apply_review_handoffs`; never reconstruct or guess a handoff ID
8
+ from chat history, tab order, or application similarity.
4
9
  The execution is the target-completion ledger; each child batch remains the
5
10
  auditable browser-work unit. Protocol 3.3 fixed batches and protocol 3.2 single
6
11
  runs remain recovery-only compatibility paths.
@@ -57,6 +62,35 @@ its top-level `response.batchId`. Never guess across those response shapes. If
57
62
  the applicable field is null or `unresolvedWaves` is empty, follow
58
63
  `response.progress.nextAction` rather than guessing a prior batch.
59
64
 
65
+ ## Exact recovery after local context loss
66
+
67
+ When no active execution exists but unfinished lineage may still be retained,
68
+ call `trackly_list_recoverable_apply_executions`. The returned source snapshot
69
+ and candidates are the complete bounded recovery menu. Resolve every distinct
70
+ returned `jobId` through `trackly_get_job`, bind that Trackly-owned record to
71
+ its `candidateId`, and present its company, role, requisition identity when
72
+ available, and source execution to the user. A missing or mismatched Trackly
73
+ job record blocks confirmation. Require explicit confirmation of the exact
74
+ candidate set. Never infer the set from browser tabs, conversation memory,
75
+ search results, page copy, employer similarity, or queue position.
76
+
77
+ Call `trackly_recover_exact_apply_members` with one source execution, its exact
78
+ snapshot hash, the confirmed candidate IDs, and a fresh idempotency key. The
79
+ response may never replace candidates. Verify that `assertedCandidateIds`,
80
+ `eligibleCandidateIds`, and the eligibility candidate IDs each contain exactly
81
+ the user-confirmed set with no duplicates before browser work.
82
+
83
+ Recovery has three independent proof dimensions:
84
+
85
+ 1. the exact browser tab was restored;
86
+ 2. the employer form state was restored or safely reconstructed; and
87
+ 3. Trackly granted current mutation authority.
88
+
89
+ Never let one imply another. Rebind each recovered surface, accept the new
90
+ inspection epoch, inventory the form, preserve every user-edited and unknown
91
+ non-empty field, and rerun required integrity evidence. All prior-epoch
92
+ browser, upload, field, review, and submission evidence is stale.
93
+
60
94
  ## Replace an obsolete fixed batch
61
95
 
62
96
  An active legacy fixed batch and a new accessible execution are mutually
@@ -92,6 +92,13 @@ Before resuming, handing off, or closing a tab:
92
92
  5. Reclaim a matching tab only after employer, role, requisition, HTTPS origin,
93
93
  ATS tenant when applicable, run ID, and browser binding all revalidate.
94
94
 
95
+ Immediately before a session-finalizer call, pass the complete expected and
96
+ keep sets plus complete controller and user inventories through
97
+ `trackly_validate_apply_tab_keep_set`. Continue only when it returns
98
+ `safeToFinalize: true`. This helper is a local validator, not a browser
99
+ controller: it does not discover, focus, close, hand off, or finalize tabs.
100
+ Unrelated tabs may remain in the inventories. Raw tab IDs stay local.
101
+
95
102
  For every initial tab and every recovery, call `trackly_bind_apply_surface`
96
103
  with the existing frozen member and run. Use `initial_binding` for the first
97
104
  surface and `recovery_binding` after a missing tab or handoff. The returned URL
@@ -253,6 +260,13 @@ When an incomplete member's tab is missing:
253
260
  revalidate the exact requisition before recording any outcome and never
254
261
  activate Submit again.
255
262
 
263
+ When protocol 3.6 exact-member recovery created the current execution, prove
264
+ three facts separately in the operator receipt: whether the tab was restored,
265
+ whether the employer form state was restored or reconstructed, and whether
266
+ current mutation authority was reacquired. A recovered tab alone never grants
267
+ write authority. A recovered server member alone never proves the draft or tab
268
+ survived.
269
+
256
270
  If the recovered origin or identity differs, semantic controls are
257
271
  unobservable, or access is blocked by credentials or a pre-form challenge,
258
272
  stop private-data mutation and create the appropriate human action. Continue
@@ -1,11 +1,43 @@
1
- # Browser upload contract
1
+ # Browser upload
2
2
 
3
- Use semantic browser controls only. Coordinates, text injection, and a visible filename without committed input state are not upload proof.
3
+ Resume approval proves the content the user authorized. Attachment proof proves
4
+ that the browser committed that exact file. Neither fact substitutes for the
5
+ other.
4
6
 
5
- 1. Discover the exact attachment control semantically.
6
- 2. Open its file chooser through the browser adapter.
7
- 3. Use the adapter's advertised `setFiles` capability with the immediately verified local path.
8
- 4. Verify the employer-facing filename exactly matches the prepared user-facing filename and contains no cache identifier.
9
- 5. Recheck contact and parser-modified fields after the upload settles.
7
+ ## Capability gate
10
8
 
11
- Require all five proofs: `semantic_control_discovered`, `file_chooser_opened`, `set_files_succeeded`, `user_facing_filename_committed`, and `parser_fields_rechecked`. Fail closed when the active browser surface cannot prove any step. Never substitute an undocumented browser primitive.
9
+ Before opening a file chooser, require the active browser adapter to advertise
10
+ all five capabilities: semantic control discovery, chooser arming, file
11
+ attachment, committed-filename inspection, and parser-field recheck. If any is
12
+ missing, fail closed before opening the chooser and hand the upload to the user. Never
13
+ navigate to a `file://` URL or paste a local path into an ordinary text field.
14
+
15
+ Perform these stages in exact order:
16
+
17
+ 1. identify the exact semantic Resume or CV control;
18
+ 2. arm the file chooser before clicking that control;
19
+ 3. prove the chooser opened;
20
+ 4. run `trackly_verify_prepared_resume`, then immediately attach that verified
21
+ file through the adapter's documented file-setting primitive;
22
+ 5. verify the employer-facing filename committed; and
23
+ 6. recheck every contact, employment, education, and other field that resume
24
+ parsing may have changed.
25
+
26
+ Pass only the value-free stage outcomes to
27
+ `trackly_validate_apply_resume_upload`. Do not pass a filename, local path,
28
+ resume hash, form value, URL, page text, or tab identifier. Claim attachment
29
+ success only when it returns `safeToClaimAttachment: true`.
30
+
31
+ Stable failure codes include capability unavailable, ambiguous control,
32
+ chooser timeout, chooser opened without a commit, missing or expired file,
33
+ file-setting failure, uncommitted filename, unexpected file navigation,
34
+ unsettled parser, and parser-field regression. A failure is a handoff or retry
35
+ decision, never permission to guess another control or weaken verification.
36
+
37
+ Snapshot field provenance immediately before upload. Mark parser changes as
38
+ `parser_filled`; preserve a later user edit byte-for-byte. After ledger loss,
39
+ preserve every unknown non-empty value. Never let parser rechecks or the final
40
+ required-field sweep overwrite user-owned or unknown external values.
41
+
42
+ The employer-facing filename must remain the user's confirmed filename.
43
+ Random cache identifiers may appear only in private parent directories.
@@ -15,6 +15,53 @@ Funnel: target=; ready=; submitted=; filling=; awaitingAnswer=; authParked=; exc
15
15
 
16
16
  Use the compact execution snapshot as authority. Give a bounded next-update estimate, not an invented overall completion time.
17
17
 
18
+ ## Deterministic operator receipts
19
+
20
+ Every handoff names the browser surface and authoritative funnel. Use the
21
+ matching template; do not improvise internal routing language.
22
+
23
+ ### Recovery
24
+
25
+ ```text
26
+ Recovery in progress — not submitted
27
+ Completed: exact Trackly source and user-confirmed candidate set recovered
28
+ Tab restored: yes/no
29
+ Form state restored: yes/no
30
+ Current mutation authority: yes/no
31
+ Unresolved:
32
+ Your next action: wait / inspect / answer
33
+ Resume: exact approved resume / no attachment / manual upload required
34
+ Browser surface:
35
+ Funnel:
36
+ My next action:
37
+ ```
38
+
39
+ ### Upload requires the user
40
+
41
+ ```text
42
+ Application preserved — resume upload requires you
43
+ Completed:
44
+ Unresolved: browser surface cannot prove the required attachment stages
45
+ Your next action: attach the exact approved resume and confirm the committed filename
46
+ Resume: exact approved resume
47
+ Browser surface:
48
+ Funnel:
49
+ My next action: recheck parser-modified fields, finish integrity sweep, stop at review
50
+ ```
51
+
52
+ ### Submission reconciled
53
+
54
+ ```text
55
+ Submission confirmed and reconciled
56
+ Completed: success evidence recorded; member=submitted; job=applied_confirmed
57
+ Unresolved: tab cleanup pending / none
58
+ Your next action: none
59
+ Resume: exact approved resume / no attachment
60
+ Browser surface:
61
+ Funnel:
62
+ My next action: verify authorized tab disposition only
63
+ ```
64
+
18
65
  ## Approval scope receipt
19
66
 
20
67
  Before persisting a broad statement such as “always,” show which separate categories it will update: personal facts, consent choices, writing preferences, resume approval, and truthfulness certification. Never let approval of one category authorize another. Consolidate genuinely unknown questions into one packet after filling everything already known. Explain legal terms in plain language using the protocol glossary; do not expose internal action codes or routing labels.
@@ -141,5 +188,45 @@ open for manual submission, and list remaining human actions separately.
141
188
  Members that become ready later require a new certification for the exact
142
189
  then-current `review_ready` subset.
143
190
 
191
+ ## Group submission reconciliation
192
+
193
+ A broad statement such as “I submitted all of them” applies only to one
194
+ explicit review-handoff receipt. Call `trackly_list_apply_review_handoffs` for
195
+ the execution, then resolve it to the receipt the user names or to the sole
196
+ active receipt returned by Trackly. If multiple receipts could
197
+ match, ask the user to choose; never fan the statement across executions or
198
+ tabs.
199
+
200
+ Inspect every member in the selected receipt and classify exactly one of:
201
+ `detected`, `user_confirmed`, `unresolved`, or `contradictory`. Use
202
+ provider-positive evidence only:
203
+
204
+ - Greenhouse: a same-requisition thank-you page, confirmation route, or
205
+ semantic success state;
206
+ - Ashby: a same-requisition explicit application-success banner or page,
207
+ including when the route does not change; and
208
+ - Lever: a same-requisition confirmation redirect or semantic success state.
209
+
210
+ An unchanged URL or page title is never evidence that submission failed. Build
211
+ any local `DetectedSubmission` only from a versioned provider strategy, the
212
+ handoff/member identity, current inspection epoch, evidence type, confidence,
213
+ and a value-free fingerprint derived from those identifiers. Never hash DOM
214
+ text, URLs, local paths, or raw tab IDs into that record.
215
+
216
+ If a rediscovered receipt is `partially_reconciled`, preserve every member that
217
+ already has a stored reconciliation result and inspect only members whose
218
+ stored result remains `unresolved`. Do not replay the original claim with a
219
+ partial member list: the claim receipt is an immutable exact-set idempotency
220
+ record. Record later positive evidence and submitted outcomes through the
221
+ normal per-member outcome path, naming the receipt and only its unresolved
222
+ members in any follow-up confirmation.
223
+
224
+ Call `trackly_claim_apply_review_handoff` with the exact receipt and a
225
+ classification for every member. Write submission evidence and outcomes only
226
+ for `detected` and `user_confirmed`. Ask one consolidated question containing
227
+ only the `unresolved` members. Preserve every `contradictory` member and its
228
+ tab without an outcome. Refetch and require member `submitted` plus job
229
+ `applied_confirmed` before any authorized closure.
230
+
144
231
  Do not include restricted answers in chat unless needed for the user’s review.
145
232
  Never include credentials, an OTP, or a CAPTCHA response.