job-application-agent 3.4.2 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/installer/src/cli.mjs +8 -1
- package/installer/src/installer.mjs +8 -0
- package/job-application-agent/SKILL.md +32 -2
- package/job-application-agent/capabilities.json +3 -1
- package/job-application-agent/references/ACCOUNTING.md +104 -0
- package/job-application-agent/references/AUTONOMY.md +2 -0
- package/job-application-agent/references/CLOUD_STATE.md +2 -0
- package/job-application-agent/references/FREE_AI.md +39 -0
- package/job-application-agent/references/OUTREACH.md +210 -0
- package/job-application-agent/references/RUNS.md +35 -0
- package/job-application-agent/references/SCHEMAS.md +12 -2
- package/job-application-agent/references/agent-box/README.md +77 -0
- package/job-application-agent/references/agent-box/novnc.service.example +16 -0
- package/job-application-agent/scripts/application-accounting.mjs +246 -0
- package/job-application-agent/scripts/ats/answer-inject.mjs +203 -0
- package/job-application-agent/scripts/ats/submit-adapters.mjs +194 -0
- package/job-application-agent/scripts/attention-questions.mjs +111 -0
- package/job-application-agent/scripts/attention-resume-submit.mjs +450 -0
- package/job-application-agent/scripts/attention-runner-poll.mjs +328 -0
- package/job-application-agent/scripts/captcha-vendor.mjs +328 -0
- package/job-application-agent/scripts/cloud-state-client.mjs +62 -8
- package/job-application-agent/scripts/job-application.mjs +243 -27
- package/job-application-agent/scripts/novnc-display-guard.mjs +300 -0
- package/job-application-agent/scripts/outreach-cli.mjs +95 -0
- package/job-application-agent/scripts/outreach-domain.mjs +287 -0
- package/job-application-agent/scripts/outreach-store.mjs +72 -0
- package/job-application-agent/scripts/session-binding.mjs +474 -0
- package/job-application-agent/scripts/version.mjs +1 -1
- package/job-application-agent/tests/accounting-cli.test.mjs +86 -0
- package/job-application-agent/tests/accounting-cloud-client.test.mjs +183 -0
- package/job-application-agent/tests/accounting-retry-cli.test.mjs +96 -0
- package/job-application-agent/tests/accounting-source-race.test.mjs +109 -0
- package/job-application-agent/tests/answer-inject-captcha.test.mjs +169 -0
- package/job-application-agent/tests/application-accounting.test.mjs +315 -0
- package/job-application-agent/tests/attention-resume-submit.test.mjs +119 -0
- package/job-application-agent/tests/attention-runner-poll.test.mjs +135 -0
- package/job-application-agent/tests/fixtures/outreach.mjs +22 -0
- package/job-application-agent/tests/job-application.test.mjs +5 -0
- package/job-application-agent/tests/novnc-display-guard.test.mjs +50 -0
- package/job-application-agent/tests/outreach-cli.test.mjs +73 -0
- package/job-application-agent/tests/outreach.test.mjs +178 -0
- package/job-application-agent/tests/privacy-audit.test.mjs +2 -0
- package/job-application-agent/tests/review-cadence.test.mjs +66 -0
- package/job-application-agent/tests/session-binding.test.mjs +102 -0
- package/job-application-agent/tests/skill-contract.test.mjs +25 -0
- package/job-application-agent/tests/workflow-state.test.mjs +30 -3
- package/package.json +6 -3
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Hosted fill session binding — local-only contract so a paused attention run
|
|
4
|
+
* can reattach to the same headed Chrome tab / display / VNC path.
|
|
5
|
+
*
|
|
6
|
+
* Never syncs to cloud state (browser profile paths stay on the runner host).
|
|
7
|
+
*
|
|
8
|
+
* Hard product rule: live noVNC must share the fill display
|
|
9
|
+
* DISPLAY=:99 → x11vnc → localhost:5900
|
|
10
|
+
* TigerVNC :1 / 5901 is a product failure (cold desktop / wrong session).
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* node scripts/session-binding.mjs write --stdin
|
|
14
|
+
* node scripts/session-binding.mjs read --attention-id attention-…
|
|
15
|
+
* node scripts/session-binding.mjs check --stdin
|
|
16
|
+
* node scripts/session-binding.mjs path --attention-id attention-…
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdir, readFile, writeFile, chmod, access } from "node:fs/promises";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { pathToFileURL } from "node:url";
|
|
22
|
+
|
|
23
|
+
export const SESSION_BINDING_VERSION = 1;
|
|
24
|
+
|
|
25
|
+
/** Fill display on agent-box (Xvfb). */
|
|
26
|
+
export const FILL_DISPLAY = ":99";
|
|
27
|
+
|
|
28
|
+
/** x11vnc port that mirrors FILL_DISPLAY. */
|
|
29
|
+
export const FILL_VNC_PORT = 5900;
|
|
30
|
+
|
|
31
|
+
/** TigerVNC default — must never be the live panel target. */
|
|
32
|
+
export const FORBIDDEN_VNC_PORT = 5901;
|
|
33
|
+
|
|
34
|
+
/** TigerVNC display — never the fill/live share. */
|
|
35
|
+
export const FORBIDDEN_DISPLAY = ":1";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {{
|
|
39
|
+
* version: number,
|
|
40
|
+
* attentionId: string,
|
|
41
|
+
* jobUrl: string,
|
|
42
|
+
* applicationId?: string,
|
|
43
|
+
* roundId?: string,
|
|
44
|
+
* browserProfilePath: string,
|
|
45
|
+
* display: string,
|
|
46
|
+
* vncPort: number,
|
|
47
|
+
* tabHint?: { title?: string, urlContains?: string },
|
|
48
|
+
* createdAt: string,
|
|
49
|
+
* pausedAt?: string,
|
|
50
|
+
* }} SessionBinding
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {unknown} input
|
|
55
|
+
* @returns {{ ok: true, binding: SessionBinding } | { ok: false, error: string }}
|
|
56
|
+
*/
|
|
57
|
+
export function validateSessionBinding(input) {
|
|
58
|
+
const value = input && typeof input === "object" ? /** @type {Record<string, unknown>} */ (input) : null;
|
|
59
|
+
if (!value) return { ok: false, error: "session_binding_required" };
|
|
60
|
+
|
|
61
|
+
const attentionId = trimString(value.attentionId);
|
|
62
|
+
const jobUrl = trimString(value.jobUrl ?? value.url);
|
|
63
|
+
const browserProfilePath = trimString(value.browserProfilePath);
|
|
64
|
+
const display = normalizeDisplay(value.display ?? FILL_DISPLAY);
|
|
65
|
+
const vncPort = normalizePort(value.vncPort ?? FILL_VNC_PORT);
|
|
66
|
+
const applicationId = optionalTrim(value.applicationId);
|
|
67
|
+
const roundId = optionalTrim(value.roundId);
|
|
68
|
+
const createdAt = optionalTrim(value.createdAt) || new Date().toISOString();
|
|
69
|
+
const pausedAt = optionalTrim(value.pausedAt) || createdAt;
|
|
70
|
+
|
|
71
|
+
if (!attentionId) return { ok: false, error: "attentionId_required" };
|
|
72
|
+
if (!jobUrl) return { ok: false, error: "jobUrl_required" };
|
|
73
|
+
if (!isHttpUrl(jobUrl)) return { ok: false, error: "jobUrl_invalid" };
|
|
74
|
+
if (!browserProfilePath) return { ok: false, error: "browserProfilePath_required" };
|
|
75
|
+
if (browserProfilePath.length > 1024) return { ok: false, error: "browserProfilePath_too_long" };
|
|
76
|
+
if (!display) return { ok: false, error: "display_required" };
|
|
77
|
+
if (display === FORBIDDEN_DISPLAY) {
|
|
78
|
+
return { ok: false, error: "display_forbidden_tiger_vnc_use_fill_display_:99" };
|
|
79
|
+
}
|
|
80
|
+
if (display !== FILL_DISPLAY) {
|
|
81
|
+
return { ok: false, error: `display_must_be_${FILL_DISPLAY}` };
|
|
82
|
+
}
|
|
83
|
+
if (vncPort == null) return { ok: false, error: "vncPort_invalid" };
|
|
84
|
+
if (vncPort === FORBIDDEN_VNC_PORT) {
|
|
85
|
+
return { ok: false, error: "vncPort_forbidden_5901_use_fill_x11vnc_5900" };
|
|
86
|
+
}
|
|
87
|
+
if (vncPort !== FILL_VNC_PORT) {
|
|
88
|
+
return { ok: false, error: `vncPort_must_be_${FILL_VNC_PORT}` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @type {SessionBinding["tabHint"] | undefined} */
|
|
92
|
+
let tabHint;
|
|
93
|
+
if (value.tabHint != null) {
|
|
94
|
+
if (typeof value.tabHint !== "object") return { ok: false, error: "tabHint_invalid" };
|
|
95
|
+
const hint = /** @type {Record<string, unknown>} */ (value.tabHint);
|
|
96
|
+
const title = optionalTrim(hint.title);
|
|
97
|
+
const urlContains = optionalTrim(hint.urlContains ?? hint.urlPattern);
|
|
98
|
+
if (title || urlContains) {
|
|
99
|
+
tabHint = {
|
|
100
|
+
...(title ? { title } : {}),
|
|
101
|
+
...(urlContains ? { urlContains } : {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @type {SessionBinding} */
|
|
107
|
+
const binding = {
|
|
108
|
+
version: SESSION_BINDING_VERSION,
|
|
109
|
+
attentionId,
|
|
110
|
+
jobUrl,
|
|
111
|
+
browserProfilePath,
|
|
112
|
+
display,
|
|
113
|
+
vncPort,
|
|
114
|
+
createdAt,
|
|
115
|
+
pausedAt,
|
|
116
|
+
...(applicationId ? { applicationId } : {}),
|
|
117
|
+
...(roundId ? { roundId } : {}),
|
|
118
|
+
...(tabHint ? { tabHint } : {}),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// P1.5: optional judgment prompts for resume inject (never candidate responses).
|
|
122
|
+
if (Array.isArray(value.questions) && value.questions.length) {
|
|
123
|
+
binding.questions = value.questions
|
|
124
|
+
.slice(0, 8)
|
|
125
|
+
.map((item) => {
|
|
126
|
+
if (!item || typeof item !== "object") return null;
|
|
127
|
+
const id = trimString(item.id).slice(0, 120);
|
|
128
|
+
const prompt = trimString(item.prompt).slice(0, 800);
|
|
129
|
+
if (!id || !prompt) return null;
|
|
130
|
+
return {
|
|
131
|
+
id,
|
|
132
|
+
prompt,
|
|
133
|
+
kind: trimString(item.kind || "judgment").slice(0, 40) || "judgment",
|
|
134
|
+
required: item.required !== false,
|
|
135
|
+
};
|
|
136
|
+
})
|
|
137
|
+
.filter(Boolean);
|
|
138
|
+
}
|
|
139
|
+
if (value.aiAssistanceDiscouraged === true) {
|
|
140
|
+
binding.aiAssistanceDiscouraged = true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { ok: true, binding };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build a binding from attention pause fields (skill helper).
|
|
148
|
+
* @param {{
|
|
149
|
+
* attentionId: string,
|
|
150
|
+
* jobUrl: string,
|
|
151
|
+
* browserProfilePath: string,
|
|
152
|
+
* applicationId?: string,
|
|
153
|
+
* roundId?: string,
|
|
154
|
+
* display?: string,
|
|
155
|
+
* vncPort?: number,
|
|
156
|
+
* tabHint?: { title?: string, urlContains?: string },
|
|
157
|
+
* createdAt?: string,
|
|
158
|
+
* }} fields
|
|
159
|
+
*/
|
|
160
|
+
export function createSessionBinding(fields) {
|
|
161
|
+
const result = validateSessionBinding({
|
|
162
|
+
...fields,
|
|
163
|
+
display: fields.display ?? FILL_DISPLAY,
|
|
164
|
+
vncPort: fields.vncPort ?? FILL_VNC_PORT,
|
|
165
|
+
});
|
|
166
|
+
if (!result.ok) throw new Error(result.error);
|
|
167
|
+
return result.binding;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Compare live page URL against bound jobUrl. Refuse resume on drift.
|
|
172
|
+
* @param {string} boundJobUrl
|
|
173
|
+
* @param {string} livePageUrl
|
|
174
|
+
* @param {{ urlContains?: string }} [tabHint]
|
|
175
|
+
*/
|
|
176
|
+
export function assertSameTab(boundJobUrl, livePageUrl, tabHint = {}) {
|
|
177
|
+
const bound = trimString(boundJobUrl);
|
|
178
|
+
const live = trimString(livePageUrl);
|
|
179
|
+
if (!bound || !live) {
|
|
180
|
+
return { ok: false, reason: "url_missing", message: "Bound jobUrl and live page URL are required." };
|
|
181
|
+
}
|
|
182
|
+
let boundOriginPath;
|
|
183
|
+
let liveOriginPath;
|
|
184
|
+
try {
|
|
185
|
+
boundOriginPath = canonicalizeUrlForTab(bound);
|
|
186
|
+
liveOriginPath = canonicalizeUrlForTab(live);
|
|
187
|
+
} catch {
|
|
188
|
+
return { ok: false, reason: "url_invalid", message: "Could not parse bound or live URL." };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const hint = trimString(tabHint?.urlContains);
|
|
192
|
+
if (hint && !live.toLowerCase().includes(hint.toLowerCase())) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
reason: "tab_hint_mismatch",
|
|
196
|
+
message: `Live URL does not contain tabHint.urlContains (${hint}). Refuse resume — do not open a cold listing.`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (boundOriginPath === liveOriginPath) {
|
|
201
|
+
return { ok: true, reason: "exact", message: "Live tab matches bound jobUrl." };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Confirmation pages may append a path segment or query onto the filled form URL.
|
|
205
|
+
// Never accept a shorter listing URL as a match (that is cold-tab drift).
|
|
206
|
+
const boundBase = boundOriginPath.split("?")[0];
|
|
207
|
+
const liveBase = liveOriginPath.split("?")[0];
|
|
208
|
+
if (
|
|
209
|
+
liveBase === boundBase
|
|
210
|
+
|| liveBase.startsWith(`${boundBase}/`)
|
|
211
|
+
|| (liveOriginPath.startsWith(`${boundBase}?`) && liveBase === boundBase)
|
|
212
|
+
) {
|
|
213
|
+
return { ok: true, reason: "same_application_path", message: "Live tab is on the same application path family." };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
reason: "tab_drift",
|
|
219
|
+
message: [
|
|
220
|
+
"Live tab URL drifted from the paused filled form.",
|
|
221
|
+
`bound=${boundOriginPath}`,
|
|
222
|
+
`live=${liveOriginPath}`,
|
|
223
|
+
"Refuse resume submit. Re-open the filled application tab or fail closed.",
|
|
224
|
+
].join(" "),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Default local path for a binding (never cloud).
|
|
230
|
+
* @param {string} stateDir
|
|
231
|
+
* @param {string} attentionId
|
|
232
|
+
*/
|
|
233
|
+
export function sessionBindingPath(stateDir, attentionId) {
|
|
234
|
+
const id = trimString(attentionId);
|
|
235
|
+
if (!id) throw new Error("attentionId_required");
|
|
236
|
+
const safe = id.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
237
|
+
return join(stateDir, "session-bindings", `${safe}.json`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @param {string} filePath
|
|
242
|
+
* @param {SessionBinding} binding
|
|
243
|
+
*/
|
|
244
|
+
export async function writeSessionBindingFile(filePath, binding) {
|
|
245
|
+
const checked = validateSessionBinding(binding);
|
|
246
|
+
if (!checked.ok) throw new Error(checked.error);
|
|
247
|
+
await mkdir(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
248
|
+
await writeFile(filePath, `${JSON.stringify(checked.binding, null, 2)}\n`, { mode: 0o600 });
|
|
249
|
+
await chmod(filePath, 0o600).catch(() => {});
|
|
250
|
+
return checked.binding;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @param {string} filePath
|
|
255
|
+
* @returns {Promise<SessionBinding | null>}
|
|
256
|
+
*/
|
|
257
|
+
export async function readSessionBindingFile(filePath) {
|
|
258
|
+
try {
|
|
259
|
+
await access(filePath);
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
const raw = JSON.parse(await readFile(filePath, "utf8"));
|
|
264
|
+
const checked = validateSessionBinding(raw);
|
|
265
|
+
if (!checked.ok) throw new Error(`invalid_session_binding: ${checked.error}`);
|
|
266
|
+
return checked.binding;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Extract optional local-only binding fields from an attention add payload.
|
|
271
|
+
* These must never be appended to the cloud attention event.
|
|
272
|
+
* @param {Record<string, unknown>} value
|
|
273
|
+
*/
|
|
274
|
+
export function extractSessionBindingFields(value) {
|
|
275
|
+
const keys = ["browserProfilePath", "display", "vncPort", "tabHint"];
|
|
276
|
+
/** @type {Record<string, unknown>} */
|
|
277
|
+
const out = {};
|
|
278
|
+
let present = false;
|
|
279
|
+
for (const key of keys) {
|
|
280
|
+
if (Object.prototype.hasOwnProperty.call(value, key) && value[key] != null) {
|
|
281
|
+
out[key] = value[key];
|
|
282
|
+
present = true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return present ? out : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export const SESSION_BINDING_ATTENTION_KEYS = Object.freeze([
|
|
289
|
+
"browserProfilePath",
|
|
290
|
+
"display",
|
|
291
|
+
"vncPort",
|
|
292
|
+
"tabHint",
|
|
293
|
+
]);
|
|
294
|
+
|
|
295
|
+
function trimString(value) {
|
|
296
|
+
return typeof value === "string" ? value.trim() : "";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function optionalTrim(value) {
|
|
300
|
+
const s = trimString(value);
|
|
301
|
+
return s || undefined;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function normalizeDisplay(value) {
|
|
305
|
+
const raw = trimString(value);
|
|
306
|
+
if (!raw) return "";
|
|
307
|
+
return raw.startsWith(":") ? raw : `:${raw}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function normalizePort(value) {
|
|
311
|
+
const n = typeof value === "number" ? value : Number(String(value ?? "").trim());
|
|
312
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) return null;
|
|
313
|
+
return n;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isHttpUrl(value) {
|
|
317
|
+
try {
|
|
318
|
+
const url = new URL(value);
|
|
319
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
320
|
+
} catch {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function canonicalizeUrlForTab(value) {
|
|
326
|
+
const url = new URL(value);
|
|
327
|
+
url.hash = "";
|
|
328
|
+
// Drop common tracking params; keep ATS path identity.
|
|
329
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
330
|
+
if (/^(utm_|fbclid|gclid|mc_)/i.test(key)) url.searchParams.delete(key);
|
|
331
|
+
}
|
|
332
|
+
const path = url.pathname.replace(/\/+$/, "") || "/";
|
|
333
|
+
return `${url.origin}${path}${url.search}`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function resolveStateDir(env = process.env) {
|
|
337
|
+
return trimString(env.JOB_APPLICATION_AGENT_STATE_DIR)
|
|
338
|
+
|| join(trimString(env.HOME) || "/tmp", ".job-application-agent");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* @param {string[]} argv
|
|
343
|
+
*/
|
|
344
|
+
export function parseSessionBindingArgs(argv) {
|
|
345
|
+
const args = { command: "", attentionId: "", help: false };
|
|
346
|
+
const rest = [];
|
|
347
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
348
|
+
const flag = argv[i];
|
|
349
|
+
const next = argv[i + 1];
|
|
350
|
+
if (!args.command && !flag.startsWith("-")) {
|
|
351
|
+
args.command = flag;
|
|
352
|
+
} else if (flag === "--attention-id" || flag === "--id") {
|
|
353
|
+
args.attentionId = String(next ?? "").trim();
|
|
354
|
+
i += 1;
|
|
355
|
+
} else if (flag === "--stdin") {
|
|
356
|
+
args.stdin = true;
|
|
357
|
+
} else if (flag === "--help" || flag === "-h") {
|
|
358
|
+
args.help = true;
|
|
359
|
+
} else if (flag.startsWith("-")) {
|
|
360
|
+
throw new Error(`Unknown flag: ${flag}`);
|
|
361
|
+
} else {
|
|
362
|
+
rest.push(flag);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
args.rest = rest;
|
|
366
|
+
return args;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function readStdinJson() {
|
|
370
|
+
const chunks = [];
|
|
371
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
372
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
373
|
+
if (!text) throw new Error("stdin_json_required");
|
|
374
|
+
return JSON.parse(text);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function printHelp() {
|
|
378
|
+
console.log(`Usage:
|
|
379
|
+
node scripts/session-binding.mjs write --stdin
|
|
380
|
+
node scripts/session-binding.mjs read --attention-id <id>
|
|
381
|
+
node scripts/session-binding.mjs check --stdin
|
|
382
|
+
node scripts/session-binding.mjs path --attention-id <id>
|
|
383
|
+
|
|
384
|
+
write stdin JSON:
|
|
385
|
+
{ "attentionId", "jobUrl", "browserProfilePath",
|
|
386
|
+
"applicationId?", "roundId?", "display?" (default :99),
|
|
387
|
+
"vncPort?" (default 5900), "tabHint?" }
|
|
388
|
+
|
|
389
|
+
Hard rule: display=:99, vncPort=5900. Never 5901 / :1.
|
|
390
|
+
Bindings are local-only under $JOB_APPLICATION_AGENT_STATE_DIR/session-bindings/.
|
|
391
|
+
`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function main(argv = process.argv.slice(2)) {
|
|
395
|
+
let args;
|
|
396
|
+
try {
|
|
397
|
+
args = parseSessionBindingArgs(argv);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
console.error(`[session-binding] ${error instanceof Error ? error.message : error}`);
|
|
400
|
+
process.exitCode = 1;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (args.help || !args.command) {
|
|
404
|
+
printHelp();
|
|
405
|
+
process.exitCode = args.help ? 0 : 1;
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const stateDir = resolveStateDir();
|
|
410
|
+
|
|
411
|
+
if (args.command === "check") {
|
|
412
|
+
const input = args.stdin ? await readStdinJson() : {};
|
|
413
|
+
const result = validateSessionBinding(input);
|
|
414
|
+
console.log(JSON.stringify(result, null, 2));
|
|
415
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (args.command === "path") {
|
|
420
|
+
if (!args.attentionId) {
|
|
421
|
+
console.error("[session-binding] --attention-id is required");
|
|
422
|
+
process.exitCode = 1;
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
console.log(sessionBindingPath(stateDir, args.attentionId));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (args.command === "read") {
|
|
430
|
+
if (!args.attentionId) {
|
|
431
|
+
console.error("[session-binding] --attention-id is required");
|
|
432
|
+
process.exitCode = 1;
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const file = sessionBindingPath(stateDir, args.attentionId);
|
|
436
|
+
const binding = await readSessionBindingFile(file);
|
|
437
|
+
if (!binding) {
|
|
438
|
+
console.error(`[session-binding] not found: ${file}`);
|
|
439
|
+
process.exitCode = 1;
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
console.log(JSON.stringify(binding, null, 2));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (args.command === "write") {
|
|
447
|
+
const input = args.stdin ? await readStdinJson() : {};
|
|
448
|
+
if (args.attentionId && !input.attentionId) input.attentionId = args.attentionId;
|
|
449
|
+
const checked = validateSessionBinding(input);
|
|
450
|
+
if (!checked.ok) {
|
|
451
|
+
console.error(`[session-binding] ${checked.error}`);
|
|
452
|
+
process.exitCode = 1;
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const file = sessionBindingPath(stateDir, checked.binding.attentionId);
|
|
456
|
+
await writeSessionBindingFile(file, checked.binding);
|
|
457
|
+
console.log(JSON.stringify({ ok: true, path: file, binding: checked.binding }, null, 2));
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
console.error(`[session-binding] unknown command: ${args.command}`);
|
|
462
|
+
printHelp();
|
|
463
|
+
process.exitCode = 1;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const isDirect = import.meta.url === pathToFileURL(process.argv[1] ?? "").href
|
|
467
|
+
|| process.argv[1]?.endsWith("session-binding.mjs");
|
|
468
|
+
|
|
469
|
+
if (isDirect) {
|
|
470
|
+
main().catch((error) => {
|
|
471
|
+
console.error(`[session-binding] ${error instanceof Error ? error.message : error}`);
|
|
472
|
+
process.exitCode = 1;
|
|
473
|
+
});
|
|
474
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SKILL_VERSION = '3.
|
|
1
|
+
export const SKILL_VERSION = '3.6.0';
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
|
|
9
|
+
async function fixture(t) {
|
|
10
|
+
const dir = await mkdtemp(join(tmpdir(), 'accounting-cli-'));
|
|
11
|
+
t.after(() => rm(dir, { recursive: true, force: true }));
|
|
12
|
+
await writeFile(join(dir, 'telemetry.json'), JSON.stringify({ enabled: false, disclosed: true }));
|
|
13
|
+
const env = { ...process.env, JOB_APPLICATION_AGENT_STATE_DIR: dir, JOB_APPLICATION_AGENT_CLOUD_CONFIG: join(dir, 'absent.json'), JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL: 'http://127.0.0.1:9' };
|
|
14
|
+
const run = (args, input) => JSON.parse(execFileSync(process.execPath, [script, ...args], { env, encoding: 'utf8', input: JSON.stringify(input) }));
|
|
15
|
+
const fail = (args, input) => spawnSync(process.execPath, [script, ...args], { env, encoding: 'utf8', input: JSON.stringify(input) });
|
|
16
|
+
return { dir, run, fail };
|
|
17
|
+
}
|
|
18
|
+
const application = (roundId) => ({ id: 'app', company: 'Example', role: 'Senior Engineer', url: 'https://example.test/jobs/1', source: 'email', discoverySourceId: 'indeed', score: 90, status: 'submitted', submittedAt: '2026-01-01T00:00:00Z', approval: 'STANDING AUTHORIZATION', roundId });
|
|
19
|
+
const failure = { id: 'bounce-1', applicationId: 'app', attemptId: 'initial:app', type: 'delivery-failed', evidenceType: 'final-delivery-failure', evidence: 'Matched final failure for the original recruiting message.', occurredAt: '2026-01-02T00:00:00Z' };
|
|
20
|
+
|
|
21
|
+
test('late delivery failure corrects completed round and review without rewriting applications', async (t) => {
|
|
22
|
+
const {dir, run} = await fixture(t);
|
|
23
|
+
const roundId = 'legacy-round';
|
|
24
|
+
await writeFile(join(dir, 'rounds.ndjson'), [ { type:'started', roundId, requestedCount:1, occurredAt:'2026-01-01T00:00:00Z'}, {type:'completed',roundId,occurredAt:'2026-01-01T01:00:00Z'} ].map(JSON.stringify).join('\n')+'\n');
|
|
25
|
+
const raw = JSON.stringify(application(roundId))+'\n';
|
|
26
|
+
await writeFile(join(dir, 'applications.ndjson'), raw);
|
|
27
|
+
run(['ledger','delivery','--stdin'], failure);
|
|
28
|
+
run(['ledger','delivery','--stdin'], failure);
|
|
29
|
+
const status = run(['round','status',roundId]);
|
|
30
|
+
assert.equal(status.completed,true);
|
|
31
|
+
assert.equal(status.confirmedCount,0);
|
|
32
|
+
assert.equal(status.shortfallCount,1);
|
|
33
|
+
assert.equal(status.needsRecovery,true);
|
|
34
|
+
assert.equal(run(['ledger','review']).submittedTotal,0);
|
|
35
|
+
assert.equal(await readFile(join(dir,'applications.ndjson'),'utf8'),raw);
|
|
36
|
+
assert.equal((await readFile(join(dir,'delivery.ndjson'),'utf8')).trim().split('\n').length,1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('new rounds derive source totals and require qualified lead linkage', async (t) => {
|
|
40
|
+
const {run,fail} = await fixture(t);
|
|
41
|
+
const {roundId} = run(['round','start','--stdin'],{requestedCount:1});
|
|
42
|
+
assert.notEqual(fail(['round','source','--stdin'],{roundId,sourceId:'indeed',status:'searched',reviewedCount:20,qualifiedCount:1,evidence:'Unsupported summary'}).status,0);
|
|
43
|
+
const lead = { id:'lead-1',roundId,sourceId:'indeed',company:'Example',role:'Senior Engineer',url:'https://example.test/jobs/1',disposition:'qualified',evidence:'Meets the evidenced target requirements.',applicationId:'app',observedAt:'2026-01-01T00:00:00Z' };
|
|
44
|
+
run(['round','lead','--stdin'],lead);
|
|
45
|
+
run(['round','lead','--stdin'],lead);
|
|
46
|
+
for(const sourceId of ['indeed','linkedin-jobs-feed','hacker-news-who-is-hiring']) run(['round','source','--stdin'],{roundId,sourceId,status:'searched',evidence:'Actual synthetic search'});
|
|
47
|
+
run(['ledger','add','--stdin'],application(roundId));
|
|
48
|
+
const status=run(['round','status',roundId]);
|
|
49
|
+
assert.equal(status.discovery.sources.find(s=>s.sourceId==='indeed').reviewedCount,1);
|
|
50
|
+
assert.equal(run(['round','leads',roundId]).leads.length,1);
|
|
51
|
+
assert.equal(run(['round','complete','--stdin'],{roundId,concentrationReason:'stronger-fit',concentrationEvidence:'Only this source had a qualified role.'}).completed,true);
|
|
52
|
+
run(['round','lead','--stdin'],{...lead,id:'revision',supersedes:lead.id,disposition:'closed-stale'});
|
|
53
|
+
assert.equal(run(['round','status',roundId]).completed,true);
|
|
54
|
+
assert.equal(run(['round','leads',roundId]).qualifiedCount,0);
|
|
55
|
+
assert.notEqual(fail(['round','lead','--stdin'],{...lead,id:'new-lead',url:'https://different.example/job'}).status,0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
for (const scenario of [
|
|
59
|
+
{ name: 'different requisition IDs at the same URL', application: { employerJobId: 'req-1' }, lead: { employerJobId: 'req-2' }, matched: false },
|
|
60
|
+
{ name: 'different companies sharing a requisition ID and URL', application: { employerJobId: 'req-1' }, lead: { employerJobId: 'req-1', company: 'Another Company' }, matched: false },
|
|
61
|
+
{ name: 'different companies sharing a URL without requisition IDs', application: {}, lead: { company: 'Another Company' }, matched: false },
|
|
62
|
+
{ name: 'matching company and requisition ID across URL aliases', application: { employerJobId: 'req-1' }, lead: { employerJobId: 'req-1', url: 'https://ats.example.test/alias/1' }, matched: true },
|
|
63
|
+
{ name: 'matching company and URL when only one requisition ID is known', application: { employerJobId: 'req-1' }, lead: {}, matched: true },
|
|
64
|
+
]) {
|
|
65
|
+
test(`qualified lead attribution checks ${scenario.name}`, async (t) => {
|
|
66
|
+
const { dir, run, fail } = await fixture(t);
|
|
67
|
+
const { roundId } = run(['round', 'start', '--stdin'], { requestedCount: 1 });
|
|
68
|
+
const app = { ...application(roundId), ...scenario.application };
|
|
69
|
+
await writeFile(join(dir, 'applications.ndjson'), JSON.stringify(app) + '\n');
|
|
70
|
+
run(['round', 'lead', '--stdin'], {
|
|
71
|
+
id: 'qualified-lead', roundId, sourceId: 'indeed', company: 'Example', role: app.role,
|
|
72
|
+
url: app.url, disposition: 'qualified', applicationId: app.id,
|
|
73
|
+
observedAt: '2026-01-01T00:00:00Z', evidence: 'Synthetic verified requisition assessment.',
|
|
74
|
+
...scenario.lead,
|
|
75
|
+
});
|
|
76
|
+
for (const sourceId of ['indeed', 'linkedin-jobs-feed', 'hacker-news-who-is-hiring']) {
|
|
77
|
+
run(['round', 'source', '--stdin'], { roundId, sourceId, status: 'searched', evidence: 'Synthetic search performed.' });
|
|
78
|
+
}
|
|
79
|
+
const status = run(['round', 'status', roundId]);
|
|
80
|
+
assert.deepEqual(status.discovery.missingLeadApplicationIds, scenario.matched ? [] : [app.id]);
|
|
81
|
+
const completion = fail(['round', 'complete', '--stdin'], {
|
|
82
|
+
roundId, concentrationReason: 'stronger-fit', concentrationEvidence: 'Only the selected source had a qualifying opening.',
|
|
83
|
+
});
|
|
84
|
+
assert.equal(completion.status === 0, scenario.matched, completion.stderr);
|
|
85
|
+
});
|
|
86
|
+
}
|