trackly-cli 0.7.3 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,7 +35,7 @@ trackly jobs --function product
35
35
 
36
36
  ## At a Glance
37
37
 
38
- 1,900+ companies | 128K+ jobs | 40+ ATS types | CLI + MCP | 24 local MCP tools
38
+ 1,900+ companies | 128K+ jobs | 40+ ATS types | CLI + MCP | 36 local MCP tools
39
39
 
40
40
  ## CLI Commands
41
41
 
@@ -177,11 +177,23 @@ Then use natural language in any of these clients:
177
177
  | trackly_get_application_profile | Get versioned profile answers and provenance |
178
178
  | trackly_get_profile_onboarding | Get schema plus missing questions |
179
179
  | trackly_update_application_profile | Save scoped answers with optimistic concurrency |
180
- | trackly_start_apply_run | Start a manual-submit browser run |
180
+ | trackly_create_apply_batch | Freeze an exact recent-first approved batch |
181
+ | trackly_get_active_apply_batch | Recover the newest unexpired active batch after context loss |
182
+ | trackly_get_apply_batch | Read frozen membership with opaque pagination |
183
+ | trackly_claim_apply_batch | Acquire or renew a batch mutation lease |
184
+ | trackly_checkpoint_apply_batch | Bulk-record redacted inspection checkpoints and human actions |
185
+ | trackly_bind_apply_surface | Bind an initial or recovered browser surface to the existing run and exact requisition URL |
186
+ | trackly_record_apply_surface_evidence | Record current-epoch inventory, missing-tab, close-receipt, and post-close absence evidence |
187
+ | trackly_record_apply_submission_evidence | Record redacted submit-request, success-page, user-confirmation, or provider-receipt evidence |
188
+ | trackly_approve_apply_batch_resume | Approve one exact default resume for the current frozen run set |
189
+ | trackly_certify_apply_batch_truth | Certify final answer and wording fingerprints after every other review-readiness gate |
190
+ | trackly_start_apply_run | Start or reuse a manual-submit browser run |
181
191
  | trackly_get_apply_evidence | Get aggregate, value-free beta evidence and release readiness |
182
192
  | trackly_get_apply_protocol | Get current workflow and compatibility rules |
183
193
  | trackly_report_apply_observation | Send redacted ATS mechanics feedback |
194
+ | trackly_report_apply_observations | Bulk-send up to 20 leased, batch-bound redacted observations |
184
195
  | trackly_record_application_outcome | Record review or confirmed submission outcome |
196
+ | trackly_record_application_outcomes | Bulk-record up to 20 leased batch outcomes with per-member conflicts |
185
197
  | trackly_prepare_resume | Prepare a private expiring resume file for upload |
186
198
  | trackly_verify_prepared_resume | Recheck the confirmed resume immediately before attachment |
187
199
 
@@ -249,7 +261,7 @@ trackly config --base-url http://127.0.0.1:3000 # Point at a different backend
249
261
  | Job search + filters | Yes | Yes | Yes |
250
262
  | Apply/save/dismiss | Yes | Yes | Yes |
251
263
  | AI-powered search | Yes (trackly ask) | Yes | Yes |
252
- | MCP integration | Yes (20 local tools) | -- | -- |
264
+ | MCP integration | Yes (36 local tools) | -- | -- |
253
265
  | Browser required | No | Yes | No |
254
266
  | Best for | Terminal + AI agents | Visual browsing | Custom integrations |
255
267
 
@@ -263,7 +275,7 @@ Install trackly-cli (`npm install -g trackly-cli`), authenticate with `trackly l
263
275
 
264
276
  **What MCP servers exist for job searching?**
265
277
 
266
- trackly-cli includes a built-in MCP server with 24 tools for job search, company lookup, discovery preferences, application tracking, profile onboarding, beta evidence, and manual-submit form preparation. Run `trackly mcp` or use `trackly agent setup --client claude`.
278
+ trackly-cli includes a built-in MCP server with 36 tools for job search, company lookup, discovery preferences, application tracking, frozen-batch orchestration, profile onboarding, beta evidence, and manual-submit form preparation. Run `trackly mcp` or use `trackly agent setup --client claude`.
267
279
 
268
280
  **How do I use Claude Code for job hunting?**
269
281
 
package/bin/trackly CHANGED
@@ -1159,6 +1159,8 @@ async function cmdAgent(args) {
1159
1159
  console.log(color('bold', 'Trackly Apply doctor'));
1160
1160
  console.log(`Overall: ${color(report.ok ? 'green' : 'yellow', report.ok ? 'ready' : 'needs attention')}`);
1161
1161
  console.log(`Authenticated: ${report.authenticated ? 'yes' : 'no'}`);
1162
+ console.log(`CLI: ${report.cliVersion}; MCP contract: ${report.mcpContractVersion}`);
1163
+ console.log(`Skill: ${report.skillVersion}; digest: ${report.skillPackIntegrity.expectedDigest || 'unavailable'}`);
1162
1164
  console.log(`Protocol: ${report.protocol?.version || 'unavailable'}; compatible=${report.compatible ? 'yes' : 'no'}`);
1163
1165
  console.log(`Semantic browser + computer use: ${report.browserControl.semanticReady ? 'ready' : 'not verified'}`);
1164
1166
  for (const client of report.clients) {
@@ -1,22 +1,41 @@
1
1
  {
2
- "contractVersion": "3.2.0",
2
+ "contractVersion": "3.3.1",
3
3
  "constants": {
4
4
  "applyScenarioCodes": ["browser_reclaim", "resume_upload", "resume_parser_recheck", "semantic_boolean_commit", "custom_select_commit", "multi_step_navigation", "free_text_voice", "required_error_sweep", "final_consent", "handoff_reclaim", "critical_contact_integrity", "manual_submit_boundary", "job_identity_match"],
5
- "applyBrowserSurfaces": ["codex_in_app", "chrome_extension", "claude_in_chrome"]
5
+ "applyBrowserSurfaces": ["codex_in_app", "chrome_extension", "claude_in_chrome"],
6
+ "applyCheckpointActionCodes": ["answer/unknown", "auth/sign_in", "auth/otp", "captcha/before_form", "captcha/at_submit", "artifact/upload_required", "legal/decision_required", "consent/decision_required", "review/manual_submit", "trust/origin_mismatch", "observability/unverifiable_state"],
7
+ "applyCheckpointPacketPhases": ["first_pass", "delta"],
8
+ "applySurfaceBindingReasons": ["initial_binding", "recovery_binding"],
9
+ "applySurfaceEvidenceTypes": ["surface_inventory_reconciled", "surface_missing", "surface_close_attempted", "surface_close_receipt", "surface_post_close_absent", "surface_close_failed"],
10
+ "applySurfaceOwnershipStates": ["controller_owned", "user_owned", "controller_user_union", "unresolved"],
11
+ "applySubmissionEvidenceTypes": ["request_accepted", "confirmation_detected", "provider_receipt_detected"],
12
+ "applySubmissionEvidenceSources": ["submission_request", "success_page", "user_confirmation", "provider_receipt", "post_submit_receipt"]
6
13
  },
7
14
  "tools": {
8
- "trackly_get_apply_queue": "{limit:z.number().int().min(1).max(100).optional()}",
15
+ "trackly_get_apply_queue": "{limit:z.number().int().min(1).max(100).optional(),cursor:z.string().min(1).max(2048).optional()}",
9
16
  "trackly_get_application_profile": "{includeSensitive:z.boolean().optional(),provider:z.string().max(100).optional(),companyId:z.string().max(100).optional()}",
10
17
  "trackly_get_profile_onboarding": "{}",
11
18
  "trackly_update_application_profile": "{expectedRevision:z.number().int().min(1),source:z.enum(['web','ios','macos','codex','claude','mcp']).optional(),changes:z.array(z.discriminatedUnion('scope',[z.object({key:z.string().min(1).max(200),state:z.enum(['unknown','answered','intentionally_blank','declined']),value:z.any().optional(),scope:z.literal('global'),questionLabel:z.string().max(1000).optional()}),z.object({key:z.string().min(1).max(200),state:z.enum(['unknown','answered','intentionally_blank','declined']),value:z.any().optional(),scope:z.literal('provider'),scopeValue:z.string().min(1).max(200),questionLabel:z.string().max(1000).optional()}),z.object({key:z.string().min(1).max(200),state:z.enum(['unknown','answered','intentionally_blank','declined']),value:z.any().optional(),scope:z.literal('company'),scopeValue:z.string().min(1).max(200),questionLabel:z.string().max(1000).optional()})])).max(100).optional(),education:z.array(z.object({school:z.string().min(1).max(500),degree:z.string().max(500).nullable().optional(),fieldOfStudy:z.string().max(500).nullable().optional(),gpa:z.string().max(50).nullable().optional(),startDate:z.string().max(50).nullable().optional(),endDate:z.string().max(50).nullable().optional()})).max(20).optional(),confirmProfile:z.boolean().optional(),sensitiveStorageConsent:z.boolean().optional()}",
12
- "trackly_start_apply_run": "{jobId:z.number().int().min(1),clientName:z.string().max(100).optional()}",
19
+ "trackly_create_apply_batch": "{limit:z.number().int().min(1).max(100),idempotencyKey:z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY)}",
20
+ "trackly_get_active_apply_batch": "{limit:z.number().int().min(1).max(APPLY_BATCH_MAX_MEMBERS).optional(),cursor:z.string().min(1).max(2048).optional(),actionLimit:z.number().int().min(1).max(APPLY_BATCH_MAX_MEMBERS).optional(),actionCursor:z.string().min(1).max(2048).optional()}",
21
+ "trackly_get_apply_batch": "{batchId:z.number().int().min(1),limit:z.number().int().min(1).max(APPLY_BATCH_MAX_MEMBERS).optional(),cursor:z.string().min(1).max(2048).optional(),actionLimit:z.number().int().min(1).max(APPLY_BATCH_MAX_MEMBERS).optional(),actionCursor:z.string().min(1).max(2048).optional()}",
22
+ "trackly_claim_apply_batch": "{batchId:z.number().int().min(1),expectedRevision:z.number().int().min(1),leaseOwner:z.string().min(1).max(1024),leaseToken:z.string().min(1).max(1024),leaseDurationMs:z.number().int().min(15000).max(300000)}",
23
+ "trackly_checkpoint_apply_batch": "{batchId:z.number().int().min(1),leaseToken:z.string().min(1).max(1024),checkpoints:z.array(z.object({memberId:z.number().int().min(1),runId:z.number().int().min(1),expectedMemberVersion:z.number().int().min(1),expectedInspectionEpoch:z.number().int().min(0),inspectionEpoch:z.number().int().min(0),packetPhase:z.enum(APPLY_CHECKPOINT_PACKET_PHASES).optional(),knownFieldsCommitted:z.boolean(),resolvedActionIds:z.array(z.string().regex(/^[1-9][0-9]*$/)).max(APPLY_BATCH_MAX_ACTIONS_PER_CHECKPOINT).optional(),idempotencyKey:z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY),actions:z.array(z.object({actionCode:z.enum(APPLY_CHECKPOINT_ACTION_CODES),continuationAllowed:z.boolean(),fieldFingerprint:z.string().regex(/^[a-f0-9]{64}$/).optional()})).min(1).max(APPLY_BATCH_MAX_ACTIONS_PER_CHECKPOINT)})).min(1).max(APPLY_BATCH_MAX_CHECKPOINTS_PER_REQUEST)}",
24
+ "trackly_bind_apply_surface": "{batchId:z.number().int().min(1),memberId:z.number().int().min(1),runId:z.number().int().min(1),expectedMemberVersion:z.number().int().min(1),expectedInspectionEpoch:z.number().int().min(0),leaseToken:z.string().min(1).max(1024),browserBindingHash:z.string().regex(/^[a-f0-9]{64}$/),browserSurface:z.enum(APPLY_BROWSER_SURFACES),adapterCode:z.string().regex(SAFE_OBSERVATION_CODE),bindingReason:z.enum(APPLY_SURFACE_BINDING_REASONS),idempotencyKey:z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY)}",
25
+ "trackly_record_apply_surface_evidence": "{batchId:z.number().int().min(1),memberId:z.number().int().min(1),runId:z.number().int().min(1),expectedMemberVersion:z.number().int().min(1),expectedInspectionEpoch:z.number().int().min(1),leaseToken:z.string().min(1).max(1024),browserBindingHash:z.string().regex(/^[a-f0-9]{64}$/),browserSurface:z.enum(APPLY_BROWSER_SURFACES),adapterCode:z.string().regex(SAFE_OBSERVATION_CODE),ownershipState:z.enum(APPLY_SURFACE_OWNERSHIP_STATES),completeInventory:z.boolean(),evidenceType:z.enum(APPLY_SURFACE_EVIDENCE_TYPES),idempotencyKey:z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY)}",
26
+ "trackly_record_apply_submission_evidence": "{batchId:z.number().int().min(1),memberId:z.number().int().min(1),runId:z.number().int().min(1),expectedMemberVersion:z.number().int().min(1),expectedInspectionEpoch:z.number().int().min(1),leaseToken:z.string().min(1).max(1024),browserBindingHash:z.string().regex(/^[a-f0-9]{64}$/),evidenceType:z.enum(APPLY_SUBMISSION_EVIDENCE_TYPES),evidenceSource:z.enum(APPLY_SUBMISSION_EVIDENCE_SOURCES),evidenceFingerprint:z.string().regex(/^[a-f0-9]{64}$/),idempotencyKey:z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY)}",
27
+ "trackly_approve_apply_batch_resume": "{batchId:z.number().int().min(1),leaseToken:z.string().min(1).max(1024),membershipHash:z.string().regex(/^[a-f0-9]{64}$/),profileRevision:z.number().int().min(0),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),memberRuns:z.array(z.object({memberId:z.number().int().min(1),runId:z.number().int().min(1),memberVersion:z.number().int().min(1),inspectionEpoch:z.number().int().min(0)})).min(1).max(100),expiresAt:z.string().datetime(),idempotencyKey:z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY)}",
28
+ "trackly_certify_apply_batch_truth": "truthCertificationSchema",
29
+ "trackly_start_apply_run": "startApplyRunSchema",
13
30
  "trackly_get_apply_evidence": "{windowDays:z.number().int().min(1).max(365).optional(),targetReviewedRuns:z.number().int().min(1).max(1000).optional()}",
14
31
  "trackly_get_apply_protocol": "{}",
15
- "trackly_report_apply_observation": "{runId:z.number().int().min(1),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()})}",
16
- "trackly_record_application_outcome": "{runId:z.number().int().min(1),outcome:z.enum(['review_ready','submitted','failed','blocked']),confirmation:z.string().max(500).optional()}",
32
+ "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()})}",
33
+ "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)}",
34
+ "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()}",
35
+ "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)}",
17
36
  "trackly_prepare_resume": "{runId:z.number().int().min(1),browserSurface:z.enum(APPLY_BROWSER_SURFACES),browserBindingHash:z.string().regex(/^[a-f0-9]{64}$/)}",
18
37
  "trackly_verify_prepared_resume": {
19
- "local": "{runId: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()}",
38
+ "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()}",
20
39
  "hosted": "{runId:z.number().int().min(1),confirmationId:z.string().min(1).max(200)}"
21
40
  }
22
41
  }
@@ -103,15 +103,27 @@ API-key creation attempts.
103
103
  - **trackly_get_application_profile** — Get the versioned, scoped application profile.
104
104
  - **trackly_get_profile_onboarding** — Get backend-owned questions plus only missing/unconfirmed answers.
105
105
  - **trackly_update_application_profile** — Save explicit answer states with optimistic concurrency and global/provider/company scope.
106
- - **trackly_start_apply_run** — Start a manual-submit browser run for a queued job.
106
+ - **trackly_create_apply_batch** — Freeze an exact recent-first set of approved jobs with an idempotency key.
107
+ - **trackly_get_active_apply_batch** — Recover the newest unexpired active batch before creating another after chat or browser context loss.
108
+ - **trackly_get_apply_batch** — Page a server-owned frozen batch without reordering or replacing members.
109
+ - **trackly_claim_apply_batch** — Acquire or renew the optimistic browser-mutation lease.
110
+ - **trackly_checkpoint_apply_batch** — Bulk-record up to 20 value-free inspection checkpoints, typed human actions, and per-member conflicts.
111
+ - **trackly_bind_apply_surface** — Bind an initial or recovered browser surface to the existing frozen member/run, increment its inspection epoch, and return the exact stored requisition URL.
112
+ - **trackly_record_apply_surface_evidence** — Record current-epoch, value-free inventory, missing-tab, close-receipt, post-close absence, or close-failure evidence.
113
+ - **trackly_record_apply_submission_evidence** — Record typed, redacted submit-request, success-page, explicit user-confirmation, or provider-receipt evidence without page text or external references.
114
+ - **trackly_approve_apply_batch_resume** — Approve one exact default-resume identity and content hash for the complete current frozen run set.
115
+ - **trackly_certify_apply_batch_truth** — Record an expiring late truthfulness certification over final answer and wording fingerprints after every other review-readiness gate has passed.
116
+ - **trackly_start_apply_run** — Start or reuse a manual-submit browser run with the complete frozen batch/member/lease binding.
107
117
  - **trackly_get_apply_evidence** — Get the authenticated user's aggregate beta evidence and release gate without returning profile answers or contact values.
108
118
  - **trackly_get_apply_protocol** — Get the current ATS support, browser integrity rules, and compatible skill version.
109
119
  - **trackly_report_apply_observation** — Report redacted ATS mechanics and actual scenario coverage without answer values or page text.
120
+ - **trackly_report_apply_observations** — Bulk-report up to 20 leased, current-epoch, batch-bound redacted observations in one request.
110
121
  - **trackly_record_application_outcome** — Record review readiness or a confirmed manual submission.
122
+ - **trackly_record_application_outcomes** — Bulk-record up to 20 leased, batch-bound outcomes while returning per-member conflicts explicitly.
111
123
  - **trackly_prepare_resume** — Local MCP only: materialize the default resume in a private, expiring mode-0600 cache and return filename, size, SHA-256, exact local path, and visual-confirmation metadata. Hosted MCP returns a manual/local-agent requirement.
112
124
  - **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.
113
125
 
114
- Apply contract v3 intentionally gives this verifier different local and hosted schemas: local MCP receives the full proof needed to inspect the private file, while hosted MCP accepts only run and confirmation identifiers and returns the manual/local-agent requirement. Local paths and fingerprints are never sent to the hosted verifier. Version 3.1 also records universal value-free evidence for critical-contact integrity and the manual-submit boundary. Version 3.2 authorizes the exact stored HTTPS origin for jobs Trackly ingested from employer careers sources, without granting redirect, iframe, or hostname-suffix privileges.
126
+ 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.
115
127
 
116
128
  ### Maintenance behavior
117
129
 
package/lib/agent.js CHANGED
@@ -7,15 +7,17 @@ const path = require('node:path');
7
7
  const { spawnSync } = require('node:child_process');
8
8
  const { apiRequest, downloadFile, getConfigPaths, hasAuth, maintenanceOutput } = require('./client');
9
9
  const { version: PACKAGE_VERSION } = require('../package.json');
10
- const SKILL_VERSION = '4.1.1';
10
+ const { contractVersion: MCP_CONTRACT_VERSION } = require('../contracts/trackly-apply-tools.json');
11
+ const SKILL_VERSION = '4.2.1';
11
12
  const CLI_USER_AGENT = `trackly-cli/${PACKAGE_VERSION}`;
12
13
  const MCP_USER_AGENT = `trackly-mcp/${PACKAGE_VERSION}`;
13
14
 
14
15
  const SKILL_NAME = 'trackly-apply';
15
16
  const SKILL_MAJOR = Number(SKILL_VERSION.split('.')[0]);
16
- const MIN_APPLY_PROTOCOL_VERSION = '3.1.0';
17
+ const MIN_APPLY_PROTOCOL_VERSION = '3.3.1';
17
18
  const CACHE_TTL_MS = 2 * 60 * 60 * 1000;
18
19
  const MANAGED_FILE = '.trackly-managed.json';
20
+ const SKILL_MANIFEST_VERSION = 1;
19
21
  const RESUME_PROOF_FILE = '.trackly-resume-proof.json';
20
22
  const RESUME_PROOF_KEY_BYTES = 32;
21
23
 
@@ -52,6 +54,43 @@ function copyDirectory(source, destination) {
52
54
  }
53
55
  }
54
56
 
57
+ function skillContentManifest(root) {
58
+ const files = [];
59
+
60
+ function collect(directory, relativeDirectory = '') {
61
+ const names = fs.readdirSync(directory).sort();
62
+ for (const name of names) {
63
+ if (!relativeDirectory && name === MANAGED_FILE) continue;
64
+ const absolutePath = path.join(directory, name);
65
+ const relativePath = relativeDirectory ? `${relativeDirectory}/${name}` : name;
66
+ const stat = fs.lstatSync(absolutePath);
67
+ if (stat.isDirectory()) {
68
+ collect(absolutePath, relativePath);
69
+ continue;
70
+ }
71
+ if (!stat.isFile()) {
72
+ throw new Error(`Trackly Apply skill contains an unsupported filesystem entry: ${relativePath}`);
73
+ }
74
+ const content = fs.readFileSync(absolutePath);
75
+ files.push({
76
+ path: relativePath,
77
+ sizeBytes: content.length,
78
+ sha256: crypto.createHash('sha256').update(content).digest('hex'),
79
+ });
80
+ }
81
+ }
82
+
83
+ collect(root);
84
+ const digest = crypto.createHash('sha256')
85
+ .update(JSON.stringify({ version: SKILL_MANIFEST_VERSION, files }))
86
+ .digest('hex');
87
+ return {
88
+ version: SKILL_MANIFEST_VERSION,
89
+ digest,
90
+ fileCount: files.length,
91
+ };
92
+ }
93
+
55
94
  function managedMetadata(extra = {}) {
56
95
  return JSON.stringify({ managedBy: 'trackly-cli', skill: SKILL_NAME, skillVersion: SKILL_VERSION, ...extra }, null, 2);
57
96
  }
@@ -80,7 +119,12 @@ function installCanonicalSkill() {
80
119
  const staging = `${destination}.staging.${process.pid}.${crypto.randomBytes(4).toString('hex')}`;
81
120
  try {
82
121
  copyDirectory(source, staging);
83
- fs.writeFileSync(path.join(staging, MANAGED_FILE), managedMetadata(), { mode: 0o600 });
122
+ const manifest = skillContentManifest(staging);
123
+ fs.writeFileSync(path.join(staging, MANAGED_FILE), managedMetadata({
124
+ skillManifestVersion: manifest.version,
125
+ skillContentDigest: manifest.digest,
126
+ skillFileCount: manifest.fileCount,
127
+ }), { mode: 0o600 });
84
128
  } catch (error) {
85
129
  if (fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true });
86
130
  throw error;
@@ -157,7 +201,13 @@ function linkClientSkill(client, canonical) {
157
201
  const staging = `${target}.staging.${process.pid}.${crypto.randomBytes(4).toString('hex')}`;
158
202
  try {
159
203
  copyDirectory(canonical, staging);
160
- fs.writeFileSync(path.join(staging, MANAGED_FILE), managedMetadata({ copiedFrom: canonical }), { mode: 0o600 });
204
+ const manifest = skillContentManifest(staging);
205
+ fs.writeFileSync(path.join(staging, MANAGED_FILE), managedMetadata({
206
+ copiedFrom: canonical,
207
+ skillManifestVersion: manifest.version,
208
+ skillContentDigest: manifest.digest,
209
+ skillFileCount: manifest.fileCount,
210
+ }), { mode: 0o600 });
161
211
  fs.renameSync(staging, target);
162
212
  if (backup) fs.rmSync(backup, { recursive: true, force: true });
163
213
  return { client, target, method: 'copy', status: 'installed' };
@@ -281,10 +331,47 @@ function inspectClient(client) {
281
331
  const target = clientSkillDir(client);
282
332
  const executable = commandExists(client === 'codex' ? 'codex' : 'claude');
283
333
  const metadata = managedSkillMetadata(target);
284
- const installed = fs.existsSync(path.join(target, 'SKILL.md')) && metadata?.skillVersion === SKILL_VERSION;
334
+ let expectedManifest = null;
335
+ let installedManifest = null;
336
+ try {
337
+ expectedManifest = skillContentManifest(bundledSkillDir());
338
+ installedManifest = skillContentManifest(target);
339
+ } catch (_) {}
340
+ const hasRecordedManifest = metadata?.skillManifestVersion === SKILL_MANIFEST_VERSION
341
+ && typeof metadata?.skillContentDigest === 'string'
342
+ && Number.isInteger(metadata?.skillFileCount);
343
+ const contentMatches = hasRecordedManifest
344
+ && expectedManifest !== null
345
+ && installedManifest !== null
346
+ && metadata.skillContentDigest === expectedManifest.digest
347
+ && metadata.skillContentDigest === installedManifest.digest
348
+ && metadata.skillFileCount === expectedManifest.fileCount
349
+ && metadata.skillFileCount === installedManifest.fileCount;
350
+ const skillIntegrity = contentMatches
351
+ ? 'verified'
352
+ : metadata && !hasRecordedManifest
353
+ ? 'manifest_missing'
354
+ : metadata
355
+ ? 'content_mismatch'
356
+ : 'not_managed';
357
+ const installed = fs.existsSync(path.join(target, 'SKILL.md'))
358
+ && metadata?.skillVersion === SKILL_VERSION
359
+ && skillIntegrity === 'verified';
285
360
  const mcpRegistration = mcpRegistrationState(client);
286
361
  const mcpRegistered = mcpRegistration === 'current';
287
- return { client, executable, installed, installedSkillVersion: metadata?.skillVersion || null, mcpRegistered, mcpRegistration, target };
362
+ return {
363
+ client,
364
+ executable,
365
+ installed,
366
+ installedSkillVersion: metadata?.skillVersion || null,
367
+ skillIntegrity,
368
+ expectedSkillContentDigest: expectedManifest?.digest || null,
369
+ installedSkillContentDigest: installedManifest?.digest || null,
370
+ recordedSkillContentDigest: metadata?.skillContentDigest || null,
371
+ mcpRegistered,
372
+ mcpRegistration,
373
+ target,
374
+ };
288
375
  }
289
376
 
290
377
  function cacheDir() {
@@ -368,11 +455,21 @@ function resumeCacheName(fileName, contentType, now = Date.now(), nonce = crypto
368
455
  return path.join(`${now}-${nonce}`, safeResumeName(fileName, contentType));
369
456
  }
370
457
 
371
- function resumeConfirmation(fileName, sha256, sizeBytes, exactLocalPath, runId, expiresAt, confirmationId) {
458
+ function resumeConfirmation(
459
+ fileName,
460
+ sha256,
461
+ sizeBytes,
462
+ exactLocalPath,
463
+ runId,
464
+ expiresAt,
465
+ confirmationId,
466
+ resumeId = null,
467
+ ) {
372
468
  return {
373
469
  required: true,
374
470
  source: 'trackly_default_resume',
375
471
  runId,
472
+ ...(resumeId == null ? {} : { resumeId }),
376
473
  confirmationId,
377
474
  fileName,
378
475
  sha256,
@@ -450,6 +547,10 @@ function verifyResumeProofSignature(manifest) {
450
547
  function materializeResume(download, options = {}) {
451
548
  const runId = Number(options.runId);
452
549
  if (!Number.isInteger(runId) || runId < 1) throw new Error('A valid Trackly Apply run ID is required before preparing a resume.');
550
+ const resumeId = download.resumeId == null ? null : Number(download.resumeId);
551
+ if (resumeId !== null && (!Number.isInteger(resumeId) || resumeId < 1)) {
552
+ throw new Error('Trackly returned an invalid default resume identity.');
553
+ }
453
554
  const now = options.now ?? Date.now();
454
555
  const nonce = options.nonce ?? crypto.randomBytes(4).toString('hex');
455
556
  const confirmationId = options.confirmationId ?? crypto.randomUUID();
@@ -471,11 +572,13 @@ function materializeResume(download, options = {}) {
471
572
  runId,
472
573
  expiresAt,
473
574
  confirmationId,
575
+ resumeId,
474
576
  );
475
577
  const manifestPath = path.join(path.dirname(destination), RESUME_PROOF_FILE);
476
578
  try {
477
579
  const manifest = signedResumeProof({
478
580
  runId,
581
+ resumeId,
479
582
  confirmationId,
480
583
  exactLocalPath: destination,
481
584
  fileName,
@@ -494,6 +597,7 @@ function materializeResume(download, options = {}) {
494
597
  }
495
598
  return {
496
599
  path: destination,
600
+ resumeId,
497
601
  fileName,
498
602
  contentType: validated.contentType,
499
603
  sha256: validated.digest,
@@ -542,6 +646,11 @@ function verifyPreparedResume(proof, now = Date.now()) {
542
646
  }
543
647
 
544
648
  const bindingMatches = manifest.runId === runId
649
+ && (
650
+ proof.resumeId == null
651
+ ? manifest.resumeId == null
652
+ : manifest.resumeId === Number(proof.resumeId)
653
+ )
545
654
  && manifest.confirmationId === proof.confirmationId
546
655
  && manifest.exactLocalPath === expectedPath
547
656
  && manifest.fileName === path.basename(expectedPath)
@@ -570,6 +679,7 @@ function verifyPreparedResume(proof, now = Date.now()) {
570
679
  return {
571
680
  verified: true,
572
681
  runId,
682
+ resumeId: manifest.resumeId ?? null,
573
683
  confirmationId: proof.confirmationId,
574
684
  exactLocalPath: expectedPath,
575
685
  sha256,
@@ -621,6 +731,9 @@ async function prepareResume(runId, browserSurface, browserBindingHash, userAgen
621
731
  if (Number(download.applyRunId) !== normalizedRunId) {
622
732
  throw new Error('Trackly did not confirm that the resume belongs to the active Apply run.');
623
733
  }
734
+ if (!Number.isInteger(Number(download.resumeId)) || Number(download.resumeId) < 1) {
735
+ throw new Error('Trackly did not confirm the default resume identity.');
736
+ }
624
737
  return materializeResume(download, { runId: normalizedRunId });
625
738
  } finally {
626
739
  download.buffer.fill(0);
@@ -631,7 +744,12 @@ function protocolAtLeast(version, minimum = MIN_APPLY_PROTOCOL_VERSION) {
631
744
  const parse = (value) => String(value || '').split('.').map((part) => Number(part));
632
745
  const actual = parse(version);
633
746
  const required = parse(minimum);
634
- if (actual.length < 2 || actual.some((part) => !Number.isInteger(part) || part < 0)) return false;
747
+ if (
748
+ actual.length < 2
749
+ || required.length < 2
750
+ || actual.some((part) => !Number.isInteger(part) || part < 0)
751
+ || required.some((part) => !Number.isInteger(part) || part < 0)
752
+ ) return false;
635
753
  for (let index = 0; index < Math.max(actual.length, required.length); index++) {
636
754
  const left = actual[index] || 0;
637
755
  const right = required[index] || 0;
@@ -640,6 +758,47 @@ function protocolAtLeast(version, minimum = MIN_APPLY_PROTOCOL_VERSION) {
640
758
  return true;
641
759
  }
642
760
 
761
+ function evaluateApplyCompatibility(protocol, clients) {
762
+ const compatibleSkillMajor = Number(protocol?.compatibleSkillMajor);
763
+ const minimumSkillVersion = typeof protocol?.compatibleSkillMinimumVersion === 'string'
764
+ ? protocol.compatibleSkillMinimumVersion
765
+ : null;
766
+ const minimumCliVersion = typeof protocol?.compatibleCliMinimumVersion === 'string'
767
+ ? protocol.compatibleCliMinimumVersion
768
+ : null;
769
+ const mcpContractCompatible = protocol?.mcpContractVersion === MCP_CONTRACT_VERSION;
770
+ const cliMinimumSatisfied = minimumCliVersion
771
+ ? protocolAtLeast(PACKAGE_VERSION, minimumCliVersion)
772
+ : false;
773
+ const protocolCompatible = compatibleSkillMajor === SKILL_MAJOR
774
+ && protocolAtLeast(protocol?.version, MIN_APPLY_PROTOCOL_VERSION);
775
+ const compatibleClientSkills = minimumSkillVersion
776
+ ? clients
777
+ .filter((client) => {
778
+ const installedMajor = Number(String(client.installedSkillVersion || '').split('.')[0]);
779
+ return client.installed === true
780
+ && installedMajor === compatibleSkillMajor
781
+ && protocolAtLeast(client.installedSkillVersion, minimumSkillVersion);
782
+ })
783
+ .map((client) => client.client)
784
+ : [];
785
+ const skillMinimumSatisfied = compatibleClientSkills.length > 0;
786
+ return {
787
+ protocolCompatible,
788
+ mcpContractVersion: MCP_CONTRACT_VERSION,
789
+ mcpContractCompatible,
790
+ minimumCliVersion,
791
+ cliMinimumSatisfied,
792
+ minimumSkillVersion,
793
+ compatibleClientSkills,
794
+ skillMinimumSatisfied,
795
+ compatible: protocolCompatible
796
+ && mcpContractCompatible
797
+ && cliMinimumSatisfied
798
+ && skillMinimumSatisfied,
799
+ };
800
+ }
801
+
643
802
  function liveBrowserReady(browserControl) {
644
803
  return (browserControl?.codex === true && browserControl?.codexComputerUse === true)
645
804
  || browserControl?.claude === true;
@@ -654,13 +813,38 @@ async function doctorAgent() {
654
813
  codexComputerUse,
655
814
  claude: null,
656
815
  };
816
+ const tabInventory = {
817
+ controllerOwnedTabs: 'runtime_verification_required',
818
+ userOwnedTabs: 'runtime_verification_required',
819
+ unionReconciliation: 'runtime_verification_required',
820
+ detectedSurfaces: [],
821
+ unionInventoryDetected: false,
822
+ requiredForClosedClaim: true,
823
+ note: 'Installed plugins do not prove controller-owned and user-owned tab union inventory. The active browser client must verify both inventories before claiming a tab is closed.',
824
+ };
825
+ const clients = ['codex', 'claude'].map(inspectClient);
826
+ const skillIntegrityFailures = clients
827
+ .filter((client) => (
828
+ client.installedSkillVersion === SKILL_VERSION
829
+ && client.skillIntegrity !== 'verified'
830
+ ))
831
+ .map((client) => ({ client: client.client, integrity: client.skillIntegrity }));
657
832
  const report = {
833
+ cliVersion: PACKAGE_VERSION,
834
+ mcpContractVersion: MCP_CONTRACT_VERSION,
658
835
  skillVersion: SKILL_VERSION,
659
836
  authenticated: hasAuth(),
660
- clients: ['codex', 'claude'].map(inspectClient),
837
+ clients,
838
+ skillPackIntegrity: {
839
+ ok: skillIntegrityFailures.length === 0,
840
+ expectedDigest: clients.find((client) => client.expectedSkillContentDigest)
841
+ ?.expectedSkillContentDigest || null,
842
+ failures: skillIntegrityFailures,
843
+ },
661
844
  browserControl: {
662
845
  ...browserControl,
663
846
  semanticReady: liveBrowserReady(browserControl),
847
+ tabInventory,
664
848
  note: 'A live run requires both semantic browser control and computer use. Claude capability must be verified by the active client before the run starts.',
665
849
  },
666
850
  cache,
@@ -682,8 +866,8 @@ async function doctorAgent() {
682
866
  completeness: profile.profile?.completeness,
683
867
  hasDefaultResume: Boolean(profile.profile?.defaultResume),
684
868
  };
685
- report.compatible = Number(report.protocol.compatibleSkillMajor) === SKILL_MAJOR
686
- && protocolAtLeast(report.protocol.version, MIN_APPLY_PROTOCOL_VERSION);
869
+ report.compatibility = evaluateApplyCompatibility(report.protocol, report.clients);
870
+ report.compatible = report.compatibility.compatible;
687
871
  if (report.profile.hasDefaultResume) {
688
872
  report.resume = {
689
873
  available: true,
@@ -697,6 +881,7 @@ async function doctorAgent() {
697
881
  report.ok = report.authenticated
698
882
  && report.compatible
699
883
  && report.browserControl.semanticReady
884
+ && report.skillPackIntegrity.ok
700
885
  && report.clients.some((client) => client.executable && client.installed && client.mcpRegistered)
701
886
  && Boolean(report.profile?.hasDefaultResume)
702
887
  && report.resume.available
@@ -724,6 +909,7 @@ module.exports = {
724
909
  cleanResumeCache,
725
910
  clientSkillDir,
726
911
  doctorAgent,
912
+ evaluateApplyCompatibility,
727
913
  inspectClient,
728
914
  liveBrowserReady,
729
915
  materializeResume,
package/lib/client.js CHANGED
@@ -509,7 +509,15 @@ async function _doRefresh() {
509
509
  return null;
510
510
  }
511
511
 
512
- function apiRequest(method, endpoint, data = null, skipAuth = false, _isRetry = false, userAgent = null) {
512
+ function apiRequest(
513
+ method,
514
+ endpoint,
515
+ data = null,
516
+ skipAuth = false,
517
+ _isRetry = false,
518
+ userAgent = null,
519
+ additionalHeaders = null
520
+ ) {
513
521
  return new Promise((resolve, reject) => {
514
522
  let url;
515
523
  try {
@@ -522,6 +530,27 @@ function apiRequest(method, endpoint, data = null, skipAuth = false, _isRetry =
522
530
  const isHttps = url.protocol === 'https:';
523
531
  const lib = isHttps ? https : http;
524
532
  const headers = getRequestHeaders(skipAuth, userAgent);
533
+ if (additionalHeaders !== null) {
534
+ const entries = (
535
+ typeof additionalHeaders === 'object'
536
+ && additionalHeaders !== null
537
+ && !Array.isArray(additionalHeaders)
538
+ && Object.getPrototypeOf(additionalHeaders) === Object.prototype
539
+ )
540
+ ? Object.entries(additionalHeaders)
541
+ : [];
542
+ const idempotencyEntry = entries.find(([name]) => name.toLowerCase() === 'idempotency-key');
543
+ if (
544
+ entries.length !== 1
545
+ || !idempotencyEntry
546
+ || typeof idempotencyEntry[1] !== 'string'
547
+ || !/^[\x20-\x7e]{16,200}$/.test(idempotencyEntry[1])
548
+ ) {
549
+ reject(new Error('Only a valid Idempotency-Key additional header is permitted.'));
550
+ return;
551
+ }
552
+ headers['Idempotency-Key'] = idempotencyEntry[1];
553
+ }
525
554
  const authMethod = getAuthMethod();
526
555
  const timeoutMs = getRequestTimeoutMs();
527
556
  const hasSensitivePayload = Boolean(data && typeof data === 'object' && 'refreshToken' in data);
@@ -604,7 +633,15 @@ function apiRequest(method, endpoint, data = null, skipAuth = false, _isRetry =
604
633
  }
605
634
  if (newToken) {
606
635
  try {
607
- const retried = await apiRequest(method, endpoint, data, false, true, userAgent);
636
+ const retried = await apiRequest(
637
+ method,
638
+ endpoint,
639
+ data,
640
+ false,
641
+ true,
642
+ userAgent,
643
+ additionalHeaders
644
+ );
608
645
  return resolve(retried);
609
646
  } catch (e) { return reject(e); }
610
647
  }
@@ -730,6 +767,7 @@ function downloadFile(endpoint, userAgent = null, _isRetry = false) {
730
767
  fileName: contentDispositionFileName(disposition),
731
768
  sha256: res.headers['x-trackly-file-sha256'] || null,
732
769
  applyRunId: res.headers['x-trackly-apply-run-id'] || null,
770
+ resumeId: res.headers['x-trackly-resume-id'] || null,
733
771
  });
734
772
  });
735
773
  });