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
@@ -0,0 +1,337 @@
1
+ import { after, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createHash } from 'node:crypto';
4
+ import { DatabaseSync } from 'node:sqlite';
5
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join, resolve, win32 } from 'node:path';
8
+ import { pathInside } from '../bin/jobhunt-kit.mjs';
9
+ import { runCommand } from '../plugins/jobhunt-kit/scripts/commands.mjs';
10
+ import { backupData, purgeData, redactData, restoreData, verifyBackup } from '../plugins/jobhunt-kit/scripts/maintenance.mjs';
11
+ import { checkResume, recordResumeReview } from '../plugins/jobhunt-kit/scripts/resume.mjs';
12
+ import { confirmProfile } from '../plugins/jobhunt-kit/scripts/profile.mjs';
13
+ import { sendPacket } from '../plugins/jobhunt-kit/scripts/send-packet.mjs';
14
+ import { Tracker, initData } from '../plugins/jobhunt-kit/scripts/tracker.mjs';
15
+ import { validateSchema } from '../plugins/jobhunt-kit/scripts/validation.mjs';
16
+
17
+ const temporaryRoots = [];
18
+ after(() => {
19
+ for (const root of temporaryRoots) {
20
+ const absolute = resolve(root);
21
+ assert.ok(absolute.startsWith(resolve(tmpdir())) && absolute.includes('jobhunt-hardening-'));
22
+ rmSync(absolute, { recursive: true, force: true });
23
+ }
24
+ });
25
+
26
+ function temporary(t, prefix = 'jobhunt-hardening-') {
27
+ const root = mkdtempSync(join(tmpdir(), prefix));
28
+ temporaryRoots.push(root);
29
+ return root;
30
+ }
31
+
32
+ function ready(t) {
33
+ const dir = join(temporary(t), 'data');
34
+ initData(dir);
35
+ const file = join(dir, 'profile.json');
36
+ const profile = JSON.parse(readFileSync(file, 'utf8'));
37
+ profile.confirmed_at = new Date().toISOString();
38
+ profile.identity.name = 'Fictional Candidate';
39
+ profile.search.roles = ['Fictional Engineer'];
40
+ profile.hirify_profile_id = 123;
41
+ profile.evidence = [{ id: 'E1', text: 'Fictional evidence', source: 'fixture', confirmed: true }];
42
+ const resume = Buffer.from('Fictional resume');
43
+ writeFileSync(join(dir, 'resumes', 'fictional.txt'), resume);
44
+ profile.resume = { path: 'resumes/fictional.txt', sha256: createHash('sha256').update(resume).digest('hex'), review_status: 'ready' };
45
+ writeFileSync(file, JSON.stringify(profile));
46
+ const fixtureStore = new Tracker(dir);
47
+ fixtureStore.writeManagedJSON('profile.json', profile, { expectedSha256: createHash('sha256').update(readFileSync(file)).digest('hex'), eventKind: 'fictional_fixture_ready' });
48
+ fixtureStore.close();
49
+ return dir;
50
+ }
51
+
52
+ function prepared(t) {
53
+ const dir = ready(t);
54
+ const store = new Tracker(dir);
55
+ const run = store.runStart({ source: 'fixture' });
56
+ store.put({ run_id: run.run_id, slug: 'fictional', title: 'Original fictional title', url: 'https://example.invalid/f', route: 'hosted',
57
+ description: 'Fictional full text', read_at: new Date().toISOString(), match: { verdict: 'suitable', reasons: ['Fixture'], requirements: [{ requirement: 'Evidence', result: 'pass', evidence: 'E1' }] } });
58
+ store.runFinish({ run_id: run.run_id, status: 'complete', reason: 'Fixture complete' });
59
+ store.prepare({ slug: 'fictional', cover_letter: 'Fictional cover', hirify_profile_id: 123,
60
+ claims: [{ text: 'Fictional evidence', evidence_id: 'E1' }], answers: [], unresolved: [] });
61
+ return { dir, store };
62
+ }
63
+
64
+ test('JSON schemas reject malformed profile and vacancy input', () => {
65
+ assert.throws(() => validateSchema('profile', { schema_version: 1 }), /required property/);
66
+ assert.throws(() => validateSchema('vacancy', { run_id: 'x', slug: '../escape' }), /schema/);
67
+ });
68
+
69
+ test('only one search can run and finished runs reject late events', t => {
70
+ const dir = ready(t);
71
+ const first = new Tracker(dir);
72
+ const second = new Tracker(dir);
73
+ t.after(() => { first.close(); second.close(); });
74
+ const run = first.runStart({});
75
+ assert.throws(() => second.runStart({}), /previous running search/);
76
+ first.runFinish({ run_id: run.run_id, status: 'complete', reason: 'done' });
77
+ assert.throws(() => second.runEvent({ run_id: run.run_id, kind: 'late' }), /finished run/);
78
+ });
79
+
80
+ test('replaced auto authorization invalidates a reserved packet', t => {
81
+ const { dir, store } = prepared(t);
82
+ t.after(() => store.close());
83
+ const profileHash = store.profile().hash;
84
+ const expires_at = new Date(Date.now() + 3600000).toISOString();
85
+ store.policy({ mode: 'auto', auto: { user_authorization: 'Old fictional authorization', profile_hash: profileHash, expires_at, max_attempts_per_utc_day: 5 } });
86
+ const packet = store.begin({ slug: 'fictional' });
87
+ store.policy({ mode: 'auto', auto: { user_authorization: 'Replacement fictional authorization', profile_hash: profileHash, expires_at, max_attempts_per_utc_day: 1 } });
88
+ let calls = 0;
89
+ assert.throws(() => sendPacket(dir, packet, () => { calls++; return { status: 0 }; }), /permission changed/);
90
+ assert.equal(calls, 0);
91
+ });
92
+
93
+ test('application report uses immutable vacancy snapshot after later observations', t => {
94
+ const { store } = prepared(t);
95
+ t.after(() => store.close());
96
+ store.approve({ slug: 'fictional', user_authorization: 'Fictional approval' });
97
+ const packet = store.begin({ slug: 'fictional' });
98
+ store.dispatch(packet);
99
+ store.finish({ attempt_id: packet.attempt_id, outcome: 'submitted', application_id: 'fictional-id', evidence: 'Fixture response' });
100
+ const run = store.runStart({});
101
+ store.put({ run_id: run.run_id, slug: 'fictional', title: 'Changed live title', url: 'https://example.invalid/f', route: 'hosted',
102
+ description: 'Changed text', read_at: new Date().toISOString(), match: { verdict: 'suitable', reasons: ['Changed'], requirements: [{ requirement: 'Evidence', result: 'pass', evidence: 'E1' }] } });
103
+ assert.match(store.report().content, /Original fictional title/);
104
+ assert.doesNotMatch(store.report().content, /Changed live title/);
105
+ });
106
+
107
+ test('registered resume cannot escape local data directory', t => {
108
+ const root = temporary(t);
109
+ const dir = join(root, 'data');
110
+ initData(dir);
111
+ writeFileSync(join(root, 'outside.txt'), 'FICTIONAL OUTSIDE');
112
+ const profile = JSON.parse(readFileSync(join(dir, 'profile.json'), 'utf8'));
113
+ profile.resume = { path: '../outside.txt', sha256: '0'.repeat(64), review_status: 'not_reviewed' };
114
+ writeFileSync(join(dir, 'profile.json'), JSON.stringify(profile));
115
+ assert.throws(() => checkResume(dir), /escapes the local data directory/);
116
+ assert.throws(() => recordResumeReview(dir, { sha256: '0'.repeat(64), status: 'needs_changes', evidence: 'Fictional review' }), /escapes the local data directory/);
117
+ });
118
+
119
+ test('managed profile write recovers from an interrupted journal entry', t => {
120
+ const dir = join(temporary(t), 'data');
121
+ initData(dir);
122
+ const file = join(dir, 'profile.json');
123
+ const oldBytes = readFileSync(file);
124
+ const profile = JSON.parse(oldBytes);
125
+ profile.identity.name = 'Recovered Fictional Name';
126
+ const content = JSON.stringify(profile, null, 2) + '\n';
127
+ const db = new DatabaseSync(join(dir, 'history.sqlite'));
128
+ db.prepare('INSERT INTO pending_file_writes(id,name,old_sha256,new_sha256,content,event_kind,event_payload,created_at) VALUES(?,?,?,?,?,?,?,?)')
129
+ .run('fixture-write', 'profile.json', createHash('sha256').update(oldBytes).digest('hex'), createHash('sha256').update(content).digest('hex'), content, 'profile_saved', '{}', new Date().toISOString());
130
+ db.close();
131
+ const recovered = new Tracker(dir);
132
+ try {
133
+ assert.equal(recovered.profile(false).profile.identity.name, 'Recovered Fictional Name');
134
+ assert.equal(recovered.integrity().pending_file_writes.length, 0);
135
+ assert.equal(recovered.history().at(-1).payload.recovered_interrupted_write, true);
136
+ } finally { recovered.close(); }
137
+ });
138
+
139
+ test('resume review preserves a profile recovered before the command read', t => {
140
+ const dir = ready(t);
141
+ const file = join(dir, 'profile.json');
142
+ const oldBytes = readFileSync(file);
143
+ const recovered = JSON.parse(oldBytes);
144
+ recovered.identity.name = 'Recovered Fictional Name';
145
+ const content = JSON.stringify(recovered, null, 2) + '\n';
146
+ const db = new DatabaseSync(join(dir, 'history.sqlite'));
147
+ db.prepare('INSERT INTO pending_file_writes(id,name,old_sha256,new_sha256,content,event_kind,event_payload,created_at) VALUES(?,?,?,?,?,?,?,?)')
148
+ .run('resume-recovery', 'profile.json', createHash('sha256').update(oldBytes).digest('hex'), createHash('sha256').update(content).digest('hex'), content, 'profile_saved', '{}', new Date().toISOString());
149
+ db.close();
150
+ recordResumeReview(dir, { sha256: recovered.resume.sha256, status: 'needs_changes', evidence: 'Fictional review after recovery' });
151
+ const finalProfile = JSON.parse(readFileSync(file));
152
+ assert.equal(finalProfile.identity.name, 'Recovered Fictional Name');
153
+ assert.equal(finalProfile.resume.review_status, 'needs_changes');
154
+ });
155
+
156
+ test('profile confirm preserves a profile recovered before the command read', t => {
157
+ const dir = ready(t);
158
+ const file = join(dir, 'profile.json');
159
+ const oldBytes = readFileSync(file);
160
+ const recovered = JSON.parse(oldBytes);
161
+ recovered.identity.name = 'Recovered Before Confirm';
162
+ const content = JSON.stringify(recovered, null, 2) + '\n';
163
+ const db = new DatabaseSync(join(dir, 'history.sqlite'));
164
+ db.prepare('INSERT INTO pending_file_writes(id,name,old_sha256,new_sha256,content,event_kind,event_payload,created_at) VALUES(?,?,?,?,?,?,?,?)')
165
+ .run('confirm-recovery', 'profile.json', createHash('sha256').update(oldBytes).digest('hex'), createHash('sha256').update(content).digest('hex'), content, 'profile_saved', '{}', new Date().toISOString());
166
+ db.close();
167
+ confirmProfile(dir, 'Fictional explicit confirmation');
168
+ const finalProfile = JSON.parse(readFileSync(file));
169
+ assert.equal(finalProfile.identity.name, 'Recovered Before Confirm');
170
+ assert.ok(finalProfile.confirmed_at);
171
+ });
172
+
173
+ test('managed JSON refuses pretty output above the readable size limit', t => {
174
+ const dir = join(temporary(t), 'data');
175
+ initData(dir);
176
+ const file = join(dir, 'profile.json');
177
+ const original = readFileSync(file);
178
+ const profile = JSON.parse(original);
179
+ profile.skills = Array.from({ length: 85000 }, () => ({ name: 'Fictional' }));
180
+ const store = new Tracker(dir);
181
+ try {
182
+ assert.throws(() => store.writeManagedJSON('profile.json', profile, { expectedSha256: createHash('sha256').update(original).digest('hex'), eventKind: 'fixture' }), /serialized output exceeds/);
183
+ assert.deepEqual(readFileSync(file), original);
184
+ assert.equal(store.integrity().pending_file_writes.length, 0);
185
+ } finally { store.close(); }
186
+ });
187
+
188
+ test('backup verifies, restore keeps an automatic safety copy, and redaction is audited', t => {
189
+ const root = temporary(t);
190
+ const dir = join(root, 'data');
191
+ initData(dir);
192
+ const backup = join(root, 'backup');
193
+ assert.equal(backupData(dir, backup).files > 0, true);
194
+ assert.equal(verifyBackup(backup).valid, true);
195
+ const profile = JSON.parse(readFileSync(join(dir, 'profile.json'), 'utf8'));
196
+ profile.identity.name = 'Fictional Changed';
197
+ writeFileSync(join(dir, 'profile.json'), JSON.stringify(profile));
198
+ const restored = restoreData(dir, backup, 'RESTORE VERIFIED JOBHUNT BACKUP');
199
+ assert.equal(JSON.parse(readFileSync(join(dir, 'profile.json'))).identity.name, null);
200
+ assert.equal(existsSync(join(restored.automatic_quarantine, 'quarantine-manifest.json')), true);
201
+ assert.equal(JSON.parse(readFileSync(join(restored.automatic_quarantine, 'profile.json'))).identity.name, 'Fictional Changed');
202
+ const store = new Tracker(dir);
203
+ const bytes = readFileSync(join(dir, 'profile.json'));
204
+ profile.identity.name = 'Fictional PII';
205
+ profile.confirmed_at = null;
206
+ store.writeManagedJSON('profile.json', profile, { expectedSha256: createHash('sha256').update(bytes).digest('hex'), eventKind: 'fixture', eventPayload: {} });
207
+ store.close();
208
+ const result = redactData(dir, { confirmation: 'REDACT LOCAL JOBHUNT PII', fields: ['identity.name'] });
209
+ assert.deepEqual(result.redacted, ['identity.name']);
210
+ assert.equal(JSON.parse(readFileSync(join(dir, 'profile.json'))).identity.name, null);
211
+ writeFileSync(join(dir, 'reports', 'latest.md'), 'FICTIONAL PRIVATE HISTORY');
212
+ const purged = purgeData(dir, { confirmation: 'PURGE LOCAL JOBHUNT DATA', backup_directory: join(root, 'purge-backup'), scopes: ['history', 'materials', 'resumes', 'profile'] });
213
+ assert.deepEqual(purged.purged, ['history', 'materials', 'resumes', 'profile', 'reports']);
214
+ assert.equal(existsSync(join(dir, 'reports', 'latest.md')), false);
215
+ assert.equal(verifyBackup(join(root, 'purge-backup')).valid, true);
216
+ const empty = new Tracker(dir);
217
+ try { assert.equal(empty.history().length, 0); } finally { empty.close(); }
218
+ });
219
+
220
+ test('restore accepts corrupted live JSON and backup verification rejects unmanifested files', t => {
221
+ const root = temporary(t);
222
+ const dir = join(root, 'data');
223
+ initData(dir);
224
+ const backup = join(root, 'backup');
225
+ backupData(dir, backup);
226
+ writeFileSync(join(backup, 'materials', 'UNMANIFESTED.txt'), 'FICTIONAL EXTRA');
227
+ assert.throws(() => verifyBackup(backup), /exactly match/);
228
+ rmSync(join(backup, 'materials', 'UNMANIFESTED.txt'));
229
+ writeFileSync(join(dir, 'profile.json'), '{broken');
230
+ const restored = restoreData(dir, backup, 'RESTORE VERIFIED JOBHUNT BACKUP');
231
+ assert.equal(verifyBackup(backup).valid, true);
232
+ assert.equal(readFileSync(join(restored.automatic_quarantine, 'profile.json'), 'utf8'), '{broken');
233
+ assert.equal(JSON.parse(readFileSync(join(dir, 'profile.json'))).schema_version, 1);
234
+ });
235
+
236
+ test('CLI verifies and restores a backup when the live profile is missing', t => {
237
+ const root = temporary(t);
238
+ const dir = join(root, 'data');
239
+ initData(dir);
240
+ const backup = join(root, 'backup');
241
+ backupData(dir, backup);
242
+ rmSync(join(dir, 'profile.json'));
243
+ assert.equal(runCommand(['backup-verify', backup, '--data', dir], { cwd: root }).valid, true);
244
+ const request = join(root, 'restore.json');
245
+ writeFileSync(request, JSON.stringify({ source: backup, confirmation: 'RESTORE VERIFIED JOBHUNT BACKUP' }));
246
+ assert.equal(runCommand(['restore', '--input', request, '--data', dir], { cwd: root }).restored, true);
247
+ assert.equal(JSON.parse(readFileSync(join(dir, 'profile.json'))).schema_version, 1);
248
+ const newDir = join(root, 'new-data');
249
+ restoreData(newDir, backup, 'RESTORE VERIFIED JOBHUNT BACKUP');
250
+ assert.equal(readFileSync(join(newDir, '.gitignore'), 'utf8'), '*\n');
251
+ });
252
+
253
+ test('backup refuses a valid JSON file that is not tracked by SQLite revision state', t => {
254
+ const root = temporary(t);
255
+ const dir = join(root, 'data');
256
+ initData(dir);
257
+ const profilePath = join(dir, 'profile.json');
258
+ const profile = JSON.parse(readFileSync(profilePath, 'utf8'));
259
+ profile.identity.name = 'Untracked Fictional Change';
260
+ writeFileSync(profilePath, JSON.stringify(profile));
261
+ assert.throws(() => backupData(dir, join(root, 'backup')), /invalid data/);
262
+ });
263
+
264
+ test('backup verifier accepts a generated-format manifest above the ordinary JSON limit', t => {
265
+ const root = temporary(t);
266
+ const dir = join(root, 'data');
267
+ initData(dir);
268
+ const backup = join(root, 'backup');
269
+ backupData(dir, backup);
270
+ const manifestPath = join(backup, 'backup-manifest.json');
271
+ const manifest = JSON.parse(readFileSync(manifestPath));
272
+ manifest.note = 'F'.repeat(2 * 1024 * 1024);
273
+ writeFileSync(manifestPath, JSON.stringify(manifest));
274
+ assert.equal(verifyBackup(backup).valid, true);
275
+ });
276
+
277
+ test('history purge and restore revoke reusable auto authorization', t => {
278
+ const root = temporary(t);
279
+ const restoredDir = ready(t);
280
+ let store = new Tracker(restoredDir);
281
+ store.policy({ mode: 'auto', auto: { user_authorization: 'Fictional scoped approval', profile_hash: store.profile().hash,
282
+ expires_at: new Date(Date.now() + 3600000).toISOString(), max_attempts_per_utc_day: 1 } });
283
+ store.close();
284
+ const backup = join(root, 'auto-backup');
285
+ backupData(restoredDir, backup);
286
+ restoreData(restoredDir, backup, 'RESTORE VERIFIED JOBHUNT BACKUP');
287
+ assert.equal(JSON.parse(readFileSync(join(restoredDir, 'profile.json'))).confirmed_at, null);
288
+ assert.equal(JSON.parse(readFileSync(join(restoredDir, 'policy.json'))).mode, 'review_each');
289
+
290
+ const purgedDir = ready(t);
291
+ store = new Tracker(purgedDir);
292
+ store.policy({ mode: 'auto', auto: { user_authorization: 'Fictional scoped approval', profile_hash: store.profile().hash,
293
+ expires_at: new Date(Date.now() + 3600000).toISOString(), max_attempts_per_utc_day: 1 } });
294
+ store.close();
295
+ purgeData(purgedDir, { confirmation: 'PURGE LOCAL JOBHUNT DATA', backup_directory: join(root, 'purge-backup'), scopes: ['history'] });
296
+ assert.equal(JSON.parse(readFileSync(join(purgedDir, 'profile.json'))).confirmed_at, null);
297
+ assert.equal(JSON.parse(readFileSync(join(purgedDir, 'policy.json'))).mode, 'review_each');
298
+ });
299
+
300
+ test('reserved attempt packet can be recreated idempotently', t => {
301
+ const { dir, store } = prepared(t);
302
+ store.approve({ slug: 'fictional', user_authorization: 'Fictional approval' });
303
+ const packet = store.begin({ slug: 'fictional' });
304
+ store.close();
305
+ const recovered = runCommand(['recover', 'packet', packet.attempt_id, '--data', dir]);
306
+ assert.equal(recovered.attempt_id, packet.attempt_id);
307
+ assert.deepEqual(JSON.parse(readFileSync(recovered.packet_path)), packet);
308
+ assert.deepEqual(runCommand(['recover', 'packet', packet.attempt_id, '--data', dir]), recovered);
309
+ });
310
+
311
+ test('v1 database migrates to v2 and closes duplicate running searches', t => {
312
+ const dir = join(temporary(t), 'legacy');
313
+ mkdirSync(dir, { recursive: true });
314
+ for (const name of ['profile.json', 'policy.json']) copyFileSync(join('plugins', 'jobhunt-kit', 'templates', name), join(dir, name));
315
+ const db = new DatabaseSync(join(dir, 'history.sqlite'));
316
+ db.exec(`CREATE TABLE runs(id TEXT PRIMARY KEY, started_at TEXT NOT NULL, finished_at TEXT,status TEXT NOT NULL,profile_hash TEXT NOT NULL,input TEXT NOT NULL,result TEXT);
317
+ CREATE TABLE jobs(id TEXT PRIMARY KEY,canonical_url TEXT UNIQUE NOT NULL,status TEXT NOT NULL,payload TEXT NOT NULL,draft TEXT,approval TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
318
+ CREATE TABLE aliases(slug TEXT PRIMARY KEY,job_id TEXT NOT NULL REFERENCES jobs(id));
319
+ CREATE TABLE observations(id INTEGER PRIMARY KEY,run_id TEXT NOT NULL REFERENCES runs(id),job_id TEXT NOT NULL REFERENCES jobs(id),at TEXT NOT NULL,payload TEXT NOT NULL);
320
+ CREATE TABLE attempts(id TEXT PRIMARY KEY,job_id TEXT UNIQUE NOT NULL REFERENCES jobs(id),started_at TEXT NOT NULL,mode TEXT NOT NULL,packet TEXT NOT NULL,result TEXT);
321
+ CREATE TABLE dispatches(attempt_id TEXT PRIMARY KEY REFERENCES attempts(id),at TEXT NOT NULL);
322
+ CREATE TABLE events(id INTEGER PRIMARY KEY,at TEXT NOT NULL,kind TEXT NOT NULL,job_id TEXT,run_id TEXT,payload TEXT NOT NULL);
323
+ INSERT INTO runs VALUES('old','2026-01-01',NULL,'running','x','{}',NULL),('new','2026-01-02',NULL,'running','x','{}',NULL);
324
+ PRAGMA user_version=1;`);
325
+ db.close();
326
+ const store = new Tracker(dir);
327
+ t.after(() => store.close());
328
+ assert.equal(store.integrity().schema_version, 2);
329
+ assert.equal(store.integrity().migrations.at(-1).version, 2);
330
+ assert.deepEqual(store.integrity().running_runs.map(row => row.id), ['new']);
331
+ assert.equal(store.runs().find(row => row.id === 'old').status, 'blocked');
332
+ });
333
+
334
+ test('Windows path containment allows a destination on another volume', () => {
335
+ assert.equal(pathInside('C:\\npm\\jobhunt-kit', 'D:\\work\\jobhunt', win32), false);
336
+ assert.equal(pathInside('C:\\npm\\jobhunt-kit', 'C:\\npm\\jobhunt-kit\\child', win32), true);
337
+ });
@@ -1,11 +1,17 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, symlinkSync, unlinkSync } from 'node:fs';
3
+ import { cpSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, symlinkSync, unlinkSync } from 'node:fs';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join, resolve, sep } from 'node:path';
7
7
  import { install } from '../bin/jobhunt-kit.mjs';
8
8
  import { installOptions } from '../bin/install-options.mjs';
9
+ import { AGENTS, parseAgents, skillDirectory, detectAgents } from '../bin/agents.mjs';
10
+
11
+ const VALIDATION_MODULES = ['ajv', 'fast-deep-equal', 'fast-uri', 'json-schema-traverse', 'require-from-string'];
12
+ function installValidationFixture(bundle) {
13
+ for (const name of VALIDATION_MODULES) cpSync(resolve('node_modules', name), join(bundle, 'node_modules', name), { recursive: true });
14
+ }
9
15
 
10
16
  function target(t) {
11
17
  const path = mkdtempSync(join(tmpdir(), 'job-search-install-'));
@@ -102,7 +108,9 @@ test('global skill can initialize separate data and generate a schedule with per
102
108
  const home=target(t);
103
109
  install('unused',{home,scope:'global',agents:['codex']});
104
110
  const workspace=join(home,'work'); mkdirSync(workspace);
105
- const cli=join(home,'.agents/skills/jobhunt-kit/bundle/scripts/cli.mjs');
111
+ const bundle=join(home,'.agents/skills/jobhunt-kit/bundle');
112
+ installValidationFixture(bundle);
113
+ const cli=join(bundle,'scripts/cli.mjs');
106
114
  const run=args=>{
107
115
  const r=spawnSync(process.execPath,[cli,...args,'--workspace',workspace],{encoding:'utf8'});
108
116
  assert.equal(r.status,0,r.stderr);return JSON.parse(r.stdout);
@@ -114,3 +122,40 @@ test('global skill can initialize separate data and generate a schedule with per
114
122
  assert.equal(result.scheduled,false);
115
123
  assert.ok(readFileSync(result.path,'utf8').includes(join(home,'.agents/skills/jobhunt-kit/bundle')));
116
124
  });
125
+ test('all supported harnesses receive complete bundles at their project and global paths', t => {
126
+ const project=target(t), home=target(t);
127
+ const projectAgents=AGENTS.filter(a=>!a.globalOnly).map(a=>a.id);
128
+ install(project,{agents:projectAgents,installDependencies:false,env:{}});
129
+ install('unused',{agents:AGENTS.map(a=>a.id),scope:'global',home,env:{}});
130
+ const expected={github:'.copilot/skills',opencode:'.config/opencode/skills',antigravity:'.gemini/config/skills',pi:'.pi/agent/skills'};
131
+ for(const a of AGENTS){
132
+ const global=join(home,expected[a.id] || `${a.dir}/skills`,'jobhunt-kit');
133
+ assert.ok(existsSync(join(global,'SKILL.md')),a.id);
134
+ assert.ok(existsSync(join(global,'bundle/scripts/setup.mjs')),a.id);
135
+ if(!a.globalOnly) assert.ok(existsSync(join(project,a.dir,'skills/jobhunt-kit/SKILL.md')),a.id);
136
+ }
137
+ assert.equal(existsSync(join(project,'.veto')),false);
138
+ });
139
+ test('provider aliases and all work in flags and interactive selection', async () => {
140
+ assert.deepEqual(parseAgents('copilot,github,.claude,claude-code,deepseek'),['github','claude','dsh']);
141
+ const quiet={input:{isTTY:false},output:{isTTY:false}};
142
+ const project=await installOptions(['--providers','all'],quiet);
143
+ assert.equal(project.agents.length,17); assert.equal(project.agents.includes('veto'),false);
144
+ const global=await installOptions(['--agents','all','--scope','global'],quiet);
145
+ assert.equal(global.agents.length,18);
146
+ await assert.rejects(installOptions(['--agents','veto'],quiet),/global/);
147
+ await assert.rejects(installOptions(['--agents','codex','--providers','cursor'],quiet),/either/);
148
+ const answers=['all','project'];
149
+ assert.equal((await installOptions([],{output:{write(){}},ask:async()=>answers.shift()})).agents.length,17);
150
+ });
151
+ test('custom global configuration directories are detected and cannot escape home', t => {
152
+ const home=target(t),cwd=target(t);
153
+ const env={OPENCODE_CONFIG_DIR:join(home,'custom/open'),HERMES_HOME:join(home,'custom/hermes'),DSH_HOME:join(home,'custom/dsh')};
154
+ for(const p of Object.values(env)) mkdirSync(p,{recursive:true});
155
+ for(const id of ['opencode','hermes','dsh']) assert.ok(detectAgents({home,cwd,env}).includes(id));
156
+ assert.equal(skillDirectory('opencode',{home,cwd,env,scope:'global'}),join(env.OPENCODE_CONFIG_DIR,'skills/jobhunt-kit'));
157
+ assert.equal(skillDirectory('opencode',{home,cwd,env:{XDG_CONFIG_HOME:join(home,'xdg')},scope:'global'}),join(home,'xdg/opencode/skills/jobhunt-kit'));
158
+ assert.throws(()=>skillDirectory('hermes',{home,cwd,env:{HERMES_HOME:home+'-sibling'},scope:'global'}),/inside the home/);
159
+ assert.throws(()=>install('unused',{home,scope:'global',agents:['codex','opencode'],env:{OPENCODE_CONFIG_DIR:cwd}}),/inside the home/);
160
+ assert.equal(existsSync(join(home,'.agents')),false);
161
+ });
@@ -39,6 +39,7 @@ function vacancy(run, slug = 'fictional-a', route = 'hosted') {
39
39
  function prepare(store, slug = 'fictional-a', route = 'hosted') {
40
40
  const run = store.runStart({ query: 'fictional' });
41
41
  store.put(vacancy(run, slug, route));
42
+ store.runFinish({ run_id: run.run_id, status: 'complete', reason: 'Synthetic fixture recorded' });
42
43
  store.prepare({ slug, cover_letter: 'Fictional "cover"\n$(do not execute) `literal`', hirify_profile_id: 123,
43
44
  claims: [{ text: 'Fictional library experience', evidence_id: 'E1' }], answers: [], unresolved: [] });
44
45
  }
@@ -53,8 +54,10 @@ test('empty template blocks search and preserves user edits on re-init', t => {
53
54
  assert.equal(JSON.parse(readFileSync(join(f.dir, 'policy.json'))).mode, 'review_each');
54
55
  f.profile.identity.name = 'Fictional candidate';
55
56
  writeFileSync(f.profilePath, JSON.stringify(f.profile));
57
+ writeFileSync(join(f.dir, '.gitignore'), '*\n!keep-this-rule\n');
56
58
  initData(f.dir);
57
59
  assert.equal(JSON.parse(readFileSync(f.profilePath)).identity.name, 'Fictional candidate');
60
+ assert.match(readFileSync(join(f.dir, '.gitignore'), 'utf8'), /keep-this-rule/);
58
61
  });
59
62
  test('canonical URL deduplication persists and reimport preserves draft status', t => {
60
63
  const { store, dir } = fixture(t);