trackly-cli 0.18.0 → 0.18.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/docs/solutions/integration-issues/access-review-proposal-receipt-validation.md +151 -0
- package/docs/trackly-tools.md +7 -7
- package/mcp/apply-tools.js +264 -33
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/trackly-apply/SKILL.md +2 -2
- package/skills/trackly-apply/references/batch-orchestration.md +29 -19
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Bind access-review approval to validated proposal receipts
|
|
3
|
+
date: 2026-09-05
|
|
4
|
+
last_updated: 2026-09-05
|
|
5
|
+
category: integration-issues
|
|
6
|
+
module: Trackly Apply access-review facade
|
|
7
|
+
problem_type: integration_issue
|
|
8
|
+
component: mcp_facade
|
|
9
|
+
symptoms:
|
|
10
|
+
- A compact backend proposal could be rejected because the client required display identity fields in both projections
|
|
11
|
+
- Active execution recovery could reject the backend's enabled metadata or expose access_review without its proposal
|
|
12
|
+
- An empty access-review proposal could pass the client response schema and reach approval handling
|
|
13
|
+
- Follow-up proposal caching and key-order-sensitive accessKnowledge comparisons could reject a valid continuation
|
|
14
|
+
- The deferment list reused the execution-wave cap instead of naming the backend's active-deferment limit
|
|
15
|
+
root_cause: missing_validation
|
|
16
|
+
resolution_type: code_fix
|
|
17
|
+
severity: high
|
|
18
|
+
related_components: [api_layer, contract_verifier, documentation]
|
|
19
|
+
tags: [trackly-apply, access-review, proposal-receipt, zod, hosted-contract, fail-closed]
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# Bind access-review approval to validated proposal receipts
|
|
23
|
+
|
|
24
|
+
## Problem
|
|
25
|
+
|
|
26
|
+
The CLI receives an immutable access-review proposal before it can advance an
|
|
27
|
+
Apply execution. Approval must remain bound to that exact server receipt across
|
|
28
|
+
the response validator, the local cache, the hosted contract verifier, and the
|
|
29
|
+
agent-facing skill instructions.
|
|
30
|
+
|
|
31
|
+
## Symptoms
|
|
32
|
+
|
|
33
|
+
The deployed 3.8.x backend uses a compact local `proposedWave` array and keeps
|
|
34
|
+
the rich approval receipt in `accessProposal.members`. The client previously
|
|
35
|
+
required company, title, provider, and URL fields in both projections, so a real
|
|
36
|
+
response failed before approval. The active endpoint also returns `enabled`,
|
|
37
|
+
`active`, and `preserved`, while a pending access review is hydrated by the
|
|
38
|
+
detail endpoint rather than inlined. The response schemas accepted an empty
|
|
39
|
+
`proposedWave` or `accessProposal` without checking its counts. An access-review
|
|
40
|
+
continuation normally requires a concrete member set, while the all-deferred
|
|
41
|
+
case legitimately has no members and must be recognized by zero available
|
|
42
|
+
candidates plus a positive deferred count. Exact recovery can also be blocked
|
|
43
|
+
by a user deferment with zero members even when available candidates remain;
|
|
44
|
+
that `recovery_blocked_by_user_deferment` rationale is a safe stop/clear path.
|
|
45
|
+
Older 3.8.0 responses may omit the
|
|
46
|
+
optional deferment mapping, so those receipts remain a safe stop/expiry state
|
|
47
|
+
until a refreshed response supplies exact IDs. After an approval returned another
|
|
48
|
+
`nextAction: access_review`, the new proposal was not cached, so the next exact
|
|
49
|
+
approval was rejected locally. Matching nested `accessKnowledge` values by raw
|
|
50
|
+
JSON text also made harmless object key order look like a changed receipt.
|
|
51
|
+
Finally, the hosted verifier selected runtime class members before rejecting
|
|
52
|
+
computed keys, allowing a computed member to shadow the reviewed method without
|
|
53
|
+
being inspected.
|
|
54
|
+
|
|
55
|
+
## Root cause
|
|
56
|
+
|
|
57
|
+
The implementation treated each response branch independently. Schema presence
|
|
58
|
+
checks did not encode nonempty review membership or compact-vs-rich projection
|
|
59
|
+
parity, active recovery did not account for the endpoint's two-step shape, the
|
|
60
|
+
approval cache handled only the first proposal, and identity comparison relied
|
|
61
|
+
on serialization order. The verifier's member lookup likewise happened before
|
|
62
|
+
its computed-key guard.
|
|
63
|
+
|
|
64
|
+
## What Didn't Work
|
|
65
|
+
|
|
66
|
+
A single proposal-response model could not safely cover start, per-execution
|
|
67
|
+
reads, active recovery, and advance because those endpoints return different
|
|
68
|
+
identity and metadata envelopes. Passing responses through raw exposed
|
|
69
|
+
unmodelled fields, while forcing every active response through the access-review
|
|
70
|
+
schema rejected ordinary and preserved-terminal recovery. Caching only the
|
|
71
|
+
first proposal, clearing replay state on every refresh, and leaving proposal
|
|
72
|
+
bindings unbounded broke either exact retry behavior or long-running process
|
|
73
|
+
memory safety. Raw JSON serialization made receipt equality depend on object-key
|
|
74
|
+
order. Reusing the execution-wave limit for deferment discovery happened to
|
|
75
|
+
match the backend's current limit of 20, but concealed a separate contract that
|
|
76
|
+
could drift independently.
|
|
77
|
+
|
|
78
|
+
## Solution
|
|
79
|
+
|
|
80
|
+
- Accept the deployed compact local projection and the optional complete future
|
|
81
|
+
display identity projection, while keeping the rich receipt's immutable
|
|
82
|
+
fields required and matching both projections by ordered job ID and
|
|
83
|
+
`accessKnowledge`.
|
|
84
|
+
- Strictly validate start, advance, and active execution envelopes, preserve
|
|
85
|
+
`enabled`/`active`/`preserved`, and hydrate a pending access review from the
|
|
86
|
+
detail endpoint only when execution revision and `nextAction` still match.
|
|
87
|
+
- Require a nonempty member set for normal access-review proposals, while
|
|
88
|
+
accepting the bounded all-deferred shape with zero available candidates and at
|
|
89
|
+
least one deferred candidate. Also accept an empty
|
|
90
|
+
`recovery_blocked_by_user_deferment` receipt when at least one candidate is
|
|
91
|
+
deferred. Validate optional deferment mappings when supplied; a legacy
|
|
92
|
+
response without mappings cannot authorize a clear action.
|
|
93
|
+
- Validate progress candidate counts against the access proposal counts while
|
|
94
|
+
the response is still an `access_review`; after approval, the proposal is a
|
|
95
|
+
historical receipt and progress is allowed to reflect the newly created wave.
|
|
96
|
+
- Canonicalize nested `accessKnowledge` objects by key before comparing the
|
|
97
|
+
simple and rich proposal representations.
|
|
98
|
+
- Cache a newly returned access-review proposal after a successful approval so
|
|
99
|
+
the next continuation uses its current revision, ordered IDs, and hash. Bound
|
|
100
|
+
pending and replayable proposal bindings with oldest-entry eviction so an
|
|
101
|
+
abandoned execution cannot retain process memory indefinitely.
|
|
102
|
+
- Reject all computed instance members before selecting the hosted runtime
|
|
103
|
+
method, with fixtures for computed methods and fields.
|
|
104
|
+
- Keep the local and adapted Apply skills explicit about
|
|
105
|
+
`accessProposal.approvalHash`, and distinguish creation `jobId` values from
|
|
106
|
+
the discovered `defermentId` required to clear a deferment.
|
|
107
|
+
- Validate deferment discovery against the backend's dedicated active-deferment
|
|
108
|
+
limit rather than coupling it to the numerically equal execution-wave limit.
|
|
109
|
+
|
|
110
|
+
## Why This Works
|
|
111
|
+
|
|
112
|
+
Each endpoint is parsed through its own strict envelope before its response is
|
|
113
|
+
returned or cached. An access-review continuation must contain both the compact
|
|
114
|
+
proposal and rich receipt, and the validator binds their ordered job IDs,
|
|
115
|
+
contiguous member positions, frozen access knowledge, counts, and approval hash.
|
|
116
|
+
Start and active recovery hydrate the detail endpoint only when execution ID,
|
|
117
|
+
revision, and next action still match, preventing a stale detail response from
|
|
118
|
+
authorizing work. Separate bounded pending and replay caches preserve exact
|
|
119
|
+
same-key idempotency while rejecting a new key or evicting abandoned execution
|
|
120
|
+
state. Canonical object comparison removes serialization-order dependence, and
|
|
121
|
+
the separately named deferment limit prevents unrelated contract constants from
|
|
122
|
+
being coupled accidentally.
|
|
123
|
+
|
|
124
|
+
## Verification
|
|
125
|
+
|
|
126
|
+
PR #135 merged as `e97309a07a8afdc6eb62b28f4313c538699c72e5`.
|
|
127
|
+
Its CI passed 520 tests and the checked-in contract-fixture run passed 81 tests.
|
|
128
|
+
|
|
129
|
+
Backend PR `#1769` merged as `254e1c6fc3f98e2f3aa8c6492702e9b9480ca22e`
|
|
130
|
+
(head `4c4dab848b1068b1bd9290b14f8d278ec643aca6`) on 2026-09-05 14:00 PDT.
|
|
131
|
+
Production `https://closeai.mba/api/first-test` reports `build_sha`
|
|
132
|
+
`d5a9abf3e12fb384d50ec998d83a2370725ae3e7`, which contains that merge, and
|
|
133
|
+
every hosted path the CLI locks is byte-identical between the two commits.
|
|
134
|
+
Protected migration 507 completed on 2026-09-06 07:13 PDT
|
|
135
|
+
(`providerDefermentsReady: true` on an idempotent rerun after the first request
|
|
136
|
+
outlived the Cloudflare proxy timeout). The hosted contract fixture was
|
|
137
|
+
recaptured from `254e1c6fc3`, and
|
|
138
|
+
`TRACKLY_BACKEND_DIR=<clean checkout at 254e1c6fc3> npm run test:hosted-contract`
|
|
139
|
+
passes with Apply 3.8.1 and the 21-tool public facade at 1.0.0.
|
|
140
|
+
|
|
141
|
+
Still pending: npm publication of 0.18.1 through the merge of PR #136, and the
|
|
142
|
+
backend rollout switch `TRACKLY_APPLY_ACCESS_KNOWLEDGE_ROLLOUT`, which is unset
|
|
143
|
+
in production, so the live protocol still advertises the 3.7.x window; that
|
|
144
|
+
activation is a backend rollout decision outside this repository.
|
|
145
|
+
|
|
146
|
+
## Prevention
|
|
147
|
+
|
|
148
|
+
When an access-review response shape changes, update the executed schema,
|
|
149
|
+
response cache lifecycle, identity comparison, hosted verifier, both skill
|
|
150
|
+
surfaces, and regression fixtures together. Treat server-provided proposal
|
|
151
|
+
identity and hashes as the only approval authority.
|
package/docs/trackly-tools.md
CHANGED
|
@@ -115,10 +115,10 @@ API-key creation attempts.
|
|
|
115
115
|
- **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.
|
|
116
116
|
- **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.
|
|
117
117
|
- **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.
|
|
118
|
-
- **trackly_advance_apply_execution** — Transactionally select the next wave from the execution's original recent-first snapshot for the declared `browserSurface`. Returns
|
|
118
|
+
- **trackly_advance_apply_execution** — Transactionally select the next wave from the execution's original recent-first snapshot for the declared `browserSurface`. Returns a compact immutable `proposedWave` with each member's job ID and value-free `accessKnowledge`, plus the rich `accessProposal` receipt with member order, rationale, and approval hash. Same-key retries return identical identity, order, and rationale. Optional hash-bound `accessReviewApproval` probes exact proposed job IDs after personal deferments are cleared.
|
|
119
119
|
- **trackly_list_apply_access_deferments** — List the current user's persistent job/company/provider access deferments. Returns only scope identities; never provider names, URLs, or raw text.
|
|
120
|
-
- **trackly_defer_apply_access** — Persist an explicit user deferment for one Trackly `jobId` at `job`, `company`, or `provider` scope. The server derives company, provider, tenant, origin, and route; provider scope applies across companies,
|
|
121
|
-
- **trackly_clear_apply_access_deferment** — Clear one deferment listed or created in the current MCP session. Exact same-session replay is idempotent
|
|
120
|
+
- **trackly_defer_apply_access** — Persist an explicit user deferment for one Trackly `jobId` at `job`, `company`, or `provider` scope. The server derives company, provider, tenant, origin, and route; provider scope applies across companies until cleared; never submit a provider name, URL, or raw text.
|
|
121
|
+
- **trackly_clear_apply_access_deferment** — Clear one deferment listed or created in the current MCP session. Exact same-session replay is idempotent; after restart, refresh the active list instead of replaying an undiscovered cleared ID.
|
|
122
122
|
- **trackly_record_apply_execution_dispositions** — Record typed, value-free live-probe classifications for the current wave. Every item requires `jobId`, one allowed `classification`, `source: 'live_probe'`, and the exact `batchId`, `memberId`, `runId`, `expectedMemberVersion`, `expectedInspectionEpoch`, and `browserSurface`; cache/static scheduling records are server-owned and cannot be submitted through MCP.
|
|
123
123
|
- **trackly_stop_apply_execution** — Stop an execution with optimistic revision and idempotency guards.
|
|
124
124
|
- **trackly_create_apply_batch** — Freeze an exact recent-first set of approved jobs with an idempotency key.
|
|
@@ -149,10 +149,10 @@ API-key creation attempts.
|
|
|
149
149
|
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.
|
|
150
150
|
|
|
151
151
|
Version 3.8.1 adds access-aware scheduling receipts, `nextAction: access_review`,
|
|
152
|
-
hash-bound
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
152
|
+
hash-bound probe approval, and job/company/provider deferment tools. Provider scope
|
|
153
|
+
applies across companies until explicitly cleared. Historical OPEN never
|
|
154
|
+
authorizes form filling; every selected job still requires a fresh non-mutating
|
|
155
|
+
live probe.
|
|
156
156
|
|
|
157
157
|
Version 3.7 adds public exact-member recovery and scoped review-handoff claims,
|
|
158
158
|
plus local tab-finalizer and resume-upload proof validators. Recovery candidates
|
package/mcp/apply-tools.js
CHANGED
|
@@ -35,6 +35,7 @@ const APPLY_ACCESS_FRESHNESS_STATES = APPLY_CONTRACT.constants.applyAccessFreshn
|
|
|
35
35
|
const APPLY_SCHEDULING_EFFECTS = APPLY_CONTRACT.constants.applySchedulingEffects;
|
|
36
36
|
const APPLY_ACCESS_DEFERMENT_SCOPES = APPLY_CONTRACT.constants.applyAccessDefermentScopes;
|
|
37
37
|
const APPLY_ACCESS_MATCHED_SCOPES = APPLY_CONTRACT.constants.applyAccessMatchedScopes;
|
|
38
|
+
const APPLY_ACCESS_DEFERMENT_MAX_ACTIVE = 20;
|
|
38
39
|
const APPLY_BATCH_MAX_MEMBERS = 100;
|
|
39
40
|
const APPLY_BATCH_MAX_CHECKPOINTS_PER_REQUEST = 20;
|
|
40
41
|
const APPLY_BATCH_MAX_ACTIONS_PER_CHECKPOINT = 25;
|
|
@@ -177,30 +178,64 @@ const accessKnowledgeSchema = z.object({
|
|
|
177
178
|
evaluatedAt: z.string().datetime(),
|
|
178
179
|
freshLiveProbeRequired: z.literal(true),
|
|
179
180
|
}).strict();
|
|
181
|
+
const OPTIONAL_WAVE_DISPLAY_FIELDS = [
|
|
182
|
+
'jobTitle', 'companyName', 'provider', 'requisitionUrl',
|
|
183
|
+
];
|
|
184
|
+
function validateOptionalWaveDisplay(member, context) {
|
|
185
|
+
const present = OPTIONAL_WAVE_DISPLAY_FIELDS.filter((field) => member[field] !== undefined);
|
|
186
|
+
if (present.length > 0 && present.length < OPTIONAL_WAVE_DISPLAY_FIELDS.length) {
|
|
187
|
+
context.addIssue({
|
|
188
|
+
code: z.ZodIssueCode.custom,
|
|
189
|
+
path: [present[0]],
|
|
190
|
+
message: 'wave member display identity fields must be complete when projected',
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
180
194
|
const proposedWaveMemberSchema = z.object({
|
|
181
195
|
jobId: z.number().int().min(1),
|
|
182
|
-
|
|
196
|
+
// The local CLI projection is intentionally compact: the deployed backend
|
|
197
|
+
// returns only the stable job identity and scheduling receipt here. Rich
|
|
198
|
+
// approval metadata lives in accessProposal.members. The optional identity
|
|
199
|
+
// fields keep the client forward-compatible with the coordinated full
|
|
200
|
+
// identity projection without trusting them over the rich receipt.
|
|
201
|
+
// Protocol 3.8.0 compact receipts omit memberPosition. When a coordinated
|
|
202
|
+
// backend projects it, validate and compare it independently of display
|
|
203
|
+
// identity fields; never reject the deployed compact shape for its absence.
|
|
204
|
+
memberPosition: z.number().int().min(0).optional(),
|
|
183
205
|
jobTitle: z.string().min(1).refine((value) => Array.from(value).length <= 300, {
|
|
184
206
|
message: 'jobTitle must contain at most 300 Unicode code points',
|
|
185
|
-
}),
|
|
207
|
+
}).optional(),
|
|
186
208
|
companyName: z.string().min(1).refine((value) => Array.from(value).length <= 300, {
|
|
187
209
|
message: 'companyName must contain at most 300 Unicode code points',
|
|
188
|
-
}),
|
|
189
|
-
provider: z.string().regex(SAFE_OBSERVATION_CODE),
|
|
210
|
+
}).optional(),
|
|
211
|
+
provider: z.string().regex(SAFE_OBSERVATION_CODE).optional(),
|
|
190
212
|
requisitionUrl: z.string().url().max(2048).refine((value) => value.startsWith('https://'), {
|
|
191
213
|
message: 'requisitionUrl must use HTTPS',
|
|
192
|
-
}),
|
|
214
|
+
}).optional(),
|
|
193
215
|
accessKnowledge: accessKnowledgeSchema,
|
|
194
|
-
}).strict();
|
|
216
|
+
}).strict().superRefine(validateOptionalWaveDisplay);
|
|
195
217
|
const blockedJobDefermentSchema = z.object({
|
|
196
218
|
jobId: z.number().int().min(1),
|
|
197
219
|
defermentId: z.number().int().min(1),
|
|
198
220
|
scope: z.enum(APPLY_ACCESS_DEFERMENT_SCOPES),
|
|
199
221
|
}).strict();
|
|
200
|
-
const richProposedWaveMemberSchema =
|
|
222
|
+
const richProposedWaveMemberSchema = z.object({
|
|
223
|
+
jobId: z.number().int().min(1),
|
|
224
|
+
memberPosition: z.number().int().min(0),
|
|
225
|
+
jobTitle: z.string().min(1).refine((value) => Array.from(value).length <= 300, {
|
|
226
|
+
message: 'jobTitle must contain at most 300 Unicode code points',
|
|
227
|
+
}).optional(),
|
|
228
|
+
companyName: z.string().min(1).refine((value) => Array.from(value).length <= 300, {
|
|
229
|
+
message: 'companyName must contain at most 300 Unicode code points',
|
|
230
|
+
}).optional(),
|
|
231
|
+
provider: z.string().regex(SAFE_OBSERVATION_CODE).optional(),
|
|
232
|
+
requisitionUrl: z.string().url().max(2048).refine((value) => value.startsWith('https://'), {
|
|
233
|
+
message: 'requisitionUrl must use HTTPS',
|
|
234
|
+
}).optional(),
|
|
235
|
+
accessKnowledge: accessKnowledgeSchema,
|
|
201
236
|
rationaleCode: z.string().regex(SAFE_OBSERVATION_CODE),
|
|
202
237
|
receiptHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
203
|
-
}).strict();
|
|
238
|
+
}).strict().superRefine(validateOptionalWaveDisplay);
|
|
204
239
|
const accessProposalSchema = z.object({
|
|
205
240
|
proposalId: z.number().int().min(1),
|
|
206
241
|
approvalHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
@@ -218,6 +253,30 @@ const accessProposalSchema = z.object({
|
|
|
218
253
|
.optional(),
|
|
219
254
|
members: z.array(richProposedWaveMemberSchema).max(APPLY_EXECUTION_MAX_TARGET),
|
|
220
255
|
}).strict().superRefine((proposal, context) => {
|
|
256
|
+
const allDeferred = proposal.rationaleCode === 'all_candidates_user_deferred';
|
|
257
|
+
const recoveryBlocked = proposal.rationaleCode === 'recovery_blocked_by_user_deferment';
|
|
258
|
+
if (proposal.members.length === 0
|
|
259
|
+
&& ((!allDeferred && !recoveryBlocked)
|
|
260
|
+
|| (allDeferred
|
|
261
|
+
&& (proposal.availableCandidateCount !== 0 || proposal.deferredCandidateCount < 1))
|
|
262
|
+
|| (recoveryBlocked && proposal.deferredCandidateCount < 1))) {
|
|
263
|
+
context.addIssue({
|
|
264
|
+
code: z.ZodIssueCode.custom,
|
|
265
|
+
path: ['members'],
|
|
266
|
+
message: 'empty accessProposal members require no available candidates and at least one deferred candidate',
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
if (proposal.members.length > 0 && (allDeferred || recoveryBlocked)) {
|
|
270
|
+
context.addIssue({
|
|
271
|
+
code: z.ZodIssueCode.custom,
|
|
272
|
+
path: ['members'],
|
|
273
|
+
message: 'terminal deferment rationales require an empty accessProposal members array',
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
// The initial 3.8.0 backend response omitted this optional mapping. When
|
|
277
|
+
// it is present, the mapping is validated and cached so the agent can offer
|
|
278
|
+
// an exact clear action; when it is absent, the proposal remains a safe
|
|
279
|
+
// stop/expiry state until a refreshed backend receipt supplies IDs.
|
|
221
280
|
if (new Set(proposal.members.map(({ jobId }) => jobId)).size !== proposal.members.length) {
|
|
222
281
|
context.addIssue({
|
|
223
282
|
code: z.ZodIssueCode.custom,
|
|
@@ -278,6 +337,20 @@ const compatibleExecutionProgressSchema = executionProgressSchema.extend({
|
|
|
278
337
|
submitted: z.number().int().min(0),
|
|
279
338
|
}).strict().optional(),
|
|
280
339
|
}).strict();
|
|
340
|
+
function canonicalizeAccessKnowledge(value) {
|
|
341
|
+
if (Array.isArray(value)) return value.map(canonicalizeAccessKnowledge);
|
|
342
|
+
if (value && typeof value === 'object') {
|
|
343
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [
|
|
344
|
+
key,
|
|
345
|
+
canonicalizeAccessKnowledge(value[key]),
|
|
346
|
+
]));
|
|
347
|
+
}
|
|
348
|
+
return value;
|
|
349
|
+
}
|
|
350
|
+
function accessKnowledgeEqual(left, right) {
|
|
351
|
+
return JSON.stringify(canonicalizeAccessKnowledge(left))
|
|
352
|
+
=== JSON.stringify(canonicalizeAccessKnowledge(right));
|
|
353
|
+
}
|
|
281
354
|
function validateMatchingProposal(response, context) {
|
|
282
355
|
const simpleIds = response.proposedWave.map(({ jobId }) => jobId);
|
|
283
356
|
const richIds = response.accessProposal.members.map(({ jobId }) => jobId);
|
|
@@ -299,12 +372,12 @@ function validateMatchingProposal(response, context) {
|
|
|
299
372
|
if (response.proposedWave.some((member, index) => {
|
|
300
373
|
const rich = response.accessProposal.members[index];
|
|
301
374
|
return !rich
|
|
302
|
-
|| member.memberPosition !== rich.memberPosition
|
|
303
|
-
|| member.jobTitle !== rich.jobTitle
|
|
304
|
-
|| member.companyName !== rich.companyName
|
|
305
|
-
|| member.provider !== rich.provider
|
|
306
|
-
|| member.requisitionUrl !== rich.requisitionUrl
|
|
307
|
-
||
|
|
375
|
+
|| (member.memberPosition !== undefined && member.memberPosition !== rich.memberPosition)
|
|
376
|
+
|| (member.jobTitle !== undefined && member.jobTitle !== rich.jobTitle)
|
|
377
|
+
|| (member.companyName !== undefined && member.companyName !== rich.companyName)
|
|
378
|
+
|| (member.provider !== undefined && member.provider !== rich.provider)
|
|
379
|
+
|| (member.requisitionUrl !== undefined && member.requisitionUrl !== rich.requisitionUrl)
|
|
380
|
+
|| !accessKnowledgeEqual(member.accessKnowledge, rich.accessKnowledge)
|
|
308
381
|
|| rich.rationaleCode !== rich.accessKnowledge.rationaleCode;
|
|
309
382
|
})) {
|
|
310
383
|
context.addIssue({
|
|
@@ -313,6 +386,26 @@ function validateMatchingProposal(response, context) {
|
|
|
313
386
|
message: 'accessProposal must bind the exact displayed frozen identities and access knowledge',
|
|
314
387
|
});
|
|
315
388
|
}
|
|
389
|
+
if (response.progress?.nextAction === 'access_review'
|
|
390
|
+
&& response.progress.availableCandidateCount !== undefined
|
|
391
|
+
&& response.progress.availableCandidateCount
|
|
392
|
+
!== response.accessProposal.availableCandidateCount) {
|
|
393
|
+
context.addIssue({
|
|
394
|
+
code: z.ZodIssueCode.custom,
|
|
395
|
+
path: ['progress', 'availableCandidateCount'],
|
|
396
|
+
message: 'progress availableCandidateCount must match accessProposal',
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
if (response.progress?.nextAction === 'access_review'
|
|
400
|
+
&& response.progress.deferredCandidateCount !== undefined
|
|
401
|
+
&& response.progress.deferredCandidateCount
|
|
402
|
+
!== response.accessProposal.deferredCandidateCount) {
|
|
403
|
+
context.addIssue({
|
|
404
|
+
code: z.ZodIssueCode.custom,
|
|
405
|
+
path: ['progress', 'deferredCandidateCount'],
|
|
406
|
+
message: 'progress deferredCandidateCount must match accessProposal',
|
|
407
|
+
});
|
|
408
|
+
}
|
|
316
409
|
}
|
|
317
410
|
const proposedWaveResponseSchema = z.object({
|
|
318
411
|
success: z.literal(true),
|
|
@@ -355,16 +448,58 @@ const getExecutionAccessReviewResponseSchema = z.object({
|
|
|
355
448
|
accessProposal: accessProposalSchema,
|
|
356
449
|
}).strict().superRefine(validateMatchingProposal);
|
|
357
450
|
const activeExecutionAccessReviewResponseSchema = getExecutionAccessReviewResponseSchema.innerType().extend({
|
|
451
|
+
enabled: z.boolean().optional(),
|
|
358
452
|
active: z.boolean().optional(),
|
|
359
453
|
preserved: z.boolean().optional(),
|
|
360
|
-
}).strict().superRefine(validateMatchingProposal);
|
|
454
|
+
}).strict().superRefine(validateMatchingProposal).superRefine(rejectInactiveExecution);
|
|
361
455
|
const startExecutionAccessReviewResponseSchema = z.object({
|
|
362
456
|
success: z.literal(true),
|
|
457
|
+
replay: z.boolean().optional(),
|
|
363
458
|
execution: applyExecutionSchema,
|
|
459
|
+
candidateCount: z.number().int().min(0).optional(),
|
|
364
460
|
progress: executionProgressSchema,
|
|
365
461
|
proposedWave: z.array(proposedWaveMemberSchema).max(APPLY_EXECUTION_MAX_TARGET),
|
|
366
462
|
accessProposal: accessProposalSchema,
|
|
367
463
|
}).strict().superRefine(validateMatchingProposal);
|
|
464
|
+
const startExecutionOrdinaryResponseSchema = z.object({
|
|
465
|
+
success: z.literal(true),
|
|
466
|
+
replay: z.boolean().optional(),
|
|
467
|
+
execution: applyExecutionSchema,
|
|
468
|
+
candidateCount: z.number().int().min(0).optional(),
|
|
469
|
+
progress: compatibleExecutionProgressSchema,
|
|
470
|
+
}).strict();
|
|
471
|
+
const startExecutionResponseSchema = z.union([
|
|
472
|
+
startExecutionAccessReviewResponseSchema,
|
|
473
|
+
startExecutionOrdinaryResponseSchema,
|
|
474
|
+
]);
|
|
475
|
+
function rejectAccessReviewWithoutProposal(response, context) {
|
|
476
|
+
if (response.progress.nextAction === 'access_review'
|
|
477
|
+
&& (response.proposedWave === undefined || response.accessProposal === undefined)) {
|
|
478
|
+
context.addIssue({
|
|
479
|
+
code: z.ZodIssueCode.custom,
|
|
480
|
+
path: ['progress', 'nextAction'],
|
|
481
|
+
message: 'access_review responses must include proposedWave and accessProposal',
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function rejectInactiveExecution(response, context) {
|
|
486
|
+
// A preserved terminal execution is intentionally returned with
|
|
487
|
+
// `active: false` for read-only reconciliation. Only reject the
|
|
488
|
+
// contradictory combination where that same response still asks the
|
|
489
|
+
// caller to perform more work. Preserved terminals are valid only with
|
|
490
|
+
// nextAction `none`.
|
|
491
|
+
if (
|
|
492
|
+
response.active === false
|
|
493
|
+
&& response.execution !== undefined
|
|
494
|
+
&& response.progress?.nextAction !== 'none'
|
|
495
|
+
) {
|
|
496
|
+
context.addIssue({
|
|
497
|
+
code: z.ZodIssueCode.custom,
|
|
498
|
+
path: ['active'],
|
|
499
|
+
message: 'active=false terminal responses must use nextAction none',
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
368
503
|
const advanceExecutionResponseSchema = z.object({
|
|
369
504
|
success: z.literal(true),
|
|
370
505
|
executionId: z.number().int().min(1),
|
|
@@ -373,12 +508,34 @@ const advanceExecutionResponseSchema = z.object({
|
|
|
373
508
|
revision: z.number().int().min(1),
|
|
374
509
|
progress: compatibleExecutionProgressSchema,
|
|
375
510
|
replay: z.boolean().optional(),
|
|
376
|
-
}).strict();
|
|
377
|
-
const
|
|
511
|
+
}).strict().superRefine(rejectAccessReviewWithoutProposal);
|
|
512
|
+
const getExecutionResponseObjectSchema = z.object({
|
|
378
513
|
success: z.literal(true),
|
|
379
514
|
execution: applyExecutionSchema,
|
|
380
515
|
progress: compatibleExecutionProgressSchema,
|
|
381
516
|
}).strict();
|
|
517
|
+
const getExecutionResponseSchema = getExecutionResponseObjectSchema.superRefine(rejectAccessReviewWithoutProposal);
|
|
518
|
+
const activeExecutionOrdinaryResponseSchema = getExecutionResponseObjectSchema.extend({
|
|
519
|
+
enabled: z.boolean().optional(),
|
|
520
|
+
active: z.boolean().optional(),
|
|
521
|
+
preserved: z.boolean().optional(),
|
|
522
|
+
}).strict().superRefine(rejectAccessReviewWithoutProposal).superRefine(rejectInactiveExecution);
|
|
523
|
+
const activeExecutionInactiveResponseSchema = z.object({
|
|
524
|
+
success: z.literal(true),
|
|
525
|
+
enabled: z.boolean().optional(),
|
|
526
|
+
active: z.literal(false),
|
|
527
|
+
preserved: z.boolean().optional(),
|
|
528
|
+
}).strict().superRefine(rejectInactiveExecution);
|
|
529
|
+
const activeExecutionEnvelopeSchema = getExecutionResponseObjectSchema.extend({
|
|
530
|
+
enabled: z.boolean().optional(),
|
|
531
|
+
active: z.boolean().optional(),
|
|
532
|
+
preserved: z.boolean().optional(),
|
|
533
|
+
}).strict().superRefine(rejectInactiveExecution);
|
|
534
|
+
const activeExecutionResponseSchema = z.union([
|
|
535
|
+
activeExecutionInactiveResponseSchema,
|
|
536
|
+
activeExecutionOrdinaryResponseSchema,
|
|
537
|
+
activeExecutionAccessReviewResponseSchema,
|
|
538
|
+
]);
|
|
382
539
|
const accessDefermentSchema = z.object({
|
|
383
540
|
id: z.number().int().min(1),
|
|
384
541
|
jobId: z.number().int().min(1),
|
|
@@ -392,7 +549,7 @@ const clearedAccessDefermentSchema = accessDefermentSchema.extend({
|
|
|
392
549
|
}).strict();
|
|
393
550
|
const accessDefermentListResponseSchema = z.object({
|
|
394
551
|
success: z.literal(true),
|
|
395
|
-
deferments: z.array(accessDefermentSchema).max(
|
|
552
|
+
deferments: z.array(accessDefermentSchema).max(APPLY_ACCESS_DEFERMENT_MAX_ACTIVE),
|
|
396
553
|
}).strict().refine(({ deferments }) => (
|
|
397
554
|
new Set(deferments.map(({ id }) => id)).size === deferments.length
|
|
398
555
|
), { message: 'deferment IDs must be unique' });
|
|
@@ -634,7 +791,7 @@ const startApplyRunSchema = z.object({
|
|
|
634
791
|
}
|
|
635
792
|
});
|
|
636
793
|
|
|
637
|
-
const APPLY_RELIABILITY_PROMPT = 'Protocol 3.7 / skill 4.8.0 reliability gate: recover active work first. Consume the exact proposedWave and frozen accessKnowledge receipts before opening any browser. Rank OPEN ahead of neutral VARIES/UNKNOWN, then fresh ACCOUNT WALL; never auto-select an active user deferment. Historical OPEN never authorizes fill_form; freshLiveProbeRequired remains true. When nextAction is access_review, display the exact accessProposal and approvalHash,
|
|
794
|
+
const APPLY_RELIABILITY_PROMPT = 'Protocol 3.7 / skill 4.8.0 reliability gate: recover active work first. Consume the exact proposedWave and frozen accessKnowledge receipts before opening any browser. Rank OPEN ahead of neutral VARIES/UNKNOWN, then fresh ACCOUNT WALL; never auto-select an active user deferment. Historical OPEN never authorizes fill_form; freshLiveProbeRequired remains true. When nextAction is access_review, including ordinary OPEN or neutral proposals, display the exact accessProposal and accessProposal.approvalHash. If the proposal has nonempty members, obtain explicit approval for those exact ordered jobIds and call the hash-bound approval continuation. If the proposal has zero members because all remaining candidates are deferred or exact recovery is blocked by a user deferment, surface the deferred count and stable deferment IDs, offer clear-deferment only for explicitly returned IDs, stop, or expiry, and never send an empty approval. List or create deferments only at job, company, or provider scope through the jobId-bound tools; provider scope applies across companies until explicitly cleared. Do not open a browser or report the queue exhausted. Retain the latest explicit target as hard, prove genuine applicant fields before counting access, obtain approval for the exact accessible jobs before form mutation, fill all deterministic fields before one true-gap question packet, and validate each value-free phase checkpoint. Every checkpoint action must use its canonical continuationAllowed value; review/manual_submit, captcha/at_submit, trust/origin_mismatch, and observability/unverifiable_state require false. Fail closed on a rejected checkpoint. 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. An active personal deferment blocks exact recovery until cleared. 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.';
|
|
638
795
|
|
|
639
796
|
function registerApplyTools(
|
|
640
797
|
server,
|
|
@@ -679,17 +836,28 @@ function registerApplyTools(
|
|
|
679
836
|
}
|
|
680
837
|
const pendingAccessProposalByExecution = new Map();
|
|
681
838
|
const replayableAccessApprovalByExecution = new Map();
|
|
839
|
+
const MAX_ACCESS_PROPOSAL_BINDINGS = 64;
|
|
840
|
+
function setBoundedAccessProposalBinding(bindings, executionId, value) {
|
|
841
|
+
bindings.delete(executionId);
|
|
842
|
+
bindings.set(executionId, value);
|
|
843
|
+
while (bindings.size > MAX_ACCESS_PROPOSAL_BINDINGS) {
|
|
844
|
+
bindings.delete(bindings.keys().next().value);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
682
847
|
function rememberAccessProposal(executionId, revision, browserSurface, response) {
|
|
683
848
|
if (response.progress?.nextAction !== 'access_review') {
|
|
684
849
|
pendingAccessProposalByExecution.delete(executionId);
|
|
685
850
|
return response;
|
|
686
851
|
}
|
|
687
|
-
pendingAccessProposalByExecution
|
|
852
|
+
setBoundedAccessProposalBinding(pendingAccessProposalByExecution, executionId, {
|
|
688
853
|
revision,
|
|
689
854
|
browserSurface,
|
|
690
855
|
approvalHash: response.accessProposal.approvalHash,
|
|
691
856
|
jobIds: response.accessProposal.members.map(({ jobId }) => jobId),
|
|
692
857
|
});
|
|
858
|
+
for (const deferment of response.accessProposal.blockedJobDeferments ?? []) {
|
|
859
|
+
discoveredDefermentIds.add(deferment.defermentId);
|
|
860
|
+
}
|
|
693
861
|
return response;
|
|
694
862
|
}
|
|
695
863
|
server.tool(
|
|
@@ -812,11 +980,38 @@ function registerApplyTools(
|
|
|
812
980
|
const rawResponse = await applyControlRequest(
|
|
813
981
|
'POST', '/api/jobscout/apply/executions', body, idempotencyKey,
|
|
814
982
|
);
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
983
|
+
let response = startExecutionResponseSchema.parse(rawResponse);
|
|
984
|
+
// An idempotent start replay may return the existing pending execution
|
|
985
|
+
// and its access_review progress without inlining the proposal. Hydrate
|
|
986
|
+
// the same strict detail response used by active recovery before the
|
|
987
|
+
// start result is exposed or cached.
|
|
988
|
+
if (response.progress.nextAction === 'access_review'
|
|
989
|
+
&& response.proposedWave === undefined) {
|
|
990
|
+
const hydrated = validateGetExecutionResponse(await applyControlRequest(
|
|
991
|
+
'GET', `/api/jobscout/apply/executions/${response.execution.id}`,
|
|
992
|
+
));
|
|
993
|
+
if (hydrated.execution?.id !== response.execution.id
|
|
994
|
+
|| hydrated.execution.revision !== response.execution.revision
|
|
995
|
+
|| hydrated.progress.nextAction !== 'access_review'
|
|
996
|
+
|| hydrated.proposedWave === undefined) {
|
|
997
|
+
throw new Error('Trackly start access review did not include its proposal.');
|
|
998
|
+
}
|
|
999
|
+
response = startExecutionAccessReviewResponseSchema.parse({
|
|
1000
|
+
...response,
|
|
1001
|
+
execution: hydrated.execution,
|
|
1002
|
+
progress: hydrated.progress,
|
|
1003
|
+
proposedWave: hydrated.proposedWave,
|
|
1004
|
+
accessProposal: hydrated.accessProposal,
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
818
1007
|
if (response?.proposedWave !== undefined && response?.execution?.id !== undefined) {
|
|
819
1008
|
rememberAccessProposal(response.execution.id, response.execution.revision, null, response);
|
|
1009
|
+
} else if (response?.execution?.id !== undefined) {
|
|
1010
|
+
// A replay may legitimately return ordinary progress after the
|
|
1011
|
+
// access-review wave was consumed. Do not retain a prior approval
|
|
1012
|
+
// receipt that could authorize a later request for the same execution.
|
|
1013
|
+
pendingAccessProposalByExecution.delete(response.execution.id);
|
|
1014
|
+
replayableAccessApprovalByExecution.delete(response.execution.id);
|
|
820
1015
|
}
|
|
821
1016
|
return response;
|
|
822
1017
|
}, 'Failed to start apply execution')
|
|
@@ -828,13 +1023,44 @@ function registerApplyTools(
|
|
|
828
1023
|
{},
|
|
829
1024
|
wrapTool(async () => {
|
|
830
1025
|
const rawResponse = await applyControlRequest('GET', '/api/jobscout/apply/executions/active');
|
|
831
|
-
const
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
1026
|
+
const pendingProposal = rawResponse?.execution !== undefined
|
|
1027
|
+
&& rawResponse?.progress?.nextAction === 'access_review'
|
|
1028
|
+
&& rawResponse?.proposedWave === undefined;
|
|
1029
|
+
let response = pendingProposal
|
|
1030
|
+
? activeExecutionEnvelopeSchema.parse(rawResponse)
|
|
1031
|
+
: activeExecutionResponseSchema.parse(rawResponse);
|
|
1032
|
+
// The deployed active endpoint reports the authoritative execution and
|
|
1033
|
+
// progress but does not inline a pending access proposal. Hydrate that
|
|
1034
|
+
// proposal from the strict execution endpoint before exposing an
|
|
1035
|
+
// access_review response, otherwise restart/recovery would stall on a
|
|
1036
|
+
// false missing-proposal error.
|
|
1037
|
+
if (
|
|
1038
|
+
response.progress?.nextAction === 'access_review'
|
|
1039
|
+
&& response.execution?.id !== undefined
|
|
1040
|
+
&& response.proposedWave === undefined
|
|
1041
|
+
) {
|
|
1042
|
+
const hydrated = validateGetExecutionResponse(await applyControlRequest(
|
|
1043
|
+
'GET', `/api/jobscout/apply/executions/${response.execution.id}`,
|
|
1044
|
+
));
|
|
1045
|
+
if (hydrated.execution?.id !== response.execution.id
|
|
1046
|
+
|| hydrated.execution.revision !== response.execution.revision
|
|
1047
|
+
|| hydrated.progress.nextAction !== 'access_review'
|
|
1048
|
+
|| hydrated.proposedWave === undefined) {
|
|
1049
|
+
throw new Error('Trackly active execution access review did not include its proposal.');
|
|
1050
|
+
}
|
|
1051
|
+
response = activeExecutionResponseSchema.parse({
|
|
1052
|
+
...response,
|
|
1053
|
+
...hydrated,
|
|
1054
|
+
enabled: response.enabled,
|
|
1055
|
+
active: response.active,
|
|
1056
|
+
preserved: response.preserved,
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
836
1059
|
if (response?.execution?.id && response.proposedWave !== undefined) {
|
|
837
1060
|
rememberAccessProposal(response.execution.id, response.execution.revision, null, response);
|
|
1061
|
+
} else if (response?.execution?.id) {
|
|
1062
|
+
pendingAccessProposalByExecution.delete(response.execution.id);
|
|
1063
|
+
replayableAccessApprovalByExecution.delete(response.execution.id);
|
|
838
1064
|
}
|
|
839
1065
|
return response;
|
|
840
1066
|
}, 'Failed to recover active apply execution')
|
|
@@ -853,6 +1079,7 @@ function registerApplyTools(
|
|
|
853
1079
|
}
|
|
854
1080
|
if (response.proposedWave === undefined) {
|
|
855
1081
|
pendingAccessProposalByExecution.delete(executionId);
|
|
1082
|
+
replayableAccessApprovalByExecution.delete(executionId);
|
|
856
1083
|
return response;
|
|
857
1084
|
}
|
|
858
1085
|
return rememberAccessProposal(executionId, response.execution.revision, null, response);
|
|
@@ -1049,7 +1276,7 @@ function registerApplyTools(
|
|
|
1049
1276
|
|
|
1050
1277
|
server.tool(
|
|
1051
1278
|
'trackly_advance_apply_execution',
|
|
1052
|
-
'Advance an execution transactionally for the current browser surface. Returns the immutable proposedWave with frozen accessKnowledge receipts and never opens a browser. Same-key replay returns the same members, order, and rationale. Optional hash-bound accessReviewApproval probes exact
|
|
1279
|
+
'Advance an execution transactionally for the current browser surface. Returns the immutable proposedWave with frozen accessKnowledge receipts and never opens a browser. Same-key replay returns the same members, order, and rationale. Optional hash-bound accessReviewApproval probes the exact proposed job IDs only after personal deferments are cleared.',
|
|
1053
1280
|
{
|
|
1054
1281
|
executionId: z.number().int().min(1),
|
|
1055
1282
|
expectedRevision: z.number().int().min(1),
|
|
@@ -1090,13 +1317,16 @@ function registerApplyTools(
|
|
|
1090
1317
|
}
|
|
1091
1318
|
if (body.accessReviewApproval) {
|
|
1092
1319
|
pendingAccessProposalByExecution.delete(executionId);
|
|
1093
|
-
replayableAccessApprovalByExecution
|
|
1320
|
+
setBoundedAccessProposalBinding(replayableAccessApprovalByExecution, executionId, {
|
|
1094
1321
|
revision: body.expectedRevision,
|
|
1095
1322
|
browserSurface: body.browserSurface,
|
|
1096
1323
|
approvalHash: body.accessReviewApproval.approvalHash,
|
|
1097
1324
|
jobIds: [...body.accessReviewApproval.jobIds],
|
|
1098
1325
|
idempotencyKey,
|
|
1099
1326
|
});
|
|
1327
|
+
if (response.proposedWave !== undefined && response.progress?.nextAction === 'access_review') {
|
|
1328
|
+
rememberAccessProposal(executionId, response.revision, body.browserSurface, response);
|
|
1329
|
+
}
|
|
1100
1330
|
} else if (response.proposedWave !== undefined) {
|
|
1101
1331
|
rememberAccessProposal(executionId, response.revision, body.browserSurface, response);
|
|
1102
1332
|
} else {
|
|
@@ -1158,7 +1388,7 @@ function registerApplyTools(
|
|
|
1158
1388
|
|
|
1159
1389
|
server.tool(
|
|
1160
1390
|
'trackly_defer_apply_access',
|
|
1161
|
-
'Persist an explicit user deferment for one Trackly job or its derived company or provider scope. Provider scope
|
|
1391
|
+
'Persist an explicit user deferment for one Trackly job or its derived company or provider scope. Provider scope applies across companies until explicitly cleared. The server derives company, provider, tenant, origin, and route from jobId; never submit a provider name, URL, or free text.',
|
|
1162
1392
|
{
|
|
1163
1393
|
jobId: z.number().int().min(1),
|
|
1164
1394
|
scope: z.enum(APPLY_ACCESS_DEFERMENT_SCOPES),
|
|
@@ -1181,7 +1411,7 @@ function registerApplyTools(
|
|
|
1181
1411
|
|
|
1182
1412
|
server.tool(
|
|
1183
1413
|
'trackly_clear_apply_access_deferment',
|
|
1184
|
-
'Clear one explicit user deferment previously listed or created in this session. An exact same-session retry remains idempotent
|
|
1414
|
+
'Clear one explicit user deferment previously listed or created in this session. An exact same-session retry remains idempotent.',
|
|
1185
1415
|
{
|
|
1186
1416
|
defermentId: z.number().int().min(1),
|
|
1187
1417
|
idempotencyKey: z.string().min(16).max(200).regex(SAFE_IDEMPOTENCY_KEY),
|
|
@@ -1206,6 +1436,7 @@ function registerApplyTools(
|
|
|
1206
1436
|
throw new Error('Access deferment response does not match the requested deferment id.');
|
|
1207
1437
|
}
|
|
1208
1438
|
rememberClearedDefermentReplay(defermentId, idempotencyKey);
|
|
1439
|
+
discoveredDefermentIds.delete(defermentId);
|
|
1209
1440
|
return response;
|
|
1210
1441
|
}, 'Failed to clear apply access deferment')
|
|
1211
1442
|
);
|
|
@@ -1745,7 +1976,7 @@ function registerApplyTools(
|
|
|
1745
1976
|
role: 'user',
|
|
1746
1977
|
content: {
|
|
1747
1978
|
type: 'text',
|
|
1748
|
-
text: 'Protocol 3.7.0 reliability gate for new work: require MCP contract 3.8.1 and skill 4.8.0. Consume the exact proposedWave with frozen accessKnowledge before opening any browser; same-key advance replay returns identical member IDs, order, and rationale. When nextAction is access_review, display the
|
|
1979
|
+
text: 'Protocol 3.7.0 reliability gate for new work: require MCP contract 3.8.1 and skill 4.8.0. Consume the exact proposedWave with frozen accessKnowledge before opening any browser; same-key advance replay returns identical member IDs, order, and rationale. When nextAction is access_review, including ordinary OPEN or neutral proposals, display the exact access-review receipt and do not report exhaustion. For a nonempty proposal, obtain explicit approval for the unchanged ordered job IDs and server approval hash before probing. For an all-deferred or recovery-blocked proposal with zero members, show the deferred count and stable job/company/provider deferment IDs, offer clear-deferment only for explicitly returned IDs, stop, or expiry, and never send an empty approval. Defer or clear only through jobId-scoped job, company, or provider tools; provider scope applies across companies until explicitly cleared. Never submit provider names, URLs, or raw chat. After complete local context loss, list bounded recovery candidates, obtain explicit confirmation of the exact set, and recover only that set. An active personal deferment blocks exact recovery until cleared. 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.',
|
|
1749
1980
|
},
|
|
1750
1981
|
}, {
|
|
1751
1982
|
role: 'user',
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackly-cli",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "trackly-cli",
|
|
9
|
-
"version": "0.18.
|
|
9
|
+
"version": "0.18.1",
|
|
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.18.
|
|
3
|
+
"version": "0.18.1",
|
|
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.18.
|
|
20
|
+
"version": "0.18.1",
|
|
21
21
|
"packages": [
|
|
22
22
|
{
|
|
23
23
|
"registryType": "npm",
|
|
24
24
|
"identifier": "trackly-cli",
|
|
25
|
-
"version": "0.18.
|
|
25
|
+
"version": "0.18.1",
|
|
26
26
|
"runtimeHint": "npx",
|
|
27
27
|
"runtimeArguments": [
|
|
28
28
|
{
|
|
@@ -59,8 +59,8 @@ Read [references/legal-clarifications.md](references/legal-clarifications.md) wh
|
|
|
59
59
|
- When a visible form asks an office-scoped profile question and the exact employer office is known, request that field only through the matching member's `officeProjections` entry using the canonical `companyId:office-identity` scope. Consume only the returned `memberOfficeProfiles` entry for that same member and exact office; never move an office answer into the global profile map or reuse it for another member, employer, or office. A recovered member may reuse the answer only when Trackly proves its frozen company identity. If a legacy snapshot cannot prove that identity, preserve the form and treat the answer as unknown instead of refilling from chat or current mutable job metadata.
|
|
60
60
|
- Recover every entry in `execution.unresolvedWaves` in ascending `waveOrder`; `currentWave` identifies only the latest scheduling wave, not the complete recovery set.
|
|
61
61
|
- Immediately consume the start response's authoritative `progress` and `nextAction`; do not issue a speculative advance first.
|
|
62
|
-
- When that authoritative `nextAction` requests another wave, call `trackly_advance_apply_execution` with the actual current `browserSurface`, latest execution revision, and a fresh idempotency key. Pause on every returned immutable `proposedWave`, including ordinary OPEN or neutral proposals. Before asking for approval, show each server-frozen member in `memberPosition` order with its job ID
|
|
63
|
-
- Progress may include `availableCandidateCount` and `deferredCandidateCount`. When `nextAction` is `access_review`, display the bounded
|
|
62
|
+
- When that authoritative `nextAction` requests another wave, call `trackly_advance_apply_execution` with the actual current `browserSurface`, latest execution revision, and a fresh idempotency key. Pause on every returned immutable `proposedWave`, including ordinary OPEN or neutral proposals. The local response uses a compact `proposedWave` projection with each member's `jobId` and `accessKnowledge`, alongside the rich `accessProposal` receipt with contiguous `memberPosition`, rationale, and approval hash. Before asking for approval, show each rich server-frozen member in exact `memberPosition` order with its job ID and value-free scheduling reason; require the compact projection's ordered job IDs and access knowledge to match `accessProposal.members[]` exactly. Missing, noncontiguous, or mismatched identity blocks approval; never substitute current search results, chat, browser state, or a later job lookup for the frozen proposal. If the proposal has no members because all remaining candidates are deferred or exact recovery is blocked by a user deferment, do not submit an empty approval: show the returned deferred count and stable job/scope/deferment IDs, then either clear only deferment IDs the user explicitly selects and request a fresh proposal, or offer stop/expiry. If a legacy receipt omits the mapping, stop or wait for expiry and request a fresh proposal rather than guessing an ID. Otherwise obtain the user's explicit approval of that exact ordered set, then call `trackly_advance_apply_execution` again with the unchanged `accessProposal.members[].jobId` order, its server-provided `accessProposal.approvalHash`, returned revision, same browser surface, and a fresh idempotency key. After `createdWave: true`, never reapprove that wave; ask only for a later `nextAction: access_review` where no wave was created. Never substitute a synthetic hash or infer approval from an earlier request to apply generally. Never describe a proposed job as accessible until the current live probe proves applicant fields. `freshLiveProbeRequired` remains true for every classification; curated OPEN never changes `allowedOperations` to `fill_form`.
|
|
63
|
+
- Progress may include `availableCandidateCount` and `deferredCandidateCount`. When `nextAction` is `access_review`, display the bounded access-review proposal, including ordinary OPEN or neutral members, do not open a browser, and do not report the queue as exhausted. For a zero-member proposal declared `all_candidates_user_deferred` or `recovery_blocked_by_user_deferment`, offer clear-deferment only for explicitly returned deferment IDs; the recovery-blocked case may still report available candidates, so do not call it queue exhaustion. If a legacy receipt omits the mapping, stop or wait for expiry and request a fresh proposal rather than guessing an ID. Never send an empty approval. For a nonempty proposal, probe exact proposed job IDs only with the server-provided hash-bound `accessReviewApproval` after any active personal deferment for those jobs is cleared. List deferments with `trackly_list_apply_access_deferments`, create one with `trackly_defer_apply_access` from `jobId` and scope `job`, `company`, or `provider`, and clear one with `trackly_clear_apply_access_deferment` using a discovered deferment id. Provider scope is the approved global access-policy boundary: the backend derives the provider from the stable job anchor and applies it across companies until explicitly cleared. Never submit a URL, provider name, or free-text identity.
|
|
64
64
|
- Recover an existing execution even when `batchOrchestration.accessibleExecution.enabled` is false. When disabled and an execution is active, recover it read-only; never start, advance, or record dispositions. Use only get or stop operations.
|
|
65
65
|
- If the active execution target differs from the user's requested target, explain the mismatch and require explicit confirmation. Only then stop it with reason `target_changed`, verify the exact execution reached `stopped` or `closed` from the stop response or `trackly_get_apply_execution`, and refetch until the active-execution response says `active: false`. A terminal execution appears with `preserved: true` only when reconciliation evidence remains, so its absence is not a blocker after the exact execution is terminal. Start the replacement target without continuing the stopped wave.
|
|
66
66
|
- When the user asks to stop, call `trackly_stop_apply_execution` with reason `user_requested`, verify the exact execution reached `stopped` or `closed` from the stop response or `trackly_get_apply_execution`, refetch until `active: false`, and never continue its unresolved waves. If the response also says `preserved: true`, keep that record read-only for reconciliation; do not require preservation when no unresolved evidence remains.
|
|
@@ -83,30 +83,40 @@ probe while Greenhouse or other `OPEN`/neutral alternatives exist. Eightfold
|
|
|
83
83
|
starts as `ACCOUNT WALL`, is independently authentication-gated, and must not be
|
|
84
84
|
opened for a browser probe under the same condition. Static safety exclusions
|
|
85
85
|
always override `OPEN`.
|
|
86
|
-
An active job, company, or provider deferment is an unconditional no-browser
|
|
87
|
-
|
|
86
|
+
An active job, company, or provider deferment is an unconditional no-browser rule,
|
|
87
|
+
regardless of whether another accessible candidate remains.
|
|
88
88
|
|
|
89
89
|
Read `progress.availableCandidateCount`, `progress.deferredCandidateCount`, and
|
|
90
|
-
`nextAction`. When `nextAction` is `access_review`, return the bounded
|
|
91
|
-
proposal,
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
`
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
90
|
+
`nextAction`. When `nextAction` is `access_review`, return the bounded
|
|
91
|
+
access-review proposal, including ordinary OPEN or neutral members, open
|
|
92
|
+
nothing, and do not report the queue as exhausted. If the proposal has no
|
|
93
|
+
members because all remaining candidates are deferred, or because exact
|
|
94
|
+
recovery is blocked by a user deferment
|
|
95
|
+
(`recovery_blocked_by_user_deferment`), show its deferred count and matching
|
|
96
|
+
stable job/scope/deferment IDs, then offer clear-deferment only for IDs the user
|
|
97
|
+
explicitly selects and the server returns. Recovery-blocked proposals may
|
|
98
|
+
still report available candidates; they are not queue exhaustion. If a legacy
|
|
99
|
+
receipt omits that mapping, stop or expiry rather than guessing an ID. Never
|
|
100
|
+
send an empty approval.
|
|
101
|
+
Clear any active personal deferment before probing. For a nonempty proposal,
|
|
102
|
+
probe those exact job IDs only with hash-bound `accessReviewApproval` after
|
|
103
|
+
clearing that deferment. List or create deferments only through
|
|
104
|
+
`trackly_list_apply_access_deferments` and `trackly_defer_apply_access` using a
|
|
105
|
+
Trackly `jobId` and scope `job`, `company`, or `provider`. Provider scope is a
|
|
106
|
+
global policy boundary derived from that stable job anchor and applies across
|
|
107
|
+
companies until explicitly cleared. Clear only through
|
|
108
|
+
`trackly_clear_apply_access_deferment` using the exact user-selected
|
|
109
|
+
`defermentId` returned by deferment discovery or creation. Never submit a URL,
|
|
110
|
+
provider name, or raw chat.
|
|
101
111
|
|
|
102
112
|
Pause after every `proposedWave`. Same-key advance replay returns the same
|
|
103
113
|
member IDs, order, and rationale. Browser probing requires explicit wave
|
|
104
|
-
approval, including when every proposed job is OPEN or neutral.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
approval, including when every proposed job is OPEN or neutral. The local
|
|
115
|
+
projection contains each member's `jobId` and value-free `accessKnowledge`;
|
|
116
|
+
the rich `accessProposal.members[]` receipt contains the contiguous position,
|
|
117
|
+
rationale, and approval metadata. Show those exact job IDs and scheduling
|
|
118
|
+
reasons in order and require the compact projection's ordered IDs and access
|
|
119
|
+
knowledge to match the rich receipt. Missing, noncontiguous, or mismatched
|
|
110
120
|
identity blocks approval; never substitute chat, browser state, search results,
|
|
111
121
|
or a later mutable job lookup. Never describe a proposed job as accessible until the current
|
|
112
122
|
probe proves it. Bind approval to the unchanged
|