job-application-agent 3.5.0 → 3.7.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.
Files changed (39) hide show
  1. package/README.md +38 -0
  2. package/installer/src/cli.mjs +23 -1
  3. package/installer/src/installer.mjs +8 -0
  4. package/job-application-agent/SKILL.md +21 -0
  5. package/job-application-agent/capabilities.json +2 -1
  6. package/job-application-agent/platforms.json +44 -0
  7. package/job-application-agent/references/AUTONOMY.md +2 -0
  8. package/job-application-agent/references/FREE_AI.md +39 -0
  9. package/job-application-agent/references/OUTREACH.md +210 -0
  10. package/job-application-agent/references/RUNS.md +29 -0
  11. package/job-application-agent/references/SCHEMAS.md +8 -2
  12. package/job-application-agent/references/agent-box/README.md +77 -0
  13. package/job-application-agent/references/agent-box/novnc.service.example +16 -0
  14. package/job-application-agent/scripts/ats/answer-inject.mjs +203 -0
  15. package/job-application-agent/scripts/ats/submit-adapters.mjs +194 -0
  16. package/job-application-agent/scripts/attention-questions.mjs +111 -0
  17. package/job-application-agent/scripts/attention-resume-submit.mjs +450 -0
  18. package/job-application-agent/scripts/attention-runner-poll.mjs +328 -0
  19. package/job-application-agent/scripts/captcha-vendor.mjs +328 -0
  20. package/job-application-agent/scripts/cloud-state-client.mjs +5 -1
  21. package/job-application-agent/scripts/job-application.mjs +117 -3
  22. package/job-application-agent/scripts/novnc-display-guard.mjs +300 -0
  23. package/job-application-agent/scripts/outreach-cli.mjs +95 -0
  24. package/job-application-agent/scripts/outreach-domain.mjs +287 -0
  25. package/job-application-agent/scripts/outreach-store.mjs +72 -0
  26. package/job-application-agent/scripts/session-binding.mjs +474 -0
  27. package/job-application-agent/scripts/version.mjs +1 -1
  28. package/job-application-agent/tests/answer-inject-captcha.test.mjs +169 -0
  29. package/job-application-agent/tests/attention-resume-submit.test.mjs +119 -0
  30. package/job-application-agent/tests/attention-runner-poll.test.mjs +135 -0
  31. package/job-application-agent/tests/fixtures/outreach.mjs +22 -0
  32. package/job-application-agent/tests/novnc-display-guard.test.mjs +50 -0
  33. package/job-application-agent/tests/outreach-cli.test.mjs +73 -0
  34. package/job-application-agent/tests/outreach.test.mjs +178 -0
  35. package/job-application-agent/tests/privacy-audit.test.mjs +2 -0
  36. package/job-application-agent/tests/session-binding.test.mjs +102 -0
  37. package/job-application-agent/tests/skill-contract.test.mjs +25 -0
  38. package/job-application-agent/tests/workflow-state.test.mjs +12 -1
  39. package/package.json +6 -3
@@ -0,0 +1,77 @@
1
+ # agent-box — fill display + live noVNC binding
2
+
3
+ Hosted fill and the buyer live panel **must share one session**.
4
+
5
+ | Piece | Value | Notes |
6
+ |-------|-------|--------|
7
+ | Fill display | `DISPLAY=:99` | Xvfb headed Chrome |
8
+ | Fill VNC | x11vnc → **`localhost:5900`** | Mirrors `:99` |
9
+ | Live HTTP front | noVNC / websockify historically **`:6080`** | Buyer iframe / magic-link target |
10
+ | Forbidden | TigerVNC **`:1` / `5901`** | Cold XFCE / wrong desktop = product failure |
11
+
12
+ ## Hard rule
13
+
14
+ **Live noVNC = fill display 5900.**
15
+ `websockify` must proxy `localhost:5900`. Never point buyer live at `5901`.
16
+
17
+ Verify on the box:
18
+
19
+ ```bash
20
+ node job-application-agent/scripts/novnc-display-guard.mjs --unit /etc/systemd/system/novnc.service
21
+ # or:
22
+ node job-application-agent/scripts/novnc-display-guard.mjs --text "$(systemctl cat novnc.service)"
23
+ ```
24
+
25
+ Example unit: [`novnc.service.example`](./novnc.service.example).
26
+
27
+ ## Session binding (pause)
28
+
29
+ When attention opens on a filled form, record a **local-only** binding:
30
+
31
+ ```bash
32
+ # Prefer optional fields on attention add (not synced to cloud):
33
+ node scripts/job-application.mjs attention add --stdin <<'JSON'
34
+ {
35
+ "roundId": "round-…",
36
+ "applicationId": "app-…",
37
+ "url": "https://jobs.ashbyhq.com/…/application",
38
+ "stage": "submission",
39
+ "blocker": "captcha",
40
+ "requiredActions": ["complete-captcha"],
41
+ "browserProfilePath": "/home/runner/.jaa-chrome-fill",
42
+ "display": ":99",
43
+ "vncPort": 5900,
44
+ "tabHint": { "urlContains": "/application" }
45
+ }
46
+ JSON
47
+
48
+ # Or write explicitly:
49
+ node scripts/session-binding.mjs write --stdin <<'JSON'
50
+ {
51
+ "attentionId": "attention-…",
52
+ "jobUrl": "https://jobs.ashbyhq.com/…/application",
53
+ "browserProfilePath": "/home/runner/.jaa-chrome-fill",
54
+ "display": ":99",
55
+ "vncPort": 5900
56
+ }
57
+ JSON
58
+ ```
59
+
60
+ Keep Chrome on that **exact filled tab** (same user-data-dir / CDP). Do not wipe the profile mid-pause. Do not navigate to a jobs listing.
61
+
62
+ ## Resume → submit
63
+
64
+ After `attention-runner-poll.mjs` exit `0`:
65
+
66
+ ```bash
67
+ node scripts/attention-resume-submit.mjs --checklist
68
+ node scripts/attention-resume-submit.mjs --attention-id attention-… --stdin <<'JSON'
69
+ {
70
+ "pageUrl": "https://jobs.ashbyhq.com/…/application",
71
+ "submitEnabled": true,
72
+ "leaseHeld": true
73
+ }
74
+ JSON
75
+ ```
76
+
77
+ Submit if the DOM is clear; ledger only after a **visible** ATS confirmation.
@@ -0,0 +1,16 @@
1
+ [Unit]
2
+ Description=noVNC for JobAppAgent fill display (Xvfb :99 / x11vnc 5900)
3
+ After=network.target x11vnc-fill.service
4
+ Requires=x11vnc-fill.service
5
+
6
+ [Service]
7
+ Type=simple
8
+ # HARD RULE: proxy fill x11vnc on localhost:5900 — NEVER TigerVNC 5901.
9
+ # Buyer live panel (ATTENTION_LIVE_SESSION_BASE_URL → :6080) must show the
10
+ # paused filled ATS form on DISPLAY=:99, not a cold desktop.
11
+ ExecStart=/usr/bin/websockify --web=/usr/share/novnc 6080 localhost:5900
12
+ Restart=on-failure
13
+ RestartSec=3
14
+
15
+ [Install]
16
+ WantedBy=multi-user.target
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Map approved attention answers → ATS narrative fields (Ashby-first).
3
+ *
4
+ * Pure helpers for the resume→submit loop. Browser automation stays outside —
5
+ * the runner receives fill instructions and injects into matching textareas.
6
+ */
7
+
8
+ import { fingerprintPrompt } from "../attention-questions.mjs";
9
+
10
+ export { fingerprintPrompt };
11
+
12
+ /**
13
+ * @typedef {{
14
+ * id?: string,
15
+ * label?: string,
16
+ * name?: string,
17
+ * placeholder?: string,
18
+ * selector?: string,
19
+ * required?: boolean,
20
+ * value?: string,
21
+ * }} NarrativeField
22
+ */
23
+
24
+ /**
25
+ * @typedef {{
26
+ * questionId: string,
27
+ * text: string,
28
+ * source?: string,
29
+ * prompt?: string,
30
+ * }} ApprovedAnswer
31
+ */
32
+
33
+ const ASHBY_NARRATIVE_SELECTORS = Object.freeze([
34
+ 'textarea[name*="question" i]',
35
+ 'textarea[id*="question" i]',
36
+ 'textarea[aria-label]',
37
+ "textarea",
38
+ ]);
39
+
40
+ /**
41
+ * @param {string} value
42
+ */
43
+ export function normalizeFieldLabel(value) {
44
+ return String(value ?? "")
45
+ .toLowerCase()
46
+ .replace(/[^\p{L}\p{N}\s]+/gu, " ")
47
+ .replace(/\s+/g, " ")
48
+ .trim();
49
+ }
50
+
51
+ /**
52
+ * Token Jaccard similarity in [0, 1].
53
+ * @param {string} left
54
+ * @param {string} right
55
+ */
56
+ export function labelSimilarity(left, right) {
57
+ const a = new Set(normalizeFieldLabel(left).split(" ").filter((t) => t.length > 2));
58
+ const b = new Set(normalizeFieldLabel(right).split(" ").filter((t) => t.length > 2));
59
+ if (!a.size || !b.size) return 0;
60
+ let overlap = 0;
61
+ for (const token of a) if (b.has(token)) overlap += 1;
62
+ return overlap / (a.size + b.size - overlap);
63
+ }
64
+
65
+ /**
66
+ * Prefer exact questionId → field.id, then best prompt/label similarity.
67
+ *
68
+ * @param {ApprovedAnswer[]} answers
69
+ * @param {NarrativeField[]} fields
70
+ * @param {{ minScore?: number }} [options]
71
+ */
72
+ export function mapAnswersToFields(answers, fields, options = {}) {
73
+ const minScore = Number.isFinite(options.minScore) ? options.minScore : 0.25;
74
+ const remaining = [...(Array.isArray(fields) ? fields : [])];
75
+ const mappings = [];
76
+ const unmatchedAnswers = [];
77
+
78
+ for (const answer of Array.isArray(answers) ? answers : []) {
79
+ const text = String(answer?.text ?? "").trim();
80
+ const questionId = String(answer?.questionId ?? "").trim();
81
+ if (!text || !questionId) continue;
82
+
83
+ let bestIndex = -1;
84
+ let bestScore = 0;
85
+ let matchedBy = "none";
86
+
87
+ for (let i = 0; i < remaining.length; i += 1) {
88
+ const field = remaining[i];
89
+ const fieldId = String(field.id ?? "").trim();
90
+ if (fieldId && fieldId === questionId) {
91
+ bestIndex = i;
92
+ bestScore = 1;
93
+ matchedBy = "question_id";
94
+ break;
95
+ }
96
+ const label = String(field.label ?? field.name ?? field.placeholder ?? "").trim();
97
+ const prompt = String(answer.prompt ?? "").trim();
98
+ const score = Math.max(
99
+ labelSimilarity(prompt, label),
100
+ labelSimilarity(questionId, label),
101
+ labelSimilarity(prompt, fieldId),
102
+ );
103
+ if (score > bestScore) {
104
+ bestScore = score;
105
+ bestIndex = i;
106
+ matchedBy = "label_similarity";
107
+ }
108
+ }
109
+
110
+ if (bestIndex < 0 || bestScore < minScore) {
111
+ unmatchedAnswers.push(answer);
112
+ continue;
113
+ }
114
+
115
+ const [field] = remaining.splice(bestIndex, 1);
116
+ const label = String(field.label ?? field.name ?? field.placeholder ?? field.id ?? "").trim();
117
+ mappings.push({
118
+ questionId,
119
+ fieldId: field.id ? String(field.id) : null,
120
+ selector: field.selector ? String(field.selector) : null,
121
+ label,
122
+ text,
123
+ source: String(answer.source ?? "typed"),
124
+ score: bestScore,
125
+ matchedBy,
126
+ });
127
+ }
128
+
129
+ return {
130
+ mappings,
131
+ unmatchedAnswers,
132
+ unmatchedFields: remaining,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Build Playwright/CDP-oriented inject plan for Ashby (and generic) textareas.
138
+ *
139
+ * @param {{
140
+ * answers?: ApprovedAnswer[],
141
+ * questions?: { id: string, prompt: string }[],
142
+ * fields?: NarrativeField[],
143
+ * pageUrl?: string,
144
+ * }} input
145
+ */
146
+ export function buildAnswerInjectPlan(input = {}) {
147
+ const questions = Array.isArray(input.questions) ? input.questions : [];
148
+ const answers = (Array.isArray(input.answers) ? input.answers : []).map((answer) => {
149
+ const question = questions.find((item) => item.id === answer.questionId);
150
+ return {
151
+ ...answer,
152
+ prompt: answer.prompt ?? question?.prompt ?? "",
153
+ };
154
+ });
155
+
156
+ const fields = Array.isArray(input.fields) && input.fields.length
157
+ ? input.fields
158
+ : answers.map((answer, index) => ({
159
+ id: answer.questionId,
160
+ label: answer.prompt || answer.questionId,
161
+ selector: `textarea >> nth=${index}`,
162
+ required: true,
163
+ }));
164
+
165
+ const mapped = mapAnswersToFields(answers, fields);
166
+ return {
167
+ adapterHint: /ashbyhq\.com/i.test(String(input.pageUrl ?? "")) ? "ashby" : "generic",
168
+ textareaSelectors: [...ASHBY_NARRATIVE_SELECTORS],
169
+ fills: mapped.mappings.map((item) => ({
170
+ questionId: item.questionId,
171
+ selector: item.selector,
172
+ label: item.label,
173
+ text: item.text,
174
+ source: item.source,
175
+ matchedBy: item.matchedBy,
176
+ score: item.score,
177
+ action: "fill_textarea",
178
+ })),
179
+ unmatchedAnswers: mapped.unmatchedAnswers,
180
+ unmatchedFields: mapped.unmatchedFields,
181
+ notes: [
182
+ "Inject approved answers into matching textareas on the BOUND filled tab only.",
183
+ "Never silent-paste model drafts — only typed / draft_approved / bank answers from the resume signal.",
184
+ "After fill, re-inspect; if required narrative remains empty → attention again.",
185
+ "Then continue existing resume→submit adapters.",
186
+ ],
187
+ };
188
+ }
189
+
190
+ /**
191
+ * @param {{ answers?: ApprovedAnswer[], blockers?: string[] }} input
192
+ */
193
+ export function shouldInjectAnswersBeforeSubmit(input = {}) {
194
+ const answers = Array.isArray(input.answers)
195
+ ? input.answers.filter((a) => String(a?.text ?? "").trim())
196
+ : [];
197
+ if (!answers.length) return { inject: false, reason: "no_answers" };
198
+ return {
199
+ inject: true,
200
+ reason: "approved_answers_present",
201
+ answerCount: answers.length,
202
+ };
203
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * ATS-agnostic resume submit / confirmation helpers.
3
+ *
4
+ * Pure selector + classification data for the hosted resume→submit loop.
5
+ * Browser automation (Playwright/CDP) stays outside this module — the runner
6
+ * injects a page snapshot; these helpers decide the next action.
7
+ *
8
+ * Prefer submit after resume when the DOM is clear (golden-path submit bias).
9
+ * filled ≠ applied until a visible confirmation surface is observed.
10
+ */
11
+
12
+ /** Absolute blockers that must stay paused for a human. */
13
+ export const ABSOLUTE_BLOCKER_HINTS = Object.freeze([
14
+ { id: "captcha", patterns: [/recaptcha/i, /hcaptcha/i, /captcha/i, /cf-turnstile/i] },
15
+ { id: "authentication", patterns: [/sign in/i, /log in/i, /sso/i, /single sign-on/i, /verify your identity/i] },
16
+ { id: "mfa", patterns: [/two-factor/i, /2fa/i, /authenticator/i, /enter (the )?code/i, /one-time password/i] },
17
+ { id: "legal-attestation", patterns: [/i agree/i, /terms of use/i, /privacy policy/i, /ai policy/i, /acknowledge/i] },
18
+ { id: "judgment", patterns: [/why (do )?you (want to )?work/i, /cover letter/i, /in your own words/i] },
19
+ { id: "government-id", patterns: [/passport/i, /driver'?s license/i, /government (issued )?id/i] },
20
+ { id: "demographic", patterns: [/gender identity/i, /race\/ethnicity/i, /veteran status/i, /disability status/i] },
21
+ ]);
22
+
23
+ /**
24
+ * @typedef {{
25
+ * id: string,
26
+ * matchHosts?: RegExp[],
27
+ * submitSelectors: string[],
28
+ * confirmationSelectors: string[],
29
+ * confirmationUrlPatterns: RegExp[],
30
+ * blockerSelectors?: string[],
31
+ * }} AtsSubmitAdapter
32
+ */
33
+
34
+ /** @type {AtsSubmitAdapter} */
35
+ export const ASHBY_ADAPTER = Object.freeze({
36
+ id: "ashby",
37
+ matchHosts: [/ashbyhq\.com$/i, /jobs\.ashbyhq\.com$/i],
38
+ submitSelectors: [
39
+ 'button[type="submit"]',
40
+ 'button:has-text("Submit")',
41
+ 'button:has-text("Submit application")',
42
+ '[data-testid="submit-application"]',
43
+ 'input[type="submit"]',
44
+ ],
45
+ confirmationSelectors: [
46
+ "text=/thank you for applying/i",
47
+ "text=/application (has been )?submitted/i",
48
+ "text=/we.?ve received your application/i",
49
+ '[data-testid="application-submitted"]',
50
+ ".application-submitted",
51
+ ],
52
+ confirmationUrlPatterns: [
53
+ /\/application-submitted\b/i,
54
+ /[?&]submitted=1\b/i,
55
+ /\/confirmation\b/i,
56
+ ],
57
+ blockerSelectors: [
58
+ "iframe[src*='recaptcha']",
59
+ "iframe[src*='hcaptcha']",
60
+ ".g-recaptcha",
61
+ "[data-testid='captcha']",
62
+ ],
63
+ });
64
+
65
+ /** @type {AtsSubmitAdapter} */
66
+ export const GENERIC_ADAPTER = Object.freeze({
67
+ id: "generic",
68
+ submitSelectors: [
69
+ 'button[type="submit"]',
70
+ 'button:has-text("Submit")',
71
+ 'button:has-text("Submit application")',
72
+ 'input[type="submit"][value*="Submit" i]',
73
+ ],
74
+ confirmationSelectors: [
75
+ "text=/thank you for applying/i",
76
+ "text=/application (has been )?submitted/i",
77
+ "text=/we.?ve received your application/i",
78
+ "text=/successfully submitted/i",
79
+ ],
80
+ confirmationUrlPatterns: [
81
+ /\/thanks?\b/i,
82
+ /\/confirmation\b/i,
83
+ /\/application-submitted\b/i,
84
+ /[?&]submitted=1\b/i,
85
+ ],
86
+ blockerSelectors: [
87
+ "iframe[src*='recaptcha']",
88
+ "iframe[src*='hcaptcha']",
89
+ ".g-recaptcha",
90
+ ],
91
+ });
92
+
93
+ export const ATS_ADAPTERS = Object.freeze([ASHBY_ADAPTER, GENERIC_ADAPTER]);
94
+
95
+ /**
96
+ * @param {string} pageUrl
97
+ * @returns {AtsSubmitAdapter}
98
+ */
99
+ export function resolveAtsAdapter(pageUrl) {
100
+ let host = "";
101
+ try {
102
+ host = new URL(pageUrl).hostname;
103
+ } catch {
104
+ return GENERIC_ADAPTER;
105
+ }
106
+ for (const adapter of ATS_ADAPTERS) {
107
+ if (adapter.id === "generic") continue;
108
+ if (adapter.matchHosts?.some((re) => re.test(host))) return adapter;
109
+ }
110
+ return GENERIC_ADAPTER;
111
+ }
112
+
113
+ /**
114
+ * @param {{
115
+ * pageUrl?: string,
116
+ * pageText?: string,
117
+ * visibleTexts?: string[],
118
+ * matchedBlockerSelectors?: string[],
119
+ * }} snapshot
120
+ * @param {{ mode?: "pause" | "resume" }} [options]
121
+ * @returns {{ blocked: boolean, blockers: string[] }}
122
+ */
123
+ export function detectAbsoluteBlockers(snapshot = {}, options = {}) {
124
+ const mode = options.mode === "resume" ? "resume" : "pause";
125
+ const blockers = new Set();
126
+ const haystack = [
127
+ String(snapshot.pageText ?? ""),
128
+ ...(Array.isArray(snapshot.visibleTexts) ? snapshot.visibleTexts : []),
129
+ ].join("\n");
130
+
131
+ // On resume, prefer active challenge signals — static legal/"why us" copy often
132
+ // remains visible after the candidate already acted in the live panel.
133
+ const hints = mode === "resume"
134
+ ? ABSOLUTE_BLOCKER_HINTS.filter((hint) => ["captcha", "authentication", "mfa", "government-id"].includes(hint.id))
135
+ : ABSOLUTE_BLOCKER_HINTS;
136
+
137
+ for (const hint of hints) {
138
+ if (hint.patterns.some((re) => re.test(haystack))) blockers.add(hint.id);
139
+ }
140
+ if (Array.isArray(snapshot.matchedBlockerSelectors) && snapshot.matchedBlockerSelectors.length) {
141
+ blockers.add("captcha");
142
+ }
143
+ return { blocked: blockers.size > 0, blockers: [...blockers] };
144
+ }
145
+
146
+ /**
147
+ * @param {{
148
+ * pageUrl?: string,
149
+ * matchedConfirmationSelectors?: string[],
150
+ * pageText?: string,
151
+ * }} snapshot
152
+ * @param {AtsSubmitAdapter} [adapter]
153
+ */
154
+ export function detectConfirmation(snapshot = {}, adapter) {
155
+ const resolved = adapter ?? resolveAtsAdapter(snapshot.pageUrl ?? "");
156
+ const url = String(snapshot.pageUrl ?? "");
157
+ if (resolved.confirmationUrlPatterns.some((re) => re.test(url))) {
158
+ return { confirmed: true, reason: "url_pattern", adapterId: resolved.id };
159
+ }
160
+ if (Array.isArray(snapshot.matchedConfirmationSelectors) && snapshot.matchedConfirmationSelectors.length) {
161
+ return {
162
+ confirmed: true,
163
+ reason: "confirmation_selector",
164
+ adapterId: resolved.id,
165
+ matched: snapshot.matchedConfirmationSelectors,
166
+ };
167
+ }
168
+ const text = String(snapshot.pageText ?? "");
169
+ if (/thank you for applying|application (has been )?submitted|we.?ve received your application/i.test(text)) {
170
+ return { confirmed: true, reason: "confirmation_text", adapterId: resolved.id };
171
+ }
172
+ return { confirmed: false, reason: "not_found", adapterId: resolved.id };
173
+ }
174
+
175
+ /**
176
+ * Guidance payload for a browser tool (selectors only — no automation here).
177
+ * @param {string} pageUrl
178
+ */
179
+ export function buildSubmitProbePlan(pageUrl) {
180
+ const adapter = resolveAtsAdapter(pageUrl);
181
+ return {
182
+ adapterId: adapter.id,
183
+ submitSelectors: [...adapter.submitSelectors],
184
+ confirmationSelectors: [...adapter.confirmationSelectors],
185
+ blockerSelectors: [...(adapter.blockerSelectors ?? [])],
186
+ confirmationUrlPatterns: adapter.confirmationUrlPatterns.map((re) => re.source),
187
+ notes: [
188
+ "Focus the same filled ATS tab from session binding (never a cold jobs listing).",
189
+ "If absolute blockers remain, update attention honestly — do not submit.",
190
+ "If clear, click submit (submit bias). Wait for a visible confirmation surface.",
191
+ "Only then: cloud intent-confirm / ledger add. filled ≠ applied.",
192
+ ],
193
+ };
194
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Pause-time attention question packaging + AI-policy detection (skill/runner).
3
+ * Mirrors site/lib/attention-questions.mjs for the published skill package.
4
+ */
5
+
6
+ export const AI_ASSISTANCE_DISCOURAGED_PATTERNS = Object.freeze([
7
+ /\bdo(?:n'?t| not)\s+use\s+ai\b/i,
8
+ /\bno\s+ai\s+assistance\b/i,
9
+ /\bwithout\s+ai\s+assistance\b/i,
10
+ /\bai[- ]generated\s+(?:answers?|content|responses?)\s+(?:are|is)\s+(?:not\s+)?(?:allowed|permitted|welcome)/i,
11
+ /\bplease\s+(?:write|answer)\s+in\s+your\s+own\s+(?:words|voice)\b/i,
12
+ /\b(?:write|answer)\s+in\s+your\s+own\s+(?:words|voice)\b/i,
13
+ /\bgenerative\s+ai\s+(?:is|are)\s+(?:not\s+)?(?:allowed|permitted)/i,
14
+ /\bchatgpt\b.*\b(?:not|never|don'?t)\b/i,
15
+ /\b(?:not|never|don'?t)\b.*\bchatgpt\b/i,
16
+ ]);
17
+
18
+ /**
19
+ * @param {unknown} text
20
+ * @returns {boolean}
21
+ */
22
+ export function detectAiAssistanceDiscouraged(text) {
23
+ const haystack = String(text ?? "");
24
+ if (!haystack.trim()) return false;
25
+ return AI_ASSISTANCE_DISCOURAGED_PATTERNS.some((re) => re.test(haystack));
26
+ }
27
+
28
+ /**
29
+ * @param {string} prompt
30
+ */
31
+ export function fingerprintPrompt(prompt) {
32
+ const normalized = String(prompt ?? "")
33
+ .toLowerCase()
34
+ .replace(/[^\p{L}\p{N}\s]+/gu, " ")
35
+ .replace(/\s+/g, " ")
36
+ .trim()
37
+ .slice(0, 240);
38
+ let hash = 2166136261;
39
+ for (let i = 0; i < normalized.length; i += 1) {
40
+ hash ^= normalized.charCodeAt(i);
41
+ hash = Math.imul(hash, 16777619);
42
+ }
43
+ return `q-${(hash >>> 0).toString(16).padStart(8, "0")}`;
44
+ }
45
+
46
+ const KINDS = new Set(["judgment", "why-us", "proud-project", "narrative", "other"]);
47
+
48
+ /**
49
+ * @param {unknown} raw
50
+ * @returns {{ id: string, prompt: string, kind: string, required: boolean }[]}
51
+ */
52
+ export function normalizeAttentionQuestions(raw) {
53
+ if (!Array.isArray(raw)) return [];
54
+ const out = [];
55
+ const seen = new Set();
56
+ for (const item of raw.slice(0, 8)) {
57
+ if (!item || typeof item !== "object") continue;
58
+ const prompt = String(item.prompt ?? item.text ?? item.label ?? "").trim().slice(0, 800);
59
+ if (!prompt) continue;
60
+ let id = String(item.id ?? "").trim().slice(0, 120);
61
+ if (!id) id = fingerprintPrompt(prompt);
62
+ if (seen.has(id)) continue;
63
+ seen.add(id);
64
+ const kindRaw = String(item.kind ?? "judgment").trim().toLowerCase().slice(0, 40);
65
+ out.push({
66
+ id,
67
+ prompt,
68
+ kind: KINDS.has(kindRaw) ? kindRaw : "judgment",
69
+ required: item.required !== false,
70
+ });
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * Extract narrative prompts from posting / form copy when the agent did not pass questions[].
77
+ * Best-effort packaging for judgment pauses.
78
+ * @param {string} pageText
79
+ * @param {{ limit?: number }} [options]
80
+ */
81
+ export function extractNarrativeQuestionsFromText(pageText, options = {}) {
82
+ const limit = Number.isFinite(options.limit) ? Math.max(1, Math.min(options.limit, 6)) : 4;
83
+ const text = String(pageText ?? "");
84
+ if (!text.trim()) return [];
85
+ const patterns = [
86
+ /why (?:do )?you (?:want to )?work(?: at| for| with)?[^\n?]{0,80}\?/gi,
87
+ /why (?:are you interested|this (?:role|company|team))[^\n?]{0,80}\?/gi,
88
+ /(?:tell us about|describe) (?:a )?project you(?:'re| are)? proud of[^\n?]{0,40}\?/gi,
89
+ /what (?:motivates|excites) you[^\n?]{0,60}\?/gi,
90
+ /in your own words[^\n?]{0,80}\?/gi,
91
+ ];
92
+ const found = [];
93
+ const seen = new Set();
94
+ for (const re of patterns) {
95
+ re.lastIndex = 0;
96
+ let match;
97
+ while ((match = re.exec(text)) && found.length < limit) {
98
+ const prompt = match[0].replace(/\s+/g, " ").trim();
99
+ const id = fingerprintPrompt(prompt);
100
+ if (seen.has(id)) continue;
101
+ seen.add(id);
102
+ found.push({
103
+ id,
104
+ prompt,
105
+ kind: /proud|project/i.test(prompt) ? "proud-project" : /why/i.test(prompt) ? "why-us" : "judgment",
106
+ required: true,
107
+ });
108
+ }
109
+ }
110
+ return found;
111
+ }