jobhunt-kit 0.2.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/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +14 -0
- package/AGENTS.md +23 -0
- package/CLAUDE.md +5 -0
- package/README.md +122 -0
- package/bin/jobhunt-kit.mjs +95 -0
- package/installer-assets/gitignore.txt +22 -0
- package/installer-assets/runtime-lock.json +492 -0
- package/installer-assets/workspace-lock.json +495 -0
- package/package.json +52 -0
- package/plugins/jobhunt-kit/.claude-plugin/plugin.json +9 -0
- package/plugins/jobhunt-kit/.codex-plugin/plugin.json +18 -0
- package/plugins/jobhunt-kit/THIRD_PARTY.md +18 -0
- package/plugins/jobhunt-kit/package-lock.json +492 -0
- package/plugins/jobhunt-kit/package.json +14 -0
- package/plugins/jobhunt-kit/references/cli.md +125 -0
- package/plugins/jobhunt-kit/references/cover-guidance.md +24 -0
- package/plugins/jobhunt-kit/references/hirify/LICENSE +202 -0
- package/plugins/jobhunt-kit/references/hirify/NOTICE +2 -0
- package/plugins/jobhunt-kit/references/hirify/SKILL.md +139 -0
- package/plugins/jobhunt-kit/references/hirify/reference.md +286 -0
- package/plugins/jobhunt-kit/references/matching.md +28 -0
- package/plugins/jobhunt-kit/references/resume-guidance.md +47 -0
- package/plugins/jobhunt-kit/references/storage.md +120 -0
- package/plugins/jobhunt-kit/references/workflow.md +89 -0
- package/plugins/jobhunt-kit/scripts/cli.mjs +9 -0
- package/plugins/jobhunt-kit/scripts/commands.mjs +157 -0
- package/plugins/jobhunt-kit/scripts/extract-resume.mjs +27 -0
- package/plugins/jobhunt-kit/scripts/hirify.mjs +31 -0
- package/plugins/jobhunt-kit/scripts/profile.mjs +79 -0
- package/plugins/jobhunt-kit/scripts/resume.mjs +92 -0
- package/plugins/jobhunt-kit/scripts/send-packet.mjs +36 -0
- package/plugins/jobhunt-kit/scripts/setup.mjs +25 -0
- package/plugins/jobhunt-kit/scripts/tracker.mjs +332 -0
- package/plugins/jobhunt-kit/skills/job-apply/SKILL.md +61 -0
- package/plugins/jobhunt-kit/skills/job-profile/SKILL.md +33 -0
- package/plugins/jobhunt-kit/skills/job-resume/SKILL.md +29 -0
- package/plugins/jobhunt-kit/skills/job-search/SKILL.md +39 -0
- package/plugins/jobhunt-kit/skills/job-track/SKILL.md +30 -0
- package/plugins/jobhunt-kit/templates/cover-letter.md +17 -0
- package/plugins/jobhunt-kit/templates/intake.md +62 -0
- package/plugins/jobhunt-kit/templates/policy.json +6 -0
- package/plugins/jobhunt-kit/templates/profile.json +29 -0
- package/plugins/jobhunt-kit/templates/profile.md +21 -0
- package/plugins/jobhunt-kit/templates/resume-review.md +30 -0
- package/plugins/jobhunt-kit/templates/scheduled-search.md +21 -0
- package/scripts/check-package.mjs +62 -0
- package/tests/commands.test.mjs +143 -0
- package/tests/installer.test.mjs +52 -0
- package/tests/tracker.test.mjs +180 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { basename, extname, join, resolve, dirname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { readJSON, writeJSON } from './profile.mjs';
|
|
7
|
+
import { Tracker } from './tracker.mjs';
|
|
8
|
+
|
|
9
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
const fingerprint = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
11
|
+
export function importResume(dir, file) {
|
|
12
|
+
const pfile = join(dir, 'profile.json');
|
|
13
|
+
const profile = readJSON(pfile);
|
|
14
|
+
const bytes = readFileSync(resolve(file));
|
|
15
|
+
if (!bytes.length) throw new Error('Resume file is empty');
|
|
16
|
+
const extension = extname(file).toLowerCase();
|
|
17
|
+
if (!['.txt', '.md', '.pdf', '.docx', '.doc', '.rtf'].includes(extension)) throw new Error('Supported resume files: txt, md, pdf, docx, doc, rtf');
|
|
18
|
+
const sha256 = fingerprint(bytes);
|
|
19
|
+
const filename = `${sha256}${extension}`;
|
|
20
|
+
const path = join(dir, 'resumes', filename);
|
|
21
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
22
|
+
if (!existsSync(path)) writeFileSync(path, bytes, { flag: 'wx' });
|
|
23
|
+
else if (fingerprint(readFileSync(path)) !== sha256) throw new Error('Stored resume fingerprint mismatch');
|
|
24
|
+
if (profile.resume?.sha256 !== sha256 || profile.resume?.path !== `resumes/${filename}`) {
|
|
25
|
+
profile.resume = { path: `resumes/${filename}`, sha256, review_status: 'not_reviewed' };
|
|
26
|
+
profile.confirmed_at = null;
|
|
27
|
+
const store = new Tracker(dir);
|
|
28
|
+
try { store.event('resume_imported', { source_name: basename(file), sha256, bytes: bytes.length }); }
|
|
29
|
+
finally { store.close(); }
|
|
30
|
+
writeJSON(pfile, profile);
|
|
31
|
+
}
|
|
32
|
+
return { path, sha256, bytes: bytes.length, review_status: profile.resume.review_status, next: 'resume check' };
|
|
33
|
+
}
|
|
34
|
+
export function checkResume(dir) {
|
|
35
|
+
const profile = readJSON(join(dir, 'profile.json'));
|
|
36
|
+
if (!profile.resume?.path) throw new Error('Import a resume first: resume <file>');
|
|
37
|
+
const path = resolve(dir, profile.resume.path);
|
|
38
|
+
const bytes = readFileSync(path);
|
|
39
|
+
const sha256 = fingerprint(bytes);
|
|
40
|
+
const extension = extname(path).toLowerCase();
|
|
41
|
+
const findings = [];
|
|
42
|
+
let text = null;
|
|
43
|
+
let extraction = 'not_performed';
|
|
44
|
+
let pages = null;
|
|
45
|
+
if (['.txt', '.md'].includes(extension)) {
|
|
46
|
+
try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); extraction = 'utf8_text'; }
|
|
47
|
+
catch { findings.push('File is not valid UTF-8 text; export it as UTF-8'); }
|
|
48
|
+
} else if (['.pdf', '.docx'].includes(extension)) {
|
|
49
|
+
const child = spawnSync(process.execPath, ['--max-old-space-size=512', join(ROOT, 'scripts', 'extract-resume.mjs'), resolve(dir), path],
|
|
50
|
+
{ encoding: 'utf8', timeout: 60000, maxBuffer: 8 * 1024 * 1024, windowsHide: true });
|
|
51
|
+
if (child.error || child.status !== 0) findings.push(`Text extraction failed: ${child.error?.message || child.stderr?.trim() || 'unknown parser error'}`);
|
|
52
|
+
else {
|
|
53
|
+
try { const parsed = JSON.parse(child.stdout); text = parsed.text; extraction = parsed.method; pages = parsed.pages; findings.push(...parsed.warnings); }
|
|
54
|
+
catch { findings.push('Parser output could not be read; inspect the original file'); }
|
|
55
|
+
}
|
|
56
|
+
} else findings.push('Legacy DOC/RTF: export to PDF, DOCX or UTF-8 text for extraction');
|
|
57
|
+
if (text !== null) {
|
|
58
|
+
if (!text.trim()) findings.push('No readable text; an image-only scan may require OCR');
|
|
59
|
+
if (text.includes('\0')) findings.push('NUL bytes found; check text encoding');
|
|
60
|
+
if (!/[\w.+-]+@[\w.-]+\.[a-z]{2,}/i.test(text)) findings.push('No email detected by a simple pattern; verify contacts');
|
|
61
|
+
if (!/(experience|опыт|employment|work history|профессиональн)/iu.test(text)) findings.push('No common experience heading detected; inspect structure');
|
|
62
|
+
}
|
|
63
|
+
const result = { file: path, sha256, registered_sha256: profile.resume.sha256,
|
|
64
|
+
fingerprint_matches: sha256 === profile.resume.sha256, bytes: bytes.length,
|
|
65
|
+
text_extraction: extraction, characters: text?.length ?? null, pages,
|
|
66
|
+
visual_review: 'not_performed', factual_review: 'not_performed', ats_test: 'not_performed', findings,
|
|
67
|
+
review_status: profile.resume.review_status, next_skill: 'job-resume' };
|
|
68
|
+
if (!result.fingerprint_matches) findings.unshift('File changed after registration; import the current version again');
|
|
69
|
+
const output = join(dir, 'materials', `resume-${sha256}`);
|
|
70
|
+
mkdirSync(output, { recursive: true });
|
|
71
|
+
writeJSON(join(output, 'checks.json'), result);
|
|
72
|
+
if (text !== null) writeFileSync(join(output, 'text.txt'), text);
|
|
73
|
+
const review = join(output, 'review.md');
|
|
74
|
+
if (!existsSync(review)) writeFileSync(review, readFileSync(join(ROOT, 'templates', 'resume-review.md')), { flag: 'wx' });
|
|
75
|
+
return { ...result, checks_path: join(output, 'checks.json'), review_path: review,
|
|
76
|
+
agent_instruction: `Use job-resume. Review ${path}; mechanical checks: ${join(output, 'checks.json')}. Complete ${review}. Do not infer an ATS score from these checks.` };
|
|
77
|
+
}
|
|
78
|
+
export function recordResumeReview(dir, review) {
|
|
79
|
+
const file = join(dir, 'profile.json');
|
|
80
|
+
const profile = readJSON(file);
|
|
81
|
+
if (!profile.resume?.path) throw new Error('Import a resume first');
|
|
82
|
+
const actual = fingerprint(readFileSync(resolve(dir, profile.resume.path)));
|
|
83
|
+
if (review.sha256 !== actual || profile.resume.sha256 !== actual) throw new Error('Review must refer to the current registered file');
|
|
84
|
+
if (!['ready', 'needs_changes'].includes(review.status) || typeof review.evidence !== 'string' || !review.evidence.trim()) throw new Error('Review needs status and evidence');
|
|
85
|
+
if (review.status === 'ready' && !['text', 'visual', 'facts'].every(k => review.checks?.[k] === 'pass')) throw new Error('Ready requires recorded text, visual and factual checks');
|
|
86
|
+
const store = new Tracker(dir);
|
|
87
|
+
try { store.event('resume_reviewed', review); } finally { store.close(); }
|
|
88
|
+
profile.resume.review_status = review.status;
|
|
89
|
+
profile.confirmed_at = null;
|
|
90
|
+
writeJSON(file, profile);
|
|
91
|
+
return { recorded: true, confirmation_required: true, status: review.status };
|
|
92
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Intentionally no retries. A durable dispatch record precedes the child process.
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { Tracker } from './tracker.mjs';
|
|
7
|
+
import { cliPath, runCLI } from './hirify.mjs';
|
|
8
|
+
|
|
9
|
+
export function sendPacket(data, packet, transport = runCLI) {
|
|
10
|
+
if (transport === runCLI) cliPath(data); // Diagnose missing executable before claiming dispatch.
|
|
11
|
+
const tracker = new Tracker(data);
|
|
12
|
+
try {
|
|
13
|
+
tracker.dispatch(packet);
|
|
14
|
+
try {
|
|
15
|
+
const r = transport(data, ['vacancy', 'apply', packet.slug, '--profile', String(packet.hirify_profile_id), '--cover', packet.cover_letter, '--json']);
|
|
16
|
+
// Raw response retained locally for the agent; no guessed vendor response schema.
|
|
17
|
+
const output = { attempt_id: packet.attempt_id, exit_code: r.status ?? null,
|
|
18
|
+
stdout: r.stdout || '', stderr: r.stderr || '', error: r.error?.message || null };
|
|
19
|
+
tracker.event('cli_response', output, tracker.job(packet.slug).id);
|
|
20
|
+
if (r.error || r.status === null) tracker.finish({ attempt_id: packet.attempt_id, outcome: 'unknown', evidence: r.error?.message || 'Process ended without exit status' });
|
|
21
|
+
return output;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
tracker.finish({ attempt_id: packet.attempt_id, outcome: 'unknown', evidence: error.message });
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
} finally { tracker.close(); }
|
|
27
|
+
}
|
|
28
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
29
|
+
try {
|
|
30
|
+
const [flag, data, path] = process.argv.slice(2);
|
|
31
|
+
if (flag !== '--data' || !data || !path) throw new Error('Usage: send-packet.mjs --data <directory> <packet.json>');
|
|
32
|
+
const result = sendPacket(data, JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, '')));
|
|
33
|
+
console.log(JSON.stringify(result, null, 2));
|
|
34
|
+
process.exitCode = result.exit_code ?? 1;
|
|
35
|
+
} catch (e) { console.error(`jobhunt-kit: ${e.message}`); process.exitCode = 1; }
|
|
36
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { copyFileSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { initData } from './tracker.mjs';
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const [flag, data, install] = process.argv.slice(2);
|
|
10
|
+
if (flag !== '--data' || !data || (install && install !== '--install-cli')) throw new Error('Usage: setup.mjs --data <directory> [--install-cli]');
|
|
11
|
+
const result = initData(data);
|
|
12
|
+
if (install) {
|
|
13
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
|
+
const runtime = join(result.data, 'runtime');
|
|
15
|
+
mkdirSync(runtime, { recursive: true });
|
|
16
|
+
for (const name of ['package.json', 'package-lock.json']) copyFileSync(join(root, name), join(runtime, name));
|
|
17
|
+
// Fixed arguments; npm on Windows is a .cmd launcher. User paths go via cwd, not shell text.
|
|
18
|
+
const options = { cwd: runtime, stdio: 'inherit', windowsHide: true };
|
|
19
|
+
const r = process.platform === 'win32'
|
|
20
|
+
? spawnSync('cmd.exe', ['/d', '/s', '/c', 'npm ci --ignore-scripts --no-audit --no-fund'], options)
|
|
21
|
+
: spawnSync('npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], options);
|
|
22
|
+
if (r.error || r.status !== 0) throw new Error(r.error?.message || 'npm ci failed; no sign-in or search was attempted');
|
|
23
|
+
}
|
|
24
|
+
console.log(JSON.stringify({ ...result, cli_install_requested: Boolean(install), authenticated: false }, null, 2));
|
|
25
|
+
} catch (e) { console.error(`jobhunt-kit: ${e.message}`); process.exitCode = 1; }
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Local bookkeeping only. No network, credentials, or application sending here.
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs';
|
|
6
|
+
import { resolve, dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
const now = () => new Date().toISOString();
|
|
11
|
+
const hash = value => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex');
|
|
12
|
+
const readJSON = path => JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''));
|
|
13
|
+
const need = (condition, message) => { if (!condition) throw new Error(message); };
|
|
14
|
+
const text = value => typeof value === 'string' && value.trim().length > 0;
|
|
15
|
+
const safeURL = value => {
|
|
16
|
+
const u = new URL(value);
|
|
17
|
+
need(['https:', 'http:'].includes(u.protocol) && !u.username && !u.password, 'Expected public HTTP(S) URL');
|
|
18
|
+
u.hash = '';
|
|
19
|
+
for (const key of [...u.searchParams.keys()]) if (/^utm_|^(fbclid|gclid)$/i.test(key)) u.searchParams.delete(key);
|
|
20
|
+
u.searchParams.sort();
|
|
21
|
+
return u.toString();
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function initData(directory) {
|
|
25
|
+
const dir = resolve(directory);
|
|
26
|
+
// Never store candidate state in a plugin cache, including a locally loaded plugin.
|
|
27
|
+
need(dir !== ROOT && !dir.startsWith(ROOT + '/') && !dir.startsWith(ROOT + '\\'), 'Data must be outside the plugin directory');
|
|
28
|
+
mkdirSync(dir, { recursive: true });
|
|
29
|
+
for (const file of ['profile.json', 'policy.json', 'profile.md', 'intake.md']) {
|
|
30
|
+
if (!existsSync(join(dir, file))) copyFileSync(join(ROOT, 'templates', file), join(dir, file));
|
|
31
|
+
}
|
|
32
|
+
for (const folder of ['resumes', 'materials', 'reports']) mkdirSync(join(dir, folder), { recursive: true });
|
|
33
|
+
writeFileSync(join(dir, '.gitignore'), '*\n');
|
|
34
|
+
const store = new Tracker(dir);
|
|
35
|
+
store.close();
|
|
36
|
+
return { data: dir, initialized: true, profile: 'empty unless previously populated' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class Tracker {
|
|
40
|
+
constructor(directory) {
|
|
41
|
+
this.dir = resolve(directory);
|
|
42
|
+
need(existsSync(join(this.dir, 'profile.json')), 'Run init first');
|
|
43
|
+
this.db = new DatabaseSync(join(this.dir, 'history.sqlite'));
|
|
44
|
+
const version = this.db.prepare('PRAGMA user_version').get().user_version;
|
|
45
|
+
if (![0, 1].includes(version)) { this.db.close(); throw new Error('Unsupported database version; explicit migration required'); }
|
|
46
|
+
this.db.exec(`PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL;
|
|
47
|
+
CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY, started_at TEXT NOT NULL, finished_at TEXT,
|
|
48
|
+
status TEXT NOT NULL, profile_hash TEXT NOT NULL, input TEXT NOT NULL, result TEXT);
|
|
49
|
+
CREATE TABLE IF NOT EXISTS jobs(id TEXT PRIMARY KEY, canonical_url TEXT UNIQUE NOT NULL,
|
|
50
|
+
status TEXT NOT NULL, payload TEXT NOT NULL, draft TEXT, approval TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
51
|
+
CREATE TABLE IF NOT EXISTS aliases(slug TEXT PRIMARY KEY, job_id TEXT NOT NULL REFERENCES jobs(id));
|
|
52
|
+
CREATE TABLE IF NOT EXISTS observations(id INTEGER PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id),
|
|
53
|
+
job_id TEXT NOT NULL REFERENCES jobs(id), at TEXT NOT NULL, payload TEXT NOT NULL);
|
|
54
|
+
CREATE TABLE IF NOT EXISTS attempts(id TEXT PRIMARY KEY, job_id TEXT UNIQUE NOT NULL REFERENCES jobs(id),
|
|
55
|
+
started_at TEXT NOT NULL, mode TEXT NOT NULL, packet TEXT NOT NULL, result TEXT);
|
|
56
|
+
CREATE TABLE IF NOT EXISTS dispatches(attempt_id TEXT PRIMARY KEY REFERENCES attempts(id), at TEXT NOT NULL);
|
|
57
|
+
CREATE TABLE IF NOT EXISTS events(id INTEGER PRIMARY KEY, at TEXT NOT NULL, kind TEXT NOT NULL,
|
|
58
|
+
job_id TEXT, run_id TEXT, payload TEXT NOT NULL);
|
|
59
|
+
PRAGMA user_version=1;`);
|
|
60
|
+
}
|
|
61
|
+
close() { this.db.close(); }
|
|
62
|
+
transaction(fn) {
|
|
63
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
64
|
+
try { const result = fn(); this.db.exec('COMMIT'); return result; }
|
|
65
|
+
catch (e) { this.db.exec('ROLLBACK'); throw e; }
|
|
66
|
+
}
|
|
67
|
+
event(kind, payload, job = null, run = null) {
|
|
68
|
+
this.db.prepare('INSERT INTO events(at,kind,job_id,run_id,payload) VALUES(?,?,?,?,?)')
|
|
69
|
+
.run(now(), kind, job, run, JSON.stringify(payload));
|
|
70
|
+
}
|
|
71
|
+
profile(requireReady = true) {
|
|
72
|
+
const profile = readJSON(join(this.dir, 'profile.json'));
|
|
73
|
+
need(profile.schema_version === 1, 'Unsupported profile schema');
|
|
74
|
+
if (requireReady) {
|
|
75
|
+
need(text(profile.confirmed_at) && Number.isFinite(Date.parse(profile.confirmed_at)), 'Candidate must confirm profile before search/apply');
|
|
76
|
+
need(Array.isArray(profile.search?.roles) && profile.search.roles.length > 0 && profile.search.roles.every(text), 'Profile needs target roles');
|
|
77
|
+
}
|
|
78
|
+
return { profile, hash: hash(profile) };
|
|
79
|
+
}
|
|
80
|
+
job(slug) {
|
|
81
|
+
const row = this.db.prepare('SELECT jobs.* FROM jobs JOIN aliases ON jobs.id=aliases.job_id WHERE aliases.slug=?').get(slug);
|
|
82
|
+
need(row, 'Unknown vacancy slug');
|
|
83
|
+
return { ...row, payload: JSON.parse(row.payload), draft: row.draft && JSON.parse(row.draft), approval: row.approval && JSON.parse(row.approval) };
|
|
84
|
+
}
|
|
85
|
+
runStart(input) {
|
|
86
|
+
const p = this.profile();
|
|
87
|
+
const id = randomUUID();
|
|
88
|
+
this.db.prepare('INSERT INTO runs VALUES(?,?,NULL,?,?,?,NULL)').run(id, now(), 'running', p.hash, JSON.stringify(input));
|
|
89
|
+
return { run_id: id, profile_hash: p.hash };
|
|
90
|
+
}
|
|
91
|
+
runEvent(input) {
|
|
92
|
+
need(this.db.prepare('SELECT id FROM runs WHERE id=?').get(input.run_id), 'Unknown run');
|
|
93
|
+
need(text(input.kind), 'Event kind required');
|
|
94
|
+
this.event(input.kind, input.data ?? {}, null, input.run_id);
|
|
95
|
+
return { recorded: true };
|
|
96
|
+
}
|
|
97
|
+
runFinish(input) {
|
|
98
|
+
need(['complete', 'partial', 'blocked', 'failed'].includes(input.status), 'Invalid run result');
|
|
99
|
+
need(text(input.reason), 'Run stop reason required');
|
|
100
|
+
const result = this.db.prepare('UPDATE runs SET status=?,finished_at=?,result=? WHERE id=? AND status=?')
|
|
101
|
+
.run(input.status, now(), JSON.stringify(input), input.run_id, 'running');
|
|
102
|
+
need(result.changes === 1, 'Unknown or already finished run');
|
|
103
|
+
return { recorded: true };
|
|
104
|
+
}
|
|
105
|
+
put(input) {
|
|
106
|
+
need(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(input.slug), 'Invalid slug');
|
|
107
|
+
need(text(input.title), 'Title required');
|
|
108
|
+
const canonical = safeURL(input.original_url || input.url);
|
|
109
|
+
safeURL(input.url);
|
|
110
|
+
need(['hosted', 'external', 'unknown'].includes(input.route), 'Invalid route');
|
|
111
|
+
need(['unreviewed', 'suitable', 'review', 'rejected'].includes(input.match?.verdict), 'Match verdict required');
|
|
112
|
+
if (input.match.verdict !== 'unreviewed') {
|
|
113
|
+
need(text(input.description) && text(input.read_at), 'Read full vacancy before assessing fit');
|
|
114
|
+
need(Array.isArray(input.match.reasons) && input.match.reasons.length > 0, 'Explain match');
|
|
115
|
+
}
|
|
116
|
+
return this.transaction(() => {
|
|
117
|
+
const run = this.db.prepare('SELECT * FROM runs WHERE id=? AND status=?').get(input.run_id, 'running');
|
|
118
|
+
need(run, 'Running search required');
|
|
119
|
+
need(run.profile_hash === this.profile().hash, 'Profile changed; start a new search run');
|
|
120
|
+
const alias = this.db.prepare('SELECT job_id FROM aliases WHERE slug=?').get(input.slug);
|
|
121
|
+
const sameURL = this.db.prepare('SELECT id FROM jobs WHERE canonical_url=?').get(canonical);
|
|
122
|
+
need(!alias || !sameURL || alias.job_id === sameURL.id, 'Conflicting identity; manual deduplication required');
|
|
123
|
+
const id = alias?.job_id || sameURL?.id || randomUUID();
|
|
124
|
+
const existing = this.db.prepare('SELECT * FROM jobs WHERE id=?').get(id);
|
|
125
|
+
// Keep canonical full text on a later card-only observation; never regress status.
|
|
126
|
+
const old = existing && JSON.parse(existing.payload);
|
|
127
|
+
const payload = old && input.match.verdict === 'unreviewed' ? old : { ...input, profile_hash: run.profile_hash };
|
|
128
|
+
if (!existing) {
|
|
129
|
+
this.db.prepare('INSERT INTO jobs VALUES(?,?,?,?,NULL,NULL,?,?)')
|
|
130
|
+
.run(id, canonical, 'discovered', JSON.stringify(payload), now(), now());
|
|
131
|
+
} else {
|
|
132
|
+
this.db.prepare('UPDATE jobs SET payload=?,updated_at=? WHERE id=?').run(JSON.stringify(payload), now(), id);
|
|
133
|
+
}
|
|
134
|
+
this.db.prepare('INSERT OR IGNORE INTO aliases VALUES(?,?)').run(input.slug, id);
|
|
135
|
+
this.db.prepare('INSERT INTO observations(run_id,job_id,at,payload) VALUES(?,?,?,?)').run(input.run_id, id, now(), JSON.stringify(input));
|
|
136
|
+
return { id, is_new: !existing, status: existing?.status || 'discovered' };
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
prepare(input) {
|
|
140
|
+
return this.transaction(() => {
|
|
141
|
+
const job = this.job(input.slug);
|
|
142
|
+
need(['discovered', 'draft', 'needs_user', 'approved'].includes(job.status), 'Vacancy cannot be drafted in this status');
|
|
143
|
+
const p = this.profile();
|
|
144
|
+
need(job.payload.profile_hash === p.hash, 'Reassess vacancy for current profile');
|
|
145
|
+
need(['suitable', 'review'].includes(job.payload.match.verdict), 'Vacancy must be assessed first');
|
|
146
|
+
need(text(input.cover_letter), 'Cover letter required');
|
|
147
|
+
need(Array.isArray(input.claims) && Array.isArray(input.unresolved), 'claims and unresolved arrays required');
|
|
148
|
+
const evidence = new Map((p.profile.evidence || []).map(e => [e.id, e]));
|
|
149
|
+
for (const claim of input.claims) need(text(claim.text) && evidence.get(claim.evidence_id)?.confirmed === true, 'Each claim needs confirmed profile evidence');
|
|
150
|
+
const draft = { ...input, profile_hash: p.hash, vacancy_hash: hash(job.payload) };
|
|
151
|
+
const status = job.payload.route === 'external' || input.unresolved.length ? 'needs_user' : 'draft';
|
|
152
|
+
this.db.prepare('UPDATE jobs SET draft=?,approval=NULL,status=?,updated_at=? WHERE id=?').run(JSON.stringify(draft), status, now(), job.id);
|
|
153
|
+
this.event('draft_prepared', { draft_hash: hash(draft) }, job.id);
|
|
154
|
+
return { slug: input.slug, status, draft_hash: hash(draft) };
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
approve(input) {
|
|
158
|
+
return this.transaction(() => {
|
|
159
|
+
const job = this.job(input.slug);
|
|
160
|
+
need(job.status === 'draft' && job.draft, 'Prepare a complete draft first');
|
|
161
|
+
need(text(input.user_authorization), 'Record explicit user approval for this exact application');
|
|
162
|
+
need(job.draft.profile_hash === this.profile().hash && job.draft.vacancy_hash === hash(job.payload), 'Draft is stale');
|
|
163
|
+
const approval = { at: now(), draft_hash: hash(job.draft), user_authorization: input.user_authorization };
|
|
164
|
+
this.db.prepare('UPDATE jobs SET approval=?,status=?,updated_at=? WHERE id=?').run(JSON.stringify(approval), 'approved', now(), job.id);
|
|
165
|
+
this.event('approved', approval, job.id);
|
|
166
|
+
return approval;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
policy(input) {
|
|
170
|
+
const current = readJSON(join(this.dir, 'policy.json'));
|
|
171
|
+
need(['review_each', 'auto'].includes(input.mode), 'Invalid policy mode');
|
|
172
|
+
if (input.mode === 'auto') {
|
|
173
|
+
need(text(input.auto?.user_authorization), 'Explicit scoped user authorization required');
|
|
174
|
+
need(Date.parse(input.auto.expires_at) > Date.now(), 'Future expiry required');
|
|
175
|
+
need(Number.isInteger(input.auto.max_attempts_per_utc_day) && input.auto.max_attempts_per_utc_day > 0, 'Positive daily attempt cap required');
|
|
176
|
+
need(input.auto.profile_hash === this.profile().hash, 'Auto authorization must bind current profile hash');
|
|
177
|
+
}
|
|
178
|
+
const policy = { ...current, mode: input.mode, auto: input.mode === 'auto' ? input.auto : null };
|
|
179
|
+
writeFileSync(join(this.dir, 'policy.json'), JSON.stringify(policy, null, 2) + '\n');
|
|
180
|
+
this.event('policy_changed', policy);
|
|
181
|
+
return policy;
|
|
182
|
+
}
|
|
183
|
+
begin(input) {
|
|
184
|
+
return this.transaction(() => {
|
|
185
|
+
const job = this.job(input.slug);
|
|
186
|
+
const p = this.profile();
|
|
187
|
+
need(['draft', 'approved'].includes(job.status) && job.draft, 'Complete unsent draft required');
|
|
188
|
+
need(job.payload.route === 'hosted', 'Only Hirify-hosted vacancies can be sent');
|
|
189
|
+
need(job.payload.match.verdict === 'suitable', 'Only suitable vacancies can be sent');
|
|
190
|
+
need(job.draft.unresolved.length === 0, 'Unresolved answers prevent sending');
|
|
191
|
+
need(job.draft.profile_hash === p.hash && job.draft.vacancy_hash === hash(job.payload), 'Draft stale; prepare and approve again');
|
|
192
|
+
need(Number.isInteger(p.profile.hirify_profile_id) && p.profile.hirify_profile_id > 0 && job.draft.hirify_profile_id === p.profile.hirify_profile_id, 'Confirm exact Hirify profile');
|
|
193
|
+
need(p.profile.resume?.review_status === 'ready', 'Resume review not ready');
|
|
194
|
+
need(text(p.profile.resume.path) && text(p.profile.resume.sha256), 'Resume file and fingerprint required');
|
|
195
|
+
const resume = resolve(this.dir, p.profile.resume.path);
|
|
196
|
+
need(createHash('sha256').update(readFileSync(resume)).digest('hex') === p.profile.resume.sha256, 'Resume file changed; review and update profile');
|
|
197
|
+
const policy = readJSON(join(this.dir, 'policy.json'));
|
|
198
|
+
const approved = job.approval?.draft_hash === hash(job.draft);
|
|
199
|
+
let mode = 'review_each';
|
|
200
|
+
if (!approved) {
|
|
201
|
+
mode = 'auto';
|
|
202
|
+
const auto = policy.auto;
|
|
203
|
+
need(policy.mode === 'auto' && auto && text(auto.user_authorization), 'Explicit approval required');
|
|
204
|
+
need(auto.profile_hash === p.hash && Date.parse(auto.expires_at) > Date.now(), 'Auto authorization expired or profile changed');
|
|
205
|
+
need(Number.isInteger(auto.max_attempts_per_utc_day) && auto.max_attempts_per_utc_day > 0, 'Invalid auto cap');
|
|
206
|
+
const requirements = job.payload.match.requirements;
|
|
207
|
+
need(Array.isArray(requirements) && requirements.length > 0 && requirements.every(r => r.result === 'pass' && text(r.evidence)), 'Auto mode requires all critical requirements checked');
|
|
208
|
+
const used = this.db.prepare('SELECT COUNT(*) AS count FROM attempts WHERE started_at>=?').get(now().slice(0, 10)).count;
|
|
209
|
+
need(used < auto.max_attempts_per_utc_day, 'Local daily attempt cap reached (UTC)');
|
|
210
|
+
}
|
|
211
|
+
// UNIQUE(job_id) is the durable duplicate-send guard, including unknown results.
|
|
212
|
+
const attempt = randomUUID();
|
|
213
|
+
const packet = { ...job.draft, slug: job.payload.slug, mode, attempt_id: attempt };
|
|
214
|
+
this.db.prepare('INSERT INTO attempts VALUES(?,?,?,?,?,NULL)').run(attempt, job.id, now(), mode, JSON.stringify(packet));
|
|
215
|
+
this.db.prepare('UPDATE jobs SET status=?,updated_at=? WHERE id=?').run('submitting', now(), job.id);
|
|
216
|
+
this.event('send_intent', { attempt_id: attempt, mode }, job.id);
|
|
217
|
+
return packet;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
finish(input) {
|
|
221
|
+
need(['submitted', 'failed', 'unknown'].includes(input.outcome), 'Invalid send outcome');
|
|
222
|
+
need(text(input.evidence), 'Response or uncertainty evidence required');
|
|
223
|
+
if (input.outcome === 'submitted') need(text(input.application_id), 'Successful Hirify response must include application ID');
|
|
224
|
+
return this.transaction(() => {
|
|
225
|
+
const attempt = this.db.prepare('SELECT * FROM attempts WHERE id=?').get(input.attempt_id);
|
|
226
|
+
need(attempt, 'Unknown attempt');
|
|
227
|
+
if (attempt.result) {
|
|
228
|
+
need(JSON.parse(attempt.result).outcome === input.outcome, 'Use resolve for an unknown result');
|
|
229
|
+
return { unchanged: true };
|
|
230
|
+
}
|
|
231
|
+
this.db.prepare('UPDATE attempts SET result=? WHERE id=?').run(JSON.stringify(input), attempt.id);
|
|
232
|
+
const status = input.outcome === 'unknown' ? 'submission_unknown' : input.outcome;
|
|
233
|
+
this.db.prepare('UPDATE jobs SET status=?,updated_at=? WHERE id=?').run(status, now(), attempt.job_id);
|
|
234
|
+
this.event('send_result', input, attempt.job_id);
|
|
235
|
+
return { status };
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
dispatch(packet) {
|
|
239
|
+
return this.transaction(() => {
|
|
240
|
+
const attempt = this.db.prepare('SELECT * FROM attempts WHERE id=?').get(packet.attempt_id);
|
|
241
|
+
need(attempt && !attempt.result && hash(JSON.parse(attempt.packet)) === hash(packet), 'Packet must exactly match pending saved attempt');
|
|
242
|
+
const job = this.job(packet.slug);
|
|
243
|
+
need(job.status === 'submitting' && job.id === attempt.job_id, 'Attempt is not pending');
|
|
244
|
+
const p = this.profile();
|
|
245
|
+
need(p.hash === packet.profile_hash && hash(job.payload) === packet.vacancy_hash, 'Profile or vacancy changed after begin');
|
|
246
|
+
need(createHash('sha256').update(readFileSync(resolve(this.dir, p.profile.resume.path))).digest('hex') === p.profile.resume.sha256, 'Resume changed after begin');
|
|
247
|
+
if (packet.mode === 'auto') {
|
|
248
|
+
const policy = readJSON(join(this.dir, 'policy.json'));
|
|
249
|
+
need(policy.mode === 'auto' && policy.auto?.profile_hash === p.hash && Date.parse(policy.auto.expires_at) > Date.now(), 'Auto permission revoked or expired');
|
|
250
|
+
} else need(job.approval?.draft_hash === hash(job.draft), 'Approval no longer valid');
|
|
251
|
+
this.db.prepare('INSERT INTO dispatches VALUES(?,?)').run(attempt.id, now());
|
|
252
|
+
this.event('cli_dispatch', { attempt_id: attempt.id }, job.id);
|
|
253
|
+
return { claimed: true };
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
resolveUnknown(input) {
|
|
257
|
+
need(['submitted', 'failed'].includes(input.outcome) && text(input.evidence), 'Verified outcome and evidence required');
|
|
258
|
+
if (input.outcome === 'submitted') need(text(input.application_id), 'Application ID required');
|
|
259
|
+
return this.transaction(() => {
|
|
260
|
+
const job = this.job(input.slug);
|
|
261
|
+
need(['submission_unknown', 'submitting'].includes(job.status), 'No uncertain attempt to resolve');
|
|
262
|
+
const attempt = this.db.prepare('SELECT * FROM attempts WHERE job_id=?').get(job.id);
|
|
263
|
+
need(attempt, 'Missing attempt');
|
|
264
|
+
this.db.prepare('UPDATE attempts SET result=? WHERE id=?').run(JSON.stringify(input), attempt.id);
|
|
265
|
+
this.db.prepare('UPDATE jobs SET status=?,updated_at=? WHERE id=?').run(input.outcome, now(), job.id);
|
|
266
|
+
this.event('uncertainty_resolved', input, job.id);
|
|
267
|
+
return { status: input.outcome, automatic_retry: false };
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
status(input) {
|
|
271
|
+
const allowed = {
|
|
272
|
+
discovered: ['dismissed', 'needs_user'], draft: ['dismissed', 'needs_user'],
|
|
273
|
+
approved: ['dismissed', 'needs_user'], needs_user: ['dismissed', 'submitted_external'],
|
|
274
|
+
submitted: ['interview', 'rejected', 'withdrawn', 'offer'],
|
|
275
|
+
submitted_external: ['interview', 'rejected', 'withdrawn', 'offer'],
|
|
276
|
+
interview: ['interview', 'rejected', 'withdrawn', 'offer'], offer: ['accepted', 'declined', 'withdrawn']
|
|
277
|
+
};
|
|
278
|
+
return this.transaction(() => {
|
|
279
|
+
const job = this.job(input.slug);
|
|
280
|
+
need(allowed[job.status]?.includes(input.status), 'Invalid status transition');
|
|
281
|
+
need(text(input.note), 'User report or source evidence required');
|
|
282
|
+
if (input.status === 'submitted_external') need(job.payload.route === 'external', 'External route required');
|
|
283
|
+
this.db.prepare('UPDATE jobs SET status=?,approval=NULL,updated_at=? WHERE id=?').run(input.status, now(), job.id);
|
|
284
|
+
this.event('status_changed', { ...input, previous: job.status }, job.id);
|
|
285
|
+
return { status: input.status };
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
list() {
|
|
289
|
+
return this.db.prepare('SELECT * FROM jobs ORDER BY created_at DESC').all().map(r => ({ ...r, payload: JSON.parse(r.payload), draft: r.draft && JSON.parse(r.draft), approval: r.approval && JSON.parse(r.approval) }));
|
|
290
|
+
}
|
|
291
|
+
history() { return this.db.prepare('SELECT * FROM events ORDER BY id').all().map(r => ({ ...r, payload: JSON.parse(r.payload) })); }
|
|
292
|
+
runs() { return this.db.prepare('SELECT * FROM runs ORDER BY started_at DESC').all().map(r => ({ ...r, input: JSON.parse(r.input), result: r.result && JSON.parse(r.result) })); }
|
|
293
|
+
report() {
|
|
294
|
+
const esc = v => String(v ?? '').replaceAll('|', '\\|').replace(/[\r\n]+/g, ' ').replaceAll('<', '<').replaceAll('>', '>');
|
|
295
|
+
const lines = ['# Поиск работы — локальный отчёт', '', `Обновлён: ${now()}`, '',
|
|
296
|
+
'История SQLite; ответы работодателей вносятся по сообщению пользователя.', '',
|
|
297
|
+
'| Вакансия | Статус | Совпадение / причины | Следующий шаг |', '|---|---|---|---|'];
|
|
298
|
+
for (const job of this.list()) {
|
|
299
|
+
const next = ({ discovered: 'Проверить и подготовить', draft: 'Согласовать отклик', approved: 'Проверить квоту и отправить',
|
|
300
|
+
needs_user: 'Ответы / ручной отклик', submitting: 'Проверить результат; не повторять', submission_unknown: 'Уточнить результат; не повторять',
|
|
301
|
+
submitted: 'Ждать ответа', submitted_external: 'Ждать ответа', interview: 'Подготовиться к интервью', offer: 'Обсудить предложение' })[job.status] || 'Нет автоматического действия';
|
|
302
|
+
lines.push(`| ${esc(job.payload.title)} (${esc(job.payload.url)}) | ${job.status} | ${esc(job.payload.match.verdict)}: ${esc(job.payload.match.reasons?.join('; '))} | ${next} |`);
|
|
303
|
+
}
|
|
304
|
+
lines.push('', '## Запуски', '');
|
|
305
|
+
for (const run of this.runs()) lines.push(`- ${run.started_at}: ${run.status}; ${esc(run.result?.reason || 'Не завершён — проверить журнал перед продолжением')}`);
|
|
306
|
+
const content = lines.join('\n') + '\n';
|
|
307
|
+
const path = join(this.dir, 'reports', 'latest.md');
|
|
308
|
+
writeFileSync(path, content);
|
|
309
|
+
return { path, content };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function main(args) {
|
|
314
|
+
need(args[0] === '--data' && text(args[1]), 'Usage: node tracker.mjs --data <directory> <command> [input.json|slug]');
|
|
315
|
+
const [directory, command, argument] = args.slice(1);
|
|
316
|
+
if (command === 'init') return initData(directory);
|
|
317
|
+
const store = new Tracker(directory);
|
|
318
|
+
try {
|
|
319
|
+
const commands = { 'run-start': 'runStart', 'run-event': 'runEvent', 'run-finish': 'runFinish',
|
|
320
|
+
put: 'put', prepare: 'prepare', approve: 'approve', policy: 'policy', begin: 'begin', finish: 'finish',
|
|
321
|
+
resolve: 'resolveUnknown', status: 'status' };
|
|
322
|
+
if (commands[command]) { need(argument, 'JSON input file required'); return store[commands[command]](readJSON(argument)); }
|
|
323
|
+
if (command === 'show') return store.job(argument);
|
|
324
|
+
if (command === 'profile') return store.profile(false);
|
|
325
|
+
if (['list', 'history', 'runs', 'report'].includes(command)) return store[command]();
|
|
326
|
+
throw new Error('Unknown command');
|
|
327
|
+
} finally { store.close(); }
|
|
328
|
+
}
|
|
329
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
330
|
+
try { console.log(JSON.stringify(main(process.argv.slice(2)), null, 2)); }
|
|
331
|
+
catch (e) { console.error(`jobhunt-kit: ${e.message}`); process.exitCode = 1; }
|
|
332
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: job-apply
|
|
3
|
+
description: Draft evidence-based cover letters and application answers, obtain per-vacancy approval or configure explicitly authorized auto-apply, send through Hirify and record outcomes. Use for applying or preparing a manual handoff.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Отклик на вакансию
|
|
7
|
+
|
|
8
|
+
Использовать [CLI](../../references/cli.md): apply prepare/preview/export/approve,
|
|
9
|
+
begin/send/finish/resolve. Begin сохраняет attempt-файл и возвращает packet_path;
|
|
10
|
+
send принимает именно этот файл. Смысл письма и разрешение остаются обязанностью
|
|
11
|
+
агента и пользователя; CLI переиспользует проверки tracker, а не заменяет их.
|
|
12
|
+
|
|
13
|
+
Прочитать [workflow](../../references/workflow.md),
|
|
14
|
+
[Hirify applying rules](../../references/hirify/SKILL.md),
|
|
15
|
+
[cover guidance](../../references/cover-guidance.md) и
|
|
16
|
+
[storage](../../references/storage.md). Определить P/D; посмотреть show и history.
|
|
17
|
+
|
|
18
|
+
## Подготовка
|
|
19
|
+
|
|
20
|
+
- Использовать прочитанный текст и актуальный match. Изменился профиль — переоценить.
|
|
21
|
+
- Подготовить точное письмо, evidence-карту и ответы. Неизвестные обязательные ответы
|
|
22
|
+
записать в unresolved и запросить у пользователя; не угадывать.
|
|
23
|
+
- `prepare` сохраняет снимок. Для external — needs_user: сохранить в D/materials/
|
|
24
|
+
handoff.md со ссылкой из reveal, письмом, готовыми ответами, недостающими полями
|
|
25
|
+
и следующим действием. Внешнюю форму не отправлять в первом этапе.
|
|
26
|
+
|
|
27
|
+
## Согласование и auto
|
|
28
|
+
|
|
29
|
+
review_each: показать роль/компанию/ссылку, выбранный профиль Hirify, что в нём будет
|
|
30
|
+
передано, весь cover и вопросы. После конкретного «отправь» записать approve с
|
|
31
|
+
реальным текстом разрешения. На изменение письма согласование повторить.
|
|
32
|
+
|
|
33
|
+
Если пользователь явно просит auto, согласовать область: текущий профиль/условия,
|
|
34
|
+
только Hirify, срок действия и максимум попыток за UTC-день. Записать policy mode=auto
|
|
35
|
+
с хешем профиля и точной формулировкой разрешения. Общая фраза «настрой автоматизацию»
|
|
36
|
+
не включает отправку. По умолчанию ничего не включено. Если обязательная проверка
|
|
37
|
+
или правило среды требует индивидуального согласия — выполнить её и в auto.
|
|
38
|
+
|
|
39
|
+
## Отправка
|
|
40
|
+
|
|
41
|
+
1. Прямо перед отправкой проверить account show, наличие квоты apply и актуальность
|
|
42
|
+
выбранного профиля через доступные read-команды Hirify. Данные локального профиля
|
|
43
|
+
не заменяют серверный профиль, и CLI не загружает локальное резюме автоматически.
|
|
44
|
+
Если профиль Hirify неполон/изменился, сначала показать отличия кандидату.
|
|
45
|
+
2. `begin` создаёт единственное намерение и возвращает attempt_id и точный пакет.
|
|
46
|
+
Сохранить вывод в D/materials/attempt.json. Begin не отправляет отклик.
|
|
47
|
+
3. Только после успешного begin в этом процессе вызвать Hirify один раз. Чтобы не
|
|
48
|
+
исказить кавычки/переносы письма, использовать
|
|
49
|
+
`node <P>/scripts/send-packet.mjs --data <D> <D>/materials/attempt.json`.
|
|
50
|
+
Команда проверяет сохранённое намерение, отмечает запуск до сети и вызывает CLI
|
|
51
|
+
с `vacancy apply <slug> --profile <id> --cover <text> --json` массивом аргументов.
|
|
52
|
+
4. По достоверному успеху сохранить finish submitted с application_id и кратким
|
|
53
|
+
ответом сервера. Достоверный отказ без отправки — failed; неопределённость — unknown.
|
|
54
|
+
Не угадывать успех по exit 0 без содержимого подтверждения. Сохранить результат
|
|
55
|
+
даже если он требует ручной проверки. Показать пользователю точный статус.
|
|
56
|
+
5. После успеха завершить индивидуальный workflow. Следующие auto-кандидаты —
|
|
57
|
+
отдельные проходы с новой проверкой бюджета. Не инициировать сообщения рекрутеру.
|
|
58
|
+
|
|
59
|
+
При аварии между begin и finish проверить историю; не повторять вызов и не сбрасывать
|
|
60
|
+
submitting. Уточнить результат у пользователя/по доступному подтверждению, затем resolve.
|
|
61
|
+
Трекер не умеет сам узнавать ответы работодателя.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: job-profile
|
|
3
|
+
description: Initialize or update a reusable job-search profile from a resume and a staged questionnaire, including preferences and verified application answers. Use for candidate onboarding, not vacancy searching.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Профиль поиска работы
|
|
7
|
+
|
|
8
|
+
Детерминированные операции выполнять [командами CLI](../../references/cli.md):
|
|
9
|
+
profile init, check, save --input, confirm --note. Они сохраняют историю и сбрасывают
|
|
10
|
+
устаревшее подтверждение. Confirm не заменяет реальное согласие кандидата.
|
|
11
|
+
|
|
12
|
+
Прочитать [общий workflow](../../references/workflow.md). Определить P и D.
|
|
13
|
+
При разработке/демонстрации шаблона не запрашивать реальные данные и не открывать аккаунт.
|
|
14
|
+
|
|
15
|
+
1. Для пользовательской инициализации выполнить tracker `init`. Он копирует пустые
|
|
16
|
+
анкеты и создаёт локальную базу; существующий профиль сохраняется.
|
|
17
|
+
2. Пройти [анкету](../../templates/intake.md) по блокам. Сначала запросить резюме,
|
|
18
|
+
извлечь уже имеющиеся ответы, затем задать только недостающие вопросы.
|
|
19
|
+
3. Заполнить D/profile.json по [описанию данных](../../references/storage.md).
|
|
20
|
+
Факты из резюме пока не confirmed; показать их кандидату. Неизвестное — null.
|
|
21
|
+
4. Проверку резюме выполнить по [job-resume](../job-resume/SKILL.md). Не задерживать
|
|
22
|
+
заполнение поисковых предпочтений, если отдельные проверки формата недоступны.
|
|
23
|
+
5. Сформировать D/profile.md и банк answers в JSON. Разделить обязательные условия
|
|
24
|
+
и пожелания. Не отправлять профиль в Hirify: локальная анкета и профиль сервиса
|
|
25
|
+
различны. Для синхронизации нужен отдельный запрос пользователя.
|
|
26
|
+
6. После подтверждения записать confirmed_at. Указать готовность отдельно к поиску
|
|
27
|
+
и к отклику, открытые вопросы и следующий шаг. Не включать auto/расписание.
|
|
28
|
+
|
|
29
|
+
Для установки CLI в отдельную рабочую папку использовать
|
|
30
|
+
`node <P>/scripts/setup.mjs --data <D> --install-cli`. Это делает npm ci с закреплённой
|
|
31
|
+
зависимостью, но не входит в аккаунт. При отсутствии авторизации предложить человеку
|
|
32
|
+
`node <P>/scripts/hirify.mjs --data <D> login`; самостоятельно login не запускать.
|
|
33
|
+
При обновлении профиля старые авторизации становятся недействительными по хешу.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: job-resume
|
|
3
|
+
description: Review a job-search resume for factual consistency, readability, parsing risks and role fit using cited guidance. Use for resume readiness or ATS questions; do not promise an ATS score.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Проверка резюме
|
|
7
|
+
|
|
8
|
+
Начать с [CLI](../../references/cli.md): `resume <file>` импортирует версию и извлекает
|
|
9
|
+
текст PDF/DOCX/TXT/MD; `resume check` сохраняет механическую диагностику и review.md.
|
|
10
|
+
Не переписывать вручную логику копирования/хеширования. Проверить text_extraction и
|
|
11
|
+
findings; затем отдельно оценить вёрстку и факты. После реальной проверки записать
|
|
12
|
+
результат через resume reviewed --input; команда не выполняет эти проверки за агента.
|
|
13
|
+
|
|
14
|
+
Прочитать [workflow](../../references/workflow.md) и
|
|
15
|
+
[процедуру с источниками](../../references/resume-guidance.md).
|
|
16
|
+
Получить текущий файл, целевую роль, рынок и profile.json; не подставлять цели
|
|
17
|
+
владельца шаблона. Если профиля ещё нет, запросить только необходимый контекст.
|
|
18
|
+
|
|
19
|
+
Проверить текст, факты и вёрстку раздельно. Использовать доступный скилл работы
|
|
20
|
+
с реальным форматом файла. Если парсер/рендер недоступен, указать границу проверки.
|
|
21
|
+
Не загружать резюме в сторонний ATS-checker без разрешения пользователя.
|
|
22
|
+
|
|
23
|
+
Сохранить результат по [шаблону отчёта](../../templates/resume-review.md) в
|
|
24
|
+
D/materials/resume-review.md. Предлагать конкретные правки, не создавать достижения.
|
|
25
|
+
Если запрошена правка, сохранить новую версию, проверить её и обновить путь, SHA-256
|
|
26
|
+
и review_status в профиле. Повторно подтвердить изменённые факты с кандидатом.
|
|
27
|
+
|
|
28
|
+
Проверка шаблонных/вымышленных файлов не доказывает качество реального резюме и
|
|
29
|
+
не является испытанием реального ATS. В итогах перечислять только сделанные проверки.
|