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
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "jobhunt-kit-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "jobhunt-kit-plugin",
9
- "version": "0.3.0",
9
+ "version": "0.5.0",
10
10
  "dependencies": {
11
+ "ajv": "8.20.0",
11
12
  "hirify-cli": "0.4.5",
12
13
  "mammoth": "1.12.2",
13
14
  "pdf-parse": "2.4.5"
@@ -224,6 +225,22 @@
224
225
  "node": ">=10.0.0"
225
226
  }
226
227
  },
228
+ "node_modules/ajv": {
229
+ "version": "8.20.0",
230
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
231
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
232
+ "license": "MIT",
233
+ "dependencies": {
234
+ "fast-deep-equal": "^3.1.3",
235
+ "fast-uri": "^3.0.1",
236
+ "json-schema-traverse": "^1.0.0",
237
+ "require-from-string": "^2.0.2"
238
+ },
239
+ "funding": {
240
+ "type": "github",
241
+ "url": "https://github.com/sponsors/epoberezkin"
242
+ }
243
+ },
227
244
  "node_modules/argparse": {
228
245
  "version": "1.0.10",
229
246
  "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -280,6 +297,28 @@
280
297
  "underscore": "^1.13.1"
281
298
  }
282
299
  },
300
+ "node_modules/fast-deep-equal": {
301
+ "version": "3.1.3",
302
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
303
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
304
+ "license": "MIT"
305
+ },
306
+ "node_modules/fast-uri": {
307
+ "version": "3.1.7",
308
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
309
+ "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
310
+ "funding": [
311
+ {
312
+ "type": "github",
313
+ "url": "https://github.com/sponsors/fastify"
314
+ },
315
+ {
316
+ "type": "opencollective",
317
+ "url": "https://opencollective.com/fastify"
318
+ }
319
+ ],
320
+ "license": "BSD-3-Clause"
321
+ },
283
322
  "node_modules/hirify-cli": {
284
323
  "version": "0.4.5",
285
324
  "resolved": "https://registry.npmjs.org/hirify-cli/-/hirify-cli-0.4.5.tgz",
@@ -310,6 +349,12 @@
310
349
  "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
311
350
  "license": "MIT"
312
351
  },
352
+ "node_modules/json-schema-traverse": {
353
+ "version": "1.0.0",
354
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
355
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
356
+ "license": "MIT"
357
+ },
313
358
  "node_modules/jszip": {
314
359
  "version": "3.10.1",
315
360
  "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
@@ -440,6 +485,15 @@
440
485
  "util-deprecate": "~1.0.1"
441
486
  }
442
487
  },
488
+ "node_modules/require-from-string": {
489
+ "version": "2.0.2",
490
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
491
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
492
+ "license": "MIT",
493
+ "engines": {
494
+ "node": ">=0.10.0"
495
+ }
496
+ },
443
497
  "node_modules/safe-buffer": {
444
498
  "version": "5.1.2",
445
499
  "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "jobhunt-kit-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": ">=24.0.0"
8
8
  },
9
9
  "dependencies": {
10
+ "ajv": "8.20.0",
10
11
  "hirify-cli": "0.4.5",
11
12
  "mammoth": "1.12.2",
12
13
  "pdf-parse": "2.4.5"
@@ -1,6 +1,8 @@
1
1
  # Команды рабочих сценариев
2
2
 
3
- Установка: `npx jobhunt-kit install [папка]` с выбором Codex/Claude Code и области.
3
+ Установка: `npx jobhunt-kit install [папка]` с выбором агентов и области.
4
+ Список: `npx jobhunt-kit agents`. Все для выбранной области: `--agents all`.
5
+ `--providers` поддерживается как синоним `--agents`.
4
6
  Без вопросов: `npx jobhunt-kit install --agents codex,claude --scope project`.
5
7
  Глобально: `npx jobhunt-kit install --agents claude --scope global` (без пути).
6
8
  В глобальном режиме устанавливаются только навыки; зависимости и данные создаёт
@@ -23,7 +25,7 @@
23
25
  | job-resume | Копия по SHA-256, извлечение PDF/DOCX/TXT/MD, простые проверки текста, сохранение отчёта, запись завершённой проверки | Визуальная оценка, истинность фактов, соответствие роли и содержательные правки |
24
26
  | job-search | Сбор профиля, бюджетов и известных вакансий; журнал запусков/результатов, запрет второго активного запуска через CLI | Актуальные фильтры Hirify, preview, живой поиск, оценка требований и ранжирование |
25
27
  | job-apply | Снимок черновика, экспорт текста/ответов, согласование, резервирование и однократный вызов CLI, запись исхода | Написание письма из фактов, проверка живого профиля/квоты, получение разрешения, разбор ответа сервиса |
26
- | job-track | Списки/история/статусы/Markdown; заполнение prompt для расписания | Интерпретация сообщения кандидата, создание задачи в планировщике после запроса |
28
+ | job-track | Списки/история/статусы/Markdown, integrity/recovery, backup/restore, privacy-команды; заполнение prompt для расписания | Интерпретация сообщения кандидата, создание задачи в планировщике после запроса |
27
29
 
28
30
  Технические функции: `profile.mjs`, `resume.mjs`, `extract-resume.mjs`, `commands.mjs`;
29
31
  история и защита отправки переиспользуют `tracker.mjs` и `send-packet.mjs`.
@@ -127,3 +129,29 @@ schedule.json: `{"frequency":"По будням в 09:00","timezone":"Europe/Ber
127
129
  materials/scheduled-search.md, а не включённая задача; auto-разрешение не выдаётся.
128
130
  Нужен --workspace с установленным шаблоном, чтобы prompt не ссылался на временный
129
131
  кеш npx. Планировщик выбирает агент по явному запросу пользователя.
132
+
133
+ ## Целостность, резервирование и приватность
134
+
135
+ ```text
136
+ npx jobhunt-kit doctor
137
+ npx jobhunt-kit recover inspect
138
+ npx jobhunt-kit recover apply --input recovery.json
139
+ npx jobhunt-kit recover packet <attempt_id>
140
+ npx jobhunt-kit backup ../jobhunt-backup
141
+ npx jobhunt-kit backup-verify ../jobhunt-backup
142
+ npx jobhunt-kit restore --input restore.json
143
+ npx jobhunt-kit privacy map
144
+ npx jobhunt-kit privacy export ../jobhunt-private-export
145
+ npx jobhunt-kit privacy redact --input redact.json
146
+ npx jobhunt-kit privacy purge --input purge.json
147
+ ```
148
+
149
+ `doctor` проверяет JSON Schema, SQLite integrity/foreign keys, активный run,
150
+ неразрешённые attempts и незавершённые файловые операции. Recovery ничего не угадывает:
151
+ для run нужны id/status/reason, для attempt — slug/outcome/evidence.
152
+
153
+ Restore требует `{"source":"...","confirmation":"RESTORE VERIFIED JOBHUNT BACKUP"}`
154
+ и перед заменой создаёт автоматический сырой pre-restore quarantine-снимок. Redact требует
155
+ `REDACT LOCAL JOBHUNT PII` и массив dotted profile fields. Purge требует фразу
156
+ `PURGE LOCAL JOBHUNT DATA`, scopes и отдельный backup_directory; удаление без
157
+ проверяемой резервной копии не выполняется.
@@ -0,0 +1,12 @@
1
+ # Установка в агенты
2
+
3
+ Пути сверены с [каталогом провайдеров Impeccable](https://github.com/pbakaus/impeccable/blob/main/crates/skills/src/providers.rs)
4
+ и [его README](https://github.com/pbakaus/impeccable#installation), 2026-09-08.
5
+ Для Veto README описывает только глобальный каталог.
6
+ Для Copilot глобальный путь `.copilot/skills` соответствует
7
+ [документации GitHub](https://docs.github.com/en/copilot/concepts/agents/about-agent-skills).
8
+
9
+ Установщик копирует переносимый SKILL.md и ресурсы. Он не меняет настройки
10
+ доверия, не устанавливает сами приложения и не запускает их. Автоматические тесты
11
+ проверяют все пути, комплектность файлов и конфликты; загрузка навыка в каждом
12
+ из приложений отдельно не проверена.
@@ -0,0 +1,24 @@
1
+ # Локальные данные и приватность
2
+
3
+ Jobhunt Kit не шифрует данные. Рабочая папка `D` должна быть доступна только
4
+ пользователю и не должна синхронизироваться или публиковаться без его решения.
5
+
6
+ | Путь | Возможные персональные данные | Назначение |
7
+ |---|---|---|
8
+ | `profile.json` | Имя, контакты, опыт, ответы, разрешения на работу | Авторитетный профиль кандидата |
9
+ | `policy.json` | Текст auto-разрешения, срок и лимит | Авторитетная политика отправки |
10
+ | `history.sqlite` | Вакансии, письма, ответы, approvals, application snapshots; legacy v1 мог хранить полные снимки профиля | Авторитетная история |
11
+ | `resumes/` | Неизменяемые копии резюме | Проверка и отправочный контекст |
12
+ | `materials/` | Извлечённый текст, письма, ответы, attempt packets | Рабочие материалы агента |
13
+ | `reports/` | Названия вакансий, ссылки и статусы | Производное представление истории |
14
+
15
+ `privacy map` возвращает абсолютные пути этой карты. `privacy export` создаёт ту же
16
+ проверяемую копию, что backup. `privacy redact` меняет перечисленные dotted-поля
17
+ профиля, сбрасывает подтверждение и удаляет полные profile snapshots/user authorization
18
+ из legacy events; для полного удаления истории использовать purge scope `history`.
19
+
20
+ `privacy purge` требует точную фразу подтверждения и отдельный `backup_directory`.
21
+ До удаления создаётся и проверяется полная резервная копия. Scopes: `profile`,
22
+ `history`, `resumes`, `materials`, `reports`. Очистка `history` всегда очищает и
23
+ производные `reports`, отзывает auto-разрешение и подтверждение профиля. Purge не удаляет внешний файл, из которого ранее
24
+ импортировали резюме, и не отзывает уже отправленные заявления на стороне Hirify.
@@ -30,7 +30,8 @@ P/D — абсолютные пути из workflow.md. JSON-ответы ком
30
30
  Дата confirmed_at — ISO 8601. Profile-команда отдаёт hash текущего JSON для разрешения
31
31
  auto. Хеш зависит от сериализации объекта: изменение/перестановка ключей может
32
32
  потребовать нового согласования (консервативное поведение).
33
- resume.path — путь относительно D или абсолютный локальный; sha256 — байтов файла.
33
+ resume.path — только относительный путь внутри D, штатно `resumes/<sha>.<ext>`;
34
+ внешний файл сначала импортируется командой resume. sha256 — хеш байтов файла.
34
35
  Пример вычисления в PowerShell: `(Get-FileHash <file> -Algorithm SHA256).Hash.ToLower()`.
35
36
  readiness и confirmed — утверждения агента по фактической проверке, не автоматическая
36
37
  проверка истинности биографии. Не отмечать их только ради прохождения команды.
@@ -109,12 +110,17 @@ External или недостающие ответы → needs_user. После
109
110
  можно dismissed. После отправки: interview / rejected / withdrawn / offer;
110
111
  из offer: accepted / declined / withdrawn. Status — локальная запись, не действие сервиса.
111
112
 
112
- Схема SQLite v1: runs, jobs, aliases, observations, attempts, dispatches, events.
113
+ Схема SQLite v2: runs, jobs, aliases, observations, attempts, dispatches, events,
114
+ file_revisions и pending_file_writes. Миграция v1→v2 сохраняет данные, закрывает
115
+ старые дублирующиеся running runs, записывается в schema_migrations и добавляет
116
+ immutable application snapshots.
113
117
  В attempts UNIQUE(job_id), в dispatches PRIMARY KEY(attempt_id): повтор не проходит
114
118
  после перезапуска процесса. Сбой после намерения требует ручного разбора.
115
119
  Нет универсальной транзакции между SQLite и Hirify: невозможно обещать exactly-once
116
120
  доставку через сеть. Здесь предотвращаются автоматические повторные отправки.
117
121
 
118
- Историю читать через команды. Для резервирования закрыть работающие процессы и
119
- сохранить весь D. Обновление схемы пока не требуется: это первая версия; будущий
120
- апгрейд обязан иметь явную миграцию и резервную копию.
122
+ Историю читать через команды. Для резервирования использовать backup и backup-verify.
123
+ Restore сначала создаёт сырой автоматический pre-restore quarantine-снимок, не требующий
124
+ валидности текущих данных. Перед активацией восстановленной истории restore отзывает
125
+ auto-разрешение и подтверждение профиля. Doctor проверяет JSON Schema,
126
+ SQLite, foreign keys, незавершённые runs/attempts и журнал атомарных файловых операций.
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "jobhunt-kit/draft",
4
+ "type": "object",
5
+ "required": ["slug", "cover_letter", "hirify_profile_id", "claims", "answers", "unresolved"],
6
+ "properties": {
7
+ "slug": { "type": "string", "minLength": 1 }, "cover_letter": { "type": "string", "minLength": 1 },
8
+ "hirify_profile_id": { "type": ["integer", "null"], "minimum": 1 },
9
+ "claims": { "type": "array", "items": { "type": "object", "required": ["text", "evidence_id"], "properties": { "text": { "type": "string", "minLength": 1 }, "evidence_id": { "type": "string", "minLength": 1 } }, "additionalProperties": false } },
10
+ "answers": { "type": "array" }, "unresolved": { "type": "array", "items": { "type": "string", "minLength": 1 } }
11
+ },
12
+ "additionalProperties": true
13
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "jobhunt-kit/finish",
4
+ "type": "object",
5
+ "required": ["attempt_id", "outcome", "evidence"],
6
+ "properties": {
7
+ "attempt_id": { "type": "string", "minLength": 1 }, "outcome": { "enum": ["submitted", "failed", "unknown"] },
8
+ "evidence": { "type": "string", "minLength": 1 }, "application_id": { "type": "string", "minLength": 1 }
9
+ },
10
+ "additionalProperties": true
11
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "jobhunt-kit/policy",
4
+ "type": "object",
5
+ "required": ["schema_version", "mode", "search", "auto"],
6
+ "properties": {
7
+ "schema_version": { "const": 1 },
8
+ "mode": { "enum": ["review_each", "auto"] },
9
+ "search": {
10
+ "type": "object",
11
+ "required": ["target", "max_cards", "max_reads", "max_reveals", "max_minutes"],
12
+ "properties": {
13
+ "target": { "type": "integer", "minimum": 1 }, "max_cards": { "type": "integer", "minimum": 1 },
14
+ "max_reads": { "type": "integer", "minimum": 1 }, "max_reveals": { "type": "integer", "minimum": 0 },
15
+ "max_minutes": { "type": "integer", "minimum": 1 }
16
+ },
17
+ "additionalProperties": false
18
+ },
19
+ "auto": {
20
+ "anyOf": [
21
+ { "type": "null" },
22
+ {
23
+ "type": "object", "required": ["user_authorization", "profile_hash", "expires_at", "max_attempts_per_utc_day"],
24
+ "properties": {
25
+ "user_authorization": { "type": "string", "minLength": 1 },
26
+ "profile_hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
27
+ "expires_at": { "type": "string", "format": "date-time" },
28
+ "max_attempts_per_utc_day": { "type": "integer", "minimum": 1 }
29
+ },
30
+ "additionalProperties": false
31
+ }
32
+ ]
33
+ }
34
+ },
35
+ "additionalProperties": false
36
+ }
@@ -0,0 +1,80 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "jobhunt-kit/profile",
4
+ "type": "object",
5
+ "required": ["schema_version", "confirmed_at", "identity", "search", "languages", "experience", "education", "skills", "achievements", "evidence", "answers", "availability", "writing", "hirify_profile_id", "resume"],
6
+ "properties": {
7
+ "schema_version": { "const": 1 },
8
+ "confirmed_at": { "type": ["string", "null"], "format": "date-time" },
9
+ "identity": {
10
+ "type": "object",
11
+ "required": ["name", "email", "phone", "city", "country", "timezone", "contact_links"],
12
+ "properties": {
13
+ "name": { "type": ["string", "null"] }, "email": { "type": ["string", "null"] },
14
+ "phone": { "type": ["string", "null"] }, "city": { "type": ["string", "null"] },
15
+ "country": { "type": ["string", "null"] }, "timezone": { "type": ["string", "null"] },
16
+ "contact_links": { "type": "array", "items": { "type": "string" } }
17
+ },
18
+ "additionalProperties": false
19
+ },
20
+ "search": {
21
+ "type": "object",
22
+ "required": ["roles", "levels", "countries", "work_modes", "employment_types", "industries", "excluded_companies", "excluded_keywords", "work_authorization", "hard_constraints", "preferences", "feed_ids", "queries", "salary"],
23
+ "properties": {
24
+ "roles": { "$ref": "#/$defs/strings" }, "levels": { "$ref": "#/$defs/strings" },
25
+ "countries": { "$ref": "#/$defs/strings" }, "work_modes": { "$ref": "#/$defs/strings" },
26
+ "employment_types": { "$ref": "#/$defs/strings" }, "industries": { "$ref": "#/$defs/strings" },
27
+ "excluded_companies": { "$ref": "#/$defs/strings" }, "excluded_keywords": { "$ref": "#/$defs/strings" },
28
+ "work_authorization": { "$ref": "#/$defs/strings" }, "hard_constraints": { "$ref": "#/$defs/strings" },
29
+ "preferences": { "$ref": "#/$defs/strings" }, "feed_ids": { "type": "array", "items": { "type": ["string", "integer"] } },
30
+ "queries": { "$ref": "#/$defs/strings" },
31
+ "relocation": {}, "travel": {}, "working_hours": {}, "sponsorship": {},
32
+ "unknown_salary": { "type": "string" }, "unknown_critical_requirement": { "type": "string" },
33
+ "salary": {
34
+ "type": "object",
35
+ "required": ["min", "target", "currency", "period", "tax_basis"],
36
+ "properties": {
37
+ "min": { "type": ["number", "null"], "minimum": 0 }, "target": { "type": ["number", "null"], "minimum": 0 },
38
+ "currency": { "type": ["string", "null"] }, "period": { "type": ["string", "null"] }, "tax_basis": { "type": ["string", "null"] }
39
+ },
40
+ "additionalProperties": false
41
+ }
42
+ },
43
+ "additionalProperties": false
44
+ },
45
+ "languages": { "type": "array", "items": { "$ref": "#/$defs/object" } },
46
+ "experience": { "type": "array", "items": { "$ref": "#/$defs/evidenced" } },
47
+ "education": { "type": "array", "items": { "$ref": "#/$defs/evidenced" } },
48
+ "skills": { "type": "array", "items": { "$ref": "#/$defs/evidenced" } },
49
+ "achievements": { "type": "array", "items": { "$ref": "#/$defs/evidenced" } },
50
+ "evidence": {
51
+ "type": "array",
52
+ "items": {
53
+ "type": "object", "required": ["id", "text", "source", "confirmed"],
54
+ "properties": { "id": { "type": "string", "minLength": 1 }, "text": { "type": "string", "minLength": 1 }, "source": { "type": "string", "minLength": 1 }, "confirmed": { "type": "boolean" } },
55
+ "additionalProperties": true
56
+ }
57
+ },
58
+ "answers": { "type": "array", "items": { "$ref": "#/$defs/evidenced" } },
59
+ "availability": { "$ref": "#/$defs/object" }, "writing": { "$ref": "#/$defs/object" },
60
+ "hirify_profile_id": { "type": ["integer", "null"], "minimum": 1 },
61
+ "resume": {
62
+ "type": "object", "required": ["path", "sha256", "review_status"],
63
+ "properties": {
64
+ "path": { "type": ["string", "null"] }, "sha256": { "type": ["string", "null"], "pattern": "^[a-f0-9]{64}$" },
65
+ "review_status": { "enum": ["not_reviewed", "needs_changes", "ready"] }
66
+ },
67
+ "additionalProperties": false
68
+ }
69
+ },
70
+ "$defs": {
71
+ "strings": { "type": "array", "items": { "type": "string", "minLength": 1 } },
72
+ "object": { "type": "object", "additionalProperties": true },
73
+ "evidenced": {
74
+ "type": "object",
75
+ "properties": { "evidence_ids": { "type": "array", "items": { "type": "string", "minLength": 1 } } },
76
+ "additionalProperties": true
77
+ }
78
+ },
79
+ "additionalProperties": false
80
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "jobhunt-kit/status",
4
+ "type": "object",
5
+ "required": ["slug", "status", "note"],
6
+ "properties": {
7
+ "slug": { "type": "string", "minLength": 1 },
8
+ "status": { "enum": ["dismissed", "needs_user", "submitted_external", "interview", "rejected", "withdrawn", "offer", "accepted", "declined"] },
9
+ "note": { "type": "string", "minLength": 1 }
10
+ },
11
+ "additionalProperties": true
12
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "jobhunt-kit/vacancy",
4
+ "type": "object",
5
+ "required": ["run_id", "slug", "title", "url", "route", "match"],
6
+ "properties": {
7
+ "run_id": { "type": "string", "minLength": 1 }, "slug": { "type": "string", "pattern": "^[a-zA-Z0-9][a-zA-Z0-9_-]*$" },
8
+ "title": { "type": "string", "minLength": 1 }, "company": { "type": ["string", "null"] },
9
+ "url": { "type": "string", "format": "uri" }, "original_url": { "type": "string", "format": "uri" },
10
+ "apply_url": { "type": "string", "format": "uri" }, "route": { "enum": ["hosted", "external", "unknown"] },
11
+ "description": { "type": "string" }, "read_at": { "type": "string", "format": "date-time" },
12
+ "match": {
13
+ "type": "object", "required": ["verdict"],
14
+ "properties": {
15
+ "verdict": { "enum": ["unreviewed", "suitable", "review", "rejected"] },
16
+ "reasons": { "type": "array", "items": { "type": "string", "minLength": 1 } },
17
+ "requirements": {
18
+ "type": "array", "items": {
19
+ "type": "object", "required": ["requirement", "result", "evidence"],
20
+ "properties": { "requirement": { "type": "string", "minLength": 1 }, "result": { "enum": ["pass", "fail", "unknown"] }, "evidence": { "type": "string" } },
21
+ "additionalProperties": true
22
+ }
23
+ }
24
+ },
25
+ "additionalProperties": true
26
+ }
27
+ },
28
+ "additionalProperties": true
29
+ }
@@ -1,5 +1,5 @@
1
1
  import { parseArgs } from 'node:util';
2
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs';
3
3
  import { resolve, join, dirname, basename } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { createHash } from 'node:crypto';
@@ -8,27 +8,36 @@ import { readJSON, writeJSON, validateProfile, saveProfile, confirmProfile } fro
8
8
  import { importResume, checkResume, recordResumeReview } from './resume.mjs';
9
9
  import { cliPath } from './hirify.mjs';
10
10
  import { sendPacket } from './send-packet.mjs';
11
+ import { backupData, privacyMap, purgeData, redactData, restoreData, verifyBackup } from './maintenance.mjs';
12
+ import { atomicWrite, atomicWriteJSON, localPath } from './files.mjs';
13
+ import { validateSchema } from './validation.mjs';
11
14
 
12
15
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
13
16
  const required = (value, name) => { if (!value) throw new Error(`${name} required`); return value; };
14
17
  function using(dir, fn) { const s = new Tracker(dir); try { return fn(s); } finally { s.close(); } }
18
+ function persistAttemptPacket(dir, store, attemptId) {
19
+ const packet = store.attemptPacket(attemptId);
20
+ const path = localPath(dir, `materials/attempt-${attemptId}.json`);
21
+ atomicWriteJSON(path, packet, { base: dir });
22
+ return { packet_path: path, attempt_id: attemptId, sent: false };
23
+ }
15
24
  function exportDraft(dir, job) {
16
25
  if (!job.draft) throw new Error('Prepare a draft first');
17
26
  const version = createHash('sha256').update(JSON.stringify(job.draft)).digest('hex').slice(0, 16);
18
27
  const folder = join(dir, 'materials', `application-${job.id}-${version}`);
19
28
  mkdirSync(folder, { recursive: true });
20
- writeFileSync(join(folder, 'cover-letter.txt'), job.draft.cover_letter);
29
+ atomicWrite(join(folder, 'cover-letter.txt'), job.draft.cover_letter, { base: dir });
21
30
  writeJSON(join(folder, 'answers.json'), job.draft.answers || []);
22
31
  writeJSON(join(folder, 'draft.json'), job.draft);
23
32
  const destination = job.payload.apply_url || job.payload.original_url || job.payload.url;
24
- writeFileSync(join(folder, 'handoff.md'), [
33
+ atomicWrite(join(folder, 'handoff.md'), [
25
34
  '# Материалы отклика', '', `Вакансия: ${job.payload.title}`, `Ссылка: ${destination}`, '',
26
35
  `Маршрут: ${job.payload.route}. Статус: ${job.status}.`,
27
36
  'Письмо: cover-letter.txt. Ответы: answers.json. Полный снимок: draft.json.', '',
28
37
  '## Требует действия', ...(job.draft.unresolved || []).map(x => `- ${x}`), '',
29
38
  job.payload.route === 'external' ? 'Отклик отправляет пользователь. Если ссылка на форму ещё не раскрыта, сначала получить её через Hirify reveal.' : 'Перед отправкой проверить профиль Hirify и текущую квоту; требуется действующее согласование.',
30
39
  '', 'Экспорт файлов не отправляет отклик.'
31
- ].join('\n'));
40
+ ].join('\n'), { base: dir });
32
41
  return { directory: folder, handoff: join(folder, 'handoff.md'), sent: false };
33
42
  }
34
43
  function searchContext(dir, workspace) {
@@ -37,6 +46,7 @@ function searchContext(dir, workspace) {
37
46
  const validation = validateProfile(p.profile);
38
47
  if (!validation.valid) throw new Error(validation.errors.join('; '));
39
48
  const policy = readJSON(join(dir, 'policy.json'));
49
+ validateSchema('policy', policy);
40
50
  for (const k of ['target', 'max_cards', 'max_reads', 'max_reveals', 'max_minutes']) {
41
51
  if (!Number.isInteger(policy.search?.[k]) || policy.search[k] < (k === 'max_reveals' ? 0 : 1)) throw new Error(`Invalid search budget: ${k}`);
42
52
  }
@@ -50,7 +60,7 @@ function searchContext(dir, workspace) {
50
60
  const task = join(dir, 'materials', 'search-task.md');
51
61
  const localPlugin = join(workspace, 'plugins', 'jobhunt-kit');
52
62
  const plugin = existsSync(join(localPlugin, 'skills', 'job-search', 'SKILL.md')) ? localPlugin : ROOT;
53
- writeFileSync(task, `Use job-search at ${join(plugin, 'skills', 'job-search', 'SKILL.md')}.\nRead ${path} and ${join(dir, 'profile.json')}.\nCheck live Hirify quotas and filter guide, preview filters, then search. Record runs and observations using the CLI.\nNo live search has been performed by this context command.\n`);
63
+ atomicWrite(task, `Use job-search at ${join(plugin, 'skills', 'job-search', 'SKILL.md')}.\nRead ${path} and ${join(dir, 'profile.json')}.\nCheck live Hirify quotas and filter guide, preview filters, then search. Record runs and observations using the CLI.\nNo live search has been performed by this context command.\n`, { base: dir });
54
64
  return { ...context, context_path: path, task_path: task };
55
65
  });
56
66
  }
@@ -69,7 +79,7 @@ function scheduleTemplate(dir, workspace, input) {
69
79
  '<абсолютный workspace>': workspace, '<абсолютный путь плагина>': plugin,
70
80
  '<абсолютный путь локальных данных>': dir, '<search / prepare / auto>': input.mode })) prompt = prompt.replaceAll(key, String(value));
71
81
  const path = join(dir, 'materials', 'scheduled-search.md');
72
- writeFileSync(path, prompt);
82
+ atomicWrite(path, prompt, { base: dir });
73
83
  return { path, scheduled: false, auto_permission_granted: false, next: 'Review prompt and create a task using your agent scheduler' };
74
84
  }
75
85
  export function runCommand(args, { cwd = process.cwd(), transport } = {}) {
@@ -85,12 +95,29 @@ export function runCommand(args, { cwd = process.cwd(), transport } = {}) {
85
95
  let cli; try { cli = { available: true, path: cliPath(dir), version: '0.4.5' }; }
86
96
  catch (e) { cli = { available: false, reason: e.message }; }
87
97
  const initialized = existsSync(join(dir, 'profile.json'));
98
+ let integrity = null;
99
+ let profile = null;
100
+ if (initialized) {
101
+ try { integrity = using(dir, store => store.integrity()); }
102
+ catch (error) { integrity = { database_ok: false, error: error.message }; }
103
+ try { profile = validateProfile(readJSON(join(dir, 'profile.json'))); }
104
+ catch (error) { profile = { valid: false, errors: [error.message], warnings: [] }; }
105
+ }
88
106
  return { node: process.versions.node, workspace, data: dir, initialized, cli,
89
- profile: initialized ? validateProfile(readJSON(join(dir, 'profile.json'))) : null, network_checked: false,
107
+ profile, integrity, network_checked: false,
90
108
  next: initialized ? 'profile check' : 'profile init' };
91
109
  }
92
110
  if (command === 'profile' && argument) throw new Error('Unexpected profile argument; use --input or --note');
93
111
  if (command === 'profile' && action === 'init') return initData(dir);
112
+ if (command === 'backup-verify') {
113
+ if (!action || argument) throw new Error('backup-verify requires one backup directory');
114
+ return verifyBackup(resolve(cwd, action));
115
+ }
116
+ if (command === 'restore') {
117
+ if (action || argument) throw new Error('restore uses --input with source and confirmation');
118
+ const request = input();
119
+ return restoreData(dir, resolve(cwd, required(request.source, 'source')), request.confirmation);
120
+ }
94
121
  if (!existsSync(join(dir, 'profile.json'))) throw new Error(`No profile at ${dir}. Run profile init first.`);
95
122
  if (command === 'profile') {
96
123
  if (argument) throw new Error('Unexpected profile argument; use --input or --note');
@@ -115,7 +142,6 @@ export function runCommand(args, { cwd = process.cwd(), transport } = {}) {
115
142
  const methods = { start: 'runStart', event: 'runEvent', record: 'put', finish: 'runFinish' };
116
143
  if (!methods[action]) throw new Error('search: plan | start | event | record | finish (JSON via --input)');
117
144
  return using(dir, s => {
118
- if (action === 'start' && s.runs().some(r => r.status === 'running')) throw new Error('Finish or resolve the previous running search before starting another');
119
145
  return s[methods[action]](input());
120
146
  });
121
147
  }
@@ -125,9 +151,8 @@ export function runCommand(args, { cwd = process.cwd(), transport } = {}) {
125
151
  if (action === 'export') return using(dir, s => exportDraft(dir, s.job(required(argument, 'slug'))));
126
152
  if (action === 'begin') return using(dir, s => {
127
153
  const packet = s.begin({ slug: required(argument, 'slug') });
128
- const path = join(dir, 'materials', `attempt-${packet.attempt_id}.json`);
129
- writeJSON(path, packet);
130
- return { packet_path: path, attempt_id: packet.attempt_id, sent: false };
154
+ try { return persistAttemptPacket(dir, s, packet.attempt_id); }
155
+ catch (error) { throw new Error(`Attempt reserved but packet write failed. Recover with: recover packet ${packet.attempt_id}. ${error.message}`); }
131
156
  });
132
157
  const methods = { prepare: 'prepare', approve: 'approve', finish: 'finish', resolve: 'resolveUnknown' };
133
158
  if (methods[action]) return using(dir, s => s[methods[action]](input()));
@@ -146,6 +171,27 @@ export function runCommand(args, { cwd = process.cwd(), transport } = {}) {
146
171
  if (action) throw new Error(`${command} takes no positional arguments`);
147
172
  return using(dir, s => s[command]());
148
173
  }
174
+ if (command === 'backup') {
175
+ if (!action || argument) throw new Error('backup requires one destination directory');
176
+ return backupData(dir, resolve(cwd, action));
177
+ }
178
+ if (command === 'recover') {
179
+ if (!action || action === 'inspect') return using(dir, store => store.integrity());
180
+ if (action === 'apply') return using(dir, store => store.recover(input()));
181
+ if (action === 'packet') return using(dir, store => persistAttemptPacket(dir, store, required(argument, 'attempt_id')));
182
+ throw new Error('recover: inspect | apply --input file | packet <attempt_id>');
183
+ }
184
+ if (command === 'privacy') {
185
+ if (!action || action === 'map') return privacyMap(dir);
186
+ if (action === 'export') return backupData(dir, resolve(cwd, required(argument, 'destination')));
187
+ if (['redact', 'purge'].includes(action) && argument) throw new Error(`privacy ${action} uses --input`);
188
+ if (action === 'redact') return redactData(dir, input());
189
+ if (action === 'purge') {
190
+ const request = input();
191
+ return purgeData(dir, { ...request, backup_directory: resolve(cwd, required(request.backup_directory, 'backup_directory')) });
192
+ }
193
+ throw new Error('privacy: map | export <directory> | redact/purge --input file');
194
+ }
149
195
  if (command === 'policy') {
150
196
  if (argument) throw new Error('Unexpected policy argument');
151
197
  if (!action || action === 'show') return readJSON(join(dir, 'policy.json'));
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // Parser subprocess: local bytes only, bounded by parent timeout/memory/output limits.
3
- import { readFileSync, existsSync } from 'node:fs';
4
- import { extname, join, resolve } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import { extname, join, relative, resolve } from 'node:path';
5
5
  import { createRequire } from 'node:module';
6
+ import { localPath, MAX_RESUME_BYTES, readBytes } from './files.mjs';
6
7
 
7
8
  try {
8
9
  const [dir, file] = process.argv.slice(2);
@@ -10,7 +11,8 @@ try {
10
11
  const bundled = createRequire(import.meta.url);
11
12
  const runtime = existsSync(local) ? createRequire(local) : bundled;
12
13
  const load = name => { try { return runtime(name); } catch (error) { if (error.code !== 'MODULE_NOT_FOUND') throw error; return bundled(name); } };
13
- const bytes = readFileSync(file);
14
+ const safeFile = localPath(dir, relative(resolve(dir), resolve(file)));
15
+ const bytes = readBytes(safeFile, { base: resolve(dir), maxBytes: MAX_RESUME_BYTES });
14
16
  let result;
15
17
  if (extname(file).toLowerCase() === '.pdf') {
16
18
  const { PDFParse } = load('pdf-parse');