jobhunt-kit 0.3.0 → 0.5.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 (40) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +58 -11
  3. package/bin/agents.mjs +66 -0
  4. package/bin/install-options.mjs +21 -18
  5. package/bin/jobhunt-kit.mjs +17 -6
  6. package/installer-assets/gitignore.txt +1 -0
  7. package/installer-assets/runtime-lock.json +56 -2
  8. package/installer-assets/workspace-lock.json +56 -2
  9. package/package.json +3 -1
  10. package/plugins/jobhunt-kit/.claude-plugin/plugin.json +1 -1
  11. package/plugins/jobhunt-kit/.codex-plugin/plugin.json +1 -1
  12. package/plugins/jobhunt-kit/package-lock.json +56 -2
  13. package/plugins/jobhunt-kit/package.json +2 -1
  14. package/plugins/jobhunt-kit/references/cli.md +30 -2
  15. package/plugins/jobhunt-kit/references/harnesses.md +12 -0
  16. package/plugins/jobhunt-kit/references/privacy.md +24 -0
  17. package/plugins/jobhunt-kit/references/storage.md +11 -5
  18. package/plugins/jobhunt-kit/schemas/draft.schema.json +13 -0
  19. package/plugins/jobhunt-kit/schemas/finish.schema.json +11 -0
  20. package/plugins/jobhunt-kit/schemas/policy.schema.json +36 -0
  21. package/plugins/jobhunt-kit/schemas/profile.schema.json +80 -0
  22. package/plugins/jobhunt-kit/schemas/status.schema.json +12 -0
  23. package/plugins/jobhunt-kit/schemas/vacancy.schema.json +29 -0
  24. package/plugins/jobhunt-kit/scripts/commands.mjs +57 -11
  25. package/plugins/jobhunt-kit/scripts/extract-resume.mjs +5 -3
  26. package/plugins/jobhunt-kit/scripts/files.mjs +69 -0
  27. package/plugins/jobhunt-kit/scripts/maintenance.mjs +292 -0
  28. package/plugins/jobhunt-kit/scripts/profile.mjs +23 -20
  29. package/plugins/jobhunt-kit/scripts/resume.mjs +42 -38
  30. package/plugins/jobhunt-kit/scripts/setup.mjs +8 -10
  31. package/plugins/jobhunt-kit/scripts/tracker.mjs +188 -33
  32. package/plugins/jobhunt-kit/scripts/validation.mjs +30 -0
  33. package/plugins/jobhunt-kit/skills/job-apply/SKILL.md +4 -2
  34. package/plugins/jobhunt-kit/skills/job-resume/SKILL.md +3 -2
  35. package/plugins/jobhunt-kit/skills/job-track/SKILL.md +8 -0
  36. package/scripts/check-package.mjs +7 -0
  37. package/scripts/pack-smoke.mjs +53 -0
  38. package/tests/hardening.test.mjs +337 -0
  39. package/tests/installer.test.mjs +47 -2
  40. package/tests/tracker.test.mjs +3 -0
@@ -2,14 +2,15 @@
2
2
  // Local bookkeeping only. No network, credentials, or application sending here.
3
3
  import { DatabaseSync } from 'node:sqlite';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
- import { mkdirSync, readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs';
5
+ import { mkdirSync, readFileSync, existsSync } from 'node:fs';
6
6
  import { resolve, dirname, join } from 'node:path';
7
7
  import { fileURLToPath, pathToFileURL } from 'node:url';
8
+ import { atomicWrite, localPath, MAX_JSON_BYTES, readBytes, readJSON, sha256 } from './files.mjs';
9
+ import { schemaErrors, validateSchema } from './validation.mjs';
8
10
 
9
11
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
12
  const now = () => new Date().toISOString();
11
13
  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
14
  const need = (condition, message) => { if (!condition) throw new Error(message); };
14
15
  const text = value => typeof value === 'string' && value.trim().length > 0;
15
16
  const safeURL = value => {
@@ -25,12 +26,12 @@ export function initData(directory) {
25
26
  const dir = resolve(directory);
26
27
  // Never store candidate state in a plugin cache, including a locally loaded plugin.
27
28
  need(dir !== ROOT && !dir.startsWith(ROOT + '/') && !dir.startsWith(ROOT + '\\'), 'Data must be outside the plugin directory');
28
- mkdirSync(dir, { recursive: true });
29
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
29
30
  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
+ if (!existsSync(join(dir, file))) atomicWrite(join(dir, file), readFileSync(join(ROOT, 'templates', file)), { base: dir });
31
32
  }
32
33
  for (const folder of ['resumes', 'materials', 'reports']) mkdirSync(join(dir, folder), { recursive: true });
33
- writeFileSync(join(dir, '.gitignore'), '*\n');
34
+ if (!existsSync(join(dir, '.gitignore'))) atomicWrite(join(dir, '.gitignore'), '*\n', { base: dir });
34
35
  const store = new Tracker(dir);
35
36
  store.close();
36
37
  return { data: dir, initialized: true, profile: 'empty unless previously populated' };
@@ -39,11 +40,12 @@ export function initData(directory) {
39
40
  export class Tracker {
40
41
  constructor(directory) {
41
42
  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'));
43
+ need(existsSync(localPath(this.dir, 'profile.json')), 'Run init first');
44
+ this.db = new DatabaseSync(localPath(this.dir, 'history.sqlite'));
44
45
  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;
46
+ if (![0, 1, 2].includes(version)) { this.db.close(); throw new Error('Unsupported database version; explicit migration required'); }
47
+ this.db.exec('PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL;');
48
+ if (version === 0) this.db.exec(`
47
49
  CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY, started_at TEXT NOT NULL, finished_at TEXT,
48
50
  status TEXT NOT NULL, profile_hash TEXT NOT NULL, input TEXT NOT NULL, result TEXT);
49
51
  CREATE TABLE IF NOT EXISTS jobs(id TEXT PRIMARY KEY, canonical_url TEXT UNIQUE NOT NULL,
@@ -52,11 +54,47 @@ export class Tracker {
52
54
  CREATE TABLE IF NOT EXISTS observations(id INTEGER PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id),
53
55
  job_id TEXT NOT NULL REFERENCES jobs(id), at TEXT NOT NULL, payload TEXT NOT NULL);
54
56
  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);
57
+ started_at TEXT NOT NULL, mode TEXT NOT NULL, packet TEXT NOT NULL, result TEXT,
58
+ policy_hash TEXT, application_snapshot TEXT NOT NULL);
56
59
  CREATE TABLE IF NOT EXISTS dispatches(attempt_id TEXT PRIMARY KEY REFERENCES attempts(id), at TEXT NOT NULL);
57
60
  CREATE TABLE IF NOT EXISTS events(id INTEGER PRIMARY KEY, at TEXT NOT NULL, kind TEXT NOT NULL,
58
61
  job_id TEXT, run_id TEXT, payload TEXT NOT NULL);
59
- PRAGMA user_version=1;`);
62
+ CREATE UNIQUE INDEX IF NOT EXISTS one_running_search ON runs(status) WHERE status='running';
63
+ CREATE TABLE IF NOT EXISTS file_revisions(name TEXT PRIMARY KEY, revision INTEGER NOT NULL, sha256 TEXT NOT NULL, updated_at TEXT NOT NULL);
64
+ CREATE TABLE IF NOT EXISTS pending_file_writes(id TEXT PRIMARY KEY, name TEXT NOT NULL, old_sha256 TEXT NOT NULL,
65
+ new_sha256 TEXT NOT NULL, content TEXT NOT NULL, event_kind TEXT NOT NULL, event_payload TEXT NOT NULL, created_at TEXT NOT NULL,
66
+ UNIQUE(name));
67
+ CREATE TABLE IF NOT EXISTS schema_migrations(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL, details TEXT NOT NULL);
68
+ INSERT OR IGNORE INTO schema_migrations VALUES(2,datetime('now'),'fresh schema v2');
69
+ PRAGMA user_version=2;`);
70
+ if (version === 1) this.migrateV1();
71
+ this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS one_pending_write_per_file ON pending_file_writes(name)');
72
+ this.db.exec("CREATE TABLE IF NOT EXISTS schema_migrations(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL, details TEXT NOT NULL)");
73
+ this.db.prepare('INSERT OR IGNORE INTO schema_migrations VALUES(?,?,?)').run(2, now(), 'schema v2 registered');
74
+ this.recoverFileWrites();
75
+ this.seedFileRevision('profile.json');
76
+ this.seedFileRevision('policy.json');
77
+ }
78
+ migrateV1() {
79
+ this.transaction(() => {
80
+ const running = this.db.prepare("SELECT id FROM runs WHERE status='running' ORDER BY started_at DESC").all();
81
+ for (const row of running.slice(1)) {
82
+ const result = { status: 'blocked', reason: 'Closed during schema v2 migration because another newer search was active' };
83
+ this.db.prepare("UPDATE runs SET status='blocked',finished_at=?,result=? WHERE id=?").run(now(), JSON.stringify(result), row.id);
84
+ }
85
+ this.db.exec(`ALTER TABLE attempts ADD COLUMN policy_hash TEXT;
86
+ ALTER TABLE attempts ADD COLUMN application_snapshot TEXT;
87
+ UPDATE attempts SET application_snapshot=json_object('legacy',1,'packet',json(packet)) WHERE application_snapshot IS NULL;
88
+ CREATE UNIQUE INDEX one_running_search ON runs(status) WHERE status='running';
89
+ CREATE TABLE file_revisions(name TEXT PRIMARY KEY, revision INTEGER NOT NULL, sha256 TEXT NOT NULL, updated_at TEXT NOT NULL);
90
+ CREATE TABLE pending_file_writes(id TEXT PRIMARY KEY, name TEXT NOT NULL, old_sha256 TEXT NOT NULL,
91
+ new_sha256 TEXT NOT NULL, content TEXT NOT NULL, event_kind TEXT NOT NULL, event_payload TEXT NOT NULL, created_at TEXT NOT NULL,
92
+ UNIQUE(name));
93
+ CREATE TABLE schema_migrations(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL, details TEXT NOT NULL);
94
+ PRAGMA user_version=2;`);
95
+ this.db.prepare('INSERT INTO schema_migrations VALUES(?,?,?)').run(2, now(), JSON.stringify({ from: 1, closed_duplicate_runs: running.slice(1).map(row => row.id) }));
96
+ this.event('schema_migrated', { from: 1, to: 2, closed_duplicate_runs: running.slice(1).map(row => row.id) });
97
+ });
60
98
  }
61
99
  close() { this.db.close(); }
62
100
  transaction(fn) {
@@ -68,8 +106,60 @@ export class Tracker {
68
106
  this.db.prepare('INSERT INTO events(at,kind,job_id,run_id,payload) VALUES(?,?,?,?,?)')
69
107
  .run(now(), kind, job, run, JSON.stringify(payload));
70
108
  }
109
+ seedFileRevision(name) {
110
+ const path = localPath(this.dir, name);
111
+ const digest = sha256(readBytes(path, { base: this.dir }));
112
+ this.db.prepare('INSERT OR IGNORE INTO file_revisions VALUES(?,?,?,?)').run(name, 1, digest, now());
113
+ }
114
+ recoverFileWrites() {
115
+ if (!this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='pending_file_writes'").get()) return;
116
+ for (const row of this.db.prepare('SELECT * FROM pending_file_writes ORDER BY created_at').all()) {
117
+ this.transaction(() => {
118
+ const pending = this.db.prepare('SELECT * FROM pending_file_writes WHERE id=?').get(row.id);
119
+ if (!pending) return;
120
+ const path = localPath(this.dir, pending.name);
121
+ const actual = existsSync(path) ? sha256(readBytes(path, { base: this.dir })) : null;
122
+ if (actual !== pending.new_sha256 && actual !== pending.old_sha256) throw new Error(`Managed file ${pending.name} changed during an interrupted write; inspect it before continuing`);
123
+ if (actual !== pending.new_sha256) atomicWrite(path, pending.content, { base: this.dir });
124
+ const revision = (this.db.prepare('SELECT revision FROM file_revisions WHERE name=?').get(row.name)?.revision || 0) + 1;
125
+ this.db.prepare('INSERT INTO file_revisions VALUES(?,?,?,?) ON CONFLICT(name) DO UPDATE SET revision=excluded.revision,sha256=excluded.sha256,updated_at=excluded.updated_at')
126
+ .run(pending.name, revision, pending.new_sha256, now());
127
+ this.event(pending.event_kind, { ...JSON.parse(pending.event_payload), revision, sha256: pending.new_sha256, recovered_interrupted_write: true });
128
+ this.db.prepare('DELETE FROM pending_file_writes WHERE id=?').run(pending.id);
129
+ });
130
+ }
131
+ }
132
+ writeManagedJSON(name, value, { expectedSha256, eventKind, eventPayload = {} }) {
133
+ need(['profile.json', 'policy.json'].includes(name), 'Unsupported managed file');
134
+ const path = localPath(this.dir, name);
135
+ const content = JSON.stringify(value, null, 2) + '\n';
136
+ need(Buffer.byteLength(content, 'utf8') <= MAX_JSON_BYTES, `${name} serialized output exceeds the ${MAX_JSON_BYTES} byte limit`);
137
+ const newDigest = sha256(content);
138
+ const id = randomUUID();
139
+ this.transaction(() => {
140
+ const oldDigest = sha256(readBytes(path, { base: this.dir }));
141
+ need(!expectedSha256 || oldDigest === expectedSha256, `${name} changed concurrently; reload and retry`);
142
+ this.db.prepare('INSERT INTO pending_file_writes(id,name,old_sha256,new_sha256,content,event_kind,event_payload,created_at) VALUES(?,?,?,?,?,?,?,?)')
143
+ .run(id, name, oldDigest, newDigest, content, eventKind, JSON.stringify(eventPayload), now());
144
+ });
145
+ const revision = this.transaction(() => {
146
+ const pending = this.db.prepare('SELECT * FROM pending_file_writes WHERE id=?').get(id);
147
+ need(pending, `${name} write was superseded; reload and retry`);
148
+ const actual = sha256(readBytes(path, { base: this.dir }));
149
+ need(actual === pending.old_sha256 || actual === pending.new_sha256, `${name} changed concurrently; reload and retry`);
150
+ if (actual !== pending.new_sha256) atomicWrite(path, pending.content, { base: this.dir });
151
+ const revision = (this.db.prepare('SELECT revision FROM file_revisions WHERE name=?').get(name)?.revision || 0) + 1;
152
+ this.db.prepare('INSERT INTO file_revisions VALUES(?,?,?,?) ON CONFLICT(name) DO UPDATE SET revision=excluded.revision,sha256=excluded.sha256,updated_at=excluded.updated_at')
153
+ .run(name, revision, newDigest, now());
154
+ this.event(eventKind, { ...eventPayload, revision, sha256: newDigest });
155
+ this.db.prepare('DELETE FROM pending_file_writes WHERE id=?').run(id);
156
+ return revision;
157
+ });
158
+ return { revision, sha256: newDigest };
159
+ }
71
160
  profile(requireReady = true) {
72
- const profile = readJSON(join(this.dir, 'profile.json'));
161
+ const profile = readJSON(localPath(this.dir, 'profile.json'), { base: this.dir });
162
+ validateSchema('profile', profile);
73
163
  need(profile.schema_version === 1, 'Unsupported profile schema');
74
164
  if (requireReady) {
75
165
  need(text(profile.confirmed_at) && Number.isFinite(Date.parse(profile.confirmed_at)), 'Candidate must confirm profile before search/apply');
@@ -83,26 +173,34 @@ export class Tracker {
83
173
  return { ...row, payload: JSON.parse(row.payload), draft: row.draft && JSON.parse(row.draft), approval: row.approval && JSON.parse(row.approval) };
84
174
  }
85
175
  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 };
176
+ return this.transaction(() => {
177
+ need(!this.db.prepare("SELECT id FROM runs WHERE status='running'").get(), 'Finish or recover the previous running search before starting another');
178
+ const p = this.profile();
179
+ const id = randomUUID();
180
+ this.db.prepare('INSERT INTO runs VALUES(?,?,NULL,?,?,?,NULL)').run(id, now(), 'running', p.hash, JSON.stringify(input));
181
+ return { run_id: id, profile_hash: p.hash };
182
+ });
90
183
  }
91
184
  runEvent(input) {
92
- need(this.db.prepare('SELECT id FROM runs WHERE id=?').get(input.run_id), 'Unknown run');
93
185
  need(text(input.kind), 'Event kind required');
94
- this.event(input.kind, input.data ?? {}, null, input.run_id);
95
- return { recorded: true };
186
+ return this.transaction(() => {
187
+ need(this.db.prepare("SELECT id FROM runs WHERE id=? AND status='running'").get(input.run_id), 'Unknown or finished run');
188
+ this.event(input.kind, input.data ?? {}, null, input.run_id);
189
+ return { recorded: true };
190
+ });
96
191
  }
97
192
  runFinish(input) {
98
193
  need(['complete', 'partial', 'blocked', 'failed'].includes(input.status), 'Invalid run result');
99
194
  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 };
195
+ return this.transaction(() => {
196
+ const result = this.db.prepare('UPDATE runs SET status=?,finished_at=?,result=? WHERE id=? AND status=?')
197
+ .run(input.status, now(), JSON.stringify(input), input.run_id, 'running');
198
+ need(result.changes === 1, 'Unknown or already finished run');
199
+ return { recorded: true };
200
+ });
104
201
  }
105
202
  put(input) {
203
+ validateSchema('vacancy', input);
106
204
  need(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(input.slug), 'Invalid slug');
107
205
  need(text(input.title), 'Title required');
108
206
  const canonical = safeURL(input.original_url || input.url);
@@ -140,6 +238,7 @@ export class Tracker {
140
238
  return this.transaction(() => {
141
239
  const job = this.job(input.slug);
142
240
  need(['discovered', 'draft', 'needs_user', 'approved'].includes(job.status), 'Vacancy cannot be drafted in this status');
241
+ validateSchema('draft', input);
143
242
  const p = this.profile();
144
243
  need(job.payload.profile_hash === p.hash, 'Reassess vacancy for current profile');
145
244
  need(['suitable', 'review'].includes(job.payload.match.verdict), 'Vacancy must be assessed first');
@@ -167,7 +266,9 @@ export class Tracker {
167
266
  });
168
267
  }
169
268
  policy(input) {
170
- const current = readJSON(join(this.dir, 'policy.json'));
269
+ const path = localPath(this.dir, 'policy.json');
270
+ const currentBytes = readBytes(path, { base: this.dir });
271
+ const current = JSON.parse(currentBytes.toString('utf8').replace(/^\uFEFF/, ''));
171
272
  need(['review_each', 'auto'].includes(input.mode), 'Invalid policy mode');
172
273
  if (input.mode === 'auto') {
173
274
  need(text(input.auto?.user_authorization), 'Explicit scoped user authorization required');
@@ -176,8 +277,11 @@ export class Tracker {
176
277
  need(input.auto.profile_hash === this.profile().hash, 'Auto authorization must bind current profile hash');
177
278
  }
178
279
  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);
280
+ validateSchema('policy', policy);
281
+ const autoAudit = policy.auto && { profile_hash: policy.auto.profile_hash, expires_at: policy.auto.expires_at,
282
+ max_attempts_per_utc_day: policy.auto.max_attempts_per_utc_day };
283
+ this.writeManagedJSON('policy.json', policy, { expectedSha256: sha256(currentBytes), eventKind: 'policy_changed',
284
+ eventPayload: { mode: policy.mode, auto: autoAudit } });
181
285
  return policy;
182
286
  }
183
287
  begin(input) {
@@ -192,17 +296,19 @@ export class Tracker {
192
296
  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
297
  need(p.profile.resume?.review_status === 'ready', 'Resume review not ready');
194
298
  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);
299
+ const resume = localPath(this.dir, p.profile.resume.path);
196
300
  need(createHash('sha256').update(readFileSync(resume)).digest('hex') === p.profile.resume.sha256, 'Resume file changed; review and update profile');
197
301
  const policy = readJSON(join(this.dir, 'policy.json'));
198
302
  const approved = job.approval?.draft_hash === hash(job.draft);
199
303
  let mode = 'review_each';
304
+ let policyHash = null;
200
305
  if (!approved) {
201
306
  mode = 'auto';
202
307
  const auto = policy.auto;
203
308
  need(policy.mode === 'auto' && auto && text(auto.user_authorization), 'Explicit approval required');
204
309
  need(auto.profile_hash === p.hash && Date.parse(auto.expires_at) > Date.now(), 'Auto authorization expired or profile changed');
205
310
  need(Number.isInteger(auto.max_attempts_per_utc_day) && auto.max_attempts_per_utc_day > 0, 'Invalid auto cap');
311
+ policyHash = hash(policy);
206
312
  const requirements = job.payload.match.requirements;
207
313
  need(Array.isArray(requirements) && requirements.length > 0 && requirements.every(r => r.result === 'pass' && text(r.evidence)), 'Auto mode requires all critical requirements checked');
208
314
  const used = this.db.prepare('SELECT COUNT(*) AS count FROM attempts WHERE started_at>=?').get(now().slice(0, 10)).count;
@@ -210,20 +316,24 @@ export class Tracker {
210
316
  }
211
317
  // UNIQUE(job_id) is the durable duplicate-send guard, including unknown results.
212
318
  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));
319
+ const applicationSnapshot = { vacancy: job.payload, draft: job.draft, profile_hash: p.hash, created_at: now() };
320
+ const packet = { ...job.draft, slug: job.payload.slug, mode, attempt_id: attempt, policy_hash: policyHash, application_snapshot_hash: hash(applicationSnapshot) };
321
+ this.db.prepare('INSERT INTO attempts(id,job_id,started_at,mode,packet,result,policy_hash,application_snapshot) VALUES(?,?,?,?,?,NULL,?,?)')
322
+ .run(attempt, job.id, now(), mode, JSON.stringify(packet), policyHash, JSON.stringify(applicationSnapshot));
215
323
  this.db.prepare('UPDATE jobs SET status=?,updated_at=? WHERE id=?').run('submitting', now(), job.id);
216
324
  this.event('send_intent', { attempt_id: attempt, mode }, job.id);
217
325
  return packet;
218
326
  });
219
327
  }
220
328
  finish(input) {
329
+ validateSchema('finish', input);
221
330
  need(['submitted', 'failed', 'unknown'].includes(input.outcome), 'Invalid send outcome');
222
331
  need(text(input.evidence), 'Response or uncertainty evidence required');
223
332
  if (input.outcome === 'submitted') need(text(input.application_id), 'Successful Hirify response must include application ID');
224
333
  return this.transaction(() => {
225
334
  const attempt = this.db.prepare('SELECT * FROM attempts WHERE id=?').get(input.attempt_id);
226
335
  need(attempt, 'Unknown attempt');
336
+ if (['submitted', 'unknown'].includes(input.outcome)) need(this.db.prepare('SELECT attempt_id FROM dispatches WHERE attempt_id=?').get(attempt.id), 'Cannot record a network outcome before dispatch');
227
337
  if (attempt.result) {
228
338
  need(JSON.parse(attempt.result).outcome === input.outcome, 'Use resolve for an unknown result');
229
339
  return { unchanged: true };
@@ -243,10 +353,14 @@ export class Tracker {
243
353
  need(job.status === 'submitting' && job.id === attempt.job_id, 'Attempt is not pending');
244
354
  const p = this.profile();
245
355
  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');
356
+ need(createHash('sha256').update(readFileSync(localPath(this.dir, p.profile.resume.path))).digest('hex') === p.profile.resume.sha256, 'Resume changed after begin');
247
357
  if (packet.mode === 'auto') {
248
- const policy = readJSON(join(this.dir, 'policy.json'));
358
+ const policy = readJSON(localPath(this.dir, 'policy.json'), { base: this.dir });
359
+ const policyHash = hash(policy);
249
360
  need(policy.mode === 'auto' && policy.auto?.profile_hash === p.hash && Date.parse(policy.auto.expires_at) > Date.now(), 'Auto permission revoked or expired');
361
+ need(packet.policy_hash && attempt.policy_hash === packet.policy_hash && policyHash === packet.policy_hash, 'Auto permission changed after begin');
362
+ const used = this.db.prepare('SELECT COUNT(*) AS count FROM attempts WHERE started_at>=?').get(now().slice(0, 10)).count;
363
+ need(used <= policy.auto.max_attempts_per_utc_day, 'Auto daily cap changed after begin');
250
364
  } else need(job.approval?.draft_hash === hash(job.draft), 'Approval no longer valid');
251
365
  this.db.prepare('INSERT INTO dispatches VALUES(?,?)').run(attempt.id, now());
252
366
  this.event('cli_dispatch', { attempt_id: attempt.id }, job.id);
@@ -268,6 +382,7 @@ export class Tracker {
268
382
  });
269
383
  }
270
384
  status(input) {
385
+ validateSchema('status', input);
271
386
  const allowed = {
272
387
  discovered: ['dismissed', 'needs_user'], draft: ['dismissed', 'needs_user'],
273
388
  approved: ['dismissed', 'needs_user'], needs_user: ['dismissed', 'submitted_external'],
@@ -288,6 +403,43 @@ export class Tracker {
288
403
  list() {
289
404
  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
405
  }
406
+ attempts() {
407
+ return this.db.prepare('SELECT * FROM attempts ORDER BY started_at DESC').all().map(row => ({ ...row,
408
+ packet: JSON.parse(row.packet), result: row.result && JSON.parse(row.result), application_snapshot: JSON.parse(row.application_snapshot) }));
409
+ }
410
+ attemptPacket(attemptId) {
411
+ need(text(attemptId), 'Attempt id required');
412
+ const row = this.db.prepare('SELECT packet FROM attempts WHERE id=?').get(attemptId);
413
+ need(row, 'Unknown attempt');
414
+ return JSON.parse(row.packet);
415
+ }
416
+ integrity() {
417
+ const database = this.db.prepare('PRAGMA integrity_check').all().map(row => row.integrity_check);
418
+ const foreignKeys = this.db.prepare('PRAGMA foreign_key_check').all();
419
+ const files = {};
420
+ for (const [name, schema] of [['profile.json', 'profile'], ['policy.json', 'policy']]) {
421
+ try {
422
+ const value = readJSON(localPath(this.dir, name), { base: this.dir });
423
+ const errors = schemaErrors(schema, value);
424
+ const actual = sha256(readBytes(localPath(this.dir, name), { base: this.dir }));
425
+ const tracked = this.db.prepare('SELECT revision,sha256 FROM file_revisions WHERE name=?').get(name);
426
+ files[name] = { valid: errors.length === 0, errors, revision: tracked?.revision || null,
427
+ tracked_sha256_matches: tracked?.sha256 === actual };
428
+ } catch (error) { files[name] = { valid: false, errors: [error.message] }; }
429
+ }
430
+ return { schema_version: this.db.prepare('PRAGMA user_version').get().user_version,
431
+ database_ok: database.length === 1 && database[0] === 'ok', database, foreign_key_violations: foreignKeys,
432
+ files, running_runs: this.db.prepare("SELECT id,started_at FROM runs WHERE status='running'").all(),
433
+ unresolved_attempts: this.db.prepare("SELECT attempts.id,jobs.status,attempts.started_at FROM attempts JOIN jobs ON jobs.id=attempts.job_id WHERE jobs.status IN ('submitting','submission_unknown')").all(),
434
+ pending_file_writes: this.db.prepare('SELECT id,name,created_at FROM pending_file_writes').all(),
435
+ migrations: this.db.prepare('SELECT * FROM schema_migrations ORDER BY version').all() };
436
+ }
437
+ recover(input) {
438
+ need(input && typeof input === 'object', 'Recovery input required');
439
+ if (input.type === 'run') return this.runFinish({ run_id: input.run_id, status: input.status || 'blocked', reason: input.reason });
440
+ if (input.type === 'attempt') return this.resolveUnknown({ slug: input.slug, outcome: input.outcome, evidence: input.evidence, application_id: input.application_id });
441
+ throw new Error('Recovery type must be run or attempt');
442
+ }
291
443
  history() { return this.db.prepare('SELECT * FROM events ORDER BY id').all().map(r => ({ ...r, payload: JSON.parse(r.payload) })); }
292
444
  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
445
  report() {
@@ -295,17 +447,20 @@ export class Tracker {
295
447
  const lines = ['# Поиск работы — локальный отчёт', '', `Обновлён: ${now()}`, '',
296
448
  'История SQLite; ответы работодателей вносятся по сообщению пользователя.', '',
297
449
  '| Вакансия | Статус | Совпадение / причины | Следующий шаг |', '|---|---|---|---|'];
450
+ const attempts = new Map(this.attempts().map(attempt => [attempt.job_id, attempt]));
298
451
  for (const job of this.list()) {
452
+ const snapshot = attempts.get(job.id)?.application_snapshot?.vacancy;
453
+ const shown = ['submitting', 'submission_unknown', 'submitted', 'failed', 'interview', 'rejected', 'withdrawn', 'offer', 'accepted', 'declined'].includes(job.status) && snapshot ? snapshot : job.payload;
299
454
  const next = ({ discovered: 'Проверить и подготовить', draft: 'Согласовать отклик', approved: 'Проверить квоту и отправить',
300
455
  needs_user: 'Ответы / ручной отклик', submitting: 'Проверить результат; не повторять', submission_unknown: 'Уточнить результат; не повторять',
301
456
  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} |`);
457
+ lines.push(`| ${esc(shown.title)} (${esc(shown.url)}) | ${job.status} | ${esc(shown.match.verdict)}: ${esc(shown.match.reasons?.join('; '))} | ${next} |`);
303
458
  }
304
459
  lines.push('', '## Запуски', '');
305
460
  for (const run of this.runs()) lines.push(`- ${run.started_at}: ${run.status}; ${esc(run.result?.reason || 'Не завершён — проверить журнал перед продолжением')}`);
306
461
  const content = lines.join('\n') + '\n';
307
462
  const path = join(this.dir, 'reports', 'latest.md');
308
- writeFileSync(path, content);
463
+ atomicWrite(path, content, { base: this.dir });
309
464
  return { path, content };
310
465
  }
311
466
  }
@@ -0,0 +1,30 @@
1
+ import Ajv2020 from 'ajv/dist/2020.js';
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
7
+ const ajv = new Ajv2020({ allErrors: true, strict: true, allowUnionTypes: true });
8
+ ajv.addFormat('date-time', value => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value) && Number.isFinite(Date.parse(value)));
9
+ ajv.addFormat('uri', value => {
10
+ try { const url = new URL(value); return Boolean(url.protocol); } catch { return false; }
11
+ });
12
+
13
+ const validators = new Map();
14
+ for (const name of ['profile', 'policy', 'vacancy', 'draft', 'finish', 'status']) {
15
+ const schema = JSON.parse(readFileSync(join(ROOT, 'schemas', `${name}.schema.json`), 'utf8'));
16
+ validators.set(name, ajv.compile(schema));
17
+ }
18
+
19
+ export function validateSchema(name, value) {
20
+ const validate = validators.get(name);
21
+ if (!validate) throw new Error(`Unknown schema: ${name}`);
22
+ if (validate(value)) return value;
23
+ const detail = validate.errors.map(error => `${error.instancePath || '/'} ${error.message}`).join('; ');
24
+ throw new Error(`${name} schema: ${detail}`);
25
+ }
26
+
27
+ export function schemaErrors(name, value) {
28
+ try { validateSchema(name, value); return []; }
29
+ catch (error) { return [error.message]; }
30
+ }
@@ -43,10 +43,12 @@ review_each: показать роль/компанию/ссылку, выбра
43
43
  не заменяют серверный профиль, и CLI не загружает локальное резюме автоматически.
44
44
  Если профиль Hirify неполон/изменился, сначала показать отличия кандидату.
45
45
  2. `begin` создаёт единственное намерение и возвращает attempt_id и точный пакет.
46
- Сохранить вывод в D/materials/attempt.json. Begin не отправляет отклик.
46
+ CLI уже сохраняет пакет в уникальный `packet_path` вида
47
+ `D/materials/attempt-<attempt_id>.json`. Использовать именно возвращённый путь,
48
+ не переименовывать и не собирать пакет вручную. Begin не отправляет отклик.
47
49
  3. Только после успешного begin в этом процессе вызвать Hirify один раз. Чтобы не
48
50
  исказить кавычки/переносы письма, использовать
49
- `node <P>/scripts/send-packet.mjs --data <D> <D>/materials/attempt.json`.
51
+ `node <P>/scripts/send-packet.mjs --data <D> <packet_path из begin>`.
50
52
  Команда проверяет сохранённое намерение, отмечает запуск до сети и вызывает CLI
51
53
  с `vacancy apply <slug> --profile <id> --cover <text> --json` массивом аргументов.
52
54
  4. По достоверному успеху сохранить finish submitted с application_id и кратким
@@ -20,8 +20,9 @@ findings; затем отдельно оценить вёрстку и факт
20
20
  с реальным форматом файла. Если парсер/рендер недоступен, указать границу проверки.
21
21
  Не загружать резюме в сторонний ATS-checker без разрешения пользователя.
22
22
 
23
- Сохранить результат по [шаблону отчёта](../../templates/resume-review.md) в
24
- D/materials/resume-review.md. Предлагать конкретные правки, не создавать достижения.
23
+ Сохранить результат по [шаблону отчёта](../../templates/resume-review.md) в точный
24
+ versioned `review_path`, возвращённый `resume check` (`D/materials/resume-<sha>/review.md`).
25
+ Предлагать конкретные правки, не создавать достижения.
25
26
  Если запрошена правка, сохранить новую версию, проверить её и обновить путь, SHA-256
26
27
  и review_status в профиле. Повторно подтвердить изменённые факты с кандидатом.
27
28
 
@@ -28,3 +28,11 @@ note (содержание, источник, дата). Не заявлять,
28
28
 
29
29
  Ничего не отправлять работодателям из скилла трекинга. Отмена локального статуса
30
30
  не отзывает отправленное через Hirify заявление. Напоминания — только пользователю.
31
+
32
+ Для диагностики использовать doctor и recover inspect; recover apply закрывает run
33
+ или подтверждает исход attempt только по переданному пользователем доказательству.
34
+ Backup/restore всегда проверяют точный inventory manifest, SQLite и согласованность
35
+ ревизий JSON; restore требует точную фразу и сохраняет сырой quarantine-снимок
36
+ подтверждения. Privacy map и [карта данных](../../references/privacy.md) показывают
37
+ размещение PII, export создаёт проверяемую копию, redact и purge требуют точных фраз,
38
+ а purge ещё и обязательный backup_directory.
@@ -33,6 +33,13 @@ const codex = load(join(root, '.codex-plugin/plugin.json'));
33
33
  const claude = load(join(root, '.claude-plugin/plugin.json'));
34
34
  assert.equal(codex.name, 'jobhunt-kit'); assert.equal(claude.name, codex.name);
35
35
  assert.equal(codex.version, claude.version);
36
+ assert.equal(load('package.json').version, codex.version);
37
+ assert.equal(load('plugins/jobhunt-kit/package.json').version, codex.version);
38
+ assert.equal(load('.claude-plugin/marketplace.json').plugins[0].version, codex.version);
39
+ for (const name of ['profile', 'policy', 'vacancy', 'draft', 'finish', 'status']) {
40
+ const schema = load(join(root, 'schemas', `${name}.schema.json`));
41
+ assert.equal(schema.$schema, 'https://json-schema.org/draft/2020-12/schema');
42
+ }
36
43
  for (const file of ['.agents/plugins/marketplace.json', '.claude-plugin/marketplace.json']) {
37
44
  const market = load(file);
38
45
  for (const plugin of market.plugins) {
@@ -0,0 +1,53 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { delimiter, join, resolve } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+
8
+ const root = mkdtempSync(join(tmpdir(), 'jobhunt-pack-smoke-'));
9
+ try {
10
+ const env = { ...process.env, npm_config_cache: join(root, 'npm-cache') };
11
+ const npmCli = process.env.npm_execpath;
12
+ if (!npmCli) throw new Error('Run this smoke check through npm run pack:smoke');
13
+ const runNpm = (args, options = {}) => execFileSync(process.execPath, [npmCli, ...args], { env, ...options });
14
+ const packed = JSON.parse(runNpm(['pack', '--json', '--pack-destination', root], { cwd: resolve('.'), encoding: 'utf8' }));
15
+ const archive = join(root, packed[0].filename);
16
+ const consumer = join(root, 'consumer');
17
+ mkdirSync(consumer);
18
+ execFileSync('tar', ['-xzf', archive, '-C', consumer], { stdio: 'pipe' });
19
+ const installed = join(consumer, 'package');
20
+ const { install } = await import(pathToFileURL(join(installed, 'bin', 'jobhunt-kit.mjs')));
21
+ const workspace = join(root, 'installed-workspace');
22
+ const result = install(workspace, { source: installed, installDependencies: false, agents: ['codex'] });
23
+ assert.equal(result.scope, 'project');
24
+ for (const name of ['ajv', 'fast-deep-equal', 'fast-uri', 'json-schema-traverse', 'require-from-string']) {
25
+ cpSync(resolve('node_modules', name), join(workspace, 'node_modules', name), { recursive: true });
26
+ }
27
+ const { runCommand } = await import(pathToFileURL(join(workspace, 'plugins', 'jobhunt-kit', 'scripts', 'commands.mjs')));
28
+ const initialized = runCommand(['profile', 'init', '--workspace', workspace], { cwd: workspace });
29
+ assert.equal(initialized.initialized, true);
30
+ const doctor = runCommand(['doctor', '--workspace', workspace], { cwd: workspace });
31
+ assert.equal(doctor.integrity.database_ok, true);
32
+ assert.equal(JSON.parse(readFileSync(join(workspace, 'plugins', 'jobhunt-kit', 'package.json'))).dependencies.ajv, '8.20.0');
33
+ const globalHome = join(root, 'global-home');
34
+ const global = install('.', { source: installed, installDependencies: false, agents: ['codex'], scope: 'global', home: globalHome });
35
+ const bundle = join(global.agents[0].path, 'bundle');
36
+ assert.equal(existsSync(join(bundle, 'node_modules')), false);
37
+ const globalData = join(root, 'global-data');
38
+ const fakeBin = join(root, 'fake-bin');
39
+ mkdirSync(fakeBin);
40
+ const fakeNpm = join(fakeBin, 'fake-npm.mjs');
41
+ writeFileSync(fakeNpm, `import { cpSync, mkdirSync, readdirSync } from 'node:fs';\nimport { join } from 'node:path';\nconst destination=join(process.cwd(),'node_modules');\nmkdirSync(destination,{recursive:true});\nfor(const name of readdirSync(process.env.JOBHUNT_SMOKE_MODULES)){if(name!=='.bin'&&name!=='jobhunt-kit')cpSync(join(process.env.JOBHUNT_SMOKE_MODULES,name),join(destination,name),{recursive:true});}\n`);
42
+ writeFileSync(join(fakeBin, 'npm.cmd'), `@echo off\r\n"${process.execPath}" "${fakeNpm}" %*\r\n`);
43
+ writeFileSync(join(fakeBin, 'npm'), `#!${process.execPath}\nimport '${fakeNpm.replaceAll('\\', '/')}';\n`, { mode: 0o755 });
44
+ const isolatedEnv = { ...env, PATH: `${fakeBin}${delimiter}${process.env.PATH}`, JOBHUNT_SMOKE_MODULES: resolve('node_modules') };
45
+ execFileSync(process.execPath, [join(bundle, 'scripts', 'setup.mjs'), '--data', globalData, '--install-cli'], { env: isolatedEnv, stdio: 'pipe', timeout: 60000 });
46
+ assert.equal(existsSync(join(bundle, 'node_modules', 'ajv')), true);
47
+ assert.equal(JSON.parse(readFileSync(join(globalData, 'profile.json'))).schema_version, 1);
48
+ console.log(`PACK_SMOKE_OK: ${packed[0].filename}, ${packed[0].entryCount} entries`);
49
+ } finally {
50
+ const absolute = resolve(root);
51
+ assert.ok(absolute.startsWith(resolve(tmpdir())) && absolute.includes('jobhunt-pack-smoke-'));
52
+ rmSync(absolute, { recursive: true, force: true });
53
+ }