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.
Files changed (50) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +14 -0
  3. package/AGENTS.md +23 -0
  4. package/CLAUDE.md +5 -0
  5. package/README.md +122 -0
  6. package/bin/jobhunt-kit.mjs +95 -0
  7. package/installer-assets/gitignore.txt +22 -0
  8. package/installer-assets/runtime-lock.json +492 -0
  9. package/installer-assets/workspace-lock.json +495 -0
  10. package/package.json +52 -0
  11. package/plugins/jobhunt-kit/.claude-plugin/plugin.json +9 -0
  12. package/plugins/jobhunt-kit/.codex-plugin/plugin.json +18 -0
  13. package/plugins/jobhunt-kit/THIRD_PARTY.md +18 -0
  14. package/plugins/jobhunt-kit/package-lock.json +492 -0
  15. package/plugins/jobhunt-kit/package.json +14 -0
  16. package/plugins/jobhunt-kit/references/cli.md +125 -0
  17. package/plugins/jobhunt-kit/references/cover-guidance.md +24 -0
  18. package/plugins/jobhunt-kit/references/hirify/LICENSE +202 -0
  19. package/plugins/jobhunt-kit/references/hirify/NOTICE +2 -0
  20. package/plugins/jobhunt-kit/references/hirify/SKILL.md +139 -0
  21. package/plugins/jobhunt-kit/references/hirify/reference.md +286 -0
  22. package/plugins/jobhunt-kit/references/matching.md +28 -0
  23. package/plugins/jobhunt-kit/references/resume-guidance.md +47 -0
  24. package/plugins/jobhunt-kit/references/storage.md +120 -0
  25. package/plugins/jobhunt-kit/references/workflow.md +89 -0
  26. package/plugins/jobhunt-kit/scripts/cli.mjs +9 -0
  27. package/plugins/jobhunt-kit/scripts/commands.mjs +157 -0
  28. package/plugins/jobhunt-kit/scripts/extract-resume.mjs +27 -0
  29. package/plugins/jobhunt-kit/scripts/hirify.mjs +31 -0
  30. package/plugins/jobhunt-kit/scripts/profile.mjs +79 -0
  31. package/plugins/jobhunt-kit/scripts/resume.mjs +92 -0
  32. package/plugins/jobhunt-kit/scripts/send-packet.mjs +36 -0
  33. package/plugins/jobhunt-kit/scripts/setup.mjs +25 -0
  34. package/plugins/jobhunt-kit/scripts/tracker.mjs +332 -0
  35. package/plugins/jobhunt-kit/skills/job-apply/SKILL.md +61 -0
  36. package/plugins/jobhunt-kit/skills/job-profile/SKILL.md +33 -0
  37. package/plugins/jobhunt-kit/skills/job-resume/SKILL.md +29 -0
  38. package/plugins/jobhunt-kit/skills/job-search/SKILL.md +39 -0
  39. package/plugins/jobhunt-kit/skills/job-track/SKILL.md +30 -0
  40. package/plugins/jobhunt-kit/templates/cover-letter.md +17 -0
  41. package/plugins/jobhunt-kit/templates/intake.md +62 -0
  42. package/plugins/jobhunt-kit/templates/policy.json +6 -0
  43. package/plugins/jobhunt-kit/templates/profile.json +29 -0
  44. package/plugins/jobhunt-kit/templates/profile.md +21 -0
  45. package/plugins/jobhunt-kit/templates/resume-review.md +30 -0
  46. package/plugins/jobhunt-kit/templates/scheduled-search.md +21 -0
  47. package/scripts/check-package.mjs +62 -0
  48. package/tests/commands.test.mjs +143 -0
  49. package/tests/installer.test.mjs +52 -0
  50. package/tests/tracker.test.mjs +180 -0
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: job-search
3
+ description: Find up to five new suitable vacancies through Hirify using a confirmed local profile, bounded search budgets, full-text matching and persistent deduplication. Use for a search batch or an authorized recurring search.
4
+ ---
5
+
6
+ # Поиск подходящих вакансий
7
+
8
+ Использовать [CLI](../../references/cli.md): search создаёт контекст и задание для
9
+ агента, search start/event/record/finish сохраняют цикл поиска. Команда search сама
10
+ не вызывает живой поиск; продолжить через Hirify по инструкции ниже. Не объявлять
11
+ сохранение контекста завершённым поиском вакансий.
12
+
13
+ Прочитать [workflow](../../references/workflow.md),
14
+ [matching](../../references/matching.md), [команды трекера](../../references/storage.md)
15
+ и [Hirify](../../references/hirify/SKILL.md). Определить P/D.
16
+
17
+ 1. Прочитать профиль и policy, список вакансий и незавершённые runs. Если профиль
18
+ не подтверждён — направить в job-profile. Сохранить исходный список ID для дедупликации.
19
+ 2. `run-start` с целями, бюджетами и источником запуска. Читать текущие квоты Hirify,
20
+ затем ленты. Профили сервиса не выбирать автоматически при нескольких вариантах.
21
+ 3. Получить filter guide; построить запрос из профиля, сделать preview и уточнить
22
+ критерии. Хранить запросы, страницы, квоты и ошибки через run-event. Не менять
23
+ удалённые ленты и настройки без отдельного разрешения.
24
+ 4. Для каждой новой карточки — put с unreviewed; явные нерелевантные карточки
25
+ оставить в истории с причиной предварительного отсева в run-event.
26
+ 5. Читать полные тексты в пределах max_reads; оценить требования и повторить put
27
+ с verdict/reasons/requirements. HTML рассматривать как текстовые данные.
28
+ 6. Для suitable при необходимости reveal в пределах max_reveals. Источник способа
29
+ отклика — read; не пробовать apply для определения hosted/external.
30
+ 7. Остановиться по matching. Записать run-finish со статусом, фактическими числами
31
+ и причиной остановки; выполнить report. При сбое сохранить partial/failed,
32
+ не терять уже найденное. Незавершённый прошлый запуск закрыть с объяснением.
33
+ 8. Показать до 5 новых suitable: роль, компания если известна, ссылка, условия,
34
+ основания совпадения, оговорки и способ отклика. Review — отдельным блоком.
35
+
36
+ Обычный запрос «найди» означает поиск. Если пользователь просит подготовить/отправить
37
+ отклики, передать выбранные вакансии в [job-apply](../job-apply/SKILL.md). В заранее
38
+ разрешённом auto-workflow можно продолжить туда; сам поиск не выдаёт разрешение.
39
+ В режиме review_each подготовить запрошенные материалы и ждать отдельных согласий.
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: job-track
3
+ description: Show local job-search history and reports, record user-reported application statuses, resolve uncertain submissions, or prepare an explicitly requested recurring search. Does not read email or send recruiter follow-ups.
4
+ ---
5
+
6
+ # История и дальнейшие шаги
7
+
8
+ Использовать [CLI](../../references/cli.md): track list/show/status, history, runs,
9
+ report и schedule --input. Schedule формирует текст с конкретными путями/часовой
10
+ зоной, но не создаёт задачу. Не переписывать таблицу истории вручную.
11
+
12
+ Прочитать [workflow](../../references/workflow.md) и [storage](../../references/storage.md).
13
+ Для обзора выполнить list, runs, history и report; для вакансии — show <slug>.
14
+ Показывать статус, дату, основания, источник обновления и следующий шаг.
15
+
16
+ Новые сообщения пользователя об интервью/отказе/оффере записывать через status с
17
+ note (содержание, источник, дата). Не заявлять, что Hirify сам сообщил эти статусы.
18
+ Внешний отклик отметить submitted_external только после подтверждения пользователя.
19
+ Для submitting/submission_unknown использовать resolve при доказанном исходе,
20
+ не предлагать автоматический повтор. Отказ работодателя — rejected, ошибка отправки — failed.
21
+
22
+ Если пользователь явно просит регулярный поиск, адаптировать
23
+ [шаблон расписания](../../templates/scheduled-search.md), уточнив частоту/часовой пояс
24
+ и доступность машины. Создать задачу штатным инструментом агента, если он доступен.
25
+ Если инструмента нет — сохранить готовый prompt и инструкции, честно сказать, что
26
+ задача не создана. В Codex использовать automation_update; в другой среде её scheduler.
27
+ Не создавать расписание при установке шаблона. Параллельные поиски по одному D не запускать.
28
+
29
+ Ничего не отправлять работодателям из скилла трекинга. Отмена локального статуса
30
+ не отзывает отправленное через Hirify заявление. Напоминания — только пользователю.
@@ -0,0 +1,17 @@
1
+ # Черновик сопроводительного письма
2
+
3
+ Этот шаблон задаёт структуру; его нельзя отправлять с незаполненными полями.
4
+
5
+ 1. Обращение, если адресат достоверно известен, и название роли.
6
+ 2. Почему задача интересна кандидату — из его слов и проверенного описания вакансии.
7
+ 3. Один-два подтверждённых примера: задача → действие → результат, связанные с требованиями.
8
+ 4. Короткое приглашение обсудить роль. Доступность/контакт только если это нужно.
9
+
10
+ Отдельно от отправляемого текста сохранить:
11
+
12
+ - Для каждого фактического утверждения — evidence ID из profile.json.
13
+ - Неизвестные ответы и вопросы работодателю.
14
+ - Язык, выбранный профиль Hirify и точный способ отправки.
15
+
16
+ Для CLI хранить только текст письма в поле cover_letter файла draft.json.
17
+ Не включать в письмо внутренние оценки, evidence ID и служебные пояснения.
@@ -0,0 +1,62 @@
1
+ # Анкета инициализации профиля
2
+
3
+ Заполнять поэтапно, 3–5 вопросов за сообщение. Сначала извлечь ответы из резюме,
4
+ затем уточнить недостающее. Не просить человека повторять уже известное.
5
+ Можно ответить «не определился», «не применимо» или отказаться от необязательного поля.
6
+ `null` означает неизвестно, а не «нет». Это не анкета конкретного владельца проекта.
7
+
8
+ ## 1. Исходные материалы
9
+
10
+ - Прикрепите актуальное резюме или укажите локальный файл. Какие версии и языки актуальны?
11
+ - Есть ли портфолио, профессиональные ссылки, проекты или публикации?
12
+ - Какие сведения устарели, неточны, конфиденциальны или не должны попасть в отклики?
13
+ - На каком языке общаться с агентом? На каких языках готовить материалы?
14
+
15
+ ## 2. Цели поиска (нужно до поиска)
16
+
17
+ - Какие роли и альтернативные названия интересуют? Какой уровень и тип ответственности?
18
+ - Какие страны/города подходят, включая ограничения удалённой работы по месту проживания?
19
+ - Удалёнка, гибрид, офис — какие варианты допустимы? Возможны ли переезд и командировки?
20
+ - Полная/частичная занятость, контракт, стажировка; какие график и пересечение часовых поясов?
21
+ - Минимальная и желаемая компенсация: число, валюта, месяц/год/час, gross/net?
22
+ Рассматривать ли вакансии без указанной суммы? Бонусы/доли считать отдельно?
23
+ - Отрасли и типы компаний: желаемые и исключённые? Есть ли конкретные исключения?
24
+ - Какие условия обязательны, а какие предпочтительны? Чем готовы поступиться?
25
+ - Есть ли право работать в выбранных странах, нужна ли поддержка с визой?
26
+ Записать ответ кандидата; не делать правовых выводов.
27
+
28
+ ## 3. Контакты и типовые поля анкет (нужно до отклика, не всё до поиска)
29
+
30
+ - Имя для резюме/откликов; email; телефон с кодом страны; предпочтительный контакт?
31
+ - Город, страна проживания и часовой пояс? Полный адрес обычно не нужен.
32
+ - Какие профессиональные ссылки можно передавать работодателям?
33
+ - Когда готовы начать, срок уведомления, удобные часы интервью?
34
+ - Языки и уровень владения: самооценка или сертификат с датой?
35
+ - Как ответить на «расскажите о себе», «почему ищете работу», «почему эта роль»?
36
+ - Какие фактические ответы есть на вопросы об опыте, руководстве, инструментах,
37
+ обучении, сертификатах? Длительность опыта не удваивать при пересечении проектов.
38
+ - Есть ли уже отправленные отклики/знакомства с работодателями, которые нужно импортировать?
39
+
40
+ Не собирать заранее паспортные данные, банковские реквизиты, дату рождения,
41
+ семейное положение, медицинские сведения и контакты рекомендателей. Не выбирать
42
+ демографические ответы за человека. Если внешняя форма требует таких данных — ручной шаг.
43
+
44
+ ## 4. Проверяемые примеры
45
+
46
+ - 2–5 значимых результатов: задача, личный вклад, результат и откуда известно число?
47
+ - Какие кейсы можно обсуждать публично, какие только обезличенно?
48
+ - Какие пробелы/переходы требуют объяснения? Уточнять без оценки и давления.
49
+
50
+ ## 5. Настройки процесса
51
+
52
+ - Есть ли нужный профиль или сохранённые ленты Hirify? Выбрать ID вместе с пользователем.
53
+ - По умолчанию до 5 новых подходящих вакансий за поиск. Подходят ли бюджеты policy.json?
54
+ - Подтверждать каждый отклик (по умолчанию) или отдельно настроить ограниченную
55
+ автоотправку через job-apply? Установка шаблона не включает её.
56
+ - Нужно ли расписание? Если да: частота, часовой пояс, где запускать и когда уведомлять?
57
+
58
+ ## Финальное подтверждение
59
+
60
+ Показать резюме профиля: цели, обязательные условия, факты, неизвестное, контакты для
61
+ передачи и выбранный Hirify-профиль. После подтверждения заполнить confirmed_at.
62
+ Согласие с профилем не является согласием на отправку откликов.
@@ -0,0 +1,6 @@
1
+ {
2
+ "schema_version": 1,
3
+ "mode": "review_each",
4
+ "search": { "target": 5, "max_cards": 100, "max_reads": 20, "max_reveals": 5, "max_minutes": 15 },
5
+ "auto": null
6
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "schema_version": 1,
3
+ "confirmed_at": null,
4
+ "identity": {
5
+ "name": null, "email": null, "phone": null, "city": null,
6
+ "country": null, "timezone": null, "contact_links": []
7
+ },
8
+ "search": {
9
+ "roles": [], "levels": [], "countries": [], "work_modes": [],
10
+ "employment_types": [], "industries": [], "excluded_companies": [],
11
+ "excluded_keywords": [], "relocation": null, "travel": null,
12
+ "working_hours": null, "work_authorization": [], "sponsorship": null,
13
+ "salary": { "min": null, "target": null, "currency": null, "period": null, "tax_basis": null },
14
+ "hard_constraints": [], "preferences": [],
15
+ "unknown_salary": "ask", "unknown_critical_requirement": "ask",
16
+ "feed_ids": [], "queries": []
17
+ },
18
+ "languages": [],
19
+ "experience": [],
20
+ "education": [],
21
+ "skills": [],
22
+ "achievements": [],
23
+ "evidence": [],
24
+ "answers": [],
25
+ "availability": { "notice_period": null, "start_date": null, "interview_hours": null },
26
+ "writing": { "resume_languages": [], "cover_languages": [], "tone": null, "motivation": null },
27
+ "hirify_profile_id": null,
28
+ "resume": { "path": null, "sha256": null, "review_status": "not_reviewed" }
29
+ }
@@ -0,0 +1,21 @@
1
+ # Профиль кандидата
2
+
3
+ Не заполнен. Сначала пройти анкету. Источник фактов — `profile.json`.
4
+ Это читаемое представление: изменения переносить в JSON и подтверждать у кандидата.
5
+
6
+ ## Позиционирование
7
+
8
+ Целевые роли и уровень, подтверждённый опыт, сильные стороны и примеры результатов.
9
+
10
+ ## Что ищу
11
+
12
+ Обязательные условия отдельно от предпочтений. География, формат работы, компенсация
13
+ с валютой, периодом и gross/net; готовность к переезду, график, язык.
14
+
15
+ ## Банк фактов
16
+
17
+ Для каждого утверждения указать evidence ID из JSON. Неизвестные значения не угадывать.
18
+
19
+ ## Открытые вопросы
20
+
21
+ Противоречия, недостающие сведения и детали, требующие подтверждения кандидата.
@@ -0,0 +1,30 @@
1
+ # Проверка резюме
2
+
3
+ Дата / версия файла / SHA-256 / целевая роль и рынок:
4
+
5
+ ## Итог
6
+
7
+ Готово / нужны изменения / заблокировано недостающими фактами.
8
+ Это локальная экспертная проверка, не результат реального ATS работодателя.
9
+
10
+ ## Что действительно проверено
11
+
12
+ Извлечение текста / порядок чтения / визуальный просмотр / ссылки / факты с кандидатом.
13
+ Для каждого — выполнено, не выполнено или неприменимо и каким способом проверяли.
14
+
15
+ ## Замечания
16
+
17
+ | Приоритет | Место и наблюдение | Почему мешает | Предлагаемое исправление | Источник правила |
18
+ |---|---|---|---|---|
19
+
20
+ ## Факты и вопросы
21
+
22
+ Противоречия, отсутствующие даты, неподтверждённые цифры; отдельные вопросы кандидату.
23
+
24
+ ## Соответствие роли
25
+
26
+ Требование → доказательство в резюме → реальный пробел или улучшение формулировки.
27
+
28
+ ## После исправлений
29
+
30
+ Какие проверки повторены и какие ограничения остались.
@@ -0,0 +1,21 @@
1
+ # Шаблон задания для планировщика
2
+
3
+ Перед сохранением заменить все поля в угловых скобках. Это текст задания,
4
+ а не уже включённое расписание. Частота: <расписание>, часовой пояс: <IANA timezone>.
5
+ Рабочая папка: <абсолютный workspace>, P: <абсолютный путь плагина>,
6
+ D: <абсолютный путь локальных данных>. Согласованный режим: <search / prepare / auto>.
7
+
8
+ Используй job-search из P с подтверждённым профилем D. Найди до пяти новых подходящих
9
+ вакансий через Hirify, соблюдая меньший из текущих квот сервиса и бюджетов policy.json.
10
+ Сохрани запросы, результаты, причины совпадения и завершение запуска в локальном трекере.
11
+ Не запускай второй поиск, пока предыдущий по этому D активен; проверь его состояние.
12
+
13
+ В search только сообщи результаты. В prepare подготовь письма для согласования.
14
+ В auto используй job-apply только при действующем явном разрешении в policy и всех
15
+ пройденных проверках; иначе сохрани материалы для пользователя. Не отправляй внешние
16
+ формы и не меняй настройки Hirify. Не повторяй отклики с неопределённым результатом.
17
+
18
+ Обнови Markdown-отчёт. Не уведомляй, если нет новых подходящих вакансий или значимых
19
+ изменений. Уведоми о новых suitable, отправленных откликах, сбое, истёкшем разрешении
20
+ или требуемом действии. Укажи ссылки на локальные материалы. Не читай почту и не пиши
21
+ рекрутерам. Если профиль не заполнен — сообщи один раз, что нужна инициализация.
@@ -0,0 +1,62 @@
1
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
2
+ import { resolve, dirname, join } from 'node:path';
3
+ import assert from 'node:assert/strict';
4
+
5
+ const root = resolve('plugins/jobhunt-kit');
6
+ function walk(dir) {
7
+ return readdirSync(dir, { withFileTypes: true }).flatMap(e => e.name === 'node_modules' ? [] : e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)]);
8
+ }
9
+ const files = walk(root);
10
+ for (const file of files) {
11
+ assert.ok(!/\.(sqlite|db|pdf|docx|zip)(-|$)/i.test(file), `Personal/runtime artifact in plugin: ${file}`);
12
+ if (file.endsWith('.json')) JSON.parse(readFileSync(file, 'utf8'));
13
+ if (file.endsWith('.md')) {
14
+ const body = readFileSync(file, 'utf8');
15
+ assert.ok(!body.includes('[TODO:'), `Unfinished scaffold: ${file}`);
16
+ for (const m of body.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) {
17
+ const link = m[1];
18
+ if (/^(https?:|#)/.test(link)) continue;
19
+ assert.ok(existsSync(resolve(dirname(file), link.split('#')[0])), `Broken link ${file}: ${link}`);
20
+ }
21
+ }
22
+ }
23
+ const load = path => JSON.parse(readFileSync(path, 'utf8'));
24
+ const profile = load(join(root, 'templates/profile.json'));
25
+ assert.equal(profile.confirmed_at, null);
26
+ assert.equal(profile.hirify_profile_id, null);
27
+ assert.deepEqual(profile.search.roles, []);
28
+ assert.deepEqual(profile.evidence, []);
29
+ assert.ok(Object.values(profile.identity).every(v => v === null || Array.isArray(v) && v.length === 0));
30
+ assert.equal(load(join(root, 'templates/policy.json')).mode, 'review_each');
31
+ assert.equal(load(join(root, 'templates/policy.json')).auto, null);
32
+ const codex = load(join(root, '.codex-plugin/plugin.json'));
33
+ const claude = load(join(root, '.claude-plugin/plugin.json'));
34
+ assert.equal(codex.name, 'jobhunt-kit'); assert.equal(claude.name, codex.name);
35
+ assert.equal(codex.version, claude.version);
36
+ for (const file of ['.agents/plugins/marketplace.json', '.claude-plugin/marketplace.json']) {
37
+ const market = load(file);
38
+ for (const plugin of market.plugins) {
39
+ const source = typeof plugin.source === 'string' ? plugin.source : plugin.source.path;
40
+ assert.ok(existsSync(resolve(source, '.codex-plugin/plugin.json')));
41
+ }
42
+ }
43
+ const skills = readdirSync(join(root, 'skills'));
44
+ assert.equal(skills.length, 5);
45
+ for (const skill of skills) {
46
+ const body = readFileSync(join(root, 'skills', skill, 'SKILL.md'), 'utf8');
47
+ assert.ok(body.startsWith('---\n') || body.startsWith('---\r\n'));
48
+ assert.match(body, new RegExp(`name: ${skill}\\r?\\n`));
49
+ assert.match(body, /description: .+/);
50
+ }
51
+ for (const folder of ['.', root]) {
52
+ const pkg = load(join(folder, 'package.json'));
53
+ const lock = load(join(folder, 'package-lock.json'));
54
+ assert.equal(lock.packages['node_modules/hirify-cli'].version, '0.4.5');
55
+ assert.deepEqual(lock.packages[''].dependencies || lock.packages[''].devDependencies, pkg.dependencies || pkg.devDependencies);
56
+ }
57
+ for (const [source, asset] of [
58
+ ['.gitignore', 'installer-assets/gitignore.txt'],
59
+ ['package-lock.json', 'installer-assets/workspace-lock.json'],
60
+ ['plugins/jobhunt-kit/package-lock.json', 'installer-assets/runtime-lock.json']
61
+ ]) assert.ok(readFileSync(source).equals(readFileSync(asset)), `Installer asset drift: ${asset}`);
62
+ console.log(`PACKAGE_OK: ${skills.length} skills, ${files.length} plugin files, valid JSON, local links, empty profile, disabled auto, pinned dependency`);
@@ -0,0 +1,143 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, readFileSync, writeFileSync, existsSync, rmSync, mkdirSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve, sep, dirname } from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { createRequire } from 'node:module';
8
+ import { runCommand } from '../plugins/jobhunt-kit/scripts/commands.mjs';
9
+ import { validateProfile } from '../plugins/jobhunt-kit/scripts/profile.mjs';
10
+
11
+ function fixture(t) {
12
+ const cwd = mkdtempSync(join(tmpdir(), 'job-search-command-'));
13
+ t.after(() => {
14
+ assert.ok(resolve(cwd).startsWith(resolve(tmpdir()) + sep) && cwd.includes('job-search-command-'));
15
+ rmSync(cwd, { recursive: true, force: true });
16
+ });
17
+ const call = (...args) => runCommand(args, { cwd });
18
+ const input = (data, name = 'input.json') => { writeFileSync(join(cwd, name), JSON.stringify(data)); return name; };
19
+ return { cwd, call, input, dir: join(cwd, 'local/jobhunt-kit') };
20
+ }
21
+ function ready(f) {
22
+ f.call('profile', 'init');
23
+ writeFileSync(join(f.cwd, 'fictional.txt'), 'Fictional Candidate\nfictional@example.invalid\nExperience\nLibrary work.');
24
+ f.call('resume', 'fictional.txt');
25
+ const profile = f.call('profile', 'show').profile;
26
+ profile.search.roles = ['Fictional librarian']; profile.hirify_profile_id = 123;
27
+ profile.evidence = [{ id: 'E1', text: 'Library work', source: 'fictional test', confirmed: true }];
28
+ f.call('profile', 'save', '--input', f.input(profile));
29
+ f.call('resume', 'reviewed', '--input', f.input({ sha256: profile.resume.sha256, status: 'ready', checks: { text: 'pass', visual: 'pass', facts: 'pass' }, evidence: 'Synthetic review only' }));
30
+ f.call('profile', 'confirm', '--note', 'Synthetic candidate confirmation');
31
+ }
32
+ test('doctor does not create candidate data and profile init creates only empty templates', t => {
33
+ const f = fixture(t);
34
+ assert.equal(f.call('doctor').initialized, false);
35
+ assert.equal(existsSync(f.dir), false);
36
+ f.call('profile', 'init');
37
+ assert.equal(f.call('profile', 'check').valid, false);
38
+ assert.throws(() => f.call('profile', 'confirm', '--note', 'test'), /target role/);
39
+ });
40
+ test('profile save validates facts, clears confirmation and records explicit confirmation', t => {
41
+ const f = fixture(t); ready(f);
42
+ assert.equal(f.call('profile', 'check').confirmed, true);
43
+ const p = f.call('profile', 'show').profile;
44
+ p.search.roles = ['Fictional archivist'];
45
+ f.call('profile', 'save', '--input', f.input(p));
46
+ assert.equal(f.call('profile', 'check').confirmed, false);
47
+ assert.ok(f.call('history').some(e => e.kind === 'profile_saved'));
48
+ p.evidence.push(p.evidence[0]);
49
+ assert.equal(validateProfile(p).valid, false);
50
+ });
51
+ test('resume imports immutable copy, performs text checks and preserves human review on rerun', t => {
52
+ const f = fixture(t); f.call('profile', 'init');
53
+ writeFileSync(join(f.cwd, 'fictional notes.txt'), 'Experience\nFictional text');
54
+ const r = f.call('resume', 'fictional notes.txt');
55
+ assert.equal(r.fingerprint_matches, true); assert.equal(r.text_extraction, 'utf8_text');
56
+ assert.equal(r.ats_test, 'not_performed'); assert.ok(r.findings.some(x => x.includes('email')));
57
+ writeFileSync(r.review_path, 'Human review must survive');
58
+ f.call('resume', 'check');
59
+ assert.equal(readFileSync(r.review_path, 'utf8'), 'Human review must survive');
60
+ writeFileSync(join(f.cwd, 'fictional notes.txt'), 'Changed original');
61
+ assert.match(readFileSync(r.file, 'utf8'), /Fictional text/);
62
+ });
63
+ test('binary resume is not falsely described as parsed or ready', t => {
64
+ const f = fixture(t); f.call('profile', 'init');
65
+ writeFileSync(join(f.cwd, 'fictional.pdf'), 'Not a real PDF; test only');
66
+ const r = f.call('resume', 'fictional.pdf');
67
+ assert.equal(r.text_extraction, 'not_performed');
68
+ assert.equal(r.review_status, 'not_reviewed');
69
+ assert.throws(() => f.call('resume', 'reviewed', '--input', f.input({sha256:r.sha256,status:'ready',evidence:'test'})), /recorded text/);
70
+ });
71
+ test('search creates an honest local context and prevents overlapping CLI runs', t => {
72
+ const f = fixture(t); ready(f);
73
+ const context = f.call('search');
74
+ assert.equal(context.live_search_executed, false); assert.equal(context.budgets.target, 5);
75
+ const run = f.call('search', 'start', '--input', f.input({ query: 'fictional' }));
76
+ assert.throws(() => f.call('search', 'start', '--input', f.input({})), /previous running/);
77
+ f.call('search', 'finish', '--input', f.input({run_id:run.run_id,status:'complete',reason:'Synthetic no results'}));
78
+ assert.match(f.call('report').content, /Synthetic no results/);
79
+ });
80
+ test('application commands preserve approval, export and exactly-one dispatch guards', t => {
81
+ const f = fixture(t); ready(f);
82
+ const run = f.call('search', 'start', '--input', f.input({}));
83
+ f.call('search', 'record', '--input', f.input({run_id:run.run_id,slug:'fictional',title:'Fictional role',url:'https://example.invalid/job',route:'hosted',description:'Test only',read_at:'2026-01-01T00:00:00Z',match:{verdict:'suitable',reasons:['test'],requirements:[]}}));
84
+ f.call('apply', 'prepare', '--input', f.input({slug:'fictional',cover_letter:'Fictional cover\nLiteral $() and quotes "',claims:[{text:'Library work',evidence_id:'E1'}],answers:[],unresolved:[],hirify_profile_id:123}));
85
+ const exported = f.call('apply', 'export', 'fictional');
86
+ assert.equal(exported.sent, false);
87
+ assert.match(readFileSync(join(exported.directory,'cover-letter.txt'),'utf8'), /Literal/);
88
+ assert.throws(() => f.call('apply', 'begin', 'fictional'), /approval required/);
89
+ f.call('apply', 'approve', '--input', f.input({slug:'fictional',user_authorization:'Synthetic individual approval'}));
90
+ const packet = f.call('apply','begin','fictional');
91
+ let calls=0;
92
+ const send = () => runCommand(['apply','send','--input',packet.packet_path],{cwd:f.cwd,transport:()=>{calls++;return {status:0,stdout:'synthetic response',stderr:''};}});
93
+ send(); assert.throws(send); assert.equal(calls,1);
94
+ f.call('apply','finish','--input',f.input({attempt_id:packet.attempt_id,outcome:'submitted',application_id:'fictional-123',evidence:'Synthetic confirmed response'}));
95
+ f.call('track','status','fictional','--input',f.input({slug:'different-untrusted-slug',status:'interview',note:'Synthetic user report'}));
96
+ assert.equal(f.call('track','show','fictional').status,'interview');
97
+ });
98
+ test('schedule generates prompt with persistent workspace paths and never grants auto', t => {
99
+ const f=fixture(t); f.call('profile','init');
100
+ const plugin=join(f.cwd,'plugins/jobhunt-kit/skills/job-search'); mkdirSync(plugin,{recursive:true}); writeFileSync(join(plugin,'SKILL.md'),'Fictional path fixture');
101
+ const r=f.call('schedule','--input',f.input({frequency:'Every weekday at 9',timezone:'UTC',mode:'auto'}));
102
+ assert.equal(r.scheduled,false); assert.equal(r.auto_permission_granted,false);
103
+ assert.ok(readFileSync(r.path,'utf8').includes(join(f.cwd,'plugins/jobhunt-kit')));
104
+ assert.equal(f.call('policy').mode,'review_each');
105
+ assert.throws(()=>f.call('schedule','--input',f.input({frequency:'daily',timezone:'invalid-zone',mode:'search'})));
106
+ });
107
+ test('CLI uses requested workspace independently of package working directory', t => {
108
+ const f=fixture(t);
109
+ const r=spawnSync(process.execPath,[resolve('bin/jobhunt-kit.mjs'),'doctor','--workspace',f.cwd],{encoding:'utf8'});
110
+ assert.equal(r.status,0,r.stderr);
111
+ assert.equal(JSON.parse(r.stdout).workspace,f.cwd);
112
+ assert.equal(existsSync(f.dir),false);
113
+ });
114
+ test('PDF text is extracted locally from a generated fictional one-page document', t => {
115
+ const f=fixture(t);f.call('profile','init');
116
+ const stream='BT /F1 12 Tf 30 100 Td (Fictional Resume Experience) Tj ET';
117
+ const objects=['<< /Type /Catalog /Pages 2 0 R >>','<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
118
+ '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
119
+ '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`];
120
+ let pdf='%PDF-1.4\n';const offsets=[0];
121
+ objects.forEach((o,i)=>{offsets.push(Buffer.byteLength(pdf));pdf+=`${i+1} 0 obj\n${o}\nendobj\n`;});
122
+ const xref=Buffer.byteLength(pdf);
123
+ pdf+=`xref\n0 6\n0000000000 65535 f \n${offsets.slice(1).map(n=>`${String(n).padStart(10,'0')} 00000 n \n`).join('')}trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
124
+ writeFileSync(join(f.cwd,'synthetic.pdf'),pdf);
125
+ const result=f.call('resume','synthetic.pdf');
126
+ assert.equal(result.text_extraction,'pdf-parse',JSON.stringify(result.findings));
127
+ assert.match(readFileSync(join(dirname(result.checks_path),'text.txt'),'utf8'),/Fictional Resume Experience/);
128
+ assert.equal(result.visual_review,'not_performed');
129
+ });
130
+ test('DOCX text is extracted locally from a generated fictional document', async t => {
131
+ const f=fixture(t);f.call('profile','init');
132
+ const require=createRequire(import.meta.url);
133
+ const JSZip=createRequire(require.resolve('mammoth/package.json'))('jszip');
134
+ const zip=new JSZip();
135
+ zip.file('[Content_Types].xml','<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>');
136
+ zip.file('_rels/.rels','<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>');
137
+ zip.file('word/document.xml','<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Fictional DOCX Experience</w:t></w:r></w:p></w:body></w:document>');
138
+ writeFileSync(join(f.cwd,'synthetic.docx'),await zip.generateAsync({type:'nodebuffer'}));
139
+ const result=f.call('resume','synthetic.docx');
140
+ assert.equal(result.text_extraction,'mammoth',JSON.stringify(result.findings));
141
+ assert.match(readFileSync(join(dirname(result.checks_path),'text.txt'),'utf8'),/Fictional DOCX Experience/);
142
+ assert.equal(result.factual_review,'not_performed');
143
+ });
@@ -0,0 +1,52 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, symlinkSync, unlinkSync } from 'node:fs';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { tmpdir } from 'node:os';
6
+ import { join, resolve, sep } from 'node:path';
7
+ import { install } from '../bin/jobhunt-kit.mjs';
8
+
9
+ function target(t) {
10
+ const path = mkdtempSync(join(tmpdir(), 'job-search-install-'));
11
+ t.after(() => {
12
+ assert.ok(resolve(path).startsWith(resolve(tmpdir()) + sep) && path.includes('job-search-install-'));
13
+ rmSync(path, { recursive: true, force: true });
14
+ });
15
+ return join(path, 'new workspace');
16
+ }
17
+ test('installs a complete template, including npm-omitted files, without personal state', t => {
18
+ const path = target(t);
19
+ const result = install(path, { installDependencies: false });
20
+ assert.ok(result.copied > 30);
21
+ for (const name of ['.gitignore', 'package-lock.json', 'plugins/jobhunt-kit/package-lock.json',
22
+ '.agents/plugins/marketplace.json', '.claude-plugin/marketplace.json', 'plugins/jobhunt-kit/skills/job-profile/SKILL.md']) assert.ok(existsSync(join(path, name)), name);
23
+ assert.equal(existsSync(join(path, 'local')), false);
24
+ assert.equal(existsSync(join(path, '.git')), false);
25
+ });
26
+ test('repeat installation preserves candidate files and copies nothing', t => {
27
+ const path = target(t);
28
+ install(path, { installDependencies: false });
29
+ mkdirSync(join(path, 'local'));
30
+ writeFileSync(join(path, 'local', 'fictional.md'), 'Fictional private test data');
31
+ const result = install(path, { installDependencies: false });
32
+ assert.equal(result.copied, 0);
33
+ assert.equal(readFileSync(join(path, 'local', 'fictional.md'), 'utf8'), 'Fictional private test data');
34
+ });
35
+ test('conflicting file stops before copying any files', t => {
36
+ const path = target(t);
37
+ mkdirSync(path); writeFileSync(join(path, 'README.md'), 'Existing user document');
38
+ assert.throws(() => install(path, { installDependencies: false }), /Existing file differs/);
39
+ assert.equal(existsSync(join(path, 'plugins')), false);
40
+ assert.equal(readFileSync(join(path, 'README.md'), 'utf8'), 'Existing user document');
41
+ });
42
+ test('CLI executes through the package link used by npm exec', t => {
43
+ const path = target(t);
44
+ symlinkSync(resolve('.'), path, process.platform === 'win32' ? 'junction' : 'dir');
45
+ try {
46
+ const result = spawnSync(process.execPath, [join(path, 'bin/jobhunt-kit.mjs'), 'doctor', '--workspace', join(path, '..', 'empty')], { encoding: 'utf8' });
47
+ assert.equal(result.status, 0, result.stderr);
48
+ assert.equal(JSON.parse(result.stdout).initialized, false);
49
+ } finally {
50
+ unlinkSync(path);
51
+ }
52
+ });